ampcode-connector 0.1.16 → 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 +25 -3
- package/src/providers/codex.ts +5 -2
- 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 = {
|
|
@@ -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 "";
|
|
@@ -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 =
|
|
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 =
|
|
447
|
+
const idx = lookupToolIndex(state, itemId, callId);
|
|
426
448
|
const tc = toolCalls.get(idx);
|
|
427
449
|
if (tc) tc.function.arguments += delta;
|
|
428
450
|
}
|
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
|
|
|
@@ -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) ?? "
|
|
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;
|
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
|
}
|