ampcode-connector 0.1.19 → 0.1.21

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.19",
3
+ "version": "0.1.21",
4
4
  "description": "Proxy AmpCode through local OAuth subscriptions (Claude Code, Codex, Gemini CLI, Antigravity)",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/cli/status.ts CHANGED
@@ -21,13 +21,13 @@ export interface ProviderStatus {
21
21
  const PROVIDERS: { name: ProviderName; label: string; sublabel?: string }[] = [
22
22
  { name: "anthropic", label: "Claude Code" },
23
23
  { name: "codex", label: "OpenAI Codex" },
24
- { name: "google", label: "Google", sublabel: "Gemini CLI + Antigravity" },
24
+ { name: "google", label: "Google" },
25
25
  ];
26
26
 
27
27
  const POOL_MAP: Record<ProviderName, QuotaPool[]> = {
28
28
  anthropic: ["anthropic"],
29
29
  codex: ["codex"],
30
- google: ["gemini", "antigravity"],
30
+ google: ["google"],
31
31
  };
32
32
 
33
33
  function connectionOf(name: ProviderName, account: number, creds: Credentials): ConnectionStatus {
package/src/constants.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /** Single source of truth — no magic strings scattered across files. */
2
2
 
3
3
  export const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
4
- export const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
4
+ export const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
5
+ export const ANTIGRAVITY_DAILY_SANDBOX_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
5
6
  export const AUTOPUSH_ENDPOINT = "https://autopush-cloudcode-pa.sandbox.googleapis.com";
6
7
  export const DEFAULT_ANTIGRAVITY_PROJECT = "rising-fact-p41fc";
7
8
 
8
9
  export const ANTHROPIC_API_URL = "https://api.anthropic.com";
9
- export const OPENAI_API_URL = "https://api.openai.com";
10
10
  export const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
11
11
 
12
12
  /** Codex-specific headers required by the ChatGPT backend. */
@@ -18,11 +18,13 @@ export const codexHeaders = {
18
18
  CONVERSATION_ID: "conversation_id",
19
19
  } as const;
20
20
 
21
+ export const CODEX_CLI_VERSION = "0.101.0";
22
+
21
23
  export const codexHeaderValues = {
22
24
  BETA_RESPONSES: "responses=experimental",
23
25
  ORIGINATOR: "codex_cli_rs",
24
- VERSION: "0.101.0",
25
- USER_AGENT: `codex_cli_rs/0.101.0 (${process.platform} ${process.arch})`,
26
+ VERSION: CODEX_CLI_VERSION,
27
+ USER_AGENT: `codex_cli_rs/${CODEX_CLI_VERSION} (${process.platform} ${process.arch})`,
26
28
  } as const;
27
29
 
28
30
  /** Map Amp CLI paths → ChatGPT backend paths.
@@ -40,18 +42,6 @@ export const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
40
42
  export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
41
43
  export const CLAUDE_CODE_VERSION = "2.1.77";
42
44
 
43
- export const stainlessHeaders: Readonly<Record<string, string>> = {
44
- "X-Stainless-Helper-Method": "stream",
45
- "X-Stainless-Retry-Count": "0",
46
- "X-Stainless-Runtime-Version": "v24.3.0",
47
- "X-Stainless-Package-Version": "0.74.0",
48
- "X-Stainless-Runtime": "node",
49
- "X-Stainless-Lang": "js",
50
- "X-Stainless-Arch": process.arch,
51
- "X-Stainless-Os": process.platform === "darwin" ? "MacOS" : process.platform === "win32" ? "Windows" : "Linux",
52
- "X-Stainless-Timeout": "600",
53
- };
54
-
55
45
  export const claudeCodeBetas = [
56
46
  "claude-code-20250219",
57
47
  "oauth-2025-04-20",
@@ -1,6 +1,7 @@
1
1
  /** HTTP forwarding with transport-level retry, SSE proxying, and response rewriting. */
2
2
 
3
3
  import { logger } from "../utils/logger.ts";
4
+ import { apiError } from "../utils/responses.ts";
4
5
  import * as sse from "../utils/streaming.ts";
5
6
 
6
7
  export interface ForwardOptions {
@@ -17,6 +18,24 @@ const RETRYABLE_STATUS = new Set([408, 500, 502, 503, 504]);
17
18
  const MAX_RETRIES = 3;
18
19
  const RETRY_DELAY_MS = 500;
19
20
 
21
+ const PASSTHROUGH_HEADERS = [
22
+ "content-type",
23
+ "retry-after",
24
+ "x-request-id",
25
+ "x-ratelimit-limit-requests",
26
+ "x-ratelimit-remaining-requests",
27
+ "x-ratelimit-reset-requests",
28
+ ];
29
+
30
+ function copyHeaders(source: Headers): Headers {
31
+ const dest = new Headers();
32
+ for (const name of PASSTHROUGH_HEADERS) {
33
+ const value = source.get(name);
34
+ if (value !== null) dest.set(name, value);
35
+ }
36
+ return dest;
37
+ }
38
+
20
39
  export async function forward(opts: ForwardOptions): Promise<Response> {
21
40
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
22
41
  let response: Response;
@@ -34,7 +53,7 @@ export async function forward(opts: ForwardOptions): Promise<Response> {
34
53
  await Bun.sleep(RETRY_DELAY_MS * (attempt + 1));
35
54
  continue;
36
55
  }
37
- throw err;
56
+ return transportErrorResponse(opts.providerName, err);
38
57
  }
39
58
 
40
59
  // Retry on server errors (429 handled at routing layer)
@@ -70,21 +89,25 @@ export async function forward(opts: ForwardOptions): Promise<Response> {
70
89
  });
71
90
  }
72
91
 
92
+ const headers = copyHeaders(response.headers);
93
+ headers.set("Content-Type", "application/json");
73
94
  return new Response(errorBody, {
74
95
  status: response.status,
75
- headers: { "Content-Type": "application/json" },
96
+ headers,
76
97
  });
77
98
  }
78
99
 
79
100
  const isSSE = contentType.includes("text/event-stream") || opts.streaming;
80
101
  if (isSSE) return sse.proxy(response, opts.rewrite);
81
102
 
