ampcode-connector 0.1.0
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/config.yaml +20 -0
- package/package.json +51 -0
- package/src/auth/callback-server.ts +92 -0
- package/src/auth/configs.ts +57 -0
- package/src/auth/discovery.ts +100 -0
- package/src/auth/oauth.ts +183 -0
- package/src/auth/pkce.ts +22 -0
- package/src/auth/store.ts +105 -0
- package/src/cli/ansi.ts +37 -0
- package/src/cli/setup.ts +162 -0
- package/src/cli/status.ts +52 -0
- package/src/cli/tui.ts +177 -0
- package/src/config/config.ts +108 -0
- package/src/constants.ts +64 -0
- package/src/index.ts +70 -0
- package/src/providers/anthropic.ts +65 -0
- package/src/providers/antigravity.ts +110 -0
- package/src/providers/base.ts +68 -0
- package/src/providers/codex.ts +36 -0
- package/src/providers/gemini.ts +83 -0
- package/src/proxy/rewriter.ts +69 -0
- package/src/proxy/upstream.ts +41 -0
- package/src/routing/affinity.ts +56 -0
- package/src/routing/cooldown.ts +88 -0
- package/src/routing/router.ts +201 -0
- package/src/server/server.ts +147 -0
- package/src/utils/browser.ts +19 -0
- package/src/utils/code-assist.ts +42 -0
- package/src/utils/encoding.ts +9 -0
- package/src/utils/logger.ts +80 -0
- package/src/utils/path.ts +52 -0
- package/src/utils/streaming.ts +124 -0
- package/tsconfig.json +30 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/** Forwards requests to Cloud Code Assist API using Antigravity quota.
|
|
2
|
+
* Uses the shared Google OAuth token with Antigravity headers/endpoints.
|
|
3
|
+
* Tries multiple endpoints with fallback on 5xx. */
|
|
4
|
+
|
|
5
|
+
import { google as config } from "../auth/configs.ts";
|
|
6
|
+
import * as oauth from "../auth/oauth.ts";
|
|
7
|
+
import * as store from "../auth/store.ts";
|
|
8
|
+
import { ANTIGRAVITY_DAILY_ENDPOINT, AUTOPUSH_ENDPOINT, CODE_ASSIST_ENDPOINT } from "../constants.ts";
|
|
9
|
+
import * as codeAssist from "../utils/code-assist.ts";
|
|
10
|
+
import { logger } from "../utils/logger.ts";
|
|
11
|
+
import * as path from "../utils/path.ts";
|
|
12
|
+
import type { Provider } from "./base.ts";
|
|
13
|
+
import { denied, forward } from "./base.ts";
|
|
14
|
+
|
|
15
|
+
const endpoints = [ANTIGRAVITY_DAILY_ENDPOINT, AUTOPUSH_ENDPOINT, CODE_ASSIST_ENDPOINT];
|
|
16
|
+
|
|
17
|
+
const antigravityHeaders: Readonly<Record<string, string>> = {
|
|
18
|
+
"User-Agent": "antigravity/1.15.8 darwin/arm64",
|
|
19
|
+
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
|
20
|
+
"Client-Metadata": JSON.stringify({
|
|
21
|
+
ideType: "IDE_UNSPECIFIED",
|
|
22
|
+
platform: "PLATFORM_UNSPECIFIED",
|
|
23
|
+
pluginType: "GEMINI",
|
|
24
|
+
}),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const provider: Provider = {
|
|
28
|
+
name: "Antigravity",
|
|
29
|
+
routeDecision: "LOCAL_ANTIGRAVITY",
|
|
30
|
+
|
|
31
|
+
isAvailable: (account?: number) =>
|
|
32
|
+
account !== undefined ? !!store.get("google", account)?.refreshToken : oauth.ready(config),
|
|
33
|
+
|
|
34
|
+
accountCount: () => oauth.accountCount(config),
|
|
35
|
+
|
|
36
|
+
async forward(sub, body, originalHeaders, rewrite, account = 0) {
|
|
37
|
+
const accessToken = await oauth.token(config, account);
|
|
38
|
+
if (!accessToken) return denied("Antigravity");
|
|
39
|
+
|
|
40
|
+
const creds = store.get("google", account);
|
|
41
|
+
const projectId = creds?.projectId ?? "";
|
|
42
|
+
|
|
43
|
+
const headers: Record<string, string> = {
|
|
44
|
+
...antigravityHeaders,
|
|
45
|
+
Authorization: `Bearer ${accessToken}`,
|
|
46
|
+
"Content-Type": "application/json",
|
|
47
|
+
Accept: "text/event-stream",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const anthropicBeta = originalHeaders.get("anthropic-beta");
|
|
51
|
+
if (anthropicBeta) headers["anthropic-beta"] = anthropicBeta;
|
|
52
|
+
|
|
53
|
+
const gemini = path.gemini(sub);
|
|
54
|
+
const action = gemini?.action ?? "generateContent";
|
|
55
|
+
const model = gemini?.model ?? "";
|
|
56
|
+
const requestBody = maybeWrap(body, projectId, model);
|
|
57
|
+
const unwrapThenRewrite = withUnwrap(rewrite);
|
|
58
|
+
|
|
59
|
+
return tryEndpoints(requestBody, headers, action, unwrapThenRewrite);
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function withUnwrap(rewrite?: (d: string) => string): (d: string) => string {
|
|
64
|
+
return rewrite ? (d: string) => rewrite(codeAssist.unwrap(d)) : codeAssist.unwrap;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function maybeWrap(body: string, projectId: string, model: string): string {
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(body) as Record<string, unknown>;
|
|
70
|
+
if (parsed["project"]) return body;
|
|
71
|
+
return codeAssist.wrapRequest({
|
|
72
|
+
projectId,
|
|
73
|
+
model,
|
|
74
|
+
body: parsed,
|
|
75
|
+
userAgent: "antigravity",
|
|
76
|
+
requestIdPrefix: "agent",
|
|
77
|
+
requestType: "agent",
|
|
78
|
+
});
|
|
79
|
+
} catch (err) {
|
|
80
|
+
logger.debug("Body parse failed, forwarding as-is", { error: String(err) });
|
|
81
|
+
return body;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function tryEndpoints(
|
|
86
|
+
body: string,
|
|
87
|
+
headers: Record<string, string>,
|
|
88
|
+
action: string,
|
|
89
|
+
rewrite?: (data: string) => string,
|
|
90
|
+
): Promise<Response> {
|
|
91
|
+
let lastError: Error | null = null;
|
|
92
|
+
|
|
93
|
+
for (const endpoint of endpoints) {
|
|
94
|
+
const url = codeAssist.buildUrl(endpoint, action);
|
|
95
|
+
try {
|
|
96
|
+
const response = await forward({ url, body, headers, providerName: "Antigravity", rewrite });
|
|
97
|
+
if (response.status < 500) return response;
|
|
98
|
+
lastError = new Error(`${endpoint} returned ${response.status}`);
|
|
99
|
+
logger.debug("Endpoint 5xx, trying next", { provider: "Antigravity" });
|
|
100
|
+
} catch (err) {
|
|
101
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
102
|
+
logger.debug("Endpoint failed, trying next", { error: String(err) });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return new Response(JSON.stringify({ error: `All Antigravity endpoints failed: ${lastError?.message}` }), {
|
|
107
|
+
status: 502,
|
|
108
|
+
headers: { "Content-Type": "application/json" },
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Provider interface and shared request forwarding. */
|
|
2
|
+
|
|
3
|
+
import type { RouteDecision } from "../utils/logger.ts";
|
|
4
|
+
import { logger } from "../utils/logger.ts";
|
|
5
|
+
import * as path from "../utils/path.ts";
|
|
6
|
+
import * as sse from "../utils/streaming.ts";
|
|
7
|
+
|
|
8
|
+
export interface Provider {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly routeDecision: RouteDecision;
|
|
11
|
+
isAvailable(account?: number): boolean;
|
|
12
|
+
accountCount(): number;
|
|
13
|
+
forward(
|
|
14
|
+
path: string,
|
|
15
|
+
body: string,
|
|
16
|
+
headers: Headers,
|
|
17
|
+
rewrite?: (data: string) => string,
|
|
18
|
+
account?: number,
|
|
19
|
+
): Promise<Response>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ForwardOptions {
|
|
23
|
+
url: string;
|
|
24
|
+
body: string;
|
|
25
|
+
headers: Record<string, string>;
|
|
26
|
+
providerName: string;
|
|
27
|
+
rewrite?: (data: string) => string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function forward(opts: ForwardOptions): Promise<Response> {
|
|
31
|
+
const response = await fetch(opts.url, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: opts.headers,
|
|
34
|
+
body: opts.body,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const isSSE = response.headers.get("Content-Type")?.includes("text/event-stream") || path.streaming(opts.body);
|
|
38
|
+
if (isSSE) return sse.proxy(response, opts.rewrite);
|
|
39
|
+
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
const text = await response.text();
|
|
42
|
+
logger.error(`${opts.providerName} API error`, { error: text.slice(0, 200) });
|
|
43
|
+
return new Response(text, {
|
|
44
|
+
status: response.status,
|
|
45
|
+
headers: { "Content-Type": response.headers.get("Content-Type") ?? "application/json" },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (opts.rewrite) {
|
|
50
|
+
const text = await response.text();
|
|
51
|
+
return new Response(opts.rewrite(text), {
|
|
52
|
+
status: response.status,
|
|
53
|
+
headers: { "Content-Type": response.headers.get("Content-Type") ?? "application/json" },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return new Response(response.body, {
|
|
58
|
+
status: response.status,
|
|
59
|
+
headers: { "Content-Type": response.headers.get("Content-Type") ?? "application/json" },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function denied(providerName: string): Response {
|
|
64
|
+
return new Response(JSON.stringify({ error: `No ${providerName} OAuth token available. Run login first.` }), {
|
|
65
|
+
status: 401,
|
|
66
|
+
headers: { "Content-Type": "application/json" },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Forwards requests to api.openai.com with Codex CLI OAuth token. */
|
|
2
|
+
|
|
3
|
+
import { codex as config } from "../auth/configs.ts";
|
|
4
|
+
import * as oauth from "../auth/oauth.ts";
|
|
5
|
+
import * as store from "../auth/store.ts";
|
|
6
|
+
import { OPENAI_API_URL } from "../constants.ts";
|
|
7
|
+
import * as path from "../utils/path.ts";
|
|
8
|
+
import type { Provider } from "./base.ts";
|
|
9
|
+
import { denied, forward } from "./base.ts";
|
|
10
|
+
|
|
11
|
+
export const provider: Provider = {
|
|
12
|
+
name: "OpenAI Codex",
|
|
13
|
+
routeDecision: "LOCAL_CODEX",
|
|
14
|
+
|
|
15
|
+
isAvailable: (account?: number) =>
|
|
16
|
+
account !== undefined ? !!store.get("codex", account)?.refreshToken : oauth.ready(config),
|
|
17
|
+
|
|
18
|
+
accountCount: () => oauth.accountCount(config),
|
|
19
|
+
|
|
20
|
+
async forward(sub, body, _originalHeaders, rewrite, account = 0) {
|
|
21
|
+
const accessToken = await oauth.token(config, account);
|
|
22
|
+
if (!accessToken) return denied("OpenAI Codex");
|
|
23
|
+
|
|
24
|
+
return forward({
|
|
25
|
+
url: `${OPENAI_API_URL}${sub}`,
|
|
26
|
+
body,
|
|
27
|
+
providerName: "OpenAI Codex",
|
|
28
|
+
rewrite,
|
|
29
|
+
headers: {
|
|
30
|
+
"Content-Type": "application/json",
|
|
31
|
+
Authorization: `Bearer ${accessToken}`,
|
|
32
|
+
Accept: path.streaming(body) ? "text/event-stream" : "application/json",
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** Forwards requests to Cloud Code Assist API using Gemini CLI quota.
|
|
2
|
+
* Amp CLI uses @google/genai SDK with vertexai:true — sends Vertex AI format
|
|
3
|
+
* (e.g. /v1beta1/publishers/google/models/{model}:streamGenerateContent?alt=sse).
|
|
4
|
+
* We wrap the native body in a CCA envelope, forward to cloudcode-pa, and unwrap
|
|
5
|
+
* the response so Amp CLI sees standard Vertex AI SSE chunks. */
|
|
6
|
+
|
|
7
|
+
import { google as config } from "../auth/configs.ts";
|
|
8
|
+
import * as oauth from "../auth/oauth.ts";
|
|
9
|
+
import * as store from "../auth/store.ts";
|
|
10
|
+
import { CODE_ASSIST_ENDPOINT } from "../constants.ts";
|
|
11
|
+
import * as codeAssist from "../utils/code-assist.ts";
|
|
12
|
+
import { logger } from "../utils/logger.ts";
|
|
13
|
+
import * as path from "../utils/path.ts";
|
|
14
|
+
import type { Provider } from "./base.ts";
|
|
15
|
+
import { denied, forward } from "./base.ts";
|
|
16
|
+
|
|
17
|
+
const geminiHeaders: Readonly<Record<string, string>> = {
|
|
18
|
+
"User-Agent": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
|
19
|
+
"X-Goog-Api-Client": "gl-node/22.17.0",
|
|
20
|
+
"Client-Metadata": JSON.stringify({
|
|
21
|
+
ideType: "IDE_UNSPECIFIED",
|
|
22
|
+
platform: "PLATFORM_UNSPECIFIED",
|
|
23
|
+
pluginType: "GEMINI",
|
|
24
|
+
}),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const provider: Provider = {
|
|
28
|
+
name: "Gemini CLI",
|
|
29
|
+
routeDecision: "LOCAL_GEMINI",
|
|
30
|
+
|
|
31
|
+
isAvailable: (account?: number) =>
|
|
32
|
+
account !== undefined ? !!store.get("google", account)?.refreshToken : oauth.ready(config),
|
|
33
|
+
|
|
34
|
+
accountCount: () => oauth.accountCount(config),
|
|
35
|
+
|
|
36
|
+
async forward(sub, body, _originalHeaders, rewrite, account = 0) {
|
|
37
|
+
const accessToken = await oauth.token(config, account);
|
|
38
|
+
if (!accessToken) return denied("Gemini CLI");
|
|
39
|
+
|
|
40
|
+
const creds = store.get("google", account);
|
|
41
|
+
const projectId = creds?.projectId ?? "";
|
|
42
|
+
|
|
43
|
+
const headers: Record<string, string> = {
|
|
44
|
+
...geminiHeaders,
|
|
45
|
+
Authorization: `Bearer ${accessToken}`,
|
|
46
|
+
"Content-Type": "application/json",
|
|
47
|
+
Accept: "text/event-stream",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const gemini = path.gemini(sub);
|
|
51
|
+
if (!gemini) {
|
|
52
|
+
logger.debug(`Non-model Gemini path, cannot route to CCA: ${sub}`);
|
|
53
|
+
return denied("Gemini CLI (unsupported path)");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const url = codeAssist.buildUrl(CODE_ASSIST_ENDPOINT, gemini.action);
|
|
57
|
+
const requestBody = maybeWrap(body, projectId, gemini.model);
|
|
58
|
+
const unwrapThenRewrite = withUnwrap(rewrite);
|
|
59
|
+
|
|
60
|
+
return forward({ url, body: requestBody, headers, providerName: "Gemini CLI", rewrite: unwrapThenRewrite });
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function withUnwrap(rewrite?: (d: string) => string): (d: string) => string {
|
|
65
|
+
return rewrite ? (d: string) => rewrite(codeAssist.unwrap(d)) : codeAssist.unwrap;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function maybeWrap(body: string, projectId: string, model: string): string {
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(body) as Record<string, unknown>;
|
|
71
|
+
if (parsed["project"]) return body;
|
|
72
|
+
return codeAssist.wrapRequest({
|
|
73
|
+
projectId,
|
|
74
|
+
model,
|
|
75
|
+
body: parsed,
|
|
76
|
+
userAgent: "pi-coding-agent",
|
|
77
|
+
requestIdPrefix: "pi",
|
|
78
|
+
});
|
|
79
|
+
} catch (err) {
|
|
80
|
+
logger.debug("Body parse failed, forwarding as-is", { error: String(err) });
|
|
81
|
+
return body;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE response rewriting: model name substitution + thinking block suppression.
|
|
3
|
+
*
|
|
4
|
+
* Thinking blocks are filtered when tool_use is present because the Amp client
|
|
5
|
+
* struggles with both simultaneously (ref: CLIProxyAPI response_rewriter.go:72-94).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { modelFieldPaths } from "../constants.ts";
|
|
9
|
+
|
|
10
|
+
export function rewrite(originalModel: string): (data: string) => string {
|
|
11
|
+
return (data: string) => {
|
|
12
|
+
if (data === "[DONE]") return data;
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(data) as Record<string, unknown>;
|
|
16
|
+
let modified = false;
|
|
17
|
+
|
|
18
|
+
for (const path of modelFieldPaths) {
|
|
19
|
+
const current = getField(parsed, path);
|
|
20
|
+
if (current !== undefined && current !== originalModel) {
|
|
21
|
+
setField(parsed, path, originalModel);
|
|
22
|
+
modified = true;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (suppressThinking(parsed)) modified = true;
|
|
27
|
+
|
|
28
|
+
return modified ? JSON.stringify(parsed) : data;
|
|
29
|
+
} catch {
|
|
30
|
+
return data;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getField(obj: Record<string, unknown>, path: string): unknown {
|
|
36
|
+
const parts = path.split(".");
|
|
37
|
+
let current: unknown = obj;
|
|
38
|
+
for (const part of parts) {
|
|
39
|
+
if (current == null || typeof current !== "object") return undefined;
|
|
40
|
+
current = (current as Record<string, unknown>)[part];
|
|
41
|
+
}
|
|
42
|
+
return current;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function setField(obj: Record<string, unknown>, path: string, value: unknown): void {
|
|
46
|
+
const parts = path.split(".");
|
|
47
|
+
let current: unknown = obj;
|
|
48
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
49
|
+
if (current == null || typeof current !== "object") return;
|
|
50
|
+
current = (current as Record<string, unknown>)[parts[i]!];
|
|
51
|
+
}
|
|
52
|
+
if (current != null && typeof current === "object") {
|
|
53
|
+
(current as Record<string, unknown>)[parts[parts.length - 1]!] = value;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function suppressThinking(data: Record<string, unknown>): boolean {
|
|
58
|
+
const content = data["content"];
|
|
59
|
+
if (!Array.isArray(content)) return false;
|
|
60
|
+
|
|
61
|
+
const hasToolUse = content.some((b: Record<string, unknown>) => b["type"] === "tool_use");
|
|
62
|
+
if (!hasToolUse) return false;
|
|
63
|
+
|
|
64
|
+
const hasThinking = content.some((b: Record<string, unknown>) => b["type"] === "thinking");
|
|
65
|
+
if (!hasThinking) return false;
|
|
66
|
+
|
|
67
|
+
data["content"] = content.filter((b: Record<string, unknown>) => b["type"] !== "thinking");
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Reverse proxy to ampcode.com for non-intercepted routes and fallback. */
|
|
2
|
+
|
|
3
|
+
import { logger } from "../utils/logger.ts";
|
|
4
|
+
|
|
5
|
+
export async function forward(request: Request, ampUpstreamUrl: string, ampApiKey?: string): Promise<Response> {
|
|
6
|
+
const url = new URL(request.url);
|
|
7
|
+
const upstreamUrl = new URL(url.pathname + url.search, ampUpstreamUrl);
|
|
8
|
+
|
|
9
|
+
const upstreamHost = new URL(ampUpstreamUrl).host;
|
|
10
|
+
const headers = new Headers(request.headers);
|
|
11
|
+
if (ampApiKey) headers.set("Authorization", `Bearer ${ampApiKey}`);
|
|
12
|
+
headers.set("Host", upstreamHost);
|
|
13
|
+
|
|
14
|
+
logger.debug("Forwarding to Amp upstream", { provider: "amp" });
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const response = await fetch(upstreamUrl.toString(), {
|
|
18
|
+
method: request.method,
|
|
19
|
+
headers,
|
|
20
|
+
redirect: "manual",
|
|
21
|
+
body: request.method !== "GET" && request.method !== "HEAD" ? request.body : undefined,
|
|
22
|
+
duplex: "half" as const,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const responseHeaders = new Headers(response.headers);
|
|
26
|
+
responseHeaders.delete("Content-Encoding");
|
|
27
|
+
responseHeaders.delete("Content-Length");
|
|
28
|
+
|
|
29
|
+
return new Response(response.body, {
|
|
30
|
+
status: response.status,
|
|
31
|
+
statusText: response.statusText,
|
|
32
|
+
headers: responseHeaders,
|
|
33
|
+
});
|
|
34
|
+
} catch (err) {
|
|
35
|
+
logger.error("Upstream proxy error", { error: String(err) });
|
|
36
|
+
return new Response(JSON.stringify({ error: "Failed to connect to Amp upstream", details: String(err) }), {
|
|
37
|
+
status: 502,
|
|
38
|
+
headers: { "Content-Type": "application/json" },
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Thread → (quotaPool, account) affinity map.
|
|
2
|
+
* Ensures a thread sticks to the same account for session consistency. */
|
|
3
|
+
|
|
4
|
+
import type { QuotaPool } from "./cooldown.ts";
|
|
5
|
+
|
|
6
|
+
interface AffinityEntry {
|
|
7
|
+
pool: QuotaPool;
|
|
8
|
+
account: number;
|
|
9
|
+
assignedAt: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Affinity expires after 2 hours of inactivity. */
|
|
13
|
+
const TTL_MS = 2 * 3600_000;
|
|
14
|
+
/** Cleanup stale entries every 10 minutes. */
|
|
15
|
+
const CLEANUP_INTERVAL_MS = 10 * 60_000;
|
|
16
|
+
|
|
17
|
+
const map = new Map<string, AffinityEntry>();
|
|
18
|
+
|
|
19
|
+
export function get(threadId: string): AffinityEntry | undefined {
|
|
20
|
+
const entry = map.get(threadId);
|
|
21
|
+
if (!entry) return undefined;
|
|
22
|
+
if (Date.now() - entry.assignedAt > TTL_MS) {
|
|
23
|
+
map.delete(threadId);
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
return entry;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function set(threadId: string, pool: QuotaPool, account: number): void {
|
|
30
|
+
map.set(threadId, { pool, account, assignedAt: Date.now() });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Break affinity when account is exhausted — allow re-routing. */
|
|
34
|
+
export function clear(threadId: string): void {
|
|
35
|
+
map.delete(threadId);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Count active threads pinned to a specific (pool, account). */
|
|
39
|
+
export function activeCount(pool: QuotaPool, account: number): number {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
let count = 0;
|
|
42
|
+
for (const entry of map.values()) {
|
|
43
|
+
if (entry.pool === pool && entry.account === account && now - entry.assignedAt <= TTL_MS) {
|
|
44
|
+
count++;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return count;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Periodic cleanup of expired entries. */
|
|
51
|
+
setInterval(() => {
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
for (const [threadId, entry] of map) {
|
|
54
|
+
if (now - entry.assignedAt > TTL_MS) map.delete(threadId);
|
|
55
|
+
}
|
|
56
|
+
}, CLEANUP_INTERVAL_MS);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** Per-(quotaPool, account) cooldown tracking.
|
|
2
|
+
* Distinguishes short burst 429s from quota exhaustion. */
|
|
3
|
+
|
|
4
|
+
import { logger } from "../utils/logger.ts";
|
|
5
|
+
|
|
6
|
+
export type QuotaPool = "anthropic" | "codex" | "gemini" | "antigravity";
|
|
7
|
+
|
|
8
|
+
interface CooldownEntry {
|
|
9
|
+
until: number;
|
|
10
|
+
exhausted: boolean;
|
|
11
|
+
consecutive429: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Consecutive 429s within this window count toward exhaustion detection. */
|
|
15
|
+
const CONSECUTIVE_WINDOW_MS = 2 * 60_000;
|
|
16
|
+
/** When detected as exhausted, cooldown for this long. */
|
|
17
|
+
const EXHAUSTED_COOLDOWN_MS = 2 * 3600_000;
|
|
18
|
+
/** Retry-After threshold (seconds) above which we consider quota exhausted. */
|
|
19
|
+
const EXHAUSTED_THRESHOLD_S = 300;
|
|
20
|
+
/** Consecutive 429 count to trigger exhaustion detection. */
|
|
21
|
+
const EXHAUSTED_CONSECUTIVE = 3;
|
|
22
|
+
/** Default burst cooldown when no Retry-After header. */
|
|
23
|
+
const DEFAULT_BURST_S = 30;
|
|
24
|
+
|
|
25
|
+
const entries = new Map<string, CooldownEntry>();
|
|
26
|
+
|
|
27
|
+
function key(pool: QuotaPool, account: number): string {
|
|
28
|
+
return `${pool}:${account}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isCoolingDown(pool: QuotaPool, account: number): boolean {
|
|
32
|
+
const entry = entries.get(key(pool, account));
|
|
33
|
+
if (!entry) return false;
|
|
34
|
+
if (Date.now() >= entry.until) {
|
|
35
|
+
entries.delete(key(pool, account));
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isExhausted(pool: QuotaPool, account: number): boolean {
|
|
42
|
+
const entry = entries.get(key(pool, account));
|
|
43
|
+
if (!entry) return false;
|
|
44
|
+
if (Date.now() >= entry.until) {
|
|
45
|
+
entries.delete(key(pool, account));
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
return entry.exhausted;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function record429(pool: QuotaPool, account: number, retryAfterSeconds?: number): void {
|
|
52
|
+
const k = key(pool, account);
|
|
53
|
+
const entry = entries.get(k) ?? { until: 0, exhausted: false, consecutive429: 0 };
|
|
54
|
+
|
|
55
|
+
entry.consecutive429++;
|
|
56
|
+
const retryAfter = retryAfterSeconds ?? DEFAULT_BURST_S;
|
|
57
|
+
|
|
58
|
+
if (retryAfter > EXHAUSTED_THRESHOLD_S || entry.consecutive429 >= EXHAUSTED_CONSECUTIVE) {
|
|
59
|
+
entry.exhausted = true;
|
|
60
|
+
entry.until = Date.now() + EXHAUSTED_COOLDOWN_MS;
|
|
61
|
+
logger.warn(`Quota exhausted: ${k}`, { cooldownMinutes: EXHAUSTED_COOLDOWN_MS / 60_000 });
|
|
62
|
+
} else {
|
|
63
|
+
entry.until = Date.now() + retryAfter * 1000;
|
|
64
|
+
logger.debug(`Burst cooldown: ${k}`, { retryAfterSeconds: retryAfter });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
entries.set(k, entry);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function recordSuccess(pool: QuotaPool, account: number): void {
|
|
71
|
+
const k = key(pool, account);
|
|
72
|
+
const entry = entries.get(k);
|
|
73
|
+
if (entry) {
|
|
74
|
+
entry.consecutive429 = 0;
|
|
75
|
+
entry.exhausted = false;
|
|
76
|
+
entries.delete(k);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Parse Retry-After header (seconds or HTTP-date). */
|
|
81
|
+
export function parseRetryAfter(header: string | null): number | undefined {
|
|
82
|
+
if (!header) return undefined;
|
|
83
|
+
const seconds = Number(header);
|
|
84
|
+
if (!Number.isNaN(seconds)) return seconds;
|
|
85
|
+
const date = Date.parse(header);
|
|
86
|
+
if (!Number.isNaN(date)) return Math.max(0, Math.ceil((date - Date.now()) / 1000));
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|