ampcode-connector 0.1.15 → 0.1.18

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.15",
3
+ "version": "0.1.18",
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,14 @@ export const claudeCodeBetas = [
56
56
  "claude-code-20250219",
57
57
  "oauth-2025-04-20",
58
58
  "interleaved-thinking-2025-05-14",
59
+ "redact-thinking-2026-02-12",
60
+ "context-management-2025-06-27",
59
61
  "prompt-caching-scope-2026-01-05",
62
+ "advanced-tool-use-2025-11-20",
63
+ "effort-2025-11-24",
60
64
  ] as const;
61
65
 
62
- export const filteredBetaFeatures = ["context-1m-2025-08-07"] as const;
66
+ export const filteredBetaFeatures = ["fast-mode-2026-02-01"] as const;
63
67
 
64
68
  export const modelFieldPaths = [
65
69
  "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 = {
@@ -62,7 +75,7 @@ function createResponseTransformer(ampModel: string): (data: string) => string {
62
75
  };
63
76
 
64
77
  return (data: string): string => {
65
- if (data === "[DONE]") return data;
78
+ if (data === "[DONE]") return "";
66
79
 
67
80
  let parsed: Record<string, unknown>;
68
81
  try {
@@ -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 "";
@@ -130,18 +146,26 @@ function createResponseTransformer(ampModel: string): (data: string) => string {
130
146
  return serializeFinish(state, finishReason, usage);
131
147
  }
132
148
 
133
- // Response incomplete — emit finish_reason "length" + usage
149
+ // Response incomplete — inspect reason to determine finish_reason
134
150
  case "response.incomplete": {
135
151
  const resp = parsed.response as Record<string, unknown>;
136
152
  const usage = extractUsage(resp?.usage as Record<string, unknown> | undefined);
137
- return serializeFinish(state, "length", usage);
153
+ const finishReason = incompleteReason(resp);
154
+ return serializeFinish(state, finishReason, usage);
138
155
  }
139
156
 
140
- // Response failed — emit finish_reason "stop" (error)
157
+ // Response failed — emit error content so the client sees the failure
141
158
  case "response.failed": {
142
159
  const resp = parsed.response as Record<string, unknown>;
143
160
  const usage = extractUsage(resp?.usage as Record<string, unknown> | undefined);
144
- return serializeFinish(state, "stop", usage);
161
+ const errorMsg = extractErrorMessage(resp);
162
+ let chunks = "";
163
+ if (errorMsg) {
164
+ chunks = serialize(state, { role: "assistant", content: `[Error] ${errorMsg}` });
165
+ chunks += "\n\n";
166
+ }
167
+ chunks += serializeFinish(state, "stop", usage);
168
+ return chunks;
145
169
  }
146
170
 
147
171
  // Reasoning/thinking delta — emit as reasoning_content (separate from content)
@@ -191,6 +215,28 @@ function serializeFinish(state: TransformState, finishReason: string, usage?: Us
191
215
  return JSON.stringify(chunk);
192
216
  }
193
217
 
218
+ /** Map Responses API incomplete reason → Chat Completions finish_reason. */
219
+ function incompleteReason(resp: Record<string, unknown> | undefined): string {
220
+ if (!resp) return "length";
221
+ const reason = resp.incomplete_details as Record<string, unknown> | undefined;
222
+ const type = reason?.reason as string | undefined;
223
+ if (type === "max_output_tokens" || type === "max_tokens") return "length";
224
+ if (type === "content_filter") return "content_filter";
225
+ return "length";
226
+ }
227
+
228
+ /** Extract a human-readable error message from a failed response. */
229
+ function extractErrorMessage(resp: Record<string, unknown> | undefined): string | null {
230
+ if (!resp) return null;
231
+ const error = resp.error as Record<string, unknown> | undefined;
232
+ if (!error) return null;
233
+ const message = error.message as string | undefined;
234
+ const code = error.code as string | undefined;
235
+ if (message) return code ? `${code}: ${message}` : message;
236
+ if (code) return code;
237
+ return null;
238
+ }
239
+
194
240
  function extractUsage(raw: Record<string, unknown> | undefined): Usage | undefined {
195
241
  if (!raw) return undefined;
196
242
  const input = (raw.input_tokens as number) ?? 0;
@@ -206,6 +252,15 @@ function extractUsage(raw: Record<string, unknown> | undefined): Usage | undefin
206
252
  };
207
253
  }
208
254
 
255
+ const FORWARDED_HEADERS = [
256
+ "x-request-id",
257
+ "request-id",
258
+ "x-ratelimit-limit-requests",
259
+ "x-ratelimit-remaining-requests",
260
+ "x-ratelimit-limit-tokens",
261
+ "x-ratelimit-remaining-tokens",
262
+ ] as const;
263
+
209
264
  /** Wrap a Codex SSE response with the Responses → Chat Completions transformer.
210
265
  * Strips Responses API event names so output looks like standard Chat Completions SSE. */
211
266
  export function transformCodexResponse(response: Response, ampModel: string): Response {
@@ -214,14 +269,17 @@ export function transformCodexResponse(response: Response, ampModel: string): Re
214
269
  const transformer = createResponseTransformer(ampModel);
215
270
  const body = transformStream(response.body, transformer);
216
271
 
217
- return new Response(body, {
218
- status: response.status,
219
- headers: {
220
- "Content-Type": "text/event-stream",
221
- "Cache-Control": "no-cache",
222
- Connection: "keep-alive",
223
- },
224
- });
272
+ const headers: Record<string, string> = {
273
+ "Content-Type": "text/event-stream",
274
+ "Cache-Control": "no-cache",
275
+ Connection: "keep-alive",
276
+ };
277
+ for (const name of FORWARDED_HEADERS) {
278
+ const value = response.headers.get(name);
279
+ if (value) headers[name] = value;
280
+ }
281
+
282
+ return new Response(body, { status: response.status, headers });
225
283
  }
226
284
 
227
285
  /** Buffer a Codex SSE response into a single Chat Completions JSON response.
@@ -295,9 +353,11 @@ export async function bufferCodexResponse(response: Response, ampModel: string):
295
353
  const item = parsed.item as Record<string, unknown>;
296
354
  if (item?.type === "function_call") {
297
355
  const callId = item.call_id as string;
356
+ const itemId = item.id as string | undefined;
298
357
  const name = item.name as string;
299
358
  const idx = state.toolCallIndex++;
300
359
  state.toolCallIds.set(callId, idx);
360
+ if (itemId) state.toolCallIds.set(itemId, idx);
301
361
  toolCalls.set(idx, { id: callId, type: "function", function: { name, arguments: "" } });
302
362
  }
303
363
  break;
@@ -305,9 +365,10 @@ export async function bufferCodexResponse(response: Response, ampModel: string):
305
365
 
306
366
  case "response.function_call_arguments.delta": {
307
367
  const delta = parsed.delta as string;
368
+ const itemId = parsed.item_id as string | undefined;
308
369
  const callId = parsed.call_id as string | undefined;
309
370
  if (delta) {
310
- const idx = callId ? (state.toolCallIds.get(callId) ?? 0) : 0;
371
+ const idx = lookupToolIndex(state, itemId, callId);
311
372
  const tc = toolCalls.get(idx);
312
373
  if (tc) tc.function.arguments += delta;
313
374
  }
@@ -324,32 +385,90 @@ export async function bufferCodexResponse(response: Response, ampModel: string):
324
385
  case "response.incomplete": {
325
386
  const resp = parsed.response as Record<string, unknown>;
326
387
  usage = extractUsage(resp?.usage as Record<string, unknown> | undefined);
327
- finishReason = "length";
388
+ finishReason = incompleteReason(resp);
328
389
  break;
329
390
  }
330
391
 
331
392
  case "response.failed": {
332
393
  const resp = parsed.response as Record<string, unknown>;
333
394
  usage = extractUsage(resp?.usage as Record<string, unknown> | undefined);
395
+ const errorMsg = extractErrorMessage(resp);
396
+ if (errorMsg) content += `[Error] ${errorMsg}`;
334
397
  break;
335
398
  }
336
399
  }
337
400
  }
338
401
  }
339
402
 
340
- // Process remaining buffer
403
+ // Process remaining buffer — reuse the same event handling as main loop
341
404
  if (sseBuffer.trim()) {
342
405
  for (const chunk of sse.parse(sseBuffer)) {
343
406
  if (chunk.data === "[DONE]") continue;
407
+
408
+ let parsed: Record<string, unknown>;
344
409
  try {
345
- const parsed = JSON.parse(chunk.data) as Record<string, unknown>;
346
- if (parsed.type === "response.completed") {
410
+ parsed = JSON.parse(chunk.data) as Record<string, unknown>;
411
+ } catch {
412
+ continue;
413
+ }
414
+
415
+ const eventType = parsed.type as string | undefined;
416
+ if (!eventType) continue;
417
+
418
+ switch (eventType) {
419
+ case "response.output_text.delta": {
420
+ const delta = parsed.delta as string;
421
+ if (delta) content += delta;
422
+ break;
423
+ }
424
+ case "response.reasoning_summary_text.delta": {
425
+ const delta = parsed.delta as string;
426
+ if (delta) reasoningContent += delta;
427
+ break;
428
+ }
429
+ case "response.output_item.added": {
430
+ const item = parsed.item as Record<string, unknown>;
431
+ if (item?.type === "function_call") {
432
+ const callId = item.call_id as string;
433
+ const itemId = item.id as string | undefined;
434
+ const name = item.name as string;
435
+ const idx = state.toolCallIndex++;
436
+ state.toolCallIds.set(callId, idx);
437
+ if (itemId) state.toolCallIds.set(itemId, idx);
438
+ toolCalls.set(idx, { id: callId, type: "function", function: { name, arguments: "" } });
439
+ }
440
+ break;
441
+ }
442
+ case "response.function_call_arguments.delta": {
443
+ const delta = parsed.delta as string;
444
+ const itemId = parsed.item_id as string | undefined;
445
+ const callId = parsed.call_id as string | undefined;
446
+ if (delta) {
447
+ const idx = lookupToolIndex(state, itemId, callId);
448
+ const tc = toolCalls.get(idx);
449
+ if (tc) tc.function.arguments += delta;
450
+ }
451
+ break;
452
+ }
453
+ case "response.completed": {
347
454
  const resp = parsed.response as Record<string, unknown>;
348
455
  usage = extractUsage(resp?.usage as Record<string, unknown> | undefined);
349
456
  finishReason = state.toolCallIndex > 0 ? "tool_calls" : "stop";
457
+ break;
458
+ }
459
+ case "response.incomplete": {
460
+ const resp = parsed.response as Record<string, unknown>;
461
+ usage = extractUsage(resp?.usage as Record<string, unknown> | undefined);
462
+ finishReason = incompleteReason(resp);
463
+ break;
464
+ }
465
+ case "response.failed": {
466
+ const resp = parsed.response as Record<string, unknown>;
467
+ usage = extractUsage(resp?.usage as Record<string, unknown> | undefined);
468
+ const errorMsg = extractErrorMessage(resp);
469
+ if (errorMsg) content += `[Error] ${errorMsg}`;
470
+ break;
350
471
  }
351
- } catch {
352
- // skip
353
472
  }
354
473
  }
355
474
  }
@@ -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
 
@@ -135,22 +135,31 @@ function transformForCodex(
135
135
  fixOrphanOutputs(parsed.input as Record<string, unknown>[]);
136
136
  }
137
137
 
138
- // Reasoning config — defaults match reference behavior
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) ?? "";
141
+ const existingReasoning = (parsed.reasoning as Record<string, unknown>) ?? {};
142
+ const topLevelEffort = parsed.reasoning_effort as string | undefined;
140
143
  parsed.reasoning = {
141
- effort: clampReasoningEffort(model, "high"),
142
- summary: "auto",
144
+ effort: clampReasoningEffort(model, topLevelEffort ?? (existingReasoning.effort as string) ?? "medium"),
145
+ summary: existingReasoning.summary ?? "auto",
143
146
  };
144
147
 
145
- parsed.text = { verbosity: "medium" };
148
+ const existingText = (parsed.text as Record<string, unknown>) ?? {};
149
+ parsed.text = { ...existingText, verbosity: existingText.verbosity ?? "medium" };
146
150
 
147
- parsed.include = ["reasoning.encrypted_content"];
151
+ const existingInclude = Array.isArray(parsed.include) ? (parsed.include as string[]) : [];
152
+ if (!existingInclude.includes("reasoning.encrypted_content")) {
153
+ existingInclude.push("reasoning.encrypted_content");
154
+ }
155
+ parsed.include = existingInclude;
148
156
 
149
157
  if (promptCacheKey) {
150
158
  parsed.prompt_cache_key = promptCacheKey;
151
159
  }
152
160
 
153
161
  // Remove fields the Codex backend doesn't accept
162
+ delete parsed.reasoning_effort; // Chat Completions field; already mapped to reasoning.effort above
154
163
  delete parsed.max_tokens;
155
164
  delete parsed.max_completion_tokens;
156
165
  delete parsed.max_output_tokens;
@@ -165,6 +174,23 @@ function transformForCodex(
165
174
  delete parsed.logit_bias;
166
175
  delete parsed.response_format;
167
176
 
177
+ // Normalize tools[] for Responses API: flatten function.{name,description,parameters,strict} to top-level
178
+ if (Array.isArray(parsed.tools)) {
179
+ parsed.tools = (parsed.tools as Record<string, unknown>[]).map((tool) => {
180
+ if (tool.type === "function" && tool.function && typeof tool.function === "object") {
181
+ const fn = tool.function as Record<string, unknown>;
182
+ return {
183
+ type: "function",
184
+ name: fn.name,
185
+ description: fn.description,
186
+ parameters: fn.parameters,
187
+ ...(fn.strict !== undefined ? { strict: fn.strict } : {}),
188
+ };
189
+ }
190
+ return tool;
191
+ });
192
+ }
193
+
168
194
  // Normalize tool_choice for Responses API
169
195
  if (parsed.tool_choice !== undefined && parsed.tool_choice !== null) {
170
196
  if (typeof parsed.tool_choice === "string") {
@@ -236,7 +262,7 @@ function convertMessages(messages: ChatMessage[]): { instructions: string | null
236
262
  input.push({
237
263
  type: "function_call_output",
238
264
  call_id: msg.tool_call_id,
239
- output: textOf(msg.content) ?? "",
265
+ output: stringifyContent(msg.content),
240
266
  });
241
267
  break;
242
268
  }
@@ -265,6 +291,18 @@ function convertUserContent(content: unknown): unknown[] {
265
291
  return [{ type: "input_text", text: String(content) }];
266
292
  }
267
293
 
294
+ /** Convert content to string, with JSON fallback for non-text values. */
295
+ function stringifyContent(content: unknown): string {
296
+ if (typeof content === "string") return content;
297
+ const text = textOf(content);
298
+ if (text !== null) return text;
299
+ try {
300
+ return JSON.stringify(content);
301
+ } catch {
302
+ return String(content ?? "");
303
+ }
304
+ }
305
+
268
306
  /** Extract text from content (string or array). */
269
307
  function textOf(content: unknown): string | null {
270
308
  if (typeof content === "string") return content;
@@ -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
  }