103
+ const headers = copyHeaders(response.headers);
104
+
82
105
  if (opts.rewrite) {
83
106
  const text = await response.text();
84
- return new Response(opts.rewrite(text), { status: response.status, headers: { "Content-Type": contentType } });
107
+ return new Response(opts.rewrite(text), { status: response.status, headers });
85
108
  }
86
109
 
87
- return new Response(response.body, { status: response.status, headers: { "Content-Type": contentType } });
110
+ return new Response(response.body, { status: response.status, headers });
88
111
  }
89
112
 
90
113
  // Unreachable, but TypeScript needs it
@@ -92,5 +115,32 @@ export async function forward(opts: ForwardOptions): Promise<Response> {
92
115
  }
93
116
 
94
117
  export function denied(providerName: string): Response {
95
- return Response.json({ error: `No ${providerName} OAuth token available. Run login first.` }, { status: 401 });
118
+ return apiError(401, `No ${providerName} OAuth token available. Run login first.`);
119
+ }
120
+
121
+ function transportErrorResponse(providerName: string, err: unknown): Response {
122
+ const message = transportErrorMessage(providerName, err);
123
+ logger.error(`${providerName} transport error after retries exhausted`, { error: String(err) });
124
+ return apiError(502, message, "connection_error");
125
+ }
126
+
127
+ function transportErrorMessage(providerName: string, err: unknown): string {
128
+ const base = `${providerName} connection error after retries were exhausted.`;
129
+ const details = String(err);
130
+
131
+ if (providerName !== "Anthropic") {
132
+ return `${base} ${details}`;
133
+ }
134
+
135
+ const looksLikeReset =
136
+ details.includes("ECONNRESET") ||
137
+ details.includes("socket connection was closed unexpectedly") ||
138
+ details.includes("tls") ||
139
+ details.includes("network");
140
+
141
+ if (!looksLikeReset) {
142
+ return `${base} ${details}`;
143
+ }
144
+
145
+ return `${base} ${details} This is often a local network issue rather than an OAuth bug: check Wi-Fi MTU (1492 is a common fix), hotspot stability, and iPhone dual-SIM Cellular Data Switching if you are tethering.`;
96
146
  }
@@ -0,0 +1,324 @@
1
+ /** Unified Google provider — merges Gemini CLI and Antigravity strategies
2
+ * with internal fallback. Tries preferred strategy first, then falls back. */
3
+
4
+ import { google as config } from "../auth/configs.ts";
5
+ import * as oauth from "../auth/oauth.ts";
6
+ import * as store from "../auth/store.ts";
7
+ import { ANTIGRAVITY_DAILY_ENDPOINT, ANTIGRAVITY_DAILY_SANDBOX_ENDPOINT, CODE_ASSIST_ENDPOINT } from "../constants.ts";
8
+ import { buildUrl, maybeWrap, withUnwrap } from "../utils/code-assist.ts";
9
+ import { logger } from "../utils/logger.ts";
10
+ import * as path from "../utils/path.ts";
11
+ import { apiError } from "../utils/responses.ts";
12
+ import type { Provider } from "./base.ts";
13
+ import { denied, forward } from "./forward.ts";
14
+
15
+ const GOOGLE_CLIENT_METADATA = JSON.stringify({
16
+ ideType: "IDE_UNSPECIFIED",
17
+ platform: "PLATFORM_UNSPECIFIED",
18
+ pluginType: "GEMINI",
19
+ });
20
+
21
+ interface GoogleStrategy {
22
+ name: string;
23
+ headers: Readonly<Record<string, string>>;
24
+ endpoints: readonly string[];
25
+ modelMapper?: (model: string) => string;
26
+ wrapOpts: {
27
+ userAgent: "antigravity" | "pi-coding-agent";
28
+ requestIdPrefix: "agent" | "pi";
29
+ requestType?: "agent" | "image_gen";
30
+ };
31
+ }
32
+
33
+ const geminiStrategy: GoogleStrategy = {
34
+ name: "gemini",
35
+ headers: {
36
+ "User-Agent": "google-cloud-sdk vscode_cloudshelleditor/0.1",
37
+ "X-Goog-Api-Client": "gl-node/22.17.0",
38
+ "Client-Metadata": GOOGLE_CLIENT_METADATA,
39
+ },
40
+ endpoints: [CODE_ASSIST_ENDPOINT],
41
+ wrapOpts: {
42
+ userAgent: "pi-coding-agent",
43
+ requestIdPrefix: "pi",
44
+ },
45
+ };
46
+
47
+ /** Antigravity uses different model names than what Amp CLI sends. */
48
+ const antigravityModelMap: Record<string, string> = {
49
+ "gemini-3-flash-preview": "gemini-3-flash",
50
+ "gemini-3-pro-preview": "gemini-3-pro-high",
51
+ "gemini-3-pro-image-preview": "gemini-3.1-flash-image",
52
+ "gemini-3.1-flash-image-preview": "gemini-3.1-flash-image",
53
+ };
54
+
55
+ const antigravityStrategy: GoogleStrategy = {
56
+ name: "antigravity",
57
+ headers: {
58
+ "User-Agent": "antigravity/1.104.0 darwin/arm64",
59
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
60
+ "Client-Metadata": GOOGLE_CLIENT_METADATA,
61
+ },
62
+ endpoints: [ANTIGRAVITY_DAILY_ENDPOINT, ANTIGRAVITY_DAILY_SANDBOX_ENDPOINT, CODE_ASSIST_ENDPOINT],
63
+ modelMapper: (model: string) => antigravityModelMap[model] ?? model,
64
+ wrapOpts: {
65
+ userAgent: "antigravity",
66
+ requestIdPrefix: "agent",
67
+ requestType: "agent",
68
+ },
69
+ };
70
+
71
+ const strategies: readonly GoogleStrategy[] = [geminiStrategy, antigravityStrategy];
72
+
73
+ /** Models that only work on the antigravity strategy. */
74
+ const ANTIGRAVITY_ONLY_MODELS = new Set(["gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview"]);
75
+
76
+ const COOLDOWN_MS = 60_000;
77
+
78
+ interface StrategyPreference {
79
+ strategy: GoogleStrategy;
80
+ until: number;
81
+ }
82
+
83
+ // Per-account strategy preference: after success, prefer that strategy;
84
+ // after failure, skip it for COOLDOWN_MS.
85
+ const preferredStrategy = new Map<number, StrategyPreference>();
86
+ const cooldowns = new Map<string, number>(); // key: `${account}:${strategy.name}`
87
+
88
+ function cooldownKey(account: number, strategy: GoogleStrategy): string {
89
+ return `${account}:${strategy.name}`;
90
+ }
91
+
92
+ function getOrderedStrategies(account: number, model?: string): GoogleStrategy[] {
93
+ const now = Date.now();
94
+
95
+ // Some models only work on antigravity — skip other strategies entirely
96
+ if (model && ANTIGRAVITY_ONLY_MODELS.has(model)) {
97
+ const cd = cooldowns.get(cooldownKey(account, antigravityStrategy));
98
+ if (cd && cd > now) return [];
99
+ return [antigravityStrategy];
100
+ }
101
+
102
+ const pref = preferredStrategy.get(account);
103
+ const ordered =
104
+ pref && pref.until > now ? [pref.strategy, ...strategies.filter((s) => s !== pref.strategy)] : [...strategies];
105
+
106
+ return ordered.filter((s) => {
107
+ const cd = cooldowns.get(cooldownKey(account, s));
108
+ return !cd || cd <= now;
109
+ });
110
+ }
111
+
112
+ function markSuccess(account: number, strategy: GoogleStrategy): void {
113
+ preferredStrategy.set(account, { strategy, until: Date.now() + COOLDOWN_MS * 10 });
114
+ cooldowns.delete(cooldownKey(account, strategy));
115
+ }
116
+
117
+ function markFailure(account: number, strategy: GoogleStrategy): void {
118
+ cooldowns.set(cooldownKey(account, strategy), Date.now() + COOLDOWN_MS);
119
+ const pref = preferredStrategy.get(account);
120
+ if (pref?.strategy === strategy) {
121
+ preferredStrategy.delete(account);
122
+ }
123
+ }
124
+
125
+ /** Buffer an SSE response and merge all chunks into a single JSON response.
126
+ * Used when we force streamGenerateContent but the client expects non-streaming JSON.
127
+ * Accumulates all candidate parts across chunks (image inlineData may be in earlier chunks). */
128
+ async function bufferSSEToJSON(response: Response): Promise<Response | null> {
129
+ const text = await response.text();
130
+ const chunks: Record<string, unknown>[] = [];
131
+
132
+ for (const line of text.split("\n")) {
133
+ if (!line.startsWith("data: ")) continue;
134
+ const data = line.slice(6).trim();
135
+ if (!data || data === "[DONE]") continue;
136
+ try {
137
+ chunks.push(JSON.parse(data) as Record<string, unknown>);
138
+ } catch {
139
+ // skip non-JSON
140
+ }
141
+ }
142
+
143
+ if (chunks.length === 0) return null;
144
+ if (chunks.length === 1) {
145
+ return new Response(JSON.stringify(chunks[0]), {
146
+ status: 200,
147
+ headers: { "Content-Type": "application/json" },
148
+ });
149
+ }
150
+
151
+ // Merge: accumulate parts from all chunks into the first candidate
152
+ const merged = chunks[0]! as Record<string, unknown>;
153
+ const allParts: unknown[] = [];
154
+
155
+ for (const chunk of chunks) {
156
+ const candidates = chunk.candidates as { content?: { parts?: unknown[] } }[] | undefined;
157
+ if (!candidates) continue;
158
+ for (const candidate of candidates) {
159
+ if (candidate.content?.parts) {
160
+ allParts.push(...candidate.content.parts);
161
+ }
162
+ }
163
+ }
164
+
165
+ // Use last chunk's metadata (finishReason, usageMetadata)
166
+ const last = chunks[chunks.length - 1]! as Record<string, unknown>;
167
+ const lastCandidates = last.candidates as Record<string, unknown>[] | undefined;
168
+ const mergedCandidates = merged.candidates as Record<string, unknown>[] | undefined;
169
+
170
+ if (mergedCandidates?.[0] && allParts.length > 0) {
171
+ const content = (mergedCandidates[0] as Record<string, unknown>).content as Record<string, unknown> | undefined;
172
+ if (content) content.parts = allParts;
173
+ if (lastCandidates?.[0]) {
174
+ (mergedCandidates[0] as Record<string, unknown>).finishReason = (
175
+ lastCandidates[0] as Record<string, unknown>
176
+ ).finishReason;
177
+ }
178
+ }
179
+ if (last.usageMetadata) merged.usageMetadata = last.usageMetadata;
180
+
181
+ return new Response(JSON.stringify(merged), {
182
+ status: 200,
183
+ headers: { "Content-Type": "application/json" },
184
+ });
185
+ }
186
+
187
+ /** Generate a fallback project ID when none is stored (matches CLIProxyAPI behavior). */
188
+ function generateProjectId(): string {
189
+ const adjectives = ["useful", "bright", "swift", "calm", "bold"];
190
+ const nouns = ["fuze", "wave", "spark", "flow", "core"];
191
+ const adj = adjectives[Math.floor(Math.random() * adjectives.length)]!;
192
+ const noun = nouns[Math.floor(Math.random() * nouns.length)]!;
193
+ const rand = crypto.randomUUID().slice(0, 5).toLowerCase();
194
+ return `${adj}-${noun}-${rand}`;
195
+ }
196
+
197
+ export const provider: Provider = {
198
+ name: "Google",
199
+ routeDecision: "LOCAL_GOOGLE",
200
+
201
+ isAvailable: (account?: number) =>
202
+ account !== undefined ? !!store.get("google", account)?.refreshToken : oauth.ready(config),
203
+
204
+ accountCount: () => oauth.accountCount(config),
205
+
206
+ async forward(sub, body, _originalHeaders, rewrite, account = 0) {
207
+ const accessToken = await oauth.token(config, account);
208
+ if (!accessToken) return denied("Google");
209
+
210
+ const creds = store.get("google", account);
211
+ const projectId = creds?.projectId || generateProjectId();
212
+ const email = creds?.email;
213
+
214
+ const modelAction = path.googleModel(sub);
215
+ if (!modelAction) {
216
+ logger.debug(`Non-model Google path, cannot route to CCA: ${sub}`);
217
+ return denied("Google (unsupported path)");
218
+ }
219
+
220
+ const unwrapThenRewrite = withUnwrap(rewrite);
221
+ const orderedStrategies = getOrderedStrategies(account, modelAction.model);
222
+
223
+ if (orderedStrategies.length === 0) {
224
+ const now = Date.now();
225
+ let minWait = COOLDOWN_MS;
226
+ for (const s of strategies) {
227
+ const until = cooldowns.get(cooldownKey(account, s));
228
+ if (until) minWait = Math.min(minWait, Math.max(0, until - now));
229
+ }
230
+ const retryAfterS = Math.ceil(minWait / 1000);
231
+ return new Response(
232
+ JSON.stringify({
233
+ error: { message: "All Google strategies cooling down", type: "rate_limit_error", code: "429" },
234
+ }),
235
+ { status: 429, headers: { "Content-Type": "application/json", "Retry-After": String(retryAfterS) } },
236
+ );
237
+ }
238
+
239
+ let saw429 = false;
240
+ let lastResponse: Response | null = null;
241
+
242
+ for (const strategy of orderedStrategies) {
243
+ const model = strategy.modelMapper ? strategy.modelMapper(modelAction.model) : modelAction.model;
244
+ const isImageModel = model.includes("image");
245
+ const wrapOpts = isImageModel ? { ...strategy.wrapOpts, requestType: "image_gen" as const } : strategy.wrapOpts;
246
+ const requestBody = maybeWrap(body.parsed, body.forwardBody, projectId, model, wrapOpts);
247
+
248
+ const headers: Record<string, string> = {
249
+ ...strategy.headers,
250
+ Authorization: `Bearer ${accessToken}`,
251
+ "Content-Type": "application/json",
252
+ Accept: body.stream ? "text/event-stream" : "application/json",
253
+ };
254
+
255
+ logger.info(`Google strategy=${strategy.name} account=${account} model=${model}`);
256
+
257
+ // Antigravity forces streaming endpoint for gemini-3-pro* and image models (matches CLIProxyAPI behavior)
258
+ const forceStream = strategy.name === "antigravity" && (model.startsWith("gemini-3-pro") || isImageModel);
259
+ const action = forceStream ? "streamGenerateContent" : modelAction.action;
260
+
261
+ for (const endpoint of strategy.endpoints) {
262
+ const url = buildUrl(endpoint, action);
263
+ try {
264
+ const forceStreamNonStreaming = forceStream && !body.stream;
265
+ const response = await forward({
266
+ url,
267
+ body: requestBody,
268
+ streaming: forceStreamNonStreaming ? true : body.stream,
269
+ headers,
270
+ providerName: `Google/${strategy.name}`,
271
+ rewrite: unwrapThenRewrite,
272
+ email,
273
+ });
274
+
275
+ // When we forced streaming but client expects JSON, buffer SSE and return last chunk
276
+ if (forceStreamNonStreaming && response.ok && response.body) {
277
+ const merged = await bufferSSEToJSON(response);
278
+ if (merged) {
279
+ markSuccess(account, strategy);
280
+ return merged;
281
+ }
282
+ }
283
+
284
+ if (response.status === 401 || response.status === 403) {
285
+ return response;
286
+ }
287
+
288
+ if (response.status === 404) {
289
+ lastResponse = response;
290
+ logger.debug(`Google strategy=${strategy.name} model not found (404), trying next endpoint`);
291
+ continue;
292
+ }
293
+
294
+ if (response.status === 429) {
295
+ saw429 = true;
296
+ lastResponse = response;
297
+ logger.debug(`Google strategy=${strategy.name} failed (${response.status}), trying next`);
298
+ break;
299
+ }
300
+
301
+ if (response.status >= 500) {
302
+ lastResponse = response;
303
+ logger.debug(`Google strategy=${strategy.name} failed (${response.status}), trying next`);
304
+ continue;
305
+ }
306
+
307
+ markSuccess(account, strategy);
308
+ return response;
309
+ } catch (err) {
310
+ lastResponse = apiError(502, `Google/${strategy.name} endpoint failed: ${String(err)}`);
311
+ logger.debug(`Google strategy=${strategy.name} endpoint error`, { error: String(err) });
312
+ }
313
+ }
314
+
315
+ markFailure(account, strategy);
316
+ }
317
+
318
+ if (saw429) {
319
+ return lastResponse ?? apiError(429, "All Google strategies rate limited", "rate_limit_error");
320
+ }
321
+
322
+ return lastResponse ?? apiError(502, "All Google strategies exhausted");
323
+ },
324
+ };
@@ -8,7 +8,7 @@ import type { QuotaPool } from "./cooldown.ts";
8
8
  interface AffinityEntry {
9
9
  pool: QuotaPool;
10
10
  account: number;
11
- assignedAt: number;
11
+ lastUsedAt: number;
12
12
  }
13
13
 
14
14
  /** Affinity expires after 2 hours of inactivity. */
@@ -51,7 +51,7 @@ class AffinityStore {
51
51
  const k = this.key(threadId, ampProvider);
52
52
  const entry = this.map.get(k);
53
53
  if (!entry) return undefined;
54
- if (Date.now() - entry.assignedAt > TTL_MS) {
54
+ if (Date.now() - entry.lastUsedAt > TTL_MS) {
55
55
  this.removeExpired(k, entry);
56
56
  return undefined;
57
57
  }
@@ -61,7 +61,7 @@ class AffinityStore {
61
61
  /** Read affinity and touch (extend TTL). */
62
62
  get(threadId: string, ampProvider: string): AffinityEntry | undefined {
63
63
  const entry = this.peek(threadId, ampProvider);
64
- if (entry) entry.assignedAt = Date.now();
64
+ if (entry) entry.lastUsedAt = Date.now();
65
65
  return entry;
66
66
  }
67
67
 
@@ -76,7 +76,7 @@ class AffinityStore {
76
76
  } else {
77
77
  this.incCount(pool, account);
78
78
  }
79
- this.map.set(k, { pool, account, assignedAt: Date.now() });
79
+ this.map.set(k, { pool, account, lastUsedAt: Date.now() });
80
80
  }
81
81
 
82
82
  /** Break affinity when account is exhausted — allow re-routing. */
@@ -100,7 +100,7 @@ class AffinityStore {
100
100
  this.cleanupTimer = setInterval(() => {
101
101
  const now = Date.now();
102
102
  for (const [k, entry] of this.map) {
103
- if (now - entry.assignedAt > TTL_MS) {
103
+ if (now - entry.lastUsedAt > TTL_MS) {
104
104
  this.removeExpired(k, entry);
105
105
  }
106
106
  }
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { logger } from "../utils/logger.ts";
5
5
 
6
- export type QuotaPool = "anthropic" | "codex" | "gemini" | "antigravity";
6
+ export type QuotaPool = "anthropic" | "codex" | "google";
7
7
 
8
8
  interface CooldownEntry {
9
9
  until: number;
@@ -74,6 +74,22 @@ class CooldownTracker {
74
74
  logger.warn(`Account disabled (403): ${k}`, { cooldownHours: FORBIDDEN_COOLDOWN_MS / 3600_000 });
75
75
  }
76
76
 
77
+ /** Return shortest remaining wait (ms) among non-exhausted entries for the given candidates.
78
+ * Returns undefined if no candidates are cooling down or all are exhausted. */
79
+ shortestBurstWait(candidates: { pool: QuotaPool; account: number }[]): number | undefined {
80
+ let shortest: number | undefined;
81
+ const now = Date.now();
82
+ for (const c of candidates) {
83
+ const entry = this.entries.get(this.key(c.pool, c.account));
84
+ if (!entry || entry.exhausted) continue;
85
+ const remaining = entry.until - now;
86
+ if (remaining > 0 && (shortest === undefined || remaining < shortest)) {
87
+ shortest = remaining;
88
+ }
89
+ }
90
+ return shortest;
91
+ }
92
+
77
93
  recordSuccess(pool: QuotaPool, account: number): void {
78
94
  this.entries.delete(this.key(pool, account));
79
95
  }
@@ -4,12 +4,14 @@ import type { ProxyConfig } from "../config/config.ts";
4
4
  import type { ParsedBody } from "../server/body.ts";
5
5
  import { logger } from "../utils/logger.ts";
6
6
  import { cooldown, parseRetryAfter, type QuotaPool } from "./cooldown.ts";
7
- import { type RouteResult, recordSuccess, reroute } from "./router.ts";
7
+ import { buildCandidates, type RouteResult, recordSuccess, reroute } from "./router.ts";
8
8
 
9
9
  /** Max reroute attempts before falling back to upstream. */
10
10
  const MAX_REROUTE_ATTEMPTS = 4;
11
11
  /** Max seconds to wait-and-retry on the same account (preserves prompt cache). */
12
12
  const CACHE_PRESERVE_WAIT_MAX_S = 10;
13
+ /** Max ms to wait when all candidates are burst-cooling before giving up. */
14
+ const BURST_WAIT_MAX_MS = 30_000;
13
15
 
14
16
  /** Status codes that trigger rerouting to a different account/pool. */
15
17
  const REROUTABLE_STATUSES = new Set([429, 403]);
@@ -41,7 +43,7 @@ export async function tryWithCachePreserve(
41
43
  await Bun.sleep(retryAfter * 1000);
42
44
  const response = await route.handler!.forward(sub, body, headers, rewrite, route.account);
43
45
 
44
- if (response.status !== 429 && response.status !== 401) {
46
+ if (response.status !== 429 && response.status !== 403 && response.status !== 401) {
45
47
  recordSuccess(route.pool!, route.account);
46
48
  return response;
47
49
  }
@@ -49,6 +51,9 @@ export async function tryWithCachePreserve(
49
51
  const nextRetryAfter = parseRetryAfter(response.headers.get("retry-after"));
50
52
  cooldown.record429(route.pool!, route.account, nextRetryAfter);
51
53
  }
54
+ if (response.status === 403) {
55
+ cooldown.record403(route.pool!, route.account);
56
+ }
52
57
  return null;
53
58
  }
54
59
 
@@ -64,7 +69,19 @@ export async function tryReroute(
64
69
  let currentAccount = initialRoute.account;
65
70
 
66
71
  for (let attempt = 0; attempt < MAX_REROUTE_ATTEMPTS; attempt++) {
67
- const next = reroute(ctx.providerName, ctx.ampModel, ctx.config, currentPool, currentAccount, ctx.threadId);
72
+ let next = reroute(ctx.providerName, ctx.ampModel, ctx.config, currentPool, currentAccount, ctx.threadId);
73
+
74
+ // All candidates cooling down — wait for shortest burst then retry
75
+ if (!next?.handler) {
76
+ const candidates = buildCandidates(ctx.providerName, ctx.config);
77
+ const waitMs = cooldown.shortestBurstWait(candidates);
78
+ if (waitMs && waitMs <= BURST_WAIT_MAX_MS) {
79
+ logger.info(`All accounts cooling, waiting ${Math.ceil(waitMs / 1000)}s for burst cooldown`);
80
+ await Bun.sleep(waitMs + 100); // small buffer
81
+ next = reroute(ctx.providerName, ctx.ampModel, ctx.config, currentPool, currentAccount, ctx.threadId);
82
+ }
83
+ }
84
+
68
85
  if (!next?.handler) break;
69
86
 
70
87
  logger.info(`REROUTE (${status}) -> ${next.decision} account=${next.account}`);
@@ -5,17 +5,16 @@
5
5
  * - Thread affinity: same thread sticks to same account
6
6
  * - Cooldown: skip 429'd accounts, detect quota exhaustion
7
7
  * - Least-connections: prefer accounts with fewer active threads
8
- * - Google cascade: gemini accounts antigravity accounts (separate quotas)
8
+ * - Google: single pool with internal strategy fallback (gemini/antigravity)
9
9
  */
10
10
 
11
11
  import type { ProviderName } from "../auth/store.ts";
12
12
  import * as store from "../auth/store.ts";
13
13
  import type { ProxyConfig } from "../config/config.ts";
14
14
  import { provider as anthropic } from "../providers/anthropic.ts";
15
- import { provider as antigravity } from "../providers/antigravity.ts";
16
15
  import type { Provider } from "../providers/base.ts";
17
16
  import { provider as codex } from "../providers/codex.ts";
18
- import { provider as gemini } from "../providers/gemini.ts";
17
+ import { provider as google } from "../providers/google.ts";
19
18
  import { logger, type RouteDecision } from "../utils/logger.ts";
20
19
  import { affinity } from "./affinity.ts";
21
20
  import { cooldown, type QuotaPool } from "./cooldown.ts";
@@ -46,10 +45,7 @@ const PROVIDER_REGISTRY = new Map<string, { configKey: keyof ProxyConfig["provid
46
45
  "google",
47
46
  {
48
47
  configKey: "google",
49
- entries: [
50
- { provider: gemini, pool: "gemini", credentialName: "google" },
51
- { provider: antigravity, pool: "antigravity", credentialName: "google" },
52
- ],
48
+ entries: [{ provider: google, pool: "google", credentialName: "google" }],
53
49
  },
54
50
  ],
55
51
  ]);
@@ -85,6 +81,13 @@ export function routeRequest(
85
81
  ): RouteResult {
86
82
  const modelStr = model ?? "unknown";
87
83
 
84
+ // Early exit if provider is disabled in config
85
+ const reg = PROVIDER_REGISTRY.get(ampProvider);
86
+ if (!reg || !config.providers[reg.configKey]) {
87
+ logger.route("AMP_UPSTREAM", ampProvider, modelStr);
88
+ return result(null, ampProvider, modelStr, 0, null);
89
+ }
90
+
88
91
  // Check thread affinity (keyed by threadId + ampProvider)
89
92
  if (threadId) {
90
93
  const pinned = affinity.get(threadId, ampProvider);
@@ -153,7 +156,7 @@ export function recordSuccess(pool: QuotaPool, account: number): void {
153
156
  cooldown.recordSuccess(pool, account);
154
157
  }
155
158
 
156
- function buildCandidates(ampProvider: string, config: ProxyConfig): Candidate[] {
159
+ export function buildCandidates(ampProvider: string, config: ProxyConfig): Candidate[] {
157
160
  const reg = PROVIDER_REGISTRY.get(ampProvider);
158
161
  if (!reg || !config.providers[reg.configKey]) return [];
159
162
 
@@ -183,18 +186,21 @@ function pickCandidate(candidates: Candidate[]): Candidate | null {
183
186
  if (available.length === 0) return null;
184
187
 
185
188
  // Pick the one with least active threads (least-connections)
186
- let best = available[0]!;
187
- let bestLoad = affinity.activeCount(best.pool, best.account);
189
+ // When multiple candidates have the same load, pick randomly for even distribution
190
+ let bestLoad = affinity.activeCount(available[0]!.pool, available[0]!.account);
191
+ let ties: Candidate[] = [available[0]!];
188
192
 
189
193
  for (let i = 1; i < available.length; i++) {
190
194
  const load = affinity.activeCount(available[i]!.pool, available[i]!.account);
191
195
  if (load < bestLoad) {
192
- best = available[i]!;
193
196
  bestLoad = load;
197
+ ties = [available[i]!];
198
+ } else if (load === bestLoad) {
199
+ ties.push(available[i]!);
194
200
  }
195
201
  }
196
202
 
197
- return best;
203
+ return ties[Math.floor(Math.random() * ties.length)]!;
198
204
  }
199
205
 
200
206
  function providerForPool(pool: QuotaPool): Provider | null {
@@ -3,7 +3,7 @@
3
3
  * Slow path: full JSON.parse only when .parsed or .forwardBody is accessed
4
4
  * (e.g. Google CCA wrapping, model rewrite). */
5
5
 
6
- import { resolveModel, rewriteBodyModel } from "../routing/models.ts";
6
+ import { resolveModel, rewriteBodyModel } from "../utils/models.ts";
7
7
  import * as path from "../utils/path.ts";
8
8
 
9
9
  export interface ParsedBody {
@@ -10,6 +10,7 @@ import { recordSuccess, routeRequest } from "../routing/router.ts";
10
10
  import { handleInternal, isLocalMethod } from "../tools/internal.ts";
11
11
  import { logger } from "../utils/logger.ts";
12
12
  import * as path from "../utils/path.ts";
13
+ import { apiError } from "../utils/responses.ts";
13
14
  import { stats } from "../utils/stats.ts";
14
15
  import { type ParsedBody, parseBody } from "./body.ts";
15
16
 
@@ -29,7 +30,7 @@ export function startServer(config: ProxyConfig): ReturnType<typeof Bun.serve> {
29
30
  return response;
30
31
  } catch (err) {
31
32
  logger.error("Unhandled server error", { error: String(err) });
32
- return Response.json({ error: "Internal proxy error" }, { status });
33
+ return apiError(status, "Internal proxy error");
33
34
  } finally {
34
35
  logger.info(`${req.method} ${url.pathname}${url.search} ${status}`, { duration: Date.now() - startTime });
35
36
  }
@@ -101,7 +102,10 @@ async function handleProvider(
101
102
  const rewrite = ampModel ? rewriter.rewrite(ampModel) : undefined;
102
103
  const handlerResponse = await route.handler.forward(sub, body, req.headers, rewrite, route.account);
103
104
 
104
- if ((handlerResponse.status === 429 || handlerResponse.status === 403) && route.pool) {
105
+ if (
106
+ (handlerResponse.status === 429 || handlerResponse.status === 403 || handlerResponse.status === 404) &&
107
+ route.pool
108
+ ) {
105
109
  const ctx = { providerName, ampModel, config, sub, body, headers: req.headers, rewrite, threadId };
106
110
  // 429: try short wait to preserve prompt cache first
107
111
  const cached =
@@ -6,18 +6,23 @@ interface WrapOptions {
6
6
  body: Record<string, unknown>;
7
7
  userAgent: "antigravity" | "pi-coding-agent";
8
8
  requestIdPrefix: "agent" | "pi";
9
- requestType?: "agent";
9
+ requestType?: "agent" | "image_gen";
10
10
  }
11
11
 
12
12
  /** Wrap a raw request body in the Cloud Code Assist envelope. */
13
13
  function wrapRequest(opts: WrapOptions): string {
14
+ const isImageGen = opts.requestType === "image_gen";
15
+ const requestId = isImageGen
16
+ ? `image_gen/${Date.now()}/${crypto.randomUUID()}/12`
17
+ : `${opts.requestIdPrefix}-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
18
+
14
19
  return JSON.stringify({
15
20
  project: opts.projectId,
16
21
  model: opts.model,
17
22
  request: opts.body,
18
23
  ...(opts.requestType && { requestType: opts.requestType }),
19
24
  userAgent: opts.userAgent,
20
- requestId: `${opts.requestIdPrefix}-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`,
25
+ requestId,
21
26
  });
22
27
  }
23
28
 
@@ -118,7 +123,11 @@ export function maybeWrap(
118
123
  raw: string,
119
124
  projectId: string,
120
125
  model: string,
121
- opts: { userAgent: "antigravity" | "pi-coding-agent"; requestIdPrefix: "agent" | "pi"; requestType?: "agent" },
126
+ opts: {
127
+ userAgent: "antigravity" | "pi-coding-agent";
128
+ requestIdPrefix: "agent" | "pi";
129
+ requestType?: "agent" | "image_gen";
130
+ },
122
131
  ): string {
123
132
  if (!parsed) return raw;
124
133
  if (parsed.project) return raw;
@@ -1,6 +1,6 @@
1
1
  /** Structured logging with route decision tracking. */
2
2
 
3
- export type RouteDecision = "LOCAL_CLAUDE" | "LOCAL_CODEX" | "LOCAL_GEMINI" | "LOCAL_ANTIGRAVITY" | "AMP_UPSTREAM";
3
+ export type RouteDecision = "LOCAL_CLAUDE" | "LOCAL_CODEX" | "LOCAL_GOOGLE" | "AMP_UPSTREAM";
4
4
 
5
5
  export type LogLevel = "debug" | "info" | "warn" | "error";
6
6
 
@@ -41,8 +41,7 @@ const LEVEL_COLORS: Record<LogLevel, string> = {
41
41
  const ROUTE_COLORS: Record<RouteDecision, string> = {
42
42
  LOCAL_CLAUDE: GREEN,
43
43
  LOCAL_CODEX: GREEN,
44
- LOCAL_GEMINI: GREEN,
45
- LOCAL_ANTIGRAVITY: GREEN,
44
+ LOCAL_GOOGLE: GREEN,
46
45
  AMP_UPSTREAM: YELLOW,
47
46
  };
48
47
 
package/src/utils/path.ts CHANGED
@@ -5,7 +5,7 @@ import { browserPrefixes, passthroughExact, passthroughPrefixes } from "../const
5
5
  const PROVIDER_RE = /^\/api\/provider\/([^/]+)/;
6
6
  const SUBPATH_RE = /^\/api\/provider\/[^/]+(\/.*)/;
7
7
  const MODEL_RE = /models\/([^/:]+)/;
8
- const GEMINI_RE = /models\/([^/:]+):(\w+)/;
8
+ const GOOGLE_MODEL_RE = /models\/([^/:]+):(\w+)/;
9
9
 
10
10
  export function passthrough(pathname: string): boolean {
11
11
  if ((passthroughExact as readonly string[]).includes(pathname)) return true;
@@ -13,8 +13,7 @@ export function passthrough(pathname: string): boolean {
13
13
  }
14
14
 
15
15
  export function browser(pathname: string): boolean {
16
- if ((passthroughExact as readonly string[]).includes(pathname)) return true;
17
- return browserPrefixes.some((prefix) => pathname.startsWith(prefix));
16
+ return browserPrefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
18
17
  }
19
18
 
20
19
  export function provider(pathname: string): string | null {
@@ -32,8 +31,8 @@ export function modelFromUrl(url: string): string | null {
32
31
  return match?.[1] ?? null;
33
32
  }
34
33
 
35
- export function gemini(url: string): { model: string; action: string } | null {
36
- const match = url.match(GEMINI_RE);
34
+ export function googleModel(url: string): { model: string; action: string } | null {
35
+ const match = url.match(GOOGLE_MODEL_RE);
37
36
  if (!match) return null;
38
37
  return { model: match[1]!, action: match[2]! };
39
38
  }
@@ -0,0 +1,3 @@
1
+ export function apiError(status: number, message: string, type = "api_error"): Response {
2
+ return Response.json({ error: { message, type, code: String(status) } }, { status });
3
+ }
@@ -1,87 +0,0 @@
1
- /** Forwards requests to Cloud Code Assist API using Antigravity quota.
2
- * Uses the shared Google OAuth token with Antigravity headers/endpoints.
3
- * Tries multiple endpoints with fallback on 5xx. */
4
-
5
- import { google as config } from "../auth/configs.ts";
6
- import * as oauth from "../auth/oauth.ts";
7
- import * as store from "../auth/store.ts";
8
- import { ANTIGRAVITY_DAILY_ENDPOINT, AUTOPUSH_ENDPOINT, CODE_ASSIST_ENDPOINT } from "../constants.ts";
9
- import { buildUrl, maybeWrap, withUnwrap } from "../utils/code-assist.ts";
10
- import { logger } from "../utils/logger.ts";
11
- import * as path from "../utils/path.ts";
12
- import type { Provider } from "./base.ts";
13
- import { denied, forward } from "./forward.ts";
14
-
15
- const endpoints = [ANTIGRAVITY_DAILY_ENDPOINT, AUTOPUSH_ENDPOINT, CODE_ASSIST_ENDPOINT];
16
-
17
- const antigravityHeaders: Readonly<Record<string, string>> = {
18
- "User-Agent": "antigravity/1.15.8 darwin/arm64",
19
- "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
20
- "Client-Metadata": JSON.stringify({
21
- ideType: "IDE_UNSPECIFIED",
22
- platform: "PLATFORM_UNSPECIFIED",
23
- pluginType: "GEMINI",
24
- }),
25
- };
26
-
27
- export const provider: Provider = {
28
- name: "Antigravity",
29
- routeDecision: "LOCAL_ANTIGRAVITY",
30
-
31
- isAvailable: (account?: number) =>
32
- account !== undefined ? !!store.get("google", account)?.refreshToken : oauth.ready(config),
33
-
34
- accountCount: () => oauth.accountCount(config),
35
-
36
- async forward(sub, body, _originalHeaders, rewrite, account = 0) {
37
- const accessToken = await oauth.token(config, account);
38
- if (!accessToken) return denied("Antigravity");
39
-
40
- const creds = store.get("google", account);
41
- const projectId = creds?.projectId ?? "";
42
-
43
- const headers: Record<string, string> = {
44
- ...antigravityHeaders,
45
- Authorization: `Bearer ${accessToken}`,
46
- "Content-Type": "application/json",
47
- Accept: body.stream ? "text/event-stream" : "application/json",
48
- };
49
- const gemini = path.gemini(sub);
50
- const action = gemini?.action ?? "generateContent";
51
- const model = gemini?.model === "gemini-3-flash-preview" ? "gemini-3-flash" : (gemini?.model ?? "");
52
- const requestBody = maybeWrap(body.parsed, body.forwardBody, projectId, model, {
53
- userAgent: "antigravity",
54
- requestIdPrefix: "agent",
55
- requestType: "agent",
56
- });
57
- const unwrapThenRewrite = withUnwrap(rewrite);
58
-
59
- return tryEndpoints(requestBody, body.stream, headers, action, unwrapThenRewrite, creds?.email);
60
- },
61
- };
62
-
63
- async function tryEndpoints(
64
- body: string,
65
- streaming: boolean,
66
- headers: Record<string, string>,
67
- action: string,
68
- rewrite?: (data: string) => string,
69
- email?: string,
70
- ): Promise<Response> {
71
- let lastError: Error | null = null;
72
-
73
- for (const endpoint of endpoints) {
74
- const url = buildUrl(endpoint, action);
75
- try {
76
- const response = await forward({ url, body, streaming, headers, providerName: "Antigravity", rewrite, email });
77
- if (response.status < 500) return response;
78
- lastError = new Error(`${endpoint} returned ${response.status}`);
79
- logger.debug("Endpoint 5xx, trying next", { provider: "Antigravity" });
80
- } catch (err) {
81
- lastError = err instanceof Error ? err : new Error(String(err));
82
- logger.debug("Endpoint failed, trying next", { error: String(err) });
83
- }
84
- }
85
-
86
- return Response.json({ error: `All Antigravity endpoints failed: ${lastError?.message}` }, { status: 502 });
87
- }
@@ -1,73 +0,0 @@
1
- /** Forwards requests to Cloud Code Assist API using Gemini CLI quota.
2
- * Amp CLI uses @google/genai SDK with vertexai:true — sends Vertex AI format
3
- * (e.g. /v1beta1/publishers/google/models/{model}:streamGenerateContent?alt=sse).
4
- * We wrap the native body in a CCA envelope, forward to cloudcode-pa, and unwrap
5
- * the response so Amp CLI sees standard Vertex AI SSE chunks. */
6
-
7
- import { google as config } from "../auth/configs.ts";
8
- import * as oauth from "../auth/oauth.ts";
9
- import * as store from "../auth/store.ts";
10
- import { CODE_ASSIST_ENDPOINT } from "../constants.ts";
11
- import { buildUrl, maybeWrap, withUnwrap } from "../utils/code-assist.ts";
12
- import { logger } from "../utils/logger.ts";
13
- import * as path from "../utils/path.ts";
14
- import type { Provider } from "./base.ts";
15
- import { denied, forward } from "./forward.ts";
16
-
17
- const geminiHeaders: Readonly<Record<string, string>> = {
18
- "User-Agent": "google-cloud-sdk vscode_cloudshelleditor/0.1",
19
- "X-Goog-Api-Client": "gl-node/22.17.0",
20
- "Client-Metadata": JSON.stringify({
21
- ideType: "IDE_UNSPECIFIED",
22
- platform: "PLATFORM_UNSPECIFIED",
23
- pluginType: "GEMINI",
24
- }),
25
- };
26
-
27
- export const provider: Provider = {
28
- name: "Gemini CLI",
29
- routeDecision: "LOCAL_GEMINI",
30
-
31
- isAvailable: (account?: number) =>
32
- account !== undefined ? !!store.get("google", account)?.refreshToken : oauth.ready(config),
33
-
34
- accountCount: () => oauth.accountCount(config),
35
-
36
- async forward(sub, body, _originalHeaders, rewrite, account = 0) {
37
- const accessToken = await oauth.token(config, account);
38
- if (!accessToken) return denied("Gemini CLI");
39
-
40
- const creds = store.get("google", account);
41
- const projectId = creds?.projectId ?? "";
42
-
43
- const headers: Record<string, string> = {
44
- ...geminiHeaders,
45
- Authorization: `Bearer ${accessToken}`,
46
- "Content-Type": "application/json",
47
- Accept: body.stream ? "text/event-stream" : "application/json",
48
- };
49
-
50
- const gemini = path.gemini(sub);
51
- if (!gemini) {
52
- logger.debug(`Non-model Gemini path, cannot route to CCA: ${sub}`);
53
- return denied("Gemini CLI (unsupported path)");
54
- }
55
-
56
- const url = buildUrl(CODE_ASSIST_ENDPOINT, gemini.action);
57
- const requestBody = maybeWrap(body.parsed, body.forwardBody, projectId, gemini.model, {
58
- userAgent: "pi-coding-agent",
59
- requestIdPrefix: "pi",
60
- });
61
- const unwrapThenRewrite = withUnwrap(rewrite);
62
-
63
- return forward({
64
- url,
65
- body: requestBody,
66
- streaming: body.stream,
67
- headers,
68
- providerName: "Gemini CLI",
69
- rewrite: unwrapThenRewrite,
70
- email: store.get("google", account)?.email,
71
- });
72
- },
73
- };
File without changes