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 +2 -1
- package/src/auth/configs.ts +1 -1
- package/src/constants.ts +8 -4
- package/src/providers/anthropic.ts +90 -15
- package/src/providers/codex-sse.ts +140 -21
- package/src/providers/codex.ts +45 -7
- package/src/providers/forward.ts +23 -1
- package/src/routing/retry.ts +38 -24
- package/src/routing/router.ts +3 -6
- package/src/server/server.ts +9 -20
- package/src/utils/code-assist.ts +67 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ampcode-connector",
|
|
3
|
-
"version": "0.1.
|
|
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"
|
package/src/auth/configs.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
47
|
-
"X-Stainless-Package-Version": "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 = ["
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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":
|
|
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
|
|
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 =
|
|
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 —
|
|
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
|
-
|
|
153
|
+
const finishReason = incompleteReason(resp);
|
|
154
|
+
return serializeFinish(state, finishReason, usage);
|
|
138
155
|
}
|
|
139
156
|
|
|
140
|
-
// Response failed — emit
|
|
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
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
346
|
-
|
|
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
|
}
|
package/src/providers/codex.ts
CHANGED
|
@@ -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, "
|
|
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
|
|
148
|
+
const existingText = (parsed.text as Record<string, unknown>) ?? {};
|
|
149
|
+
parsed.text = { ...existingText, verbosity: existingText.verbosity ?? "medium" };
|
|
146
150
|
|
|
147
|
-
parsed.include
|
|
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:
|
|
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;
|
package/src/providers/forward.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/routing/retry.ts
CHANGED
|
@@ -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,
|
|
6
|
+
import { cooldown, parseRetryAfter, type QuotaPool } from "./cooldown.ts";
|
|
7
|
+
import { type RouteResult, recordSuccess, reroute } from "./router.ts";
|
|
8
8
|
|
|
9
|
-
/** Max
|
|
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
|
-
/**
|
|
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
|
|
55
|
+
/** Reroute to different accounts/pools after a retryable failure (429/403). */
|
|
42
56
|
export async function tryReroute(
|
|
43
|
-
|
|
44
|
-
ampModel: string | null,
|
|
45
|
-
config: ProxyConfig,
|
|
57
|
+
ctx: RerouteContext,
|
|
46
58
|
initialRoute: RouteResult,
|
|
47
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
68
|
-
|
|
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
|
+
}
|
package/src/routing/router.ts
CHANGED
|
@@ -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
|
|
127
|
-
|
|
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
|
}
|
package/src/server/server.ts
CHANGED
|
@@ -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
|
|
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);
|
package/src/utils/code-assist.ts
CHANGED
|
@@ -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
|
}
|