ampcode-connector 0.1.7 → 0.1.9
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 +6 -6
- package/src/cli/setup.ts +5 -1
- package/src/cli/status.ts +12 -3
- package/src/cli/tui.ts +4 -1
- package/src/providers/anthropic.ts +1 -0
- package/src/providers/antigravity.ts +4 -3
- package/src/providers/base.ts +50 -19
- package/src/providers/codex.ts +87 -3
- package/src/providers/gemini.ts +1 -0
- package/src/routing/cooldown.ts +9 -0
- package/src/server/server.ts +7 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ampcode-connector",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Proxy AmpCode through local OAuth subscriptions (Claude Code, Codex, Gemini CLI, Antigravity)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -40,16 +40,16 @@
|
|
|
40
40
|
"format": "biome check --write src/ tests/"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
|
-
"@biomejs/biome": "^2.4.
|
|
43
|
+
"@biomejs/biome": "^2.4.2",
|
|
44
|
+
"@google/genai": "^1.42.0",
|
|
44
45
|
"@types/bun": "^1.3.9"
|
|
45
46
|
},
|
|
46
47
|
"peerDependencies": {
|
|
47
|
-
"typescript": "^5"
|
|
48
|
+
"typescript": "^5.9.3"
|
|
48
49
|
},
|
|
49
50
|
"dependencies": {
|
|
50
|
-
"@
|
|
51
|
-
"
|
|
52
|
-
"ampcode-connector": "^0.1.5",
|
|
51
|
+
"@kreuzberg/html-to-markdown": "^2.25.1",
|
|
52
|
+
"ampcode-connector": "^0.1.8",
|
|
53
53
|
"exa-js": "^2.4.0"
|
|
54
54
|
}
|
|
55
55
|
}
|
package/src/cli/setup.ts
CHANGED
|
@@ -131,6 +131,7 @@ export async function setup(): Promise<void> {
|
|
|
131
131
|
|
|
132
132
|
for (const p of providers) {
|
|
133
133
|
const connected = p.accounts.filter((a) => a.status === "connected");
|
|
134
|
+
const disabled = p.accounts.filter((a) => a.status === "disabled");
|
|
134
135
|
const total = p.accounts.filter((a) => a.status !== "disconnected");
|
|
135
136
|
|
|
136
137
|
if (connected.length > 0) {
|
|
@@ -140,7 +141,10 @@ export async function setup(): Promise<void> {
|
|
|
140
141
|
.filter(Boolean)
|
|
141
142
|
.join(", ");
|
|
142
143
|
const info = emails ? ` ${s.dim}${emails}${s.reset}` : "";
|
|
143
|
-
|
|
144
|
+
const disabledInfo = disabled.length > 0 ? ` ${s.red}${disabled.length} disabled${s.reset}` : "";
|
|
145
|
+
line(` ${p.label.padEnd(16)} ${s.green}${connected.length} account(s)${s.reset}${info}${disabledInfo}`);
|
|
146
|
+
} else if (disabled.length > 0) {
|
|
147
|
+
line(` ${p.label.padEnd(16)} ${s.red}${disabled.length} disabled${s.reset}`);
|
|
144
148
|
} else if (total.length > 0) {
|
|
145
149
|
line(` ${p.label.padEnd(16)} ${s.yellow}${total.length} expired${s.reset}`);
|
|
146
150
|
} else {
|
package/src/cli/status.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { Credentials, ProviderName } from "../auth/store.ts";
|
|
2
2
|
import * as store from "../auth/store.ts";
|
|
3
|
+
import { cooldown, type QuotaPool } from "../routing/cooldown.ts";
|
|
3
4
|
|
|
4
|
-
export type ConnectionStatus = "connected" | "expired" | "disconnected";
|
|
5
|
+
export type ConnectionStatus = "connected" | "expired" | "disabled" | "disconnected";
|
|
5
6
|
|
|
6
7
|
export interface AccountStatus {
|
|
7
8
|
account: number;
|
|
@@ -23,8 +24,16 @@ const PROVIDERS: { name: ProviderName; label: string; sublabel?: string }[] = [
|
|
|
23
24
|
{ name: "google", label: "Google", sublabel: "Gemini CLI + Antigravity" },
|
|
24
25
|
];
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
const POOL_MAP: Record<ProviderName, QuotaPool[]> = {
|
|
28
|
+
anthropic: ["anthropic"],
|
|
29
|
+
codex: ["codex"],
|
|
30
|
+
google: ["gemini", "antigravity"],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function connectionOf(name: ProviderName, account: number, creds: Credentials): ConnectionStatus {
|
|
27
34
|
if (!creds.refreshToken) return "disconnected";
|
|
35
|
+
const pools = POOL_MAP[name];
|
|
36
|
+
if (pools.some((p) => cooldown.isExhausted(p, account))) return "disabled";
|
|
28
37
|
return store.fresh(creds) ? "connected" : "expired";
|
|
29
38
|
}
|
|
30
39
|
|
|
@@ -35,7 +44,7 @@ export function all(): ProviderStatus[] {
|
|
|
35
44
|
sublabel,
|
|
36
45
|
accounts: store.getAll(name).map((e) => ({
|
|
37
46
|
account: e.account,
|
|
38
|
-
status: connectionOf(e.credentials),
|
|
47
|
+
status: connectionOf(name, e.account, e.credentials),
|
|
39
48
|
email: e.credentials.email,
|
|
40
49
|
expiresAt: e.credentials.expiresAt,
|
|
41
50
|
})),
|
package/src/cli/tui.ts
CHANGED
|
@@ -16,6 +16,7 @@ const oauthConfigs: Record<ProviderName, OAuthConfig> = {
|
|
|
16
16
|
const ICON: Record<ConnectionStatus, string> = {
|
|
17
17
|
connected: `${s.green}●${s.reset}`,
|
|
18
18
|
expired: `${s.yellow}●${s.reset}`,
|
|
19
|
+
disabled: `${s.red}●${s.reset}`,
|
|
19
20
|
disconnected: `${s.dim}○${s.reset}`,
|
|
20
21
|
};
|
|
21
22
|
|
|
@@ -109,7 +110,9 @@ function formatInfo(a: AccountStatus): string {
|
|
|
109
110
|
if (a.status === "disconnected") return `${s.dim}—${s.reset}`;
|
|
110
111
|
|
|
111
112
|
const parts: string[] = [];
|
|
112
|
-
|
|
113
|
+
if (a.status === "connected") parts.push(`${s.green}connected${s.reset}`);
|
|
114
|
+
else if (a.status === "disabled") parts.push(`${s.red}disabled${s.reset}`);
|
|
115
|
+
else parts.push(`${s.yellow}expired${s.reset}`);
|
|
113
116
|
if (a.expiresAt && a.status === "connected") parts.push(`${s.dim}${status.remaining(a.expiresAt)}${s.reset}`);
|
|
114
117
|
if (a.email) parts.push(`${s.dim}${a.email}${s.reset}`);
|
|
115
118
|
return parts.join(`${s.dim} · ${s.reset}`);
|
|
@@ -52,7 +52,7 @@ export const provider: Provider = {
|
|
|
52
52
|
|
|
53
53
|
const gemini = path.gemini(sub);
|
|
54
54
|
const action = gemini?.action ?? "generateContent";
|
|
55
|
-
const model = gemini?.model ?? "";
|
|
55
|
+
const model = gemini?.model === "gemini-3-flash-preview" ? "gemini-3-flash" : (gemini?.model ?? "");
|
|
56
56
|
const requestBody = maybeWrap(body.parsed, body.forwardBody, projectId, model, {
|
|
57
57
|
userAgent: "antigravity",
|
|
58
58
|
requestIdPrefix: "agent",
|
|
@@ -60,7 +60,7 @@ export const provider: Provider = {
|
|
|
60
60
|
});
|
|
61
61
|
const unwrapThenRewrite = withUnwrap(rewrite);
|
|
62
62
|
|
|
63
|
-
return tryEndpoints(requestBody, body.stream, headers, action, unwrapThenRewrite);
|
|
63
|
+
return tryEndpoints(requestBody, body.stream, headers, action, unwrapThenRewrite, creds?.email);
|
|
64
64
|
},
|
|
65
65
|
};
|
|
66
66
|
|
|
@@ -70,13 +70,14 @@ async function tryEndpoints(
|
|
|
70
70
|
headers: Record<string, string>,
|
|
71
71
|
action: string,
|
|
72
72
|
rewrite?: (data: string) => string,
|
|
73
|
+
email?: string,
|
|
73
74
|
): Promise<Response> {
|
|
74
75
|
let lastError: Error | null = null;
|
|
75
76
|
|
|
76
77
|
for (const endpoint of endpoints) {
|
|
77
78
|
const url = buildUrl(endpoint, action);
|
|
78
79
|
try {
|
|
79
|
-
const response = await forward({ url, body, streaming, headers, providerName: "Antigravity", rewrite });
|
|
80
|
+
const response = await forward({ url, body, streaming, headers, providerName: "Antigravity", rewrite, email });
|
|
80
81
|
if (response.status < 500) return response;
|
|
81
82
|
lastError = new Error(`${endpoint} returned ${response.status}`);
|
|
82
83
|
logger.debug("Endpoint 5xx, trying next", { provider: "Antigravity" });
|
package/src/providers/base.ts
CHANGED
|
@@ -26,32 +26,63 @@ interface ForwardOptions {
|
|
|
26
26
|
headers: Record<string, string>;
|
|
27
27
|
providerName: string;
|
|
28
28
|
rewrite?: (data: string) => string;
|
|
29
|
+
email?: string;
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
const RETRYABLE_STATUS = new Set([408, 500, 502, 503, 504]);
|
|
33
|
+
const MAX_RETRIES = 3;
|
|
34
|
+
const RETRY_DELAY_MS = 500;
|
|
35
|
+
|
|
31
36
|
export async function forward(opts: ForwardOptions): Promise<Response> {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
37
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
38
|
+
let response: Response;
|
|
39
|
+
try {
|
|
40
|
+
response = await fetch(opts.url, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: opts.headers,
|
|
43
|
+
body: opts.body,
|
|
44
|
+
});
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (attempt < MAX_RETRIES) {
|
|
47
|
+
logger.debug(`${opts.providerName} fetch error, retry ${attempt + 1}/${MAX_RETRIES}`, {
|
|
48
|
+
error: String(err),
|
|
49
|
+
});
|
|
50
|
+
await Bun.sleep(RETRY_DELAY_MS * (attempt + 1));
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Retry on server errors (429 handled at routing layer)
|
|
57
|
+
if (RETRYABLE_STATUS.has(response.status) && attempt < MAX_RETRIES) {
|
|
58
|
+
await response.text(); // consume body
|
|
59
|
+
logger.debug(`${opts.providerName} returned ${response.status}, retry ${attempt + 1}/${MAX_RETRIES}`);
|
|
60
|
+
await Bun.sleep(RETRY_DELAY_MS * (attempt + 1));
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const contentType = response.headers.get("Content-Type") ?? "application/json";
|
|
65
|
+
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
const text = await response.text();
|
|
68
|
+
const ctx = opts.email ? ` account=${opts.email}` : "";
|
|
69
|
+
logger.error(`${opts.providerName} API error (${response.status})${ctx}`, { error: text.slice(0, 200) });
|
|
70
|
+
return new Response(text, { status: response.status, headers: { "Content-Type": contentType } });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const isSSE = contentType.includes("text/event-stream") || opts.streaming;
|
|
74
|
+
if (isSSE) return sse.proxy(response, opts.rewrite);
|
|
45
75
|
|
|
46
|
-
|
|
47
|
-
|
|
76
|
+
if (opts.rewrite) {
|
|
77
|
+
const text = await response.text();
|
|
78
|
+
return new Response(opts.rewrite(text), { status: response.status, headers: { "Content-Type": contentType } });
|
|
79
|
+
}
|
|
48
80
|
|
|
49
|
-
|
|
50
|
-
const text = await response.text();
|
|
51
|
-
return new Response(opts.rewrite(text), { status: response.status, headers: { "Content-Type": contentType } });
|
|
81
|
+
return new Response(response.body, { status: response.status, headers: { "Content-Type": contentType } });
|
|
52
82
|
}
|
|
53
83
|
|
|
54
|
-
|
|
84
|
+
// Unreachable, but TypeScript needs it
|
|
85
|
+
throw new Error(`${opts.providerName}: all retries exhausted`);
|
|
55
86
|
}
|
|
56
87
|
|
|
57
88
|
export function denied(providerName: string): Response {
|
package/src/providers/codex.ts
CHANGED
|
@@ -24,13 +24,14 @@ export const provider: Provider = {
|
|
|
24
24
|
|
|
25
25
|
accountCount: () => oauth.accountCount(config),
|
|
26
26
|
|
|
27
|
-
async forward(sub, body,
|
|
27
|
+
async forward(sub, body, originalHeaders, rewrite, account = 0) {
|
|
28
28
|
const accessToken = await oauth.token(config, account);
|
|
29
29
|
if (!accessToken) return denied("OpenAI Codex");
|
|
30
30
|
|
|
31
31
|
const accountId = getAccountId(accessToken, account);
|
|
32
32
|
const codexPath = codexPathMap[sub] ?? sub;
|
|
33
|
-
const
|
|
33
|
+
const promptCacheKey = originalHeaders.get("x-amp-thread-id") ?? undefined;
|
|
34
|
+
const { body: codexBody, needsResponseTransform } = transformForCodex(body.forwardBody, promptCacheKey);
|
|
34
35
|
const ampModel = body.ampModel ?? "gpt-5.2";
|
|
35
36
|
|
|
36
37
|
const response = await forward({
|
|
@@ -40,6 +41,7 @@ export const provider: Provider = {
|
|
|
40
41
|
providerName: "OpenAI Codex",
|
|
41
42
|
// Skip generic rewrite when we need full response transform
|
|
42
43
|
rewrite: needsResponseTransform ? undefined : rewrite,
|
|
44
|
+
email: store.get("codex", account)?.email,
|
|
43
45
|
headers: {
|
|
44
46
|
"Content-Type": "application/json",
|
|
45
47
|
Authorization: `Bearer ${accessToken}`,
|
|
@@ -50,6 +52,9 @@ export const provider: Provider = {
|
|
|
50
52
|
"User-Agent": codexHeaderValues.USER_AGENT,
|
|
51
53
|
Version: codexHeaderValues.VERSION,
|
|
52
54
|
...(accountId ? { [codexHeaders.ACCOUNT_ID]: accountId } : {}),
|
|
55
|
+
...(promptCacheKey
|
|
56
|
+
? { [codexHeaders.SESSION_ID]: promptCacheKey, [codexHeaders.CONVERSATION_ID]: promptCacheKey }
|
|
57
|
+
: {}),
|
|
53
58
|
},
|
|
54
59
|
});
|
|
55
60
|
|
|
@@ -79,7 +84,20 @@ interface ToolCallItem {
|
|
|
79
84
|
function: { name: string; arguments: string };
|
|
80
85
|
}
|
|
81
86
|
|
|
82
|
-
function
|
|
87
|
+
function clampReasoningEffort(model: string, effort: string): string {
|
|
88
|
+
const modelId = model.includes("/") ? model.split("/").pop()! : model;
|
|
89
|
+
if (modelId === "gpt-5.1" && effort === "xhigh") return "high";
|
|
90
|
+
if ((modelId.startsWith("gpt-5.2") || modelId.startsWith("gpt-5.3")) && effort === "minimal") return "low";
|
|
91
|
+
if (modelId === "gpt-5.1-codex-mini") {
|
|
92
|
+
return effort === "high" || effort === "xhigh" ? "high" : "medium";
|
|
93
|
+
}
|
|
94
|
+
return effort;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function transformForCodex(
|
|
98
|
+
rawBody: string,
|
|
99
|
+
promptCacheKey?: string,
|
|
100
|
+
): { body: string; needsResponseTransform: boolean } {
|
|
83
101
|
if (!rawBody) return { body: rawBody, needsResponseTransform: false };
|
|
84
102
|
|
|
85
103
|
let parsed: Record<string, unknown>;
|
|
@@ -111,11 +129,28 @@ function transformForCodex(rawBody: string): { body: string; needsResponseTransf
|
|
|
111
129
|
// Strip id fields from input items
|
|
112
130
|
if (Array.isArray(parsed.input)) {
|
|
113
131
|
stripInputIds(parsed.input as Record<string, unknown>[]);
|
|
132
|
+
fixOrphanOutputs(parsed.input as Record<string, unknown>[]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Reasoning config — defaults match reference behavior
|
|
136
|
+
const model = (parsed.model as string) ?? "";
|
|
137
|
+
parsed.reasoning = {
|
|
138
|
+
effort: clampReasoningEffort(model, "high"),
|
|
139
|
+
summary: "auto",
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
parsed.text = { verbosity: "medium" };
|
|
143
|
+
|
|
144
|
+
parsed.include = ["reasoning.encrypted_content"];
|
|
145
|
+
|
|
146
|
+
if (promptCacheKey) {
|
|
147
|
+
parsed.prompt_cache_key = promptCacheKey;
|
|
114
148
|
}
|
|
115
149
|
|
|
116
150
|
// Remove fields the Codex backend doesn't accept
|
|
117
151
|
delete parsed.max_tokens;
|
|
118
152
|
delete parsed.max_completion_tokens;
|
|
153
|
+
delete parsed.max_output_tokens;
|
|
119
154
|
// Chat Completions fields not in Responses API
|
|
120
155
|
delete parsed.frequency_penalty;
|
|
121
156
|
delete parsed.logprobs;
|
|
@@ -127,6 +162,21 @@ function transformForCodex(rawBody: string): { body: string; needsResponseTransf
|
|
|
127
162
|
delete parsed.logit_bias;
|
|
128
163
|
delete parsed.response_format;
|
|
129
164
|
|
|
165
|
+
// Normalize tool_choice for Responses API
|
|
166
|
+
if (parsed.tool_choice !== undefined && parsed.tool_choice !== null) {
|
|
167
|
+
if (typeof parsed.tool_choice === "string") {
|
|
168
|
+
// "auto", "none", "required" pass through as-is
|
|
169
|
+
} else if (typeof parsed.tool_choice === "object") {
|
|
170
|
+
const tc = parsed.tool_choice as Record<string, unknown>;
|
|
171
|
+
if (tc.type === "function" && tc.function) {
|
|
172
|
+
const fn = tc.function as Record<string, unknown>;
|
|
173
|
+
parsed.tool_choice = { type: "function", name: fn.name };
|
|
174
|
+
} else if (tc.type === "tool" && tc.name) {
|
|
175
|
+
parsed.tool_choice = { type: "function", name: tc.name };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
130
180
|
return { body: JSON.stringify(parsed), needsResponseTransform };
|
|
131
181
|
}
|
|
132
182
|
|
|
@@ -251,6 +301,40 @@ function stripInputIds(items: Record<string, unknown>[]): void {
|
|
|
251
301
|
}
|
|
252
302
|
}
|
|
253
303
|
|
|
304
|
+
/** Convert orphan function_call_output items (no matching function_call) to assistant messages. */
|
|
305
|
+
function fixOrphanOutputs(items: Record<string, unknown>[]): void {
|
|
306
|
+
const callIds = new Set(
|
|
307
|
+
items.filter((i) => i.type === "function_call" && typeof i.call_id === "string").map((i) => i.call_id as string),
|
|
308
|
+
);
|
|
309
|
+
for (let i = 0; i < items.length; i++) {
|
|
310
|
+
const item = items[i]!;
|
|
311
|
+
if (item.type === "function_call_output" && typeof item.call_id === "string" && !callIds.has(item.call_id)) {
|
|
312
|
+
const toolName = typeof item.name === "string" ? (item.name as string) : "tool";
|
|
313
|
+
let text = "";
|
|
314
|
+
try {
|
|
315
|
+
text = typeof item.output === "string" ? (item.output as string) : JSON.stringify(item.output);
|
|
316
|
+
} catch {
|
|
317
|
+
text = String(item.output ?? "");
|
|
318
|
+
}
|
|
319
|
+
if (text.length > 16000) {
|
|
320
|
+
text = `${text.slice(0, 16000)}\n...[truncated]`;
|
|
321
|
+
}
|
|
322
|
+
items[i] = {
|
|
323
|
+
type: "message",
|
|
324
|
+
role: "assistant",
|
|
325
|
+
content: [
|
|
326
|
+
{
|
|
327
|
+
type: "output_text",
|
|
328
|
+
text: `[Previous ${toolName} result; call_id=${item.call_id}]: ${text}`,
|
|
329
|
+
annotations: [],
|
|
330
|
+
},
|
|
331
|
+
],
|
|
332
|
+
status: "completed",
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
254
338
|
/** Extract chatgpt_account_id from JWT, falling back to stored credentials. */
|
|
255
339
|
function getAccountId(accessToken: string, account: number): string | undefined {
|
|
256
340
|
const creds = store.get("codex", account);
|
package/src/providers/gemini.ts
CHANGED
package/src/routing/cooldown.ts
CHANGED
|
@@ -12,6 +12,8 @@ interface CooldownEntry {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/** When detected as exhausted, cooldown for this long. */
|
|
15
|
+
/** 403 = account disabled/revoked — long cooldown. */
|
|
16
|
+
const FORBIDDEN_COOLDOWN_MS = 24 * 3600_000;
|
|
15
17
|
const EXHAUSTED_COOLDOWN_MS = 2 * 3600_000;
|
|
16
18
|
/** Retry-After threshold (seconds) above which we consider quota exhausted. */
|
|
17
19
|
const EXHAUSTED_THRESHOLD_S = 300;
|
|
@@ -65,6 +67,13 @@ export class CooldownTracker {
|
|
|
65
67
|
this.entries.set(k, entry);
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
/** 403 = account forbidden/revoked. Immediately disable for 24h. */
|
|
71
|
+
record403(pool: QuotaPool, account: number): void {
|
|
72
|
+
const k = this.key(pool, account);
|
|
73
|
+
this.entries.set(k, { until: Date.now() + FORBIDDEN_COOLDOWN_MS, exhausted: true, consecutive429: 0 });
|
|
74
|
+
logger.warn(`Account disabled (403): ${k}`, { cooldownHours: FORBIDDEN_COOLDOWN_MS / 3600_000 });
|
|
75
|
+
}
|
|
76
|
+
|
|
68
77
|
recordSuccess(pool: QuotaPool, account: number): void {
|
|
69
78
|
this.entries.delete(this.key(pool, account));
|
|
70
79
|
}
|
package/src/server/server.ts
CHANGED
|
@@ -5,6 +5,7 @@ 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";
|
|
8
9
|
import { tryReroute, tryWithCachePreserve } from "../routing/retry.ts";
|
|
9
10
|
import { recordSuccess, routeRequest } from "../routing/router.ts";
|
|
10
11
|
import { handleInternal, isLocalMethod } from "../tools/internal.ts";
|
|
@@ -17,6 +18,7 @@ export function startServer(config: ProxyConfig): ReturnType<typeof Bun.serve> {
|
|
|
17
18
|
const server = Bun.serve({
|
|
18
19
|
port: config.port,
|
|
19
20
|
hostname: "localhost",
|
|
21
|
+
idleTimeout: 255, // seconds — LLM streaming responses can take minutes
|
|
20
22
|
|
|
21
23
|
async fetch(req) {
|
|
22
24
|
const startTime = Date.now();
|
|
@@ -30,7 +32,7 @@ export function startServer(config: ProxyConfig): ReturnType<typeof Bun.serve> {
|
|
|
30
32
|
logger.error("Unhandled server error", { error: String(err) });
|
|
31
33
|
return Response.json({ error: "Internal proxy error" }, { status });
|
|
32
34
|
} finally {
|
|
33
|
-
logger.info(`${req.method} ${url.pathname} ${status}`, { duration: Date.now() - startTime });
|
|
35
|
+
logger.info(`${req.method} ${url.pathname}${url.search} ${status}`, { duration: Date.now() - startTime });
|
|
34
36
|
}
|
|
35
37
|
},
|
|
36
38
|
});
|
|
@@ -119,6 +121,10 @@ async function handleProvider(
|
|
|
119
121
|
);
|
|
120
122
|
response = rerouted ?? (await fallbackUpstream(req, body, config));
|
|
121
123
|
}
|
|
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);
|
|
122
128
|
} else if (handlerResponse.status === 401) {
|
|
123
129
|
logger.debug("Local provider denied, falling back to upstream");
|
|
124
130
|
response = await fallbackUpstream(req, body, config);
|