clauderipple 0.7.0 → 0.8.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/CHANGELOG.md +51 -0
- package/README.ko.md +15 -1
- package/README.md +23 -3
- package/dist/app/package.json +1 -1
- package/dist/cli/src/claude-auth.js +35 -1
- package/dist/cli/src/codex.js +23 -0
- package/dist/cli/src/hooks/agent-title.js +58 -29
- package/dist/cli/src/index.js +46 -1
- package/dist/cli/src/runtime.js +3 -0
- package/dist/cli/src/settings.js +49 -0
- package/dist/mod/.claude-plugin/plugin.json +1 -0
- package/dist/mod/hooks/hooks.json +1 -0
- package/dist/mod/hooks/register.tsx +309 -0
- package/dist/mod/hooks/worker.ts +49 -0
- package/dist/mod/tsconfig.json +3 -0
- package/dist/mod/types/index.d.ts +30 -0
- package/dist/router/src/admin.js +411 -32
- package/dist/router/src/agents.js +24 -8
- package/dist/router/src/catalog.js +80 -0
- package/dist/router/src/compat.js +27 -1
- package/dist/router/src/config.js +31 -0
- package/dist/router/src/index.js +12 -1
- package/dist/router/src/ingress/server.js +3 -2
- package/dist/router/src/picker.js +33 -2
- package/dist/router/src/presets.js +2 -1
- package/dist/router/src/providers/chatgpt/index.js +42 -15
- package/dist/router/src/providers/chatgpt/translate.js +34 -6
- package/dist/router/src/providers/google/accounts.js +345 -0
- package/dist/router/src/providers/google/antigravity.js +392 -0
- package/dist/router/src/providers/google/index.js +617 -0
- package/dist/router/src/providers/google/login.js +249 -0
- package/dist/router/src/providers/google/thoughtSignatures.js +47 -0
- package/dist/router/src/providers/google/translate.js +539 -0
- package/dist/router/src/providers/google/transport.js +118 -0
- package/dist/router/src/providers/openai/translate.js +3 -3
- package/dist/router/src/proxy.js +85 -1
- package/dist/router/src/requestlog.js +2 -0
- package/dist/router/src/routing.js +22 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +420 -45
- package/dist/ui/i18n.js +76 -4
- package/dist/ui/index.html +5 -0
- package/dist/ui/style.css +4 -0
- package/docs/ARCHITECTURE.md +287 -2
- package/package.json +1 -1
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
// Google Gemini adapter. Two backends, one translation:
|
|
2
|
+
//
|
|
3
|
+
// auth "api-key" AI Studio: Anthropic Messages in, Gemini `generateContent` (or its SSE stream)
|
|
4
|
+
// out, translated back. Key on `x-goog-api-key`.
|
|
5
|
+
// auth "antigravity" Cloud Code Assist / Antigravity: a Google subscription OAuth token, the same
|
|
6
|
+
// Gemini body wrapped in the CCA envelope, several accounts rotating.
|
|
7
|
+
//
|
|
8
|
+
// The transport lives in transport.ts, the translation in translate.ts, the account store in
|
|
9
|
+
// accounts.ts, the sign-in in login.ts, and the CCA wire facts in antigravity.ts.
|
|
10
|
+
//
|
|
11
|
+
// The same reliability rules the other translated adapters follow apply here: the turn is retried
|
|
12
|
+
// with `fetchWithRetry` before any byte reaches the client, a stream that closes without Gemini
|
|
13
|
+
// saying it finished is an `overloaded_error` (§5 muse), and usage is reported only from what the
|
|
14
|
+
// vendor sent. The antigravity path picks the account the conversation is sticky to and moves to the
|
|
15
|
+
// next one only on a refusal that has not reached the client (§4 Credential pools).
|
|
16
|
+
import http from "node:http";
|
|
17
|
+
import { CredentialPool, retryAfterMs } from "../../pool.js";
|
|
18
|
+
import { credentialHeaderValues, redactErrorText } from "../../redact.js";
|
|
19
|
+
import { SseParser } from "../chatgpt/sse.js";
|
|
20
|
+
import { fetchWithRetry } from "../retry.js";
|
|
21
|
+
import { estimateTokens, formatSse, GoogleStreamMapper, toGeminiRequest } from "./translate.js";
|
|
22
|
+
import { createTransport, googleBaseUrl, resolveApiKey } from "./transport.js";
|
|
23
|
+
import { ThoughtSignatureStore } from "./thoughtSignatures.js";
|
|
24
|
+
import { conversationKey, serverToolNames, toolNameRestoreMap } from "../chatgpt/translate.js";
|
|
25
|
+
import { GoogleAccountPool } from "./accounts.js";
|
|
26
|
+
import { fetchAvailableModels, staticAntigravityModels } from "./antigravity.js";
|
|
27
|
+
const PING_MS = 15_000;
|
|
28
|
+
/** How long the CCA model list is reused before asking again. */
|
|
29
|
+
const MODELS_CACHE_MS = 60 * 60 * 1000;
|
|
30
|
+
function anthropicError(status, type, message) {
|
|
31
|
+
return { status, body: JSON.stringify({ type: "error", error: { type, message } }) };
|
|
32
|
+
}
|
|
33
|
+
/** The HTTP status Anthropic uses for each error type a mapper can report. */
|
|
34
|
+
function failureStatus(failure) {
|
|
35
|
+
return failure?.type === "overloaded_error" ? 529 : failure?.type === "rate_limit_error" ? 429 : failure?.type === "authentication_error" ? 401 : 502;
|
|
36
|
+
}
|
|
37
|
+
function vendorMessage(text) {
|
|
38
|
+
try {
|
|
39
|
+
const json = JSON.parse(text);
|
|
40
|
+
const message = json.error?.message ?? json.message;
|
|
41
|
+
const status = json.error?.status;
|
|
42
|
+
const detail = typeof message === "string" ? message : "";
|
|
43
|
+
const suffix = typeof status === "string" ? ` (${status})` : "";
|
|
44
|
+
if (detail || suffix)
|
|
45
|
+
return `${detail}${suffix}`.slice(0, 500) || "upstream request failed";
|
|
46
|
+
}
|
|
47
|
+
catch { /* retain the response text */ }
|
|
48
|
+
return text.replace(/\s+/g, " ").trim().slice(0, 500) || "upstream request failed";
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Google reports a bad key two ways: a 401/403, and a **400 whose body carries
|
|
52
|
+
* `status: "API_KEY_INVALID"`** (the Generative Language API's own shape, and what the probe meets
|
|
53
|
+
* for a wrong AI Studio key). Reading the 400 as a generic invalid request would send the user to
|
|
54
|
+
* check their request body for a key that was never the problem. Cloud Code Assist answers a refused
|
|
55
|
+
* OAuth token with `UNAUTHENTICATED`/`PERMISSION_DENIED` in the same shape.
|
|
56
|
+
*/
|
|
57
|
+
export function googleLooksLikeAuth(status, text) {
|
|
58
|
+
if (status === 401)
|
|
59
|
+
return true;
|
|
60
|
+
if (/\b(API_KEY_INVALID|PERMISSION_DENIED|UNAUTHENTICATED|API key not valid|invalid api key)\b/i.test(text))
|
|
61
|
+
return true;
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The wait a Google 429 asks for. Google APIs state it in the error body rather than a header — a
|
|
66
|
+
* `google.rpc.RetryInfo` detail with `retryDelay: "<seconds>s"`
|
|
67
|
+
* (https://cloud.google.com/apis/design/errors#error_details) — so an account would otherwise rest
|
|
68
|
+
* for the pool's default whether the limit lifts in seconds or in hours.
|
|
69
|
+
*/
|
|
70
|
+
export function googleRetryDelayMs(text) {
|
|
71
|
+
const match = /"retryDelay"\s*:\s*"(\d+(?:\.\d+)?)s"/.exec(text);
|
|
72
|
+
return match ? Math.ceil(Number(match[1]) * 1000) : undefined;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The page Google sends an account to when it wants the person behind it checked before Cloud
|
|
76
|
+
* Code Assist will answer: a 403 `PERMISSION_DENIED` whose ErrorInfo reason is
|
|
77
|
+
* `VALIDATION_REQUIRED`, with the page in `metadata.validation_url` (measured 2026-10-02 on a newly
|
|
78
|
+
* signed-in account). A fresh token does not help — only the person finishing that page does — so
|
|
79
|
+
* this is not read as a credential refusal. Only a Google accounts URL is passed on, because the
|
|
80
|
+
* dashboard turns it into a link.
|
|
81
|
+
*/
|
|
82
|
+
export function googleValidationUrl(text) {
|
|
83
|
+
try {
|
|
84
|
+
const details = JSON.parse(text).error?.details ?? [];
|
|
85
|
+
const url = details.find((d) => d.reason === "VALIDATION_REQUIRED")?.metadata?.validation_url;
|
|
86
|
+
return typeof url === "string" && url.startsWith("https://accounts.google.com/") ? url : undefined;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export function mapHttpError(status, text) {
|
|
93
|
+
const message = `Google provider: ${vendorMessage(text)}`;
|
|
94
|
+
if (status === 429)
|
|
95
|
+
return anthropicError(429, "rate_limit_error", message);
|
|
96
|
+
if (googleLooksLikeAuth(status, text))
|
|
97
|
+
return anthropicError(401, "authentication_error", message);
|
|
98
|
+
if (status === 403)
|
|
99
|
+
return anthropicError(403, "permission_error", message);
|
|
100
|
+
if (status >= 500)
|
|
101
|
+
return anthropicError(529, "api_error", message);
|
|
102
|
+
return anthropicError(400, "invalid_request_error", message);
|
|
103
|
+
}
|
|
104
|
+
function write(res, value) {
|
|
105
|
+
if (res.writableEnded || res.destroyed)
|
|
106
|
+
return 0;
|
|
107
|
+
res.write(value);
|
|
108
|
+
return Buffer.byteLength(value);
|
|
109
|
+
}
|
|
110
|
+
export class GoogleAdapter {
|
|
111
|
+
name;
|
|
112
|
+
cfg;
|
|
113
|
+
log;
|
|
114
|
+
accounts;
|
|
115
|
+
/** Cooldowns and conversation stickiness, shared with the proxy's other credential pools. */
|
|
116
|
+
pool;
|
|
117
|
+
lastInputByKey = new Map();
|
|
118
|
+
/** Thought signatures keyed by the tool_use id we minted; survives across turns of one process. */
|
|
119
|
+
thoughtSignatures = new ThoughtSignatureStore();
|
|
120
|
+
/** The account that answered last, for the dashboard's "active" mark. */
|
|
121
|
+
activeOwner = null;
|
|
122
|
+
/** Accounts Google wants checked, with its page for it; cleared when the account next answers. */
|
|
123
|
+
verifyUrls = new Map();
|
|
124
|
+
modelsCache = null;
|
|
125
|
+
modelsInFlight = null;
|
|
126
|
+
constructor(name, cfg, home, log, pool = new CredentialPool(), fetchImpl) {
|
|
127
|
+
this.name = name;
|
|
128
|
+
this.cfg = cfg;
|
|
129
|
+
this.log = log;
|
|
130
|
+
this.pool = pool;
|
|
131
|
+
this.accounts = new GoogleAccountPool({ home, log, ...(fetchImpl ? { fetch: fetchImpl } : {}) });
|
|
132
|
+
}
|
|
133
|
+
describeAuth() {
|
|
134
|
+
const all = this.accounts.summaries();
|
|
135
|
+
const usable = this.accounts.peekCredentials().filter((c) => this.pool.hasUsable(this.name, [c])).length;
|
|
136
|
+
return `accounts=${all.length} usable=${usable}`;
|
|
137
|
+
}
|
|
138
|
+
/** Whether any account could answer now, for the proxy's choice between this provider and a fallback. */
|
|
139
|
+
hasUsable() {
|
|
140
|
+
return this.pool.hasUsable(this.name, this.accounts.peekCredentials());
|
|
141
|
+
}
|
|
142
|
+
signedIn() {
|
|
143
|
+
return this.accounts.signedIn();
|
|
144
|
+
}
|
|
145
|
+
/** Every account with its rotation state. Metadata only — no token leaves. */
|
|
146
|
+
accountStatus() {
|
|
147
|
+
const credentials = this.accounts.peekCredentials();
|
|
148
|
+
const reports = new Map(this.pool.report(this.name, credentials).map((r) => [r.id, r]));
|
|
149
|
+
return this.accounts.summaries().map((summary) => {
|
|
150
|
+
const credential = credentials.find((c) => c.ownerId === summary.id);
|
|
151
|
+
const report = credential ? reports.get(credential.id) : undefined;
|
|
152
|
+
const verifyUrl = this.verifyUrls.get(summary.id);
|
|
153
|
+
const state = summary.paused ? "paused" : !credential ? "needs-login" : verifyUrl ? "needs-verification" : report?.state ?? "ready";
|
|
154
|
+
return {
|
|
155
|
+
...summary,
|
|
156
|
+
state,
|
|
157
|
+
...(state === "needs-verification" && verifyUrl ? { verifyUrl } : {}),
|
|
158
|
+
...(report?.cooldownSeconds ? { cooldownSeconds: report.cooldownSeconds } : {}),
|
|
159
|
+
active: summary.id === this.activeOwner,
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Ask Google again whether an account still needs its verification page (dashboard action). The
|
|
165
|
+
* mark otherwise clears only when the account next answers a turn, so a person who had just
|
|
166
|
+
* finished the page kept seeing "verification needed" until they sent something (2026-10-02).
|
|
167
|
+
* One small turn on the provider's first model; only the status is read.
|
|
168
|
+
*/
|
|
169
|
+
async recheckVerification(ownerId) {
|
|
170
|
+
const credential = (await this.accounts.credentials()).find((c) => c.ownerId === ownerId);
|
|
171
|
+
if (!credential || !this.verifyUrls.has(ownerId))
|
|
172
|
+
return "unknown";
|
|
173
|
+
const model = this.cfg.models?.[0]?.id ?? staticAntigravityModels()[0].id;
|
|
174
|
+
const transport = createTransport("antigravity", this.cfg.url ? { url: this.cfg.url } : {});
|
|
175
|
+
const built = transport.build({ contents: [{ role: "user", parts: [{ text: "ping" }] }], generationConfig: { maxOutputTokens: 16 } }, { model, stream: false, antigravity: { accessToken: credential.accessToken, projectId: credential.projectId, conversationKey: `recheck:${ownerId}` } });
|
|
176
|
+
try {
|
|
177
|
+
const upstream = await fetch(built.url, { method: "POST", headers: built.headers, body: built.body, signal: AbortSignal.timeout(30_000) });
|
|
178
|
+
const text = await upstream.text().catch(() => "");
|
|
179
|
+
if (upstream.ok) {
|
|
180
|
+
this.verifyUrls.delete(ownerId);
|
|
181
|
+
return "verified";
|
|
182
|
+
}
|
|
183
|
+
const verifyUrl = upstream.status === 403 ? googleValidationUrl(text) : undefined;
|
|
184
|
+
if (verifyUrl) {
|
|
185
|
+
this.verifyUrls.set(ownerId, verifyUrl);
|
|
186
|
+
return "still-required";
|
|
187
|
+
}
|
|
188
|
+
// Any other answer is not about verification; the next real turn sorts it out.
|
|
189
|
+
this.log.info(`google ${this.name}: verification recheck of ${ownerId.slice(0, 8)} answered ${upstream.status}`);
|
|
190
|
+
return "unknown";
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return "unknown";
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Put a cooling account back into rotation now (dashboard action). */
|
|
197
|
+
clearCooldown(ownerId) {
|
|
198
|
+
for (const c of this.accounts.peekCredentials())
|
|
199
|
+
if (c.ownerId === ownerId)
|
|
200
|
+
this.pool.clear(this.name, c.id);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* The models a signed-in Antigravity account can reach, from CCA's own `:fetchAvailableModels`,
|
|
204
|
+
* falling back to the static list. Null when nothing is signed in. Never throws.
|
|
205
|
+
*/
|
|
206
|
+
fetchModels() {
|
|
207
|
+
if (this.modelsCache && Date.now() - this.modelsCache.at < MODELS_CACHE_MS)
|
|
208
|
+
return Promise.resolve(this.modelsCache.models);
|
|
209
|
+
if (this.modelsInFlight)
|
|
210
|
+
return this.modelsInFlight;
|
|
211
|
+
this.modelsInFlight = this.fetchModelsOnce().then((models) => models ?? this.modelsCache?.models ?? null).finally(() => {
|
|
212
|
+
this.modelsInFlight = null;
|
|
213
|
+
});
|
|
214
|
+
return this.modelsInFlight;
|
|
215
|
+
}
|
|
216
|
+
async fetchModelsOnce() {
|
|
217
|
+
const credential = await this.anyCredential();
|
|
218
|
+
if (credential instanceof Error)
|
|
219
|
+
return null;
|
|
220
|
+
const models = await fetchAvailableModels(credential.accessToken, credential.projectId);
|
|
221
|
+
if (!models) {
|
|
222
|
+
this.log.warn(`google ${this.name}: fetchAvailableModels returned nothing; falling back to the static list`);
|
|
223
|
+
return staticAntigravityModels();
|
|
224
|
+
}
|
|
225
|
+
this.modelsCache = { at: Date.now(), models };
|
|
226
|
+
return models;
|
|
227
|
+
}
|
|
228
|
+
/** For side calls (models, project refresh) with no conversation: a usable account, else any. */
|
|
229
|
+
async anyCredential() {
|
|
230
|
+
const all = await this.accounts.credentials();
|
|
231
|
+
if (all.length === 0)
|
|
232
|
+
return new Error("no Google account: run `clauderipple google-login`");
|
|
233
|
+
return this.pool.pick(this.name, all) ?? all[0];
|
|
234
|
+
}
|
|
235
|
+
rememberInput(key, usage) {
|
|
236
|
+
const total = usage.input_tokens + usage.cache_read_input_tokens;
|
|
237
|
+
if (total <= 0)
|
|
238
|
+
return;
|
|
239
|
+
this.lastInputByKey.set(key, total);
|
|
240
|
+
if (this.lastInputByKey.size > 500)
|
|
241
|
+
this.lastInputByKey.delete(this.lastInputByKey.keys().next().value);
|
|
242
|
+
}
|
|
243
|
+
/** Handle a fully-read Messages request. Model/effort have already been resolved by routing. */
|
|
244
|
+
async handle(req, res, path, json, model, effort) {
|
|
245
|
+
if (path.startsWith("/v1/messages/count_tokens")) {
|
|
246
|
+
const body = JSON.stringify({ input_tokens: estimateTokens(json) });
|
|
247
|
+
res.writeHead(200, { "content-type": "application/json", "content-length": String(Buffer.byteLength(body)) }).end(body);
|
|
248
|
+
return { status: 200, bytes: Buffer.byteLength(body), note: "estimated" };
|
|
249
|
+
}
|
|
250
|
+
// Dropping a tool the model was meant to have is worth a line: the alternative to this drop is an
|
|
251
|
+
// empty answer with nothing logged anywhere.
|
|
252
|
+
const serverTools = serverToolNames(json.tools);
|
|
253
|
+
if (serverTools.size > 0)
|
|
254
|
+
this.log.warn(`google ${this.name}: dropped server tools for ${model}: ${[...serverTools].join(", ")} (Anthropic runs these; this provider cannot)`);
|
|
255
|
+
const effortLevels = this.cfg.models?.find((entry) => entry.id === model)?.effortLevels;
|
|
256
|
+
const caps = effortLevels ? { effortLevels } : {};
|
|
257
|
+
// A request that names no effort gets the provider's configured one, as the ChatGPT one does.
|
|
258
|
+
const useEffort = effort ?? this.cfg.defaultEffort;
|
|
259
|
+
const upstreamRequest = toGeminiRequest(json, {
|
|
260
|
+
model,
|
|
261
|
+
...(useEffort ? { effort: useEffort } : {}),
|
|
262
|
+
caps,
|
|
263
|
+
...(this.cfg.identity === undefined ? {} : { identity: this.cfg.identity }),
|
|
264
|
+
...(this.cfg.instructionsAppend ? { instructionsAppend: this.cfg.instructionsAppend } : {}),
|
|
265
|
+
thoughtSignatures: this.thoughtSignatures,
|
|
266
|
+
// The CCA path never adds the sentinel: the signature goes back on the exact functionCall the
|
|
267
|
+
// model signed (or nothing at all), rather than a fabricated validator bypass.
|
|
268
|
+
signatureSentinel: this.cfg.auth === "api-key",
|
|
269
|
+
});
|
|
270
|
+
const wantStream = json.stream === true;
|
|
271
|
+
// The same input floor behavior as the other translated adapters: the CLI snapshots message_start
|
|
272
|
+
// before usage arrives, so announce an estimate and correct it in message_delta.
|
|
273
|
+
const key = conversationKey(json);
|
|
274
|
+
const startInput = Math.max(estimateTokens(json), this.lastInputByKey.get(key) ?? 0);
|
|
275
|
+
const transport = createTransport(this.cfg.auth, { ...(this.cfg.apiKey ? { apiKey: this.cfg.apiKey } : {}), ...(this.cfg.url ? { url: this.cfg.url } : {}) });
|
|
276
|
+
if (this.cfg.auth === "antigravity") {
|
|
277
|
+
return this.handleAntigravity(res, json, upstreamRequest, transport, model, useEffort, wantStream, key, startInput);
|
|
278
|
+
}
|
|
279
|
+
const apiKey = resolveApiKey(this.cfg.apiKey);
|
|
280
|
+
if (!apiKey) {
|
|
281
|
+
const out = anthropicError(401, "authentication_error", "Google provider: no API key; set apiKey or GEMINI_API_KEY");
|
|
282
|
+
res.writeHead(out.status, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out.body)) }).end(out.body);
|
|
283
|
+
return { status: out.status, bytes: Buffer.byteLength(out.body), note: "no api key" };
|
|
284
|
+
}
|
|
285
|
+
let built;
|
|
286
|
+
try {
|
|
287
|
+
built = transport.build(upstreamRequest, { model, stream: wantStream });
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
const out = anthropicError(400, "invalid_request_error", error.message);
|
|
291
|
+
res.writeHead(out.status, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out.body)) }).end(out.body);
|
|
292
|
+
return { status: out.status, bytes: Buffer.byteLength(out.body), note: error.message };
|
|
293
|
+
}
|
|
294
|
+
const upstreamSecrets = [apiKey, ...credentialHeaderValues(Object.entries(built.headers))];
|
|
295
|
+
const controller = new AbortController();
|
|
296
|
+
const onClose = () => controller.abort();
|
|
297
|
+
res.on("close", onClose);
|
|
298
|
+
let upstream;
|
|
299
|
+
try {
|
|
300
|
+
upstream = await fetchWithRetry(built.url, {
|
|
301
|
+
method: "POST",
|
|
302
|
+
headers: built.headers,
|
|
303
|
+
body: built.body,
|
|
304
|
+
signal: controller.signal,
|
|
305
|
+
}, { log: (line) => this.log.info(`google ${this.name}: ${line}`) });
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
res.off("close", onClose);
|
|
309
|
+
if (controller.signal.aborted)
|
|
310
|
+
return { status: 0, bytes: 0, note: "client closed" };
|
|
311
|
+
const out = anthropicError(502, "api_error", `Google provider unreachable: ${error.message}`);
|
|
312
|
+
if (!res.headersSent)
|
|
313
|
+
res.writeHead(out.status, { "content-type": "application/json" }).end(out.body);
|
|
314
|
+
throw error;
|
|
315
|
+
}
|
|
316
|
+
if (!upstream.ok || !upstream.body) {
|
|
317
|
+
const text = await upstream.text().catch(() => "");
|
|
318
|
+
const safeText = redactErrorText(text, upstreamSecrets);
|
|
319
|
+
const out = mapHttpError(upstream.status, safeText);
|
|
320
|
+
this.log.warn(`google ${this.name}: upstream ${upstream.status} for ${model}: ${safeText.slice(0, 400)}`);
|
|
321
|
+
res.off("close", onClose);
|
|
322
|
+
res.writeHead(out.status, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out.body)) }).end(out.body);
|
|
323
|
+
return { status: out.status, bytes: Buffer.byteLength(out.body), note: `upstream ${upstream.status}` };
|
|
324
|
+
}
|
|
325
|
+
return this.streamBack(res, upstream, transport, json, model, wantStream, key, startInput, controller, onClose);
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* One Antigravity turn: pick the conversation's account, send, and — before any byte reaches the
|
|
329
|
+
* client — let a refusal the account caused move the turn to the next account. The account choice
|
|
330
|
+
* and its failure handling mirror the ChatGPT pool (accounts.ts), because the cache cost of moving
|
|
331
|
+
* a conversation off its account is the same (§4).
|
|
332
|
+
*/
|
|
333
|
+
async handleAntigravity(res, json, upstreamRequest, transport, model, effort, wantStream, key, startInput) {
|
|
334
|
+
const controller = new AbortController();
|
|
335
|
+
const onClose = () => controller.abort();
|
|
336
|
+
res.on("close", onClose);
|
|
337
|
+
const sent = await this.sendToAnAccount({
|
|
338
|
+
conversation: key,
|
|
339
|
+
signal: controller.signal,
|
|
340
|
+
build: (credential) => {
|
|
341
|
+
const built = transport.build(upstreamRequest, {
|
|
342
|
+
model,
|
|
343
|
+
stream: wantStream,
|
|
344
|
+
...(effort ? { effort } : {}),
|
|
345
|
+
antigravity: { accessToken: credential.accessToken, projectId: credential.projectId, conversationKey: key },
|
|
346
|
+
});
|
|
347
|
+
return { built, secrets: [credential.accessToken, ...credentialHeaderValues(Object.entries(built.headers))] };
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
if (sent.kind !== "ok") {
|
|
351
|
+
res.off("close", onClose);
|
|
352
|
+
if (sent.kind === "aborted")
|
|
353
|
+
return { status: 0, bytes: 0, note: "client closed" };
|
|
354
|
+
if (sent.kind === "unreachable") {
|
|
355
|
+
const err = anthropicError(502, "api_error", `Google provider unreachable: ${sent.error.message}`);
|
|
356
|
+
if (!res.headersSent)
|
|
357
|
+
res.writeHead(err.status, { "content-type": "application/json" }).end(err.body);
|
|
358
|
+
throw sent.error; // let the proxy feed health with the connect error
|
|
359
|
+
}
|
|
360
|
+
let err;
|
|
361
|
+
let retryAfterSeconds;
|
|
362
|
+
let note;
|
|
363
|
+
if (sent.kind === "no-account") {
|
|
364
|
+
err = anthropicError(401, "authentication_error", "no Google account: run `clauderipple google-login`");
|
|
365
|
+
note = "no credentials";
|
|
366
|
+
}
|
|
367
|
+
else if (sent.kind === "all-resting") {
|
|
368
|
+
const when = sent.backMs ? ` The first is usable again at ${new Date(Date.now() + sent.backMs).toLocaleTimeString()}.` : "";
|
|
369
|
+
err = anthropicError(429, "rate_limit_error", `Google Antigravity: every signed-in account is at its limit.${when}`);
|
|
370
|
+
if (sent.backMs)
|
|
371
|
+
retryAfterSeconds = Math.ceil(sent.backMs / 1000);
|
|
372
|
+
note = "all accounts resting";
|
|
373
|
+
}
|
|
374
|
+
else if (sent.verify) {
|
|
375
|
+
// Said plainly, with the page: the vendor's own line ("Verify your account to continue.")
|
|
376
|
+
// names neither the account nor where to go, and the URL sat past the cut of its body.
|
|
377
|
+
err = anthropicError(403, "permission_error", `Google wants the account ${sent.verify.account} verified before Antigravity will answer for it. Open this page in a browser signed in to that account, finish Google's check, then try again: ${sent.verify.url}`);
|
|
378
|
+
note = "upstream 403 verification required";
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
// A bare 403 here is a permission/region/policy problem, not a bad token (mapHttpError reads it).
|
|
382
|
+
err = mapHttpError(sent.status, sent.text);
|
|
383
|
+
retryAfterSeconds = sent.retryAfterSeconds;
|
|
384
|
+
note = `upstream ${sent.status}`;
|
|
385
|
+
}
|
|
386
|
+
res.writeHead(err.status, { "content-type": "application/json", ...(retryAfterSeconds ? { "retry-after": String(retryAfterSeconds) } : {}) }).end(err.body);
|
|
387
|
+
return { status: err.status, bytes: Buffer.byteLength(err.body), note };
|
|
388
|
+
}
|
|
389
|
+
return this.streamBack(res, sent.upstream, transport, json, model, wantStream, key, startInput, controller, onClose);
|
|
390
|
+
}
|
|
391
|
+
/** Stream (or buffer) an already-ok upstream response back to the client as Anthropic SSE. */
|
|
392
|
+
async streamBack(res, upstream, transport, json, model, wantStream, key, startInput, controller, onClose) {
|
|
393
|
+
const mapper = new GoogleStreamMapper(model, startInput, toolNameRestoreMap(json), (info) => {
|
|
394
|
+
if (info.signature)
|
|
395
|
+
this.thoughtSignatures.set(info.id, info.signature);
|
|
396
|
+
});
|
|
397
|
+
const parser = new SseParser();
|
|
398
|
+
const reader = upstream.body.getReader();
|
|
399
|
+
const decoder = new TextDecoder();
|
|
400
|
+
let bytes = 0;
|
|
401
|
+
let ping;
|
|
402
|
+
if (wantStream) {
|
|
403
|
+
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
404
|
+
for (const event of mapper.start())
|
|
405
|
+
bytes += write(res, formatSse(event));
|
|
406
|
+
ping = setInterval(() => {
|
|
407
|
+
if (!res.writableEnded)
|
|
408
|
+
bytes += write(res, formatSse({ event: "ping", data: { type: "ping" } }));
|
|
409
|
+
}, PING_MS);
|
|
410
|
+
}
|
|
411
|
+
try {
|
|
412
|
+
if (wantStream) {
|
|
413
|
+
for (;;) {
|
|
414
|
+
const { done, value } = await reader.read();
|
|
415
|
+
if (done)
|
|
416
|
+
break;
|
|
417
|
+
for (const raw of parser.feed(decoder.decode(value, { stream: true }))) {
|
|
418
|
+
const output = mapper.feed(transport.unwrap(raw));
|
|
419
|
+
this.rememberInput(key, mapper.usage);
|
|
420
|
+
for (const anthropic of output)
|
|
421
|
+
bytes += write(res, formatSse(anthropic));
|
|
422
|
+
if (mapper.isFinished)
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
if (mapper.isFinished)
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
// A non-streaming `generateContent` is one JSON object, so the body is gathered whole before
|
|
431
|
+
// it is parsed — a `DecoderStream` of a JSON document is not a stream of documents.
|
|
432
|
+
let whole = "";
|
|
433
|
+
for (;;) {
|
|
434
|
+
const { done, value } = await reader.read();
|
|
435
|
+
if (done)
|
|
436
|
+
break;
|
|
437
|
+
whole += decoder.decode(value, { stream: true });
|
|
438
|
+
}
|
|
439
|
+
whole += decoder.decode();
|
|
440
|
+
for (const raw of parseWholeBody(whole, transport)) {
|
|
441
|
+
mapper.feed(raw);
|
|
442
|
+
this.rememberInput(key, mapper.usage);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (!mapper.isFinished) {
|
|
446
|
+
// Only a vendor that said it was done is finished. A body that ends without a `finishReason`
|
|
447
|
+
// is reported as overloaded so the client asks again, instead of taking a half answer as the
|
|
448
|
+
// model's final one. Non-streaming writes no SSE here; the failure becomes the HTTP status below.
|
|
449
|
+
const tail = mapper.completed || parser.sawDone
|
|
450
|
+
? mapper.finish()
|
|
451
|
+
: mapper.fail(`${model}: upstream ended before the response completed`, "UNAVAILABLE");
|
|
452
|
+
if (wantStream)
|
|
453
|
+
for (const event of tail)
|
|
454
|
+
bytes += write(res, formatSse(event));
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
catch (error) {
|
|
458
|
+
if (!controller.signal.aborted) {
|
|
459
|
+
const tail = mapper.fail(`stream interrupted: ${error.message}`, "UNAVAILABLE");
|
|
460
|
+
if (wantStream)
|
|
461
|
+
for (const event of tail)
|
|
462
|
+
bytes += write(res, formatSse(event));
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
finally {
|
|
466
|
+
if (ping)
|
|
467
|
+
clearInterval(ping);
|
|
468
|
+
res.off("close", onClose);
|
|
469
|
+
try {
|
|
470
|
+
await reader.cancel();
|
|
471
|
+
}
|
|
472
|
+
catch { /* already closed */ }
|
|
473
|
+
}
|
|
474
|
+
const failure = mapper.failure;
|
|
475
|
+
const failedStatus = failureStatus(failure);
|
|
476
|
+
if (!wantStream) {
|
|
477
|
+
const body = failure ? anthropicError(failedStatus, failure.type, failure.message).body : JSON.stringify(mapper.message());
|
|
478
|
+
bytes = Buffer.byteLength(body);
|
|
479
|
+
res.writeHead(failure ? failedStatus : 200, { "content-type": "application/json", "content-length": String(bytes) }).end(body);
|
|
480
|
+
}
|
|
481
|
+
else if (!res.writableEnded) {
|
|
482
|
+
res.end();
|
|
483
|
+
}
|
|
484
|
+
const usage = mapper.usage;
|
|
485
|
+
return {
|
|
486
|
+
status: failure ? failedStatus : 200,
|
|
487
|
+
bytes,
|
|
488
|
+
note: failure
|
|
489
|
+
? `${wantStream ? "mid-stream " : ""}${failure.type}: ${failure.message} (in=${usage.input_tokens} cached=${usage.cache_read_input_tokens} out=${usage.output_tokens})`
|
|
490
|
+
: `in=${usage.input_tokens} cached=${usage.cache_read_input_tokens} out=${usage.output_tokens} stop=${mapper.stopReason}`,
|
|
491
|
+
usage: { input: usage.input_tokens, cached: usage.cache_read_input_tokens, output: usage.output_tokens },
|
|
492
|
+
stopReason: mapper.stopReason,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Send one Antigravity turn, moving to the next account while nothing has reached the client:
|
|
497
|
+
*
|
|
498
|
+
* - 401, or a 403 that reads as a credential refusal: one refresh of that account and a replay;
|
|
499
|
+
* refused again with a 401, the account is marked for sign-in.
|
|
500
|
+
* - 429 and 402: the account rests until its stated reset (or a default), and the next takes over.
|
|
501
|
+
* - Any other 403, 5xx, or no connection: a short rest, and the next account.
|
|
502
|
+
* - Anything else is the request's fault; another account would refuse it the same way.
|
|
503
|
+
*
|
|
504
|
+
* Each account is tried at most once per turn (a refreshed token is the same account).
|
|
505
|
+
*/
|
|
506
|
+
async sendToAnAccount(spec) {
|
|
507
|
+
const tried = new Set();
|
|
508
|
+
const replayed = new Set();
|
|
509
|
+
const refreshed = new Set();
|
|
510
|
+
let last = null;
|
|
511
|
+
let lastThrown = null;
|
|
512
|
+
for (;;) {
|
|
513
|
+
const credential = await this.pickAccount(spec.conversation, tried);
|
|
514
|
+
if (credential === "none")
|
|
515
|
+
return { kind: "no-account" };
|
|
516
|
+
if (credential === null) {
|
|
517
|
+
if (last)
|
|
518
|
+
return last;
|
|
519
|
+
if (lastThrown)
|
|
520
|
+
return { kind: "unreachable", error: lastThrown };
|
|
521
|
+
const backMs = this.soonestBackMs();
|
|
522
|
+
return { kind: "all-resting", ...(backMs ? { backMs } : {}) };
|
|
523
|
+
}
|
|
524
|
+
const { built, secrets } = spec.build(credential);
|
|
525
|
+
let upstream;
|
|
526
|
+
try {
|
|
527
|
+
// Retried on the same account, before any status or byte reaches the client, so a relay's
|
|
528
|
+
// hiccup is absorbed inside the turn instead of arriving as an error to retry by hand.
|
|
529
|
+
upstream = await fetchWithRetry(built.url, {
|
|
530
|
+
method: "POST",
|
|
531
|
+
headers: built.headers,
|
|
532
|
+
body: built.body,
|
|
533
|
+
signal: spec.signal,
|
|
534
|
+
}, { log: (line) => this.log.info(`google ${this.name}: ${line}`) });
|
|
535
|
+
}
|
|
536
|
+
catch (e) {
|
|
537
|
+
if (spec.signal.aborted)
|
|
538
|
+
return { kind: "aborted" };
|
|
539
|
+
this.pool.penalise(this.name, credential.id, 0);
|
|
540
|
+
tried.add(credential.ownerId);
|
|
541
|
+
lastThrown = e;
|
|
542
|
+
this.log.info(`google ${this.name}: account ${credential.ownerId.slice(0, 8)} unreachable (${e.message}); trying another`);
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
if (upstream.ok && upstream.body) {
|
|
546
|
+
this.pool.succeed(this.name, credential.id);
|
|
547
|
+
this.verifyUrls.delete(credential.ownerId);
|
|
548
|
+
this.activeOwner = credential.ownerId;
|
|
549
|
+
return { kind: "ok", upstream, credential };
|
|
550
|
+
}
|
|
551
|
+
const text = await upstream.text().catch(() => "");
|
|
552
|
+
const safeText = redactErrorText(text, secrets);
|
|
553
|
+
const status = upstream.status;
|
|
554
|
+
this.log.warn(`google ${this.name}: account ${credential.ownerId.slice(0, 8)} answered ${status}: ${safeText.slice(0, 400)}`);
|
|
555
|
+
// Not a rest and not a refresh: the account works again the moment its person finishes
|
|
556
|
+
// Google's page, so it stays in rotation and the next turn finds out.
|
|
557
|
+
const verifyUrl = status === 403 ? googleValidationUrl(text) : undefined;
|
|
558
|
+
if (verifyUrl) {
|
|
559
|
+
this.verifyUrls.set(credential.ownerId, verifyUrl);
|
|
560
|
+
const summary = this.accounts.summaries().find((s) => s.id === credential.ownerId);
|
|
561
|
+
last = { kind: "refused", status, text: safeText, verify: { url: verifyUrl, account: summary?.email ?? summary?.label ?? credential.ownerId.slice(0, 8) } };
|
|
562
|
+
tried.add(credential.ownerId);
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
const credentialRefused = status === 401 || (status === 403 && googleLooksLikeAuth(status, text));
|
|
566
|
+
if (credentialRefused) {
|
|
567
|
+
if (!replayed.has(credential.ownerId)) {
|
|
568
|
+
replayed.add(credential.ownerId);
|
|
569
|
+
if (await this.accounts.forceRefresh(credential.ownerId)) {
|
|
570
|
+
refreshed.add(credential.ownerId);
|
|
571
|
+
this.log.info(`google ${this.name}: account ${credential.ownerId.slice(0, 8)} refreshed after ${status}; replaying`);
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
else if (refreshed.has(credential.ownerId) && status === 401) {
|
|
576
|
+
// A token minted a moment ago and refused with a 401 anyway: the account itself is refused.
|
|
577
|
+
this.accounts.reject(credential);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
const retryHeader = upstream.headers.get("retry-after");
|
|
581
|
+
const waitMs = retryHeader ? retryAfterMs({ "retry-after": retryHeader }) : googleRetryDelayMs(text);
|
|
582
|
+
const verdict = this.pool.penalise(this.name, credential.id, credentialRefused ? 403 : status, waitMs, text);
|
|
583
|
+
last = { kind: "refused", status, text: safeText, ...(status === 429 && waitMs ? { retryAfterSeconds: Math.ceil(waitMs / 1000) } : {}) };
|
|
584
|
+
if (!verdict.retryable)
|
|
585
|
+
return last;
|
|
586
|
+
tried.add(credential.ownerId);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
/** An account for one turn: the conversation's own while it is healthy, else the first usable one. */
|
|
590
|
+
async pickAccount(conversation, tried = new Set()) {
|
|
591
|
+
const all = await this.accounts.credentials();
|
|
592
|
+
if (all.length === 0)
|
|
593
|
+
return "none";
|
|
594
|
+
const rest = all.filter((c) => !tried.has(c.ownerId));
|
|
595
|
+
return this.pool.pick(this.name, rest, conversation);
|
|
596
|
+
}
|
|
597
|
+
soonestBackMs() {
|
|
598
|
+
const reports = this.pool.report(this.name, this.accounts.peekCredentials());
|
|
599
|
+
const cooling = reports.filter((r) => r.state === "cooling" && r.cooldownSeconds).map((r) => r.cooldownSeconds * 1000);
|
|
600
|
+
return cooling.length ? Math.min(...cooling) : undefined;
|
|
601
|
+
}
|
|
602
|
+
/** Convenience for tests and diagnostics. */
|
|
603
|
+
get signatureCount() { return this.thoughtSignatures.size; }
|
|
604
|
+
}
|
|
605
|
+
/** The whole JSON body of a non-streaming response, as the one chunk the mapper reads. */
|
|
606
|
+
function parseWholeBody(text, transport) {
|
|
607
|
+
if (!text.trim())
|
|
608
|
+
return [];
|
|
609
|
+
try {
|
|
610
|
+
const parsed = JSON.parse(text);
|
|
611
|
+
return [transport.unwrap(parsed)];
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
return [];
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
export { googleBaseUrl };
|