ampcode-connector 0.1.19 → 0.1.21
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 +1 -1
- package/src/cli/status.ts +2 -2
- package/src/constants.ts +6 -16
- package/src/providers/forward.ts +55 -5
- package/src/providers/google.ts +324 -0
- package/src/routing/affinity.ts +5 -5
- package/src/routing/cooldown.ts +17 -1
- package/src/routing/retry.ts +20 -3
- package/src/routing/router.ts +18 -12
- package/src/server/body.ts +1 -1
- package/src/server/server.ts +6 -2
- package/src/utils/code-assist.ts +12 -3
- package/src/utils/logger.ts +2 -3
- package/src/utils/path.ts +4 -5
- package/src/utils/responses.ts +3 -0
- package/src/providers/antigravity.ts +0 -87
- package/src/providers/gemini.ts +0 -73
- /package/src/{routing → utils}/models.ts +0 -0
package/package.json
CHANGED
package/src/cli/status.ts
CHANGED
|
@@ -21,13 +21,13 @@ export interface ProviderStatus {
|
|
|
21
21
|
const PROVIDERS: { name: ProviderName; label: string; sublabel?: string }[] = [
|
|
22
22
|
{ name: "anthropic", label: "Claude Code" },
|
|
23
23
|
{ name: "codex", label: "OpenAI Codex" },
|
|
24
|
-
{ name: "google", label: "Google"
|
|
24
|
+
{ name: "google", label: "Google" },
|
|
25
25
|
];
|
|
26
26
|
|
|
27
27
|
const POOL_MAP: Record<ProviderName, QuotaPool[]> = {
|
|
28
28
|
anthropic: ["anthropic"],
|
|
29
29
|
codex: ["codex"],
|
|
30
|
-
google: ["
|
|
30
|
+
google: ["google"],
|
|
31
31
|
};
|
|
32
32
|
|
|
33
33
|
function connectionOf(name: ProviderName, account: number, creds: Credentials): ConnectionStatus {
|
package/src/constants.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/** Single source of truth — no magic strings scattered across files. */
|
|
2
2
|
|
|
3
3
|
export const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
|
|
4
|
-
export const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.
|
|
4
|
+
export const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
|
|
5
|
+
export const ANTIGRAVITY_DAILY_SANDBOX_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
|
|
5
6
|
export const AUTOPUSH_ENDPOINT = "https://autopush-cloudcode-pa.sandbox.googleapis.com";
|
|
6
7
|
export const DEFAULT_ANTIGRAVITY_PROJECT = "rising-fact-p41fc";
|
|
7
8
|
|
|
8
9
|
export const ANTHROPIC_API_URL = "https://api.anthropic.com";
|
|
9
|
-
export const OPENAI_API_URL = "https://api.openai.com";
|
|
10
10
|
export const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
|
11
11
|
|
|
12
12
|
/** Codex-specific headers required by the ChatGPT backend. */
|
|
@@ -18,11 +18,13 @@ export const codexHeaders = {
|
|
|
18
18
|
CONVERSATION_ID: "conversation_id",
|
|
19
19
|
} as const;
|
|
20
20
|
|
|
21
|
+
export const CODEX_CLI_VERSION = "0.101.0";
|
|
22
|
+
|
|
21
23
|
export const codexHeaderValues = {
|
|
22
24
|
BETA_RESPONSES: "responses=experimental",
|
|
23
25
|
ORIGINATOR: "codex_cli_rs",
|
|
24
|
-
VERSION:
|
|
25
|
-
USER_AGENT: `codex_cli_rs
|
|
26
|
+
VERSION: CODEX_CLI_VERSION,
|
|
27
|
+
USER_AGENT: `codex_cli_rs/${CODEX_CLI_VERSION} (${process.platform} ${process.arch})`,
|
|
26
28
|
} as const;
|
|
27
29
|
|
|
28
30
|
/** Map Amp CLI paths → ChatGPT backend paths.
|
|
@@ -40,18 +42,6 @@ export const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
|
40
42
|
export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
|
41
43
|
export const CLAUDE_CODE_VERSION = "2.1.77";
|
|
42
44
|
|
|
43
|
-
export const stainlessHeaders: Readonly<Record<string, string>> = {
|
|
44
|
-
"X-Stainless-Helper-Method": "stream",
|
|
45
|
-
"X-Stainless-Retry-Count": "0",
|
|
46
|
-
"X-Stainless-Runtime-Version": "v24.3.0",
|
|
47
|
-
"X-Stainless-Package-Version": "0.74.0",
|
|
48
|
-
"X-Stainless-Runtime": "node",
|
|
49
|
-
"X-Stainless-Lang": "js",
|
|
50
|
-
"X-Stainless-Arch": process.arch,
|
|
51
|
-
"X-Stainless-Os": process.platform === "darwin" ? "MacOS" : process.platform === "win32" ? "Windows" : "Linux",
|
|
52
|
-
"X-Stainless-Timeout": "600",
|
|
53
|
-
};
|
|
54
|
-
|
|
55
45
|
export const claudeCodeBetas = [
|
|
56
46
|
"claude-code-20250219",
|
|
57
47
|
"oauth-2025-04-20",
|
package/src/providers/forward.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** HTTP forwarding with transport-level retry, SSE proxying, and response rewriting. */
|
|
2
2
|
|
|
3
3
|
import { logger } from "../utils/logger.ts";
|
|
4
|
+
import { apiError } from "../utils/responses.ts";
|
|
4
5
|
import * as sse from "../utils/streaming.ts";
|
|
5
6
|
|
|
6
7
|
export interface ForwardOptions {
|
|
@@ -17,6 +18,24 @@ const RETRYABLE_STATUS = new Set([408, 500, 502, 503, 504]);
|
|
|
17
18
|
const MAX_RETRIES = 3;
|
|
18
19
|
const RETRY_DELAY_MS = 500;
|
|
19
20
|
|
|
21
|
+
const PASSTHROUGH_HEADERS = [
|
|
22
|
+
"content-type",
|
|
23
|
+
"retry-after",
|
|
24
|
+
"x-request-id",
|
|
25
|
+
"x-ratelimit-limit-requests",
|
|
26
|
+
"x-ratelimit-remaining-requests",
|
|
27
|
+
"x-ratelimit-reset-requests",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
function copyHeaders(source: Headers): Headers {
|
|
31
|
+
const dest = new Headers();
|
|
32
|
+
for (const name of PASSTHROUGH_HEADERS) {
|
|
33
|
+
const value = source.get(name);
|
|
34
|
+
if (value !== null) dest.set(name, value);
|
|
35
|
+
}
|
|
36
|
+
return dest;
|
|
37
|
+
}
|
|
38
|
+
|
|
20
39
|
export async function forward(opts: ForwardOptions): Promise<Response> {
|
|
21
40
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
22
41
|
let response: Response;
|
|
@@ -34,7 +53,7 @@ export async function forward(opts: ForwardOptions): Promise<Response> {
|
|
|
34
53
|
await Bun.sleep(RETRY_DELAY_MS * (attempt + 1));
|
|
35
54
|
continue;
|
|
36
55
|
}
|
|
37
|
-
|
|
56
|
+
return transportErrorResponse(opts.providerName, err);
|
|
38
57
|
}
|
|
39
58
|
|
|
40
59
|
// Retry on server errors (429 handled at routing layer)
|
|
@@ -70,21 +89,25 @@ export async function forward(opts: ForwardOptions): Promise<Response> {
|
|
|
70
89
|
});
|
|
71
90
|
}
|
|
72
91
|
|
|
92
|
+
const headers = copyHeaders(response.headers);
|
|
93
|
+
headers.set("Content-Type", "application/json");
|
|
73
94
|
return new Response(errorBody, {
|
|
74
95
|
status: response.status,
|
|
75
|
-
headers
|
|
96
|
+
headers,
|
|
76
97
|
});
|
|
77
98
|
}
|
|
78
99
|
|
|
79
100
|
const isSSE = contentType.includes("text/event-stream") || opts.streaming;
|
|
80
101
|
if (isSSE) return sse.proxy(response, opts.rewrite);
|
|
81
102
|
|
|
103
|
+
const headers = copyHeaders(response.headers);
|
|
104
|
+
|
|
82
105
|
if (opts.rewrite) {
|
|
83
106
|
const text = await response.text();
|
|
84
|
-
return new Response(opts.rewrite(text), { status: response.status, headers
|
|
107
|
+
return new Response(opts.rewrite(text), { status: response.status, headers });
|
|
85
108
|
}
|
|
86
109
|
|
|
87
|
-
return new Response(response.body, { status: response.status, headers
|
|
110
|
+
return new Response(response.body, { status: response.status, headers });
|
|
88
111
|
}
|
|
89
112
|
|
|
90
113
|
// Unreachable, but TypeScript needs it
|
|
@@ -92,5 +115,32 @@ export async function forward(opts: ForwardOptions): Promise<Response> {
|
|
|
92
115
|
}
|
|
93
116
|
|
|
94
117
|
export function denied(providerName: string): Response {
|
|
95
|
-
return
|
|
118
|
+
return apiError(401, `No ${providerName} OAuth token available. Run login first.`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function transportErrorResponse(providerName: string, err: unknown): Response {
|
|
122
|
+
const message = transportErrorMessage(providerName, err);
|
|
123
|
+
logger.error(`${providerName} transport error after retries exhausted`, { error: String(err) });
|
|
124
|
+
return apiError(502, message, "connection_error");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function transportErrorMessage(providerName: string, err: unknown): string {
|
|
128
|
+
const base = `${providerName} connection error after retries were exhausted.`;
|
|
129
|
+
const details = String(err);
|
|
130
|
+
|
|
131
|
+
if (providerName !== "Anthropic") {
|
|
132
|
+
return `${base} ${details}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const looksLikeReset =
|
|
136
|
+
details.includes("ECONNRESET") ||
|
|
137
|
+
details.includes("socket connection was closed unexpectedly") ||
|
|
138
|
+
details.includes("tls") ||
|
|
139
|
+
details.includes("network");
|
|
140
|
+
|
|
141
|
+
if (!looksLikeReset) {
|
|
142
|
+
return `${base} ${details}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return `${base} ${details} This is often a local network issue rather than an OAuth bug: check Wi-Fi MTU (1492 is a common fix), hotspot stability, and iPhone dual-SIM Cellular Data Switching if you are tethering.`;
|
|
96
146
|
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/** Unified Google provider — merges Gemini CLI and Antigravity strategies
|
|
2
|
+
* with internal fallback. Tries preferred strategy first, then falls back. */
|
|
3
|
+
|
|
4
|
+
import { google as config } from "../auth/configs.ts";
|
|
5
|
+
import * as oauth from "../auth/oauth.ts";
|
|
6
|
+
import * as store from "../auth/store.ts";
|
|
7
|
+
import { ANTIGRAVITY_DAILY_ENDPOINT, ANTIGRAVITY_DAILY_SANDBOX_ENDPOINT, CODE_ASSIST_ENDPOINT } from "../constants.ts";
|
|
8
|
+
import { buildUrl, maybeWrap, withUnwrap } from "../utils/code-assist.ts";
|
|
9
|
+
import { logger } from "../utils/logger.ts";
|
|
10
|
+
import * as path from "../utils/path.ts";
|
|
11
|
+
import { apiError } from "../utils/responses.ts";
|
|
12
|
+
import type { Provider } from "./base.ts";
|
|
13
|
+
import { denied, forward } from "./forward.ts";
|
|
14
|
+
|
|
15
|
+
const GOOGLE_CLIENT_METADATA = JSON.stringify({
|
|
16
|
+
ideType: "IDE_UNSPECIFIED",
|
|
17
|
+
platform: "PLATFORM_UNSPECIFIED",
|
|
18
|
+
pluginType: "GEMINI",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
interface GoogleStrategy {
|
|
22
|
+
name: string;
|
|
23
|
+
headers: Readonly<Record<string, string>>;
|
|
24
|
+
endpoints: readonly string[];
|
|
25
|
+
modelMapper?: (model: string) => string;
|
|
26
|
+
wrapOpts: {
|
|
27
|
+
userAgent: "antigravity" | "pi-coding-agent";
|
|
28
|
+
requestIdPrefix: "agent" | "pi";
|
|
29
|
+
requestType?: "agent" | "image_gen";
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const geminiStrategy: GoogleStrategy = {
|
|
34
|
+
name: "gemini",
|
|
35
|
+
headers: {
|
|
36
|
+
"User-Agent": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
|
37
|
+
"X-Goog-Api-Client": "gl-node/22.17.0",
|
|
38
|
+
"Client-Metadata": GOOGLE_CLIENT_METADATA,
|
|
39
|
+
},
|
|
40
|
+
endpoints: [CODE_ASSIST_ENDPOINT],
|
|
41
|
+
wrapOpts: {
|
|
42
|
+
userAgent: "pi-coding-agent",
|
|
43
|
+
requestIdPrefix: "pi",
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Antigravity uses different model names than what Amp CLI sends. */
|
|
48
|
+
const antigravityModelMap: Record<string, string> = {
|
|
49
|
+
"gemini-3-flash-preview": "gemini-3-flash",
|
|
50
|
+
"gemini-3-pro-preview": "gemini-3-pro-high",
|
|
51
|
+
"gemini-3-pro-image-preview": "gemini-3.1-flash-image",
|
|
52
|
+
"gemini-3.1-flash-image-preview": "gemini-3.1-flash-image",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const antigravityStrategy: GoogleStrategy = {
|
|
56
|
+
name: "antigravity",
|
|
57
|
+
headers: {
|
|
58
|
+
"User-Agent": "antigravity/1.104.0 darwin/arm64",
|
|
59
|
+
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
|
60
|
+
"Client-Metadata": GOOGLE_CLIENT_METADATA,
|
|
61
|
+
},
|
|
62
|
+
endpoints: [ANTIGRAVITY_DAILY_ENDPOINT, ANTIGRAVITY_DAILY_SANDBOX_ENDPOINT, CODE_ASSIST_ENDPOINT],
|
|
63
|
+
modelMapper: (model: string) => antigravityModelMap[model] ?? model,
|
|
64
|
+
wrapOpts: {
|
|
65
|
+
userAgent: "antigravity",
|
|
66
|
+
requestIdPrefix: "agent",
|
|
67
|
+
requestType: "agent",
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const strategies: readonly GoogleStrategy[] = [geminiStrategy, antigravityStrategy];
|
|
72
|
+
|
|
73
|
+
/** Models that only work on the antigravity strategy. */
|
|
74
|
+
const ANTIGRAVITY_ONLY_MODELS = new Set(["gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview"]);
|
|
75
|
+
|
|
76
|
+
const COOLDOWN_MS = 60_000;
|
|
77
|
+
|
|
78
|
+
interface StrategyPreference {
|
|
79
|
+
strategy: GoogleStrategy;
|
|
80
|
+
until: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Per-account strategy preference: after success, prefer that strategy;
|
|
84
|
+
// after failure, skip it for COOLDOWN_MS.
|
|
85
|
+
const preferredStrategy = new Map<number, StrategyPreference>();
|
|
86
|
+
const cooldowns = new Map<string, number>(); // key: `${account}:${strategy.name}`
|
|
87
|
+
|
|
88
|
+
function cooldownKey(account: number, strategy: GoogleStrategy): string {
|
|
89
|
+
return `${account}:${strategy.name}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getOrderedStrategies(account: number, model?: string): GoogleStrategy[] {
|
|
93
|
+
const now = Date.now();
|
|
94
|
+
|
|
95
|
+
// Some models only work on antigravity — skip other strategies entirely
|
|
96
|
+
if (model && ANTIGRAVITY_ONLY_MODELS.has(model)) {
|
|
97
|
+
const cd = cooldowns.get(cooldownKey(account, antigravityStrategy));
|
|
98
|
+
if (cd && cd > now) return [];
|
|
99
|
+
return [antigravityStrategy];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const pref = preferredStrategy.get(account);
|
|
103
|
+
const ordered =
|
|
104
|
+
pref && pref.until > now ? [pref.strategy, ...strategies.filter((s) => s !== pref.strategy)] : [...strategies];
|
|
105
|
+
|
|
106
|
+
return ordered.filter((s) => {
|
|
107
|
+
const cd = cooldowns.get(cooldownKey(account, s));
|
|
108
|
+
return !cd || cd <= now;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function markSuccess(account: number, strategy: GoogleStrategy): void {
|
|
113
|
+
preferredStrategy.set(account, { strategy, until: Date.now() + COOLDOWN_MS * 10 });
|
|
114
|
+
cooldowns.delete(cooldownKey(account, strategy));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function markFailure(account: number, strategy: GoogleStrategy): void {
|
|
118
|
+
cooldowns.set(cooldownKey(account, strategy), Date.now() + COOLDOWN_MS);
|
|
119
|
+
const pref = preferredStrategy.get(account);
|
|
120
|
+
if (pref?.strategy === strategy) {
|
|
121
|
+
preferredStrategy.delete(account);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Buffer an SSE response and merge all chunks into a single JSON response.
|
|
126
|
+
* Used when we force streamGenerateContent but the client expects non-streaming JSON.
|
|
127
|
+
* Accumulates all candidate parts across chunks (image inlineData may be in earlier chunks). */
|
|
128
|
+
async function bufferSSEToJSON(response: Response): Promise<Response | null> {
|
|
129
|
+
const text = await response.text();
|
|
130
|
+
const chunks: Record<string, unknown>[] = [];
|
|
131
|
+
|
|
132
|
+
for (const line of text.split("\n")) {
|
|
133
|
+
if (!line.startsWith("data: ")) continue;
|
|
134
|
+
const data = line.slice(6).trim();
|
|
135
|
+
if (!data || data === "[DONE]") continue;
|
|
136
|
+
try {
|
|
137
|
+
chunks.push(JSON.parse(data) as Record<string, unknown>);
|
|
138
|
+
} catch {
|
|
139
|
+
// skip non-JSON
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (chunks.length === 0) return null;
|
|
144
|
+
if (chunks.length === 1) {
|
|
145
|
+
return new Response(JSON.stringify(chunks[0]), {
|
|
146
|
+
status: 200,
|
|
147
|
+
headers: { "Content-Type": "application/json" },
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Merge: accumulate parts from all chunks into the first candidate
|
|
152
|
+
const merged = chunks[0]! as Record<string, unknown>;
|
|
153
|
+
const allParts: unknown[] = [];
|
|
154
|
+
|
|
155
|
+
for (const chunk of chunks) {
|
|
156
|
+
const candidates = chunk.candidates as { content?: { parts?: unknown[] } }[] | undefined;
|
|
157
|
+
if (!candidates) continue;
|
|
158
|
+
for (const candidate of candidates) {
|
|
159
|
+
if (candidate.content?.parts) {
|
|
160
|
+
allParts.push(...candidate.content.parts);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Use last chunk's metadata (finishReason, usageMetadata)
|
|
166
|
+
const last = chunks[chunks.length - 1]! as Record<string, unknown>;
|
|
167
|
+
const lastCandidates = last.candidates as Record<string, unknown>[] | undefined;
|
|
168
|
+
const mergedCandidates = merged.candidates as Record<string, unknown>[] | undefined;
|
|
169
|
+
|
|
170
|
+
if (mergedCandidates?.[0] && allParts.length > 0) {
|
|
171
|
+
const content = (mergedCandidates[0] as Record<string, unknown>).content as Record<string, unknown> | undefined;
|
|
172
|
+
if (content) content.parts = allParts;
|
|
173
|
+
if (lastCandidates?.[0]) {
|
|
174
|
+
(mergedCandidates[0] as Record<string, unknown>).finishReason = (
|
|
175
|
+
lastCandidates[0] as Record<string, unknown>
|
|
176
|
+
).finishReason;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (last.usageMetadata) merged.usageMetadata = last.usageMetadata;
|
|
180
|
+
|
|
181
|
+
return new Response(JSON.stringify(merged), {
|
|
182
|
+
status: 200,
|
|
183
|
+
headers: { "Content-Type": "application/json" },
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Generate a fallback project ID when none is stored (matches CLIProxyAPI behavior). */
|
|
188
|
+
function generateProjectId(): string {
|
|
189
|
+
const adjectives = ["useful", "bright", "swift", "calm", "bold"];
|
|
190
|
+
const nouns = ["fuze", "wave", "spark", "flow", "core"];
|
|
191
|
+
const adj = adjectives[Math.floor(Math.random() * adjectives.length)]!;
|
|
192
|
+
const noun = nouns[Math.floor(Math.random() * nouns.length)]!;
|
|
193
|
+
const rand = crypto.randomUUID().slice(0, 5).toLowerCase();
|
|
194
|
+
return `${adj}-${noun}-${rand}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export const provider: Provider = {
|
|
198
|
+
name: "Google",
|
|
199
|
+
routeDecision: "LOCAL_GOOGLE",
|
|
200
|
+
|
|
201
|
+
isAvailable: (account?: number) =>
|
|
202
|
+
account !== undefined ? !!store.get("google", account)?.refreshToken : oauth.ready(config),
|
|
203
|
+
|
|
204
|
+
accountCount: () => oauth.accountCount(config),
|
|
205
|
+
|
|
206
|
+
async forward(sub, body, _originalHeaders, rewrite, account = 0) {
|
|
207
|
+
const accessToken = await oauth.token(config, account);
|
|
208
|
+
if (!accessToken) return denied("Google");
|
|
209
|
+
|
|
210
|
+
const creds = store.get("google", account);
|
|
211
|
+
const projectId = creds?.projectId || generateProjectId();
|
|
212
|
+
const email = creds?.email;
|
|
213
|
+
|
|
214
|
+
const modelAction = path.googleModel(sub);
|
|
215
|
+
if (!modelAction) {
|
|
216
|
+
logger.debug(`Non-model Google path, cannot route to CCA: ${sub}`);
|
|
217
|
+
return denied("Google (unsupported path)");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const unwrapThenRewrite = withUnwrap(rewrite);
|
|
221
|
+
const orderedStrategies = getOrderedStrategies(account, modelAction.model);
|
|
222
|
+
|
|
223
|
+
if (orderedStrategies.length === 0) {
|
|
224
|
+
const now = Date.now();
|
|
225
|
+
let minWait = COOLDOWN_MS;
|
|
226
|
+
for (const s of strategies) {
|
|
227
|
+
const until = cooldowns.get(cooldownKey(account, s));
|
|
228
|
+
if (until) minWait = Math.min(minWait, Math.max(0, until - now));
|
|
229
|
+
}
|
|
230
|
+
const retryAfterS = Math.ceil(minWait / 1000);
|
|
231
|
+
return new Response(
|
|
232
|
+
JSON.stringify({
|
|
233
|
+
error: { message: "All Google strategies cooling down", type: "rate_limit_error", code: "429" },
|
|
234
|
+
}),
|
|
235
|
+
{ status: 429, headers: { "Content-Type": "application/json", "Retry-After": String(retryAfterS) } },
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let saw429 = false;
|
|
240
|
+
let lastResponse: Response | null = null;
|
|
241
|
+
|
|
242
|
+
for (const strategy of orderedStrategies) {
|
|
243
|
+
const model = strategy.modelMapper ? strategy.modelMapper(modelAction.model) : modelAction.model;
|
|
244
|
+
const isImageModel = model.includes("image");
|
|
245
|
+
const wrapOpts = isImageModel ? { ...strategy.wrapOpts, requestType: "image_gen" as const } : strategy.wrapOpts;
|
|
246
|
+
const requestBody = maybeWrap(body.parsed, body.forwardBody, projectId, model, wrapOpts);
|
|
247
|
+
|
|
248
|
+
const headers: Record<string, string> = {
|
|
249
|
+
...strategy.headers,
|
|
250
|
+
Authorization: `Bearer ${accessToken}`,
|
|
251
|
+
"Content-Type": "application/json",
|
|
252
|
+
Accept: body.stream ? "text/event-stream" : "application/json",
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
logger.info(`Google strategy=${strategy.name} account=${account} model=${model}`);
|
|
256
|
+
|
|
257
|
+
// Antigravity forces streaming endpoint for gemini-3-pro* and image models (matches CLIProxyAPI behavior)
|
|
258
|
+
const forceStream = strategy.name === "antigravity" && (model.startsWith("gemini-3-pro") || isImageModel);
|
|
259
|
+
const action = forceStream ? "streamGenerateContent" : modelAction.action;
|
|
260
|
+
|
|
261
|
+
for (const endpoint of strategy.endpoints) {
|
|
262
|
+
const url = buildUrl(endpoint, action);
|
|
263
|
+
try {
|
|
264
|
+
const forceStreamNonStreaming = forceStream && !body.stream;
|
|
265
|
+
const response = await forward({
|
|
266
|
+
url,
|
|
267
|
+
body: requestBody,
|
|
268
|
+
streaming: forceStreamNonStreaming ? true : body.stream,
|
|
269
|
+
headers,
|
|
270
|
+
providerName: `Google/${strategy.name}`,
|
|
271
|
+
rewrite: unwrapThenRewrite,
|
|
272
|
+
email,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// When we forced streaming but client expects JSON, buffer SSE and return last chunk
|
|
276
|
+
if (forceStreamNonStreaming && response.ok && response.body) {
|
|
277
|
+
const merged = await bufferSSEToJSON(response);
|
|
278
|
+
if (merged) {
|
|
279
|
+
markSuccess(account, strategy);
|
|
280
|
+
return merged;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (response.status === 401 || response.status === 403) {
|
|
285
|
+
return response;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (response.status === 404) {
|
|
289
|
+
lastResponse = response;
|
|
290
|
+
logger.debug(`Google strategy=${strategy.name} model not found (404), trying next endpoint`);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (response.status === 429) {
|
|
295
|
+
saw429 = true;
|
|
296
|
+
lastResponse = response;
|
|
297
|
+
logger.debug(`Google strategy=${strategy.name} failed (${response.status}), trying next`);
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (response.status >= 500) {
|
|
302
|
+
lastResponse = response;
|
|
303
|
+
logger.debug(`Google strategy=${strategy.name} failed (${response.status}), trying next`);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
markSuccess(account, strategy);
|
|
308
|
+
return response;
|
|
309
|
+
} catch (err) {
|
|
310
|
+
lastResponse = apiError(502, `Google/${strategy.name} endpoint failed: ${String(err)}`);
|
|
311
|
+
logger.debug(`Google strategy=${strategy.name} endpoint error`, { error: String(err) });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
markFailure(account, strategy);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (saw429) {
|
|
319
|
+
return lastResponse ?? apiError(429, "All Google strategies rate limited", "rate_limit_error");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return lastResponse ?? apiError(502, "All Google strategies exhausted");
|
|
323
|
+
},
|
|
324
|
+
};
|
package/src/routing/affinity.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { QuotaPool } from "./cooldown.ts";
|
|
|
8
8
|
interface AffinityEntry {
|
|
9
9
|
pool: QuotaPool;
|
|
10
10
|
account: number;
|
|
11
|
-
|
|
11
|
+
lastUsedAt: number;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/** Affinity expires after 2 hours of inactivity. */
|
|
@@ -51,7 +51,7 @@ class AffinityStore {
|
|
|
51
51
|
const k = this.key(threadId, ampProvider);
|
|
52
52
|
const entry = this.map.get(k);
|
|
53
53
|
if (!entry) return undefined;
|
|
54
|
-
if (Date.now() - entry.
|
|
54
|
+
if (Date.now() - entry.lastUsedAt > TTL_MS) {
|
|
55
55
|
this.removeExpired(k, entry);
|
|
56
56
|
return undefined;
|
|
57
57
|
}
|
|
@@ -61,7 +61,7 @@ class AffinityStore {
|
|
|
61
61
|
/** Read affinity and touch (extend TTL). */
|
|
62
62
|
get(threadId: string, ampProvider: string): AffinityEntry | undefined {
|
|
63
63
|
const entry = this.peek(threadId, ampProvider);
|
|
64
|
-
if (entry) entry.
|
|
64
|
+
if (entry) entry.lastUsedAt = Date.now();
|
|
65
65
|
return entry;
|
|
66
66
|
}
|
|
67
67
|
|
|
@@ -76,7 +76,7 @@ class AffinityStore {
|
|
|
76
76
|
} else {
|
|
77
77
|
this.incCount(pool, account);
|
|
78
78
|
}
|
|
79
|
-
this.map.set(k, { pool, account,
|
|
79
|
+
this.map.set(k, { pool, account, lastUsedAt: Date.now() });
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
/** Break affinity when account is exhausted — allow re-routing. */
|
|
@@ -100,7 +100,7 @@ class AffinityStore {
|
|
|
100
100
|
this.cleanupTimer = setInterval(() => {
|
|
101
101
|
const now = Date.now();
|
|
102
102
|
for (const [k, entry] of this.map) {
|
|
103
|
-
if (now - entry.
|
|
103
|
+
if (now - entry.lastUsedAt > TTL_MS) {
|
|
104
104
|
this.removeExpired(k, entry);
|
|
105
105
|
}
|
|
106
106
|
}
|
package/src/routing/cooldown.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { logger } from "../utils/logger.ts";
|
|
5
5
|
|
|
6
|
-
export type QuotaPool = "anthropic" | "codex" | "
|
|
6
|
+
export type QuotaPool = "anthropic" | "codex" | "google";
|
|
7
7
|
|
|
8
8
|
interface CooldownEntry {
|
|
9
9
|
until: number;
|
|
@@ -74,6 +74,22 @@ class CooldownTracker {
|
|
|
74
74
|
logger.warn(`Account disabled (403): ${k}`, { cooldownHours: FORBIDDEN_COOLDOWN_MS / 3600_000 });
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** Return shortest remaining wait (ms) among non-exhausted entries for the given candidates.
|
|
78
|
+
* Returns undefined if no candidates are cooling down or all are exhausted. */
|
|
79
|
+
shortestBurstWait(candidates: { pool: QuotaPool; account: number }[]): number | undefined {
|
|
80
|
+
let shortest: number | undefined;
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
for (const c of candidates) {
|
|
83
|
+
const entry = this.entries.get(this.key(c.pool, c.account));
|
|
84
|
+
if (!entry || entry.exhausted) continue;
|
|
85
|
+
const remaining = entry.until - now;
|
|
86
|
+
if (remaining > 0 && (shortest === undefined || remaining < shortest)) {
|
|
87
|
+
shortest = remaining;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return shortest;
|
|
91
|
+
}
|
|
92
|
+
|
|
77
93
|
recordSuccess(pool: QuotaPool, account: number): void {
|
|
78
94
|
this.entries.delete(this.key(pool, account));
|
|
79
95
|
}
|
package/src/routing/retry.ts
CHANGED
|
@@ -4,12 +4,14 @@ 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
6
|
import { cooldown, parseRetryAfter, type QuotaPool } from "./cooldown.ts";
|
|
7
|
-
import { type RouteResult, recordSuccess, reroute } from "./router.ts";
|
|
7
|
+
import { buildCandidates, type RouteResult, recordSuccess, reroute } from "./router.ts";
|
|
8
8
|
|
|
9
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
|
+
/** Max ms to wait when all candidates are burst-cooling before giving up. */
|
|
14
|
+
const BURST_WAIT_MAX_MS = 30_000;
|
|
13
15
|
|
|
14
16
|
/** Status codes that trigger rerouting to a different account/pool. */
|
|
15
17
|
const REROUTABLE_STATUSES = new Set([429, 403]);
|
|
@@ -41,7 +43,7 @@ export async function tryWithCachePreserve(
|
|
|
41
43
|
await Bun.sleep(retryAfter * 1000);
|
|
42
44
|
const response = await route.handler!.forward(sub, body, headers, rewrite, route.account);
|
|
43
45
|
|
|
44
|
-
if (response.status !== 429 && response.status !== 401) {
|
|
46
|
+
if (response.status !== 429 && response.status !== 403 && response.status !== 401) {
|
|
45
47
|
recordSuccess(route.pool!, route.account);
|
|
46
48
|
return response;
|
|
47
49
|
}
|
|
@@ -49,6 +51,9 @@ export async function tryWithCachePreserve(
|
|
|
49
51
|
const nextRetryAfter = parseRetryAfter(response.headers.get("retry-after"));
|
|
50
52
|
cooldown.record429(route.pool!, route.account, nextRetryAfter);
|
|
51
53
|
}
|
|
54
|
+
if (response.status === 403) {
|
|
55
|
+
cooldown.record403(route.pool!, route.account);
|
|
56
|
+
}
|
|
52
57
|
return null;
|
|
53
58
|
}
|
|
54
59
|
|
|
@@ -64,7 +69,19 @@ export async function tryReroute(
|
|
|
64
69
|
let currentAccount = initialRoute.account;
|
|
65
70
|
|
|
66
71
|
for (let attempt = 0; attempt < MAX_REROUTE_ATTEMPTS; attempt++) {
|
|
67
|
-
|
|
72
|
+
let next = reroute(ctx.providerName, ctx.ampModel, ctx.config, currentPool, currentAccount, ctx.threadId);
|
|
73
|
+
|
|
74
|
+
// All candidates cooling down — wait for shortest burst then retry
|
|
75
|
+
if (!next?.handler) {
|
|
76
|
+
const candidates = buildCandidates(ctx.providerName, ctx.config);
|
|
77
|
+
const waitMs = cooldown.shortestBurstWait(candidates);
|
|
78
|
+
if (waitMs && waitMs <= BURST_WAIT_MAX_MS) {
|
|
79
|
+
logger.info(`All accounts cooling, waiting ${Math.ceil(waitMs / 1000)}s for burst cooldown`);
|
|
80
|
+
await Bun.sleep(waitMs + 100); // small buffer
|
|
81
|
+
next = reroute(ctx.providerName, ctx.ampModel, ctx.config, currentPool, currentAccount, ctx.threadId);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
68
85
|
if (!next?.handler) break;
|
|
69
86
|
|
|
70
87
|
logger.info(`REROUTE (${status}) -> ${next.decision} account=${next.account}`);
|
package/src/routing/router.ts
CHANGED
|
@@ -5,17 +5,16 @@
|
|
|
5
5
|
* - Thread affinity: same thread sticks to same account
|
|
6
6
|
* - Cooldown: skip 429'd accounts, detect quota exhaustion
|
|
7
7
|
* - Least-connections: prefer accounts with fewer active threads
|
|
8
|
-
* - Google
|
|
8
|
+
* - Google: single pool with internal strategy fallback (gemini/antigravity)
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { ProviderName } from "../auth/store.ts";
|
|
12
12
|
import * as store from "../auth/store.ts";
|
|
13
13
|
import type { ProxyConfig } from "../config/config.ts";
|
|
14
14
|
import { provider as anthropic } from "../providers/anthropic.ts";
|
|
15
|
-
import { provider as antigravity } from "../providers/antigravity.ts";
|
|
16
15
|
import type { Provider } from "../providers/base.ts";
|
|
17
16
|
import { provider as codex } from "../providers/codex.ts";
|
|
18
|
-
import { provider as
|
|
17
|
+
import { provider as google } from "../providers/google.ts";
|
|
19
18
|
import { logger, type RouteDecision } from "../utils/logger.ts";
|
|
20
19
|
import { affinity } from "./affinity.ts";
|
|
21
20
|
import { cooldown, type QuotaPool } from "./cooldown.ts";
|
|
@@ -46,10 +45,7 @@ const PROVIDER_REGISTRY = new Map<string, { configKey: keyof ProxyConfig["provid
|
|
|
46
45
|
"google",
|
|
47
46
|
{
|
|
48
47
|
configKey: "google",
|
|
49
|
-
entries: [
|
|
50
|
-
{ provider: gemini, pool: "gemini", credentialName: "google" },
|
|
51
|
-
{ provider: antigravity, pool: "antigravity", credentialName: "google" },
|
|
52
|
-
],
|
|
48
|
+
entries: [{ provider: google, pool: "google", credentialName: "google" }],
|
|
53
49
|
},
|
|
54
50
|
],
|
|
55
51
|
]);
|
|
@@ -85,6 +81,13 @@ export function routeRequest(
|
|
|
85
81
|
): RouteResult {
|
|
86
82
|
const modelStr = model ?? "unknown";
|
|
87
83
|
|
|
84
|
+
// Early exit if provider is disabled in config
|
|
85
|
+
const reg = PROVIDER_REGISTRY.get(ampProvider);
|
|
86
|
+
if (!reg || !config.providers[reg.configKey]) {
|
|
87
|
+
logger.route("AMP_UPSTREAM", ampProvider, modelStr);
|
|
88
|
+
return result(null, ampProvider, modelStr, 0, null);
|
|
89
|
+
}
|
|
90
|
+
|
|
88
91
|
// Check thread affinity (keyed by threadId + ampProvider)
|
|
89
92
|
if (threadId) {
|
|
90
93
|
const pinned = affinity.get(threadId, ampProvider);
|
|
@@ -153,7 +156,7 @@ export function recordSuccess(pool: QuotaPool, account: number): void {
|
|
|
153
156
|
cooldown.recordSuccess(pool, account);
|
|
154
157
|
}
|
|
155
158
|
|
|
156
|
-
function buildCandidates(ampProvider: string, config: ProxyConfig): Candidate[] {
|
|
159
|
+
export function buildCandidates(ampProvider: string, config: ProxyConfig): Candidate[] {
|
|
157
160
|
const reg = PROVIDER_REGISTRY.get(ampProvider);
|
|
158
161
|
if (!reg || !config.providers[reg.configKey]) return [];
|
|
159
162
|
|
|
@@ -183,18 +186,21 @@ function pickCandidate(candidates: Candidate[]): Candidate | null {
|
|
|
183
186
|
if (available.length === 0) return null;
|
|
184
187
|
|
|
185
188
|
// Pick the one with least active threads (least-connections)
|
|
186
|
-
|
|
187
|
-
let bestLoad = affinity.activeCount(
|
|
189
|
+
// When multiple candidates have the same load, pick randomly for even distribution
|
|
190
|
+
let bestLoad = affinity.activeCount(available[0]!.pool, available[0]!.account);
|
|
191
|
+
let ties: Candidate[] = [available[0]!];
|
|
188
192
|
|
|
189
193
|
for (let i = 1; i < available.length; i++) {
|
|
190
194
|
const load = affinity.activeCount(available[i]!.pool, available[i]!.account);
|
|
191
195
|
if (load < bestLoad) {
|
|
192
|
-
best = available[i]!;
|
|
193
196
|
bestLoad = load;
|
|
197
|
+
ties = [available[i]!];
|
|
198
|
+
} else if (load === bestLoad) {
|
|
199
|
+
ties.push(available[i]!);
|
|
194
200
|
}
|
|
195
201
|
}
|
|
196
202
|
|
|
197
|
-
return
|
|
203
|
+
return ties[Math.floor(Math.random() * ties.length)]!;
|
|
198
204
|
}
|
|
199
205
|
|
|
200
206
|
function providerForPool(pool: QuotaPool): Provider | null {
|
package/src/server/body.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Slow path: full JSON.parse only when .parsed or .forwardBody is accessed
|
|
4
4
|
* (e.g. Google CCA wrapping, model rewrite). */
|
|
5
5
|
|
|
6
|
-
import { resolveModel, rewriteBodyModel } from "../
|
|
6
|
+
import { resolveModel, rewriteBodyModel } from "../utils/models.ts";
|
|
7
7
|
import * as path from "../utils/path.ts";
|
|
8
8
|
|
|
9
9
|
export interface ParsedBody {
|
package/src/server/server.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { recordSuccess, routeRequest } from "../routing/router.ts";
|
|
|
10
10
|
import { handleInternal, isLocalMethod } from "../tools/internal.ts";
|
|
11
11
|
import { logger } from "../utils/logger.ts";
|
|
12
12
|
import * as path from "../utils/path.ts";
|
|
13
|
+
import { apiError } from "../utils/responses.ts";
|
|
13
14
|
import { stats } from "../utils/stats.ts";
|
|
14
15
|
import { type ParsedBody, parseBody } from "./body.ts";
|
|
15
16
|
|
|
@@ -29,7 +30,7 @@ export function startServer(config: ProxyConfig): ReturnType<typeof Bun.serve> {
|
|
|
29
30
|
return response;
|
|
30
31
|
} catch (err) {
|
|
31
32
|
logger.error("Unhandled server error", { error: String(err) });
|
|
32
|
-
return
|
|
33
|
+
return apiError(status, "Internal proxy error");
|
|
33
34
|
} finally {
|
|
34
35
|
logger.info(`${req.method} ${url.pathname}${url.search} ${status}`, { duration: Date.now() - startTime });
|
|
35
36
|
}
|
|
@@ -101,7 +102,10 @@ async function handleProvider(
|
|
|
101
102
|
const rewrite = ampModel ? rewriter.rewrite(ampModel) : undefined;
|
|
102
103
|
const handlerResponse = await route.handler.forward(sub, body, req.headers, rewrite, route.account);
|
|
103
104
|
|
|
104
|
-
if (
|
|
105
|
+
if (
|
|
106
|
+
(handlerResponse.status === 429 || handlerResponse.status === 403 || handlerResponse.status === 404) &&
|
|
107
|
+
route.pool
|
|
108
|
+
) {
|
|
105
109
|
const ctx = { providerName, ampModel, config, sub, body, headers: req.headers, rewrite, threadId };
|
|
106
110
|
// 429: try short wait to preserve prompt cache first
|
|
107
111
|
const cached =
|
package/src/utils/code-assist.ts
CHANGED
|
@@ -6,18 +6,23 @@ interface WrapOptions {
|
|
|
6
6
|
body: Record<string, unknown>;
|
|
7
7
|
userAgent: "antigravity" | "pi-coding-agent";
|
|
8
8
|
requestIdPrefix: "agent" | "pi";
|
|
9
|
-
requestType?: "agent";
|
|
9
|
+
requestType?: "agent" | "image_gen";
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
/** Wrap a raw request body in the Cloud Code Assist envelope. */
|
|
13
13
|
function wrapRequest(opts: WrapOptions): string {
|
|
14
|
+
const isImageGen = opts.requestType === "image_gen";
|
|
15
|
+
const requestId = isImageGen
|
|
16
|
+
? `image_gen/${Date.now()}/${crypto.randomUUID()}/12`
|
|
17
|
+
: `${opts.requestIdPrefix}-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
|
|
18
|
+
|
|
14
19
|
return JSON.stringify({
|
|
15
20
|
project: opts.projectId,
|
|
16
21
|
model: opts.model,
|
|
17
22
|
request: opts.body,
|
|
18
23
|
...(opts.requestType && { requestType: opts.requestType }),
|
|
19
24
|
userAgent: opts.userAgent,
|
|
20
|
-
requestId
|
|
25
|
+
requestId,
|
|
21
26
|
});
|
|
22
27
|
}
|
|
23
28
|
|
|
@@ -118,7 +123,11 @@ export function maybeWrap(
|
|
|
118
123
|
raw: string,
|
|
119
124
|
projectId: string,
|
|
120
125
|
model: string,
|
|
121
|
-
opts: {
|
|
126
|
+
opts: {
|
|
127
|
+
userAgent: "antigravity" | "pi-coding-agent";
|
|
128
|
+
requestIdPrefix: "agent" | "pi";
|
|
129
|
+
requestType?: "agent" | "image_gen";
|
|
130
|
+
},
|
|
122
131
|
): string {
|
|
123
132
|
if (!parsed) return raw;
|
|
124
133
|
if (parsed.project) return raw;
|
package/src/utils/logger.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Structured logging with route decision tracking. */
|
|
2
2
|
|
|
3
|
-
export type RouteDecision = "LOCAL_CLAUDE" | "LOCAL_CODEX" | "
|
|
3
|
+
export type RouteDecision = "LOCAL_CLAUDE" | "LOCAL_CODEX" | "LOCAL_GOOGLE" | "AMP_UPSTREAM";
|
|
4
4
|
|
|
5
5
|
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
6
6
|
|
|
@@ -41,8 +41,7 @@ const LEVEL_COLORS: Record<LogLevel, string> = {
|
|
|
41
41
|
const ROUTE_COLORS: Record<RouteDecision, string> = {
|
|
42
42
|
LOCAL_CLAUDE: GREEN,
|
|
43
43
|
LOCAL_CODEX: GREEN,
|
|
44
|
-
|
|
45
|
-
LOCAL_ANTIGRAVITY: GREEN,
|
|
44
|
+
LOCAL_GOOGLE: GREEN,
|
|
46
45
|
AMP_UPSTREAM: YELLOW,
|
|
47
46
|
};
|
|
48
47
|
|
package/src/utils/path.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { browserPrefixes, passthroughExact, passthroughPrefixes } from "../const
|
|
|
5
5
|
const PROVIDER_RE = /^\/api\/provider\/([^/]+)/;
|
|
6
6
|
const SUBPATH_RE = /^\/api\/provider\/[^/]+(\/.*)/;
|
|
7
7
|
const MODEL_RE = /models\/([^/:]+)/;
|
|
8
|
-
const
|
|
8
|
+
const GOOGLE_MODEL_RE = /models\/([^/:]+):(\w+)/;
|
|
9
9
|
|
|
10
10
|
export function passthrough(pathname: string): boolean {
|
|
11
11
|
if ((passthroughExact as readonly string[]).includes(pathname)) return true;
|
|
@@ -13,8 +13,7 @@ export function passthrough(pathname: string): boolean {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export function browser(pathname: string): boolean {
|
|
16
|
-
|
|
17
|
-
return browserPrefixes.some((prefix) => pathname.startsWith(prefix));
|
|
16
|
+
return browserPrefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
|
|
18
17
|
}
|
|
19
18
|
|
|
20
19
|
export function provider(pathname: string): string | null {
|
|
@@ -32,8 +31,8 @@ export function modelFromUrl(url: string): string | null {
|
|
|
32
31
|
return match?.[1] ?? null;
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
export function
|
|
36
|
-
const match = url.match(
|
|
34
|
+
export function googleModel(url: string): { model: string; action: string } | null {
|
|
35
|
+
const match = url.match(GOOGLE_MODEL_RE);
|
|
37
36
|
if (!match) return null;
|
|
38
37
|
return { model: match[1]!, action: match[2]! };
|
|
39
38
|
}
|
|
@@ -1,87 +0,0 @@
|
|
|
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 { buildUrl, maybeWrap, withUnwrap } 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 "./forward.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: body.stream ? "text/event-stream" : "application/json",
|
|
48
|
-
};
|
|
49
|
-
const gemini = path.gemini(sub);
|
|
50
|
-
const action = gemini?.action ?? "generateContent";
|
|
51
|
-
const model = gemini?.model === "gemini-3-flash-preview" ? "gemini-3-flash" : (gemini?.model ?? "");
|
|
52
|
-
const requestBody = maybeWrap(body.parsed, body.forwardBody, projectId, model, {
|
|
53
|
-
userAgent: "antigravity",
|
|
54
|
-
requestIdPrefix: "agent",
|
|
55
|
-
requestType: "agent",
|
|
56
|
-
});
|
|
57
|
-
const unwrapThenRewrite = withUnwrap(rewrite);
|
|
58
|
-
|
|
59
|
-
return tryEndpoints(requestBody, body.stream, headers, action, unwrapThenRewrite, creds?.email);
|
|
60
|
-
},
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
async function tryEndpoints(
|
|
64
|
-
body: string,
|
|
65
|
-
streaming: boolean,
|
|
66
|
-
headers: Record<string, string>,
|
|
67
|
-
action: string,
|
|
68
|
-
rewrite?: (data: string) => string,
|
|
69
|
-
email?: string,
|
|
70
|
-
): Promise<Response> {
|
|
71
|
-
let lastError: Error | null = null;
|
|
72
|
-
|
|
73
|
-
for (const endpoint of endpoints) {
|
|
74
|
-
const url = buildUrl(endpoint, action);
|
|
75
|
-
try {
|
|
76
|
-
const response = await forward({ url, body, streaming, headers, providerName: "Antigravity", rewrite, email });
|
|
77
|
-
if (response.status < 500) return response;
|
|
78
|
-
lastError = new Error(`${endpoint} returned ${response.status}`);
|
|
79
|
-
logger.debug("Endpoint 5xx, trying next", { provider: "Antigravity" });
|
|
80
|
-
} catch (err) {
|
|
81
|
-
lastError = err instanceof Error ? err : new Error(String(err));
|
|
82
|
-
logger.debug("Endpoint failed, trying next", { error: String(err) });
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
return Response.json({ error: `All Antigravity endpoints failed: ${lastError?.message}` }, { status: 502 });
|
|
87
|
-
}
|
package/src/providers/gemini.ts
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
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 { buildUrl, maybeWrap, withUnwrap } 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 "./forward.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: body.stream ? "text/event-stream" : "application/json",
|
|
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 = buildUrl(CODE_ASSIST_ENDPOINT, gemini.action);
|
|
57
|
-
const requestBody = maybeWrap(body.parsed, body.forwardBody, projectId, gemini.model, {
|
|
58
|
-
userAgent: "pi-coding-agent",
|
|
59
|
-
requestIdPrefix: "pi",
|
|
60
|
-
});
|
|
61
|
-
const unwrapThenRewrite = withUnwrap(rewrite);
|
|
62
|
-
|
|
63
|
-
return forward({
|
|
64
|
-
url,
|
|
65
|
-
body: requestBody,
|
|
66
|
-
streaming: body.stream,
|
|
67
|
-
headers,
|
|
68
|
-
providerName: "Gemini CLI",
|
|
69
|
-
rewrite: unwrapThenRewrite,
|
|
70
|
-
email: store.get("google", account)?.email,
|
|
71
|
-
});
|
|
72
|
-
},
|
|
73
|
-
};
|
|
File without changes
|