ampcode-connector 0.1.16 → 0.1.19

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.16",
3
+ "version": "0.1.19",
4
4
  "description": "Proxy AmpCode through local OAuth subscriptions (Claude Code, Codex, Gemini CLI, Antigravity)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -49,6 +49,7 @@
49
49
  "typescript": "^5.9.3"
50
50
  },
51
51
  "dependencies": {
52
+ "@anthropic-ai/sdk": "0.74.0",
52
53
  "exa-js": "^2.4.0",
53
54
  "turndown": "^7.2.2",
54
55
  "turndown-plugin-gfm": "^1.0.2"
@@ -9,7 +9,7 @@ export const anthropic: OAuthConfig = {
9
9
  tokenUrl: ANTHROPIC_TOKEN_URL,
10
10
  callbackPort: 54545,
11
11
  callbackPath: "/callback",
12
- scopes: "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers",
12
+ scopes: "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload",
13
13
  bodyFormat: "json",
14
14
  expiryBuffer: true,
15
15
  sendStateInExchange: true,
package/src/constants.ts CHANGED
@@ -38,13 +38,13 @@ export const OPENAI_TOKEN_URL = "https://auth.openai.com/oauth/token";
38
38
  export const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
39
39
 
40
40
  export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
41
- export const CLAUDE_CODE_VERSION = "2.1.39";
41
+ export const CLAUDE_CODE_VERSION = "2.1.77";
42
42
 
43
43
  export const stainlessHeaders: Readonly<Record<string, string>> = {
44
44
  "X-Stainless-Helper-Method": "stream",
45
45
  "X-Stainless-Retry-Count": "0",
46
- "X-Stainless-Runtime-Version": "v24.13.1",
47
- "X-Stainless-Package-Version": "0.73.0",
46
+ "X-Stainless-Runtime-Version": "v24.3.0",
47
+ "X-Stainless-Package-Version": "0.74.0",
48
48
  "X-Stainless-Runtime": "node",
49
49
  "X-Stainless-Lang": "js",
50
50
  "X-Stainless-Arch": process.arch,
@@ -56,10 +56,11 @@ export const claudeCodeBetas = [
56
56
  "claude-code-20250219",
57
57
  "oauth-2025-04-20",
58
58
  "interleaved-thinking-2025-05-14",
59
+ "context-management-2025-06-27",
59
60
  "prompt-caching-scope-2026-01-05",
60
61
  ] as const;
61
62
 
62
- export const filteredBetaFeatures = ["context-1m-2025-08-07"] as const;
63
+ export const filteredBetaFeatures = ["fast-mode-2026-02-01"] as const;
63
64
 
64
65
  export const modelFieldPaths = [
65
66
  "model",
@@ -1,18 +1,29 @@
1
1
  /** Forwards requests to api.anthropic.com with Claude Code stealth headers. */
2
2
 
3
+ import { createHash } from "node:crypto";
3
4
  import { anthropic as config } from "../auth/configs.ts";
4
5
  import * as oauth from "../auth/oauth.ts";
5
6
  import * as store from "../auth/store.ts";
6
- import {
7
- ANTHROPIC_API_URL,
8
- CLAUDE_CODE_VERSION,
9
- claudeCodeBetas,
10
- filteredBetaFeatures,
11
- stainlessHeaders,
12
- } from "../constants.ts";
7
+ import { ANTHROPIC_API_URL, CLAUDE_CODE_VERSION, claudeCodeBetas, filteredBetaFeatures } from "../constants.ts";
8
+ import type { ParsedBody } from "../server/body.ts";
13
9
  import type { Provider } from "./base.ts";
14
10
  import { denied, forward } from "./forward.ts";
15
11
 
12
+ /** Headers to drop from client request (replaced by connector or irrelevant). */
13
+ const DROP_HEADERS = new Set(["host", "content-length", "connection", "x-api-key", "authorization", "anthropic-beta"]);
14
+
15
+ /** Extract X-Stainless-* and other passthrough headers from the client request. */
16
+ function passthroughHeaders(originalHeaders: Headers): Record<string, string> {
17
+ const out: Record<string, string> = {};
18
+ for (const [k, v] of originalHeaders.entries()) {
19
+ if (DROP_HEADERS.has(k)) continue;
20
+ // Drop amp-specific headers
21
+ if (k.startsWith("x-amp-")) continue;
22
+ out[k] = v;
23
+ }
24
+ return out;
25
+ }
26
+
16
27
  export const provider: Provider = {
17
28
  name: "Anthropic",
18
29
  routeDecision: "LOCAL_CLAUDE",
@@ -26,22 +37,23 @@ export const provider: Provider = {
26
37
  const accessToken = await oauth.token(config, account);
27
38
  if (!accessToken) return denied("Anthropic");
28
39
 
40
+ const fwdBody = prepareBody(body);
41
+ const betaHdr = betaHeader(originalHeaders.get("anthropic-beta"));
42
+ const clientHeaders = passthroughHeaders(originalHeaders);
43
+
29
44
  return forward({
30
45
  url: `${ANTHROPIC_API_URL}${sub}`,
31
- body: body.forwardBody,
46
+ body: fwdBody,
32
47
  streaming: body.stream,
33
48
  providerName: "Anthropic",
34
49
  rewrite,
35
50
  email: store.get("anthropic", account)?.email,
36
51
  headers: {
37
- ...stainlessHeaders,
38
- Accept: body.stream ? "text/event-stream" : "application/json",
39
- "Accept-Encoding": "br, gzip, deflate",
40
- Connection: "keep-alive",
41
- "Content-Type": "application/json",
42
- "Anthropic-Version": "2023-06-01",
52
+ // Client headers first (stainless, accept, content-type, anthropic-version, etc.)
53
+ ...clientHeaders,
54
+ // Override auth + identity
43
55
  "Anthropic-Dangerous-Direct-Browser-Access": "true",
44
- "Anthropic-Beta": betaHeader(originalHeaders.get("anthropic-beta")),
56
+ "Anthropic-Beta": betaHdr,
45
57
  "User-Agent": `claude-cli/${CLAUDE_CODE_VERSION} (external, cli)`,
46
58
  "X-App": "cli",
47
59
  Authorization: `Bearer ${accessToken}`,
@@ -50,6 +62,69 @@ export const provider: Provider = {
50
62
  },
51
63
  };
52
64
 
65
+ const BILLING_SALT = "59cf53e54c78";
66
+
67
+ /** Compute the cch checksum from the first user message text and version. */
68
+ function computeCch(firstUserText: string, version: string): string {
69
+ const chars = [4, 7, 20].map((i) => firstUserText[i] || "0").join("");
70
+ return createHash("sha256").update(`${BILLING_SALT}${chars}${version}`).digest("hex").slice(0, 5);
71
+ }
72
+
73
+ /** Extract text from the first user message in the body. */
74
+ function firstUserText(parsed: Record<string, unknown>): string {
75
+ const messages = parsed.messages as Array<{ role?: string; content?: unknown }> | undefined;
76
+ if (!Array.isArray(messages)) return "";
77
+ const userMsg = messages.find((m) => m.role === "user");
78
+ if (!userMsg) return "";
79
+ if (typeof userMsg.content === "string") return userMsg.content;
80
+ if (Array.isArray(userMsg.content)) {
81
+ const textBlock = userMsg.content.find((b: { type?: string }) => b.type === "text") as
82
+ | { text?: string }
83
+ | undefined;
84
+ return textBlock?.text ?? "";
85
+ }
86
+ return "";
87
+ }
88
+
89
+ /** Prepare body: inject billing header + strip speed field.
90
+ * Always re-injects billing header because cch depends on per-request message content.
91
+ * Shallow-copies parsed to avoid mutating the shared ParsedBody.parsed reference. */
92
+ function prepareBody(body: ParsedBody): string {
93
+ const raw = body.forwardBody;
94
+
95
+ try {
96
+ const original = body.parsed;
97
+ if (!original) return raw;
98
+
99
+ const text = firstUserText(original);
100
+ const cch = computeCch(text, CLAUDE_CODE_VERSION);
101
+ const billingLine = `x-anthropic-billing-header: cc_version=${CLAUDE_CODE_VERSION}; cc_entrypoint=cli; cch=${cch};`;
102
+
103
+ const { speed: _, system: existingSystem, ...rest } = original;
104
+
105
+ return JSON.stringify({
106
+ ...rest,
107
+ system: injectBillingHeader(existingSystem, billingLine),
108
+ });
109
+ } catch {
110
+ return raw;
111
+ }
112
+ }
113
+
114
+ /** Prepend the billing header into the system prompt, handling both array and string formats. */
115
+ function injectBillingHeader(system: unknown, billingLine: string): unknown {
116
+ if (Array.isArray(system)) {
117
+ const filtered = system.filter(
118
+ (s: { text?: string }) => !(typeof s.text === "string" && s.text.includes("x-anthropic-billing-header")),
119
+ );
120
+ return [{ type: "text", text: billingLine }, ...filtered];
121
+ }
122
+ if (typeof system === "string") {
123
+ return `${billingLine}\n${system.replace(/x-anthropic-billing-header:[^\n]*\n?/, "")}`;
124
+ }
125
+ return [{ type: "text", text: billingLine }];
126
+ }
127
+
53
128
  function betaHeader(original: string | null): string {
54
129
  const features = new Set<string>(claudeCodeBetas);
55
130
 
@@ -51,6 +51,19 @@ interface TransformState {
51
51
  toolCallIds: Map<string, number>;
52
52
  }
53
53
 
54
+ /** Resolve tool call index from item_id or call_id, falling back to 0. */
55
+ function lookupToolIndex(state: TransformState, itemId?: string, callId?: string): number {
56
+ if (itemId) {
57
+ const idx = state.toolCallIds.get(itemId);
58
+ if (idx !== undefined) return idx;
59
+ }
60
+ if (callId) {
61
+ const idx = state.toolCallIds.get(callId);
62
+ if (idx !== undefined) return idx;
63
+ }
64
+ return 0;
65
+ }
66
+
54
67
  /** Create a stateful SSE transformer: Responses API → Chat Completions. */
55
68
  function createResponseTransformer(ampModel: string): (data: string) => string {
56
69
  const state: TransformState = {
@@ -93,9 +106,11 @@ function createResponseTransformer(ampModel: string): (data: string) => string {
93
106
  }
94
107
  if (item?.type === "function_call") {
95
108
  const callId = item.call_id as string;
109
+ const itemId = item.id as string | undefined;
96
110
  const name = item.name as string;
97
111
  const idx = state.toolCallIndex++;
98
112
  state.toolCallIds.set(callId, idx);
113
+ if (itemId) state.toolCallIds.set(itemId, idx);
99
114
  return serialize(state, {
100
115
  tool_calls: [{ index: idx, id: callId, type: "function", function: { name, arguments: "" } }],
101
116
  });
@@ -113,9 +128,10 @@ function createResponseTransformer(ampModel: string): (data: string) => string {
113
128
  // Function call arguments delta
114
129
  case "response.function_call_arguments.delta": {
115
130
  const delta = parsed.delta as string;
131
+ const itemId = parsed.item_id as string | undefined;
116
132
  const callId = parsed.call_id as string | undefined;
117
133
  if (delta) {
118
- const idx = callId ? (state.toolCallIds.get(callId) ?? 0) : 0;
134
+ const idx = lookupToolIndex(state, itemId, callId);
119
135
  return serialize(state, { tool_calls: [{ index: idx, function: { arguments: delta } }] });
120
136
  }
121
137
  return "";
@@ -337,9 +353,11 @@ export async function bufferCodexResponse(response: Response, ampModel: string):
337
353
  const item = parsed.item as Record<string, unknown>;
338
354
  if (item?.type === "function_call") {
339
355
  const callId = item.call_id as string;
356
+ const itemId = item.id as string | undefined;
340
357
  const name = item.name as string;
341
358
  const idx = state.toolCallIndex++;
342
359
  state.toolCallIds.set(callId, idx);
360
+ if (itemId) state.toolCallIds.set(itemId, idx);
343
361
  toolCalls.set(idx, { id: callId, type: "function", function: { name, arguments: "" } });
344
362
  }
345
363
  break;
@@ -347,9 +365,10 @@ export async function bufferCodexResponse(response: Response, ampModel: string):
347
365
 
348
366
  case "response.function_call_arguments.delta": {
349
367
  const delta = parsed.delta as string;
368
+ const itemId = parsed.item_id as string | undefined;
350
369
  const callId = parsed.call_id as string | undefined;
351
370
  if (delta) {
352
- const idx = callId ? (state.toolCallIds.get(callId) ?? 0) : 0;
371
+ const idx = lookupToolIndex(state, itemId, callId);
353
372
  const tc = toolCalls.get(idx);
354
373
  if (tc) tc.function.arguments += delta;
355
374
  }
@@ -411,18 +430,21 @@ export async function bufferCodexResponse(response: Response, ampModel: string):
411
430
  const item = parsed.item as Record<string, unknown>;
412
431
  if (item?.type === "function_call") {
413
432
  const callId = item.call_id as string;
433
+ const itemId = item.id as string | undefined;
414
434
  const name = item.name as string;
415
435
  const idx = state.toolCallIndex++;
416
436
  state.toolCallIds.set(callId, idx);
437
+ if (itemId) state.toolCallIds.set(itemId, idx);
417
438
  toolCalls.set(idx, { id: callId, type: "function", function: { name, arguments: "" } });
418
439
  }
419
440
  break;
420
441
  }
421
442
  case "response.function_call_arguments.delta": {
422
443
  const delta = parsed.delta as string;
444
+ const itemId = parsed.item_id as string | undefined;
423
445
  const callId = parsed.call_id as string | undefined;
424
446
  if (delta) {
425
- const idx = callId ? (state.toolCallIds.get(callId) ?? 0) : 0;
447
+ const idx = lookupToolIndex(state, itemId, callId);
426
448
  const tc = toolCalls.get(idx);
427
449
  if (tc) tc.function.arguments += delta;
428
450
  }
@@ -30,7 +30,7 @@ export const provider: Provider = {
30
30
 
31
31
  const accountId = getAccountId(accessToken, account);
32
32
  const codexPath = codexPathMap[sub] ?? sub;
33
- const promptCacheKey = originalHeaders.get("x-amp-thread-id") ?? undefined;
33
+ const promptCacheKey = originalHeaders.get("x-amp-thread-id") ?? originalHeaders.get("x-session-id") ?? undefined;
34
34
  const { body: codexBody, needsResponseTransform } = transformForCodex(body.forwardBody, promptCacheKey);
35
35
  const ampModel = body.ampModel ?? "gpt-5.2";
36
36
 
@@ -136,10 +136,12 @@ function transformForCodex(
136
136
  }
137
137
 
138
138
  // Reasoning config — merge with caller-provided values, defaults match reference behavior
139
+ // Chat Completions uses top-level "reasoning_effort"; Responses API uses "reasoning.effort"
139
140
  const model = (parsed.model as string) ?? "";
140
141
  const existingReasoning = (parsed.reasoning as Record<string, unknown>) ?? {};
142
+ const topLevelEffort = parsed.reasoning_effort as string | undefined;
141
143
  parsed.reasoning = {
142
- effort: clampReasoningEffort(model, (existingReasoning.effort as string) ?? "high"),
144
+ effort: clampReasoningEffort(model, topLevelEffort ?? (existingReasoning.effort as string) ?? "medium"),
143
145
  summary: existingReasoning.summary ?? "auto",
144
146
  };
145
147
 
@@ -157,6 +159,7 @@ function transformForCodex(
157
159
  }
158
160
 
159
161
  // Remove fields the Codex backend doesn't accept
162
+ delete parsed.reasoning_effort; // Chat Completions field; already mapped to reasoning.effort above
160
163
  delete parsed.max_tokens;
161
164
  delete parsed.max_completion_tokens;
162
165
  delete parsed.max_output_tokens;
@@ -51,7 +51,29 @@ export async function forward(opts: ForwardOptions): Promise<Response> {
51
51
  const text = await response.text();
52
52
  const ctx = opts.email ? ` account=${opts.email}` : "";
53
53
  logger.error(`${opts.providerName} API error (${response.status})${ctx}`, { error: text.slice(0, 200) });
54
- return new Response(text, { status: response.status, headers: { "Content-Type": contentType } });
54
+
55
+ // Normalize non-standard error responses (e.g. {"detail":"..."}) to OpenAI format
56
+ // so Amp CLI can deserialize them (it expects {"error": {...}})
57
+ let errorBody = text;
58
+ try {
59
+ const parsed = JSON.parse(text) as Record<string, unknown>;
60
+ if (!parsed.error) {
61
+ const message = (parsed.detail as string) ?? (parsed.message as string) ?? text;
62
+ errorBody = JSON.stringify({
63
+ error: { message, type: "api_error", code: String(response.status) },
64
+ });
65
+ }
66
+ } catch {
67
+ // Not JSON — wrap raw text
68
+ errorBody = JSON.stringify({
69
+ error: { message: text, type: "api_error", code: String(response.status) },
70
+ });
71
+ }
72
+
73
+ return new Response(errorBody, {
74
+ status: response.status,
75
+ headers: { "Content-Type": "application/json" },
76
+ });
55
77
  }
56
78
 
57
79
  const isSSE = contentType.includes("text/event-stream") || opts.streaming;
@@ -1,17 +1,31 @@
1
- /** Retry logic: cache-preserving wait + reroute after 429. */
1
+ /** Retry logic: cache-preserving wait + reroute after retryable failures (429/403). */
2
2
 
3
3
  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
- import { cooldown, parseRetryAfter } from "./cooldown.ts";
7
- import { type RouteResult, recordSuccess, rerouteAfter429 } from "./router.ts";
6
+ import { cooldown, parseRetryAfter, type QuotaPool } from "./cooldown.ts";
7
+ import { type RouteResult, recordSuccess, reroute } from "./router.ts";
8
8
 
9
- /** Max 429-reroute attempts before falling back to upstream. */
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
13
 
14
- /** Wait briefly and retry on the same account to preserve prompt cache. */
14
+ /** Status codes that trigger rerouting to a different account/pool. */
15
+ const REROUTABLE_STATUSES = new Set([429, 403]);
16
+
17
+ interface RerouteContext {
18
+ providerName: string;
19
+ ampModel: string | null;
20
+ config: ProxyConfig;
21
+ sub: string;
22
+ body: ParsedBody;
23
+ headers: Headers;
24
+ rewrite: ((data: string) => string) | undefined;
25
+ threadId?: string;
26
+ }
27
+
28
+ /** Wait briefly and retry on the same account to preserve prompt cache (429 only). */
15
29
  export async function tryWithCachePreserve(
16
30
  route: RouteResult,
17
31
  sub: string,
@@ -38,35 +52,26 @@ export async function tryWithCachePreserve(
38
52
  return null;
39
53
  }
40
54
 
41
- /** Reroute to different accounts/pools after 429 (cache loss accepted). */
55
+ /** Reroute to different accounts/pools after a retryable failure (429/403). */
42
56
  export async function tryReroute(
43
- providerName: string,
44
- ampModel: string | null,
45
- config: ProxyConfig,
57
+ ctx: RerouteContext,
46
58
  initialRoute: RouteResult,
47
- sub: string,
48
- body: ParsedBody,
49
- headers: Headers,
50
- rewrite: ((data: string) => string) | undefined,
51
- initialResponse: Response,
52
- threadId?: string,
59
+ status: number,
53
60
  ): Promise<Response | null> {
54
- const retryAfter = parseRetryAfter(initialResponse.headers.get("retry-after"));
55
- logger.warn(`429 from ${initialRoute.decision} account=${initialRoute.account}`, { retryAfter });
61
+ recordFailure(initialRoute.pool!, initialRoute.account, status);
56
62
 
57
63
  let currentPool = initialRoute.pool!;
58
64
  let currentAccount = initialRoute.account;
59
65
 
60
66
  for (let attempt = 0; attempt < MAX_REROUTE_ATTEMPTS; attempt++) {
61
- const next = rerouteAfter429(providerName, ampModel, config, currentPool, currentAccount, retryAfter, threadId);
62
- if (!next) break;
67
+ const next = reroute(ctx.providerName, ctx.ampModel, ctx.config, currentPool, currentAccount, ctx.threadId);
68
+ if (!next?.handler) break;
63
69
 
64
- logger.info(`REROUTE -> ${next.decision} account=${next.account}`);
65
- const response = await next.handler!.forward(sub, body, headers, rewrite, next.account);
70
+ logger.info(`REROUTE (${status}) -> ${next.decision} account=${next.account}`);
71
+ const response = await next.handler.forward(ctx.sub, ctx.body, ctx.headers, ctx.rewrite, next.account);
66
72
 
67
- if (response.status === 429 && next.pool) {
68
- const nextRetryAfter = parseRetryAfter(response.headers.get("retry-after"));
69
- cooldown.record429(next.pool, next.account, nextRetryAfter);
73
+ if (REROUTABLE_STATUSES.has(response.status) && next.pool) {
74
+ recordFailure(next.pool, next.account, response.status);
70
75
  currentPool = next.pool;
71
76
  currentAccount = next.account;
72
77
  continue;
@@ -81,3 +86,12 @@ export async function tryReroute(
81
86
 
82
87
  return null;
83
88
  }
89
+
90
+ /** Record the appropriate cooldown based on status code. */
91
+ function recordFailure(pool: QuotaPool, account: number, status: number): void {
92
+ if (status === 403) {
93
+ cooldown.record403(pool, account);
94
+ } else {
95
+ cooldown.record429(pool, account);
96
+ }
97
+ }
@@ -123,19 +123,16 @@ export function routeRequest(
123
123
  return result(picked.provider, ampProvider, modelStr, picked.account, picked.pool);
124
124
  }
125
125
 
126
- /** Record a 429 response and attempt re-route. Returns a new RouteResult or null. */
127
- export function rerouteAfter429(
126
+ /** Record a failure on the current account and pick the next candidate.
127
+ * Caller is responsible for recording the failure (429/403) on cooldown before calling. */
128
+ export function reroute(
128
129
  ampProvider: string,
129
130
  model: string | null,
130
131
  config: ProxyConfig,
131
132
  failedPool: QuotaPool,
132
133
  failedAccount: number,
133
- retryAfterSeconds: number | undefined,
134
134
  threadId?: string,
135
135
  ): RouteResult | null {
136
- cooldown.record429(failedPool, failedAccount, retryAfterSeconds);
137
-
138
- // If exhausted, break thread affinity
139
136
  if (threadId && cooldown.isExhausted(failedPool, failedAccount)) {
140
137
  affinity.clear(threadId, ampProvider);
141
138
  }
@@ -5,7 +5,6 @@ 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";
9
8
  import { tryReroute, tryWithCachePreserve } from "../routing/retry.ts";
10
9
  import { recordSuccess, routeRequest } from "../routing/router.ts";
11
10
  import { handleInternal, isLocalMethod } from "../tools/internal.ts";
@@ -85,7 +84,7 @@ async function handleProvider(
85
84
  ): Promise<Response> {
86
85
  const startTime = Date.now();
87
86
  const sub = path.subpath(pathname);
88
- const threadId = req.headers.get("x-amp-thread-id") ?? undefined;
87
+ const threadId = req.headers.get("x-amp-thread-id") ?? req.headers.get("x-session-id") ?? undefined;
89
88
 
90
89
  const rawBody = req.method === "POST" ? await req.text() : "";
91
90
  const body = parseBody(rawBody, sub);
@@ -102,29 +101,19 @@ async function handleProvider(
102
101
  const rewrite = ampModel ? rewriter.rewrite(ampModel) : undefined;
103
102
  const handlerResponse = await route.handler.forward(sub, body, req.headers, rewrite, route.account);
104
103
 
105
- if (handlerResponse.status === 429 && route.pool) {
106
- const cached = await tryWithCachePreserve(route, sub, body, req.headers, rewrite, handlerResponse);
104
+ if ((handlerResponse.status === 429 || handlerResponse.status === 403) && route.pool) {
105
+ const ctx = { providerName, ampModel, config, sub, body, headers: req.headers, rewrite, threadId };
106
+ // 429: try short wait to preserve prompt cache first
107
+ const cached =
108
+ handlerResponse.status === 429
109
+ ? await tryWithCachePreserve(route, sub, body, req.headers, rewrite, handlerResponse)
110
+ : null;
107
111
  if (cached) {
108
112
  response = cached;
109
113
  } else {
110
- const rerouted = await tryReroute(
111
- providerName,
112
- ampModel,
113
- config,
114
- route,
115
- sub,
116
- body,
117
- req.headers,
118
- rewrite,
119
- handlerResponse,
120
- threadId,
121
- );
114
+ const rerouted = await tryReroute(ctx, route, handlerResponse.status);
122
115
  response = rerouted ?? (await fallbackUpstream(req, body, config));
123
116
  }
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);
128
117
  } else if (handlerResponse.status === 401) {
129
118
  logger.debug("Local provider denied, falling back to upstream");
130
119
  response = await fallbackUpstream(req, body, config);
@@ -46,6 +46,72 @@ export function withUnwrap(rewrite?: (d: string) => string): (d: string) => stri
46
46
  return rewrite ? (d: string) => rewrite(unwrap(d)) : unwrap;
47
47
  }
48
48
 
49
+ /** Ensure every function_response part has a non-empty name.
50
+ * Gemini API rejects requests where function_response.name is empty.
51
+ * Uses two strategies:
52
+ * 1. Positional: a model turn with N functionCall parts is followed by a user turn
53
+ * with N functionResponse parts in the same order — match by index.
54
+ * 2. ID-based fallback: match function_response.id → function_call.id.
55
+ * Handles both camelCase (functionCall) and snake_case (function_call) keys. */
56
+ function fixFunctionResponseNames(body: Record<string, unknown>): void {
57
+ const contents = body.contents;
58
+ if (!Array.isArray(contents)) return;
59
+
60
+ type Part = Record<string, unknown>;
61
+ type Content = { role?: string; parts?: Part[] };
62
+ const getFc = (p: Part) => (p.functionCall ?? p.function_call) as Record<string, unknown> | undefined;
63
+ const getFr = (p: Part) => (p.functionResponse ?? p.function_response) as Record<string, unknown> | undefined;
64
+
65
+ // Pass 1: positional matching — pair consecutive model/user turns
66
+ for (let i = 0; i < contents.length - 1; i++) {
67
+ const modelTurn = contents[i] as Content;
68
+ const userTurn = contents[i + 1] as Content;
69
+ if (modelTurn.role !== "model" || userTurn.role !== "user") continue;
70
+ if (!Array.isArray(modelTurn.parts) || !Array.isArray(userTurn.parts)) continue;
71
+
72
+ const fcParts = modelTurn.parts.filter((p) => getFc(p as Part));
73
+ const frParts = userTurn.parts.filter((p) => getFr(p as Part));
74
+ if (fcParts.length === 0 || fcParts.length !== frParts.length) continue;
75
+
76
+ for (let j = 0; j < frParts.length; j++) {
77
+ const fr = getFr(frParts[j] as Part)!;
78
+ if (typeof fr.name === "string" && fr.name) continue;
79
+ const fc = getFc(fcParts[j] as Part)!;
80
+ if (typeof fc.name === "string") {
81
+ fr.name = fc.name;
82
+ }
83
+ }
84
+ }
85
+
86
+ // Pass 2: ID-based fallback for any remaining empty names
87
+ const nameById = new Map<string, string>();
88
+ for (const content of contents) {
89
+ const parts = (content as Content)?.parts;
90
+ if (!Array.isArray(parts)) continue;
91
+ for (const part of parts) {
92
+ const fc = getFc(part as Part);
93
+ if (fc && typeof fc.name === "string" && typeof fc.id === "string") {
94
+ nameById.set(fc.id, fc.name);
95
+ }
96
+ }
97
+ }
98
+
99
+ if (nameById.size === 0) return;
100
+
101
+ for (const content of contents) {
102
+ const parts = (content as Content)?.parts;
103
+ if (!Array.isArray(parts)) continue;
104
+ for (const part of parts) {
105
+ const fr = getFr(part as Part);
106
+ if (!fr || (typeof fr.name === "string" && fr.name)) continue;
107
+ const resolved = typeof fr.id === "string" ? nameById.get(fr.id) : undefined;
108
+ if (resolved) {
109
+ fr.name = resolved;
110
+ }
111
+ }
112
+ }
113
+ }
114
+
49
115
  /** Wrap body in CCA envelope if not already wrapped. */
50
116
  export function maybeWrap(
51
117
  parsed: Record<string, unknown> | null,
@@ -56,5 +122,6 @@ export function maybeWrap(
56
122
  ): string {
57
123
  if (!parsed) return raw;
58
124
  if (parsed.project) return raw;
125
+ fixFunctionResponseNames(parsed);
59
126
  return wrapRequest({ projectId, model, body: parsed, ...opts });
60
127
  }