ampcode-connector 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ampcode-connector",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Proxy AmpCode through local OAuth subscriptions (Claude Code, Codex, Gemini CLI, Antigravity)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,16 +40,16 @@
40
40
  "format": "biome check --write src/ tests/"
41
41
  },
42
42
  "devDependencies": {
43
- "@biomejs/biome": "^2.4.0",
43
+ "@biomejs/biome": "^2.4.2",
44
+ "@google/genai": "^1.42.0",
44
45
  "@types/bun": "^1.3.9"
45
46
  },
46
47
  "peerDependencies": {
47
- "typescript": "^5"
48
+ "typescript": "^5.9.3"
48
49
  },
49
50
  "dependencies": {
50
- "@google/genai": "^1.41.0",
51
- "@kreuzberg/html-to-markdown": "^2.25.0",
52
- "ampcode-connector": "^0.1.5",
51
+ "@kreuzberg/html-to-markdown": "^2.25.1",
52
+ "ampcode-connector": "^0.1.8",
53
53
  "exa-js": "^2.4.0"
54
54
  }
55
55
  }
package/src/cli/setup.ts CHANGED
@@ -131,6 +131,7 @@ export async function setup(): Promise<void> {
131
131
 
132
132
  for (const p of providers) {
133
133
  const connected = p.accounts.filter((a) => a.status === "connected");
134
+ const disabled = p.accounts.filter((a) => a.status === "disabled");
134
135
  const total = p.accounts.filter((a) => a.status !== "disconnected");
135
136
 
136
137
  if (connected.length > 0) {
@@ -140,7 +141,10 @@ export async function setup(): Promise<void> {
140
141
  .filter(Boolean)
141
142
  .join(", ");
142
143
  const info = emails ? ` ${s.dim}${emails}${s.reset}` : "";
143
- line(` ${p.label.padEnd(16)} ${s.green}${connected.length} account(s)${s.reset}${info}`);
144
+ const disabledInfo = disabled.length > 0 ? ` ${s.red}${disabled.length} disabled${s.reset}` : "";
145
+ line(` ${p.label.padEnd(16)} ${s.green}${connected.length} account(s)${s.reset}${info}${disabledInfo}`);
146
+ } else if (disabled.length > 0) {
147
+ line(` ${p.label.padEnd(16)} ${s.red}${disabled.length} disabled${s.reset}`);
144
148
  } else if (total.length > 0) {
145
149
  line(` ${p.label.padEnd(16)} ${s.yellow}${total.length} expired${s.reset}`);
146
150
  } else {
package/src/cli/status.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { Credentials, ProviderName } from "../auth/store.ts";
2
2
  import * as store from "../auth/store.ts";
3
+ import { cooldown, type QuotaPool } from "../routing/cooldown.ts";
3
4
 
4
- export type ConnectionStatus = "connected" | "expired" | "disconnected";
5
+ export type ConnectionStatus = "connected" | "expired" | "disabled" | "disconnected";
5
6
 
6
7
  export interface AccountStatus {
7
8
  account: number;
@@ -23,8 +24,16 @@ const PROVIDERS: { name: ProviderName; label: string; sublabel?: string }[] = [
23
24
  { name: "google", label: "Google", sublabel: "Gemini CLI + Antigravity" },
24
25
  ];
25
26
 
26
- function connectionOf(creds: Credentials): ConnectionStatus {
27
+ const POOL_MAP: Record<ProviderName, QuotaPool[]> = {
28
+ anthropic: ["anthropic"],
29
+ codex: ["codex"],
30
+ google: ["gemini", "antigravity"],
31
+ };
32
+
33
+ function connectionOf(name: ProviderName, account: number, creds: Credentials): ConnectionStatus {
27
34
  if (!creds.refreshToken) return "disconnected";
35
+ const pools = POOL_MAP[name];
36
+ if (pools.some((p) => cooldown.isExhausted(p, account))) return "disabled";
28
37
  return store.fresh(creds) ? "connected" : "expired";
29
38
  }
30
39
 
@@ -35,7 +44,7 @@ export function all(): ProviderStatus[] {
35
44
  sublabel,
36
45
  accounts: store.getAll(name).map((e) => ({
37
46
  account: e.account,
38
- status: connectionOf(e.credentials),
47
+ status: connectionOf(name, e.account, e.credentials),
39
48
  email: e.credentials.email,
40
49
  expiresAt: e.credentials.expiresAt,
41
50
  })),
package/src/cli/tui.ts CHANGED
@@ -16,6 +16,7 @@ const oauthConfigs: Record<ProviderName, OAuthConfig> = {
16
16
  const ICON: Record<ConnectionStatus, string> = {
17
17
  connected: `${s.green}●${s.reset}`,
18
18
  expired: `${s.yellow}●${s.reset}`,
19
+ disabled: `${s.red}●${s.reset}`,
19
20
  disconnected: `${s.dim}○${s.reset}`,
20
21
  };
21
22
 
@@ -109,7 +110,9 @@ function formatInfo(a: AccountStatus): string {
109
110
  if (a.status === "disconnected") return `${s.dim}—${s.reset}`;
110
111
 
111
112
  const parts: string[] = [];
112
- parts.push(a.status === "connected" ? `${s.green}connected${s.reset}` : `${s.yellow}expired${s.reset}`);
113
+ if (a.status === "connected") parts.push(`${s.green}connected${s.reset}`);
114
+ else if (a.status === "disabled") parts.push(`${s.red}disabled${s.reset}`);
115
+ else parts.push(`${s.yellow}expired${s.reset}`);
113
116
  if (a.expiresAt && a.status === "connected") parts.push(`${s.dim}${status.remaining(a.expiresAt)}${s.reset}`);
114
117
  if (a.email) parts.push(`${s.dim}${a.email}${s.reset}`);
115
118
  return parts.join(`${s.dim} · ${s.reset}`);
@@ -32,6 +32,7 @@ export const provider: Provider = {
32
32
  streaming: body.stream,
33
33
  providerName: "Anthropic",
34
34
  rewrite,
35
+ email: store.get("anthropic", account)?.email,
35
36
  headers: {
36
37
  ...stainlessHeaders,
37
38
  Accept: body.stream ? "text/event-stream" : "application/json",
@@ -52,7 +52,7 @@ export const provider: Provider = {
52
52
 
53
53
  const gemini = path.gemini(sub);
54
54
  const action = gemini?.action ?? "generateContent";
55
- const model = gemini?.model ?? "";
55
+ const model = gemini?.model === "gemini-3-flash-preview" ? "gemini-3-flash" : (gemini?.model ?? "");
56
56
  const requestBody = maybeWrap(body.parsed, body.forwardBody, projectId, model, {
57
57
  userAgent: "antigravity",
58
58
  requestIdPrefix: "agent",
@@ -60,7 +60,7 @@ export const provider: Provider = {
60
60
  });
61
61
  const unwrapThenRewrite = withUnwrap(rewrite);
62
62
 
63
- return tryEndpoints(requestBody, body.stream, headers, action, unwrapThenRewrite);
63
+ return tryEndpoints(requestBody, body.stream, headers, action, unwrapThenRewrite, creds?.email);
64
64
  },
65
65
  };
66
66
 
@@ -70,13 +70,14 @@ async function tryEndpoints(
70
70
  headers: Record<string, string>,
71
71
  action: string,
72
72
  rewrite?: (data: string) => string,
73
+ email?: string,
73
74
  ): Promise<Response> {
74
75
  let lastError: Error | null = null;
75
76
 
76
77
  for (const endpoint of endpoints) {
77
78
  const url = buildUrl(endpoint, action);
78
79
  try {
79
- const response = await forward({ url, body, streaming, headers, providerName: "Antigravity", rewrite });
80
+ const response = await forward({ url, body, streaming, headers, providerName: "Antigravity", rewrite, email });
80
81
  if (response.status < 500) return response;
81
82
  lastError = new Error(`${endpoint} returned ${response.status}`);
82
83
  logger.debug("Endpoint 5xx, trying next", { provider: "Antigravity" });
@@ -26,6 +26,7 @@ interface ForwardOptions {
26
26
  headers: Record<string, string>;
27
27
  providerName: string;
28
28
  rewrite?: (data: string) => string;
29
+ email?: string;
29
30
  }
30
31
 
31
32
  const RETRYABLE_STATUS = new Set([408, 500, 502, 503, 504]);
@@ -64,7 +65,8 @@ export async function forward(opts: ForwardOptions): Promise<Response> {
64
65
 
65
66
  if (!response.ok) {
66
67
  const text = await response.text();
67
- logger.error(`${opts.providerName} API error (${response.status})`, { error: text.slice(0, 200) });
68
+ const ctx = opts.email ? ` account=${opts.email}` : "";
69
+ logger.error(`${opts.providerName} API error (${response.status})${ctx}`, { error: text.slice(0, 200) });
68
70
  return new Response(text, { status: response.status, headers: { "Content-Type": contentType } });
69
71
  }
70
72
 
@@ -41,6 +41,7 @@ export const provider: Provider = {
41
41
  providerName: "OpenAI Codex",
42
42
  // Skip generic rewrite when we need full response transform
43
43
  rewrite: needsResponseTransform ? undefined : rewrite,
44
+ email: store.get("codex", account)?.email,
44
45
  headers: {
45
46
  "Content-Type": "application/json",
46
47
  Authorization: `Bearer ${accessToken}`,
@@ -149,6 +150,7 @@ function transformForCodex(
149
150
  // Remove fields the Codex backend doesn't accept
150
151
  delete parsed.max_tokens;
151
152
  delete parsed.max_completion_tokens;
153
+ delete parsed.max_output_tokens;
152
154
  // Chat Completions fields not in Responses API
153
155
  delete parsed.frequency_penalty;
154
156
  delete parsed.logprobs;
@@ -67,6 +67,7 @@ export const provider: Provider = {
67
67
  headers,
68
68
  providerName: "Gemini CLI",
69
69
  rewrite: unwrapThenRewrite,
70
+ email: store.get("google", account)?.email,
70
71
  });
71
72
  },
72
73
  };
@@ -12,6 +12,8 @@ interface CooldownEntry {
12
12
  }
13
13
 
14
14
  /** When detected as exhausted, cooldown for this long. */
15
+ /** 403 = account disabled/revoked — long cooldown. */
16
+ const FORBIDDEN_COOLDOWN_MS = 24 * 3600_000;
15
17
  const EXHAUSTED_COOLDOWN_MS = 2 * 3600_000;
16
18
  /** Retry-After threshold (seconds) above which we consider quota exhausted. */
17
19
  const EXHAUSTED_THRESHOLD_S = 300;
@@ -65,6 +67,13 @@ export class CooldownTracker {
65
67
  this.entries.set(k, entry);
66
68
  }
67
69
 
70
+ /** 403 = account forbidden/revoked. Immediately disable for 24h. */
71
+ record403(pool: QuotaPool, account: number): void {
72
+ const k = this.key(pool, account);
73
+ this.entries.set(k, { until: Date.now() + FORBIDDEN_COOLDOWN_MS, exhausted: true, consecutive429: 0 });
74
+ logger.warn(`Account disabled (403): ${k}`, { cooldownHours: FORBIDDEN_COOLDOWN_MS / 3600_000 });
75
+ }
76
+
68
77
  recordSuccess(pool: QuotaPool, account: number): void {
69
78
  this.entries.delete(this.key(pool, account));
70
79
  }
@@ -5,6 +5,7 @@ import type { ProxyConfig } from "../config/config.ts";
5
5
  import * as rewriter from "../proxy/rewriter.ts";
6
6
  import * as upstream from "../proxy/upstream.ts";
7
7
  import { affinity } from "../routing/affinity.ts";
8
+ import { cooldown } from "../routing/cooldown.ts";
8
9
  import { tryReroute, tryWithCachePreserve } from "../routing/retry.ts";
9
10
  import { recordSuccess, routeRequest } from "../routing/router.ts";
10
11
  import { handleInternal, isLocalMethod } from "../tools/internal.ts";
@@ -31,7 +32,7 @@ export function startServer(config: ProxyConfig): ReturnType<typeof Bun.serve> {
31
32
  logger.error("Unhandled server error", { error: String(err) });
32
33
  return Response.json({ error: "Internal proxy error" }, { status });
33
34
  } finally {
34
- logger.info(`${req.method} ${url.pathname} ${status}`, { duration: Date.now() - startTime });
35
+ logger.info(`${req.method} ${url.pathname}${url.search} ${status}`, { duration: Date.now() - startTime });
35
36
  }
36
37
  },
37
38
  });
@@ -120,6 +121,10 @@ async function handleProvider(
120
121
  );
121
122
  response = rerouted ?? (await fallbackUpstream(req, body, config));
122
123
  }
124
+ } else if (handlerResponse.status === 403 && route.pool) {
125
+ cooldown.record403(route.pool, route.account);
126
+ if (threadId) affinity.clear(threadId, providerName);
127
+ response = await fallbackUpstream(req, body, config);
123
128
  } else if (handlerResponse.status === 401) {
124
129
  logger.debug("Local provider denied, falling back to upstream");
125
130
  response = await fallbackUpstream(req, body, config);