ampcode-connector 0.1.19 → 0.1.20
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 +4 -15
- package/src/providers/forward.ts +27 -4
- package/src/providers/google.ts +211 -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 +2 -1
- 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
|
@@ -6,7 +6,6 @@ export const AUTOPUSH_ENDPOINT = "https://autopush-cloudcode-pa.sandbox.googleap
|
|
|
6
6
|
export const DEFAULT_ANTIGRAVITY_PROJECT = "rising-fact-p41fc";
|
|
7
7
|
|
|
8
8
|
export const ANTHROPIC_API_URL = "https://api.anthropic.com";
|
|
9
|
-
export const OPENAI_API_URL = "https://api.openai.com";
|
|
10
9
|
export const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
|
11
10
|
|
|
12
11
|
/** Codex-specific headers required by the ChatGPT backend. */
|
|
@@ -18,11 +17,13 @@ export const codexHeaders = {
|
|
|
18
17
|
CONVERSATION_ID: "conversation_id",
|
|
19
18
|
} as const;
|
|
20
19
|
|
|
20
|
+
export const CODEX_CLI_VERSION = "0.101.0";
|
|
21
|
+
|
|
21
22
|
export const codexHeaderValues = {
|
|
22
23
|
BETA_RESPONSES: "responses=experimental",
|
|
23
24
|
ORIGINATOR: "codex_cli_rs",
|
|
24
|
-
VERSION:
|
|
25
|
-
USER_AGENT: `codex_cli_rs
|
|
25
|
+
VERSION: CODEX_CLI_VERSION,
|
|
26
|
+
USER_AGENT: `codex_cli_rs/${CODEX_CLI_VERSION} (${process.platform} ${process.arch})`,
|
|
26
27
|
} as const;
|
|
27
28
|
|
|
28
29
|
/** Map Amp CLI paths → ChatGPT backend paths.
|
|
@@ -40,18 +41,6 @@ export const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
|
40
41
|
export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
|
41
42
|
export const CLAUDE_CODE_VERSION = "2.1.77";
|
|
42
43
|
|
|
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
44
|
export const claudeCodeBetas = [
|
|
56
45
|
"claude-code-20250219",
|
|
57
46
|
"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;
|
|
@@ -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,5 @@ 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.`);
|
|
96
119
|
}
|
|
@@ -0,0 +1,211 @@
|
|
|
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, AUTOPUSH_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";
|
|
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
|
+
const antigravityStrategy: GoogleStrategy = {
|
|
48
|
+
name: "antigravity",
|
|
49
|
+
headers: {
|
|
50
|
+
"User-Agent": "antigravity/1.15.8 darwin/arm64",
|
|
51
|
+
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
|
52
|
+
"Client-Metadata": GOOGLE_CLIENT_METADATA,
|
|
53
|
+
},
|
|
54
|
+
endpoints: [ANTIGRAVITY_DAILY_ENDPOINT, AUTOPUSH_ENDPOINT, CODE_ASSIST_ENDPOINT],
|
|
55
|
+
modelMapper: (model: string) => (model === "gemini-3-flash-preview" ? "gemini-3-flash" : model),
|
|
56
|
+
wrapOpts: {
|
|
57
|
+
userAgent: "antigravity",
|
|
58
|
+
requestIdPrefix: "agent",
|
|
59
|
+
requestType: "agent",
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const strategies: readonly GoogleStrategy[] = [geminiStrategy, antigravityStrategy];
|
|
64
|
+
|
|
65
|
+
const COOLDOWN_MS = 60_000;
|
|
66
|
+
|
|
67
|
+
interface StrategyPreference {
|
|
68
|
+
strategy: GoogleStrategy;
|
|
69
|
+
until: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Per-account strategy preference: after success, prefer that strategy;
|
|
73
|
+
// after failure, skip it for COOLDOWN_MS.
|
|
74
|
+
const preferredStrategy = new Map<number, StrategyPreference>();
|
|
75
|
+
const cooldowns = new Map<string, number>(); // key: `${account}:${strategy.name}`
|
|
76
|
+
|
|
77
|
+
function cooldownKey(account: number, strategy: GoogleStrategy): string {
|
|
78
|
+
return `${account}:${strategy.name}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getOrderedStrategies(account: number): GoogleStrategy[] {
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
const pref = preferredStrategy.get(account);
|
|
84
|
+
const ordered =
|
|
85
|
+
pref && pref.until > now ? [pref.strategy, ...strategies.filter((s) => s !== pref.strategy)] : [...strategies];
|
|
86
|
+
|
|
87
|
+
return ordered.filter((s) => {
|
|
88
|
+
const cd = cooldowns.get(cooldownKey(account, s));
|
|
89
|
+
return !cd || cd <= now;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function markSuccess(account: number, strategy: GoogleStrategy): void {
|
|
94
|
+
preferredStrategy.set(account, { strategy, until: Date.now() + COOLDOWN_MS * 10 });
|
|
95
|
+
cooldowns.delete(cooldownKey(account, strategy));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function markFailure(account: number, strategy: GoogleStrategy): void {
|
|
99
|
+
cooldowns.set(cooldownKey(account, strategy), Date.now() + COOLDOWN_MS);
|
|
100
|
+
const pref = preferredStrategy.get(account);
|
|
101
|
+
if (pref?.strategy === strategy) {
|
|
102
|
+
preferredStrategy.delete(account);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const provider: Provider = {
|
|
107
|
+
name: "Google",
|
|
108
|
+
routeDecision: "LOCAL_GOOGLE",
|
|
109
|
+
|
|
110
|
+
isAvailable: (account?: number) =>
|
|
111
|
+
account !== undefined ? !!store.get("google", account)?.refreshToken : oauth.ready(config),
|
|
112
|
+
|
|
113
|
+
accountCount: () => oauth.accountCount(config),
|
|
114
|
+
|
|
115
|
+
async forward(sub, body, _originalHeaders, rewrite, account = 0) {
|
|
116
|
+
const accessToken = await oauth.token(config, account);
|
|
117
|
+
if (!accessToken) return denied("Google");
|
|
118
|
+
|
|
119
|
+
const creds = store.get("google", account);
|
|
120
|
+
const projectId = creds?.projectId ?? "";
|
|
121
|
+
const email = creds?.email;
|
|
122
|
+
|
|
123
|
+
const modelAction = path.googleModel(sub);
|
|
124
|
+
if (!modelAction) {
|
|
125
|
+
logger.debug(`Non-model Google path, cannot route to CCA: ${sub}`);
|
|
126
|
+
return denied("Google (unsupported path)");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const unwrapThenRewrite = withUnwrap(rewrite);
|
|
130
|
+
const orderedStrategies = getOrderedStrategies(account);
|
|
131
|
+
|
|
132
|
+
if (orderedStrategies.length === 0) {
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
let minWait = COOLDOWN_MS;
|
|
135
|
+
for (const s of strategies) {
|
|
136
|
+
const until = cooldowns.get(cooldownKey(account, s));
|
|
137
|
+
if (until) minWait = Math.min(minWait, Math.max(0, until - now));
|
|
138
|
+
}
|
|
139
|
+
const retryAfterS = Math.ceil(minWait / 1000);
|
|
140
|
+
return new Response(
|
|
141
|
+
JSON.stringify({
|
|
142
|
+
error: { message: "All Google strategies cooling down", type: "rate_limit_error", code: "429" },
|
|
143
|
+
}),
|
|
144
|
+
{ status: 429, headers: { "Content-Type": "application/json", "Retry-After": String(retryAfterS) } },
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let saw429 = false;
|
|
149
|
+
let lastResponse: Response | null = null;
|
|
150
|
+
|
|
151
|
+
for (const strategy of orderedStrategies) {
|
|
152
|
+
const model = strategy.modelMapper ? strategy.modelMapper(modelAction.model) : modelAction.model;
|
|
153
|
+
const requestBody = maybeWrap(body.parsed, body.forwardBody, projectId, model, strategy.wrapOpts);
|
|
154
|
+
|
|
155
|
+
const headers: Record<string, string> = {
|
|
156
|
+
...strategy.headers,
|
|
157
|
+
Authorization: `Bearer ${accessToken}`,
|
|
158
|
+
"Content-Type": "application/json",
|
|
159
|
+
Accept: body.stream ? "text/event-stream" : "application/json",
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
logger.info(`Google strategy=${strategy.name} account=${account}`);
|
|
163
|
+
|
|
164
|
+
for (const endpoint of strategy.endpoints) {
|
|
165
|
+
const url = buildUrl(endpoint, modelAction.action);
|
|
166
|
+
try {
|
|
167
|
+
const response = await forward({
|
|
168
|
+
url,
|
|
169
|
+
body: requestBody,
|
|
170
|
+
streaming: body.stream,
|
|
171
|
+
headers,
|
|
172
|
+
providerName: `Google/${strategy.name}`,
|
|
173
|
+
rewrite: unwrapThenRewrite,
|
|
174
|
+
email,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
if (response.status === 401 || response.status === 403) {
|
|
178
|
+
return response;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (response.status === 429) {
|
|
182
|
+
saw429 = true;
|
|
183
|
+
lastResponse = response;
|
|
184
|
+
logger.debug(`Google strategy=${strategy.name} failed (${response.status}), trying next`);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (response.status >= 500) {
|
|
189
|
+
lastResponse = response;
|
|
190
|
+
logger.debug(`Google strategy=${strategy.name} failed (${response.status}), trying next`);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
markSuccess(account, strategy);
|
|
195
|
+
return response;
|
|
196
|
+
} catch (err) {
|
|
197
|
+
lastResponse = apiError(502, `Google/${strategy.name} endpoint failed: ${String(err)}`);
|
|
198
|
+
logger.debug(`Google strategy=${strategy.name} endpoint error`, { error: String(err) });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
markFailure(account, strategy);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (saw429) {
|
|
206
|
+
return lastResponse ?? apiError(429, "All Google strategies rate limited", "rate_limit_error");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return lastResponse ?? apiError(502, "All Google strategies exhausted");
|
|
210
|
+
},
|
|
211
|
+
};
|
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
|
}
|
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
|