clauderipple 0.9.5 → 0.10.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 +67 -0
- package/README.md +31 -0
- package/dist/app/package.json +1 -1
- package/dist/cli/src/hooks/agent-title.js +7 -4
- package/dist/cli/src/index.js +43 -0
- package/dist/cli/src/remote.js +263 -0
- package/dist/cli/src/schtasks.js +48 -40
- package/dist/cli/src/settings.js +2 -1
- package/dist/mod/hooks/i18n.ts +252 -6
- package/dist/mod/hooks/judge.ts +35 -17
- package/dist/mod/hooks/modes.ts +56 -1
- package/dist/mod/hooks/register.tsx +238 -32
- package/dist/mod/hooks/session.ts +98 -1
- package/dist/mod/hooks/settings.js +7 -3
- package/dist/mod/hooks/settings.ts +9 -4
- package/dist/mod/hooks/stats.ts +25 -1
- package/dist/mod/hooks/ui/agents.tsx +858 -79
- package/dist/mod/hooks/ui/alerts.tsx +20 -17
- package/dist/mod/hooks/ui/art.ts +33 -7
- package/dist/mod/hooks/ui/band.tsx +262 -81
- package/dist/mod/hooks/ui/flat.ts +265 -0
- package/dist/mod/hooks/ui/kit.tsx +70 -55
- package/dist/mod/hooks/ui/look.ts +129 -0
- package/dist/mod/hooks/ui/settings.tsx +71 -46
- package/dist/mod/hooks/ui/setup.tsx +15 -13
- package/dist/mod/hooks/ui/theme.ts +3 -0
- package/dist/mod/hooks/ui/tools.ts +124 -0
- package/dist/mod/hooks/ui/view.ts +116 -3
- package/dist/mod/hooks/workers.ts +33 -6
- package/dist/mod/tests/register.test.tsx +155 -11
- package/dist/mod/tests/ui.test.tsx +375 -27
- package/dist/mod/types/index.d.ts +68 -2
- package/dist/remote/package.json +28 -0
- package/dist/remote/src/away.js +85 -0
- package/dist/remote/src/bridge-client.js +295 -0
- package/dist/remote/src/composer-menu.js +11 -0
- package/dist/remote/src/config.js +102 -0
- package/dist/remote/src/desktop.js +1018 -0
- package/dist/remote/src/e2ee.js +126 -0
- package/dist/remote/src/e2ee.ts +126 -0
- package/dist/remote/src/eventlog.js +298 -0
- package/dist/remote/src/expo-push.js +127 -0
- package/dist/remote/src/folders.js +79 -0
- package/dist/remote/src/fsutil.js +39 -0
- package/dist/remote/src/handoff.js +38 -0
- package/dist/remote/src/host-service.js +168 -0
- package/dist/remote/src/images.js +215 -0
- package/dist/remote/src/local-control.js +138 -0
- package/dist/remote/src/main.js +264 -0
- package/dist/remote/src/permissions.js +124 -0
- package/dist/remote/src/power.js +76 -0
- package/dist/remote/src/processes.js +24 -0
- package/dist/remote/src/protocol.js +32 -0
- package/dist/remote/src/protocol.ts +284 -0
- package/dist/remote/src/push-content.js +32 -0
- package/dist/remote/src/push.js +134 -0
- package/dist/remote/src/receipts.js +111 -0
- package/dist/remote/src/relay.js +438 -0
- package/dist/remote/src/router.js +208 -0
- package/dist/remote/src/sdk-launcher.js +18 -0
- package/dist/remote/src/server.js +706 -0
- package/dist/remote/src/service.js +157 -0
- package/dist/remote/src/sessions.js +1800 -0
- package/dist/remote/src/sidebar-storage.js +211 -0
- package/dist/remote/src/sidebar.js +173 -0
- package/dist/remote/src/tool-details.js +205 -0
- package/dist/remote/src/tool-details.ts +153 -0
- package/dist/remote/src/worktree.js +99 -0
- package/dist/remote/web/app.ts +746 -0
- package/dist/remote/web/conn.ts +193 -0
- package/dist/remote/web/demo.ts +134 -0
- package/dist/remote/web/dom.ts +124 -0
- package/dist/remote/web/esc.ts +4 -0
- package/dist/remote/web/i18n.ts +374 -0
- package/dist/remote/web/icon.svg +1 -0
- package/dist/remote/web/index.html +24 -0
- package/dist/remote/web/manifest.webmanifest +10 -0
- package/dist/remote/web/mascot.ts +60 -0
- package/dist/remote/web/md.ts +78 -0
- package/dist/remote/web/notify.ts +55 -0
- package/dist/remote/web/pushstate.ts +27 -0
- package/dist/remote/web/secure.ts +109 -0
- package/dist/remote/web/sidebar.ts +106 -0
- package/dist/remote/web/style.css +311 -0
- package/dist/remote/web/sw.ts +48 -0
- package/dist/remote/web/timeline.ts +224 -0
- package/dist/remote/web/views.ts +323 -0
- package/dist/router/src/admin.js +227 -4
- package/dist/router/src/config.js +30 -0
- package/dist/router/src/index.js +39 -0
- package/dist/router/src/ingress/translate.js +6 -2
- package/dist/router/src/phone.js +82 -0
- package/dist/router/src/providers/anthropic-usage.js +33 -1
- package/dist/router/src/providers/google/index.js +26 -0
- package/dist/router/src/providers/google/prompt-refusal.js +29 -0
- package/dist/router/src/providers/google/usage.js +257 -0
- package/dist/router/src/providers/grok/index.js +18 -0
- package/dist/router/src/providers/grok/usage.js +77 -0
- package/dist/router/src/providers/openai/translate.js +13 -2
- package/dist/router/src/providers/opencode-usage.js +116 -0
- package/dist/router/src/proxy.js +339 -258
- package/dist/router/src/remote-host.js +320 -0
- package/dist/router/src/remote-tunnel.js +320 -0
- package/dist/router/src/requestlog.js +48 -39
- package/dist/router/src/usage-history.js +227 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +436 -59
- package/dist/ui/i18n.js +184 -0
- package/dist/ui/index.html +24 -5
- package/dist/ui/style.css +39 -0
- package/docs/ARCHITECTURE.md +108 -8
- package/package.json +7 -1
package/dist/router/src/admin.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// POST /api/update start `clauderipple update` in the background (it restarts this router)
|
|
17
17
|
// GET /* static files from packages/ui (the GUI itself)
|
|
18
18
|
import crypto from "node:crypto";
|
|
19
|
+
import { phoneRequest } from "./phone.js";
|
|
19
20
|
import fs from "node:fs";
|
|
20
21
|
import net from "node:net";
|
|
21
22
|
import os from "node:os";
|
|
@@ -51,6 +52,8 @@ import { readClaudeAuthFile } from "./providers/anthropic-token-file.js";
|
|
|
51
52
|
import { listClaudeAccounts, removeClaudeAccount, renameClaudeAccount, setPreferredClaudeAccount } from "./providers/anthropic-accounts.js";
|
|
52
53
|
import { readChatGptAccounts, removeChatGptAccount, summarize as summarizeChatGptAccount, updateChatGptAccount } from "./providers/chatgpt/accounts.js";
|
|
53
54
|
import { exhaustedForMs, IMAGE_ASPECTS, IMAGE_BACKGROUNDS, IMAGE_FORMATS } from "./providers/chatgpt/index.js";
|
|
55
|
+
import { googleWindowsFromGroups } from "./providers/google/usage.js";
|
|
56
|
+
import { isOpenCodeGo } from "./providers/opencode-usage.js";
|
|
54
57
|
import { ANTIGRAVITY_WARNING, staticAntigravityModels } from "./providers/google/antigravity.js";
|
|
55
58
|
import { readGoogleAccounts, removeGoogleAccount, summarize as summarizeGoogleAccount, updateGoogleAccount } from "./providers/google/accounts.js";
|
|
56
59
|
import { GoogleOAuthSession } from "./providers/google/login.js";
|
|
@@ -100,6 +103,140 @@ async function claudeAccountUsage(deps) {
|
|
|
100
103
|
}
|
|
101
104
|
return rows;
|
|
102
105
|
}
|
|
106
|
+
/** A `{used_percent, window_minutes, reset_at}` snapshot window as `/api/limits` reads it, or null. */
|
|
107
|
+
function limitWindowFrom(w) {
|
|
108
|
+
if (!w || typeof w !== "object" || Array.isArray(w))
|
|
109
|
+
return null;
|
|
110
|
+
const o = w;
|
|
111
|
+
const used = o.used_percent;
|
|
112
|
+
if (typeof used !== "number" || !Number.isFinite(used))
|
|
113
|
+
return null;
|
|
114
|
+
const minutes = typeof o.window_minutes === "number" && o.window_minutes > 0 ? o.window_minutes : undefined;
|
|
115
|
+
// A reset already in milliseconds would otherwise park a window for decades; the other readers
|
|
116
|
+
// normalise the same way (chatgpt windowResetMs).
|
|
117
|
+
const reset = typeof o.reset_at === "number" && Number.isFinite(o.reset_at) ? Math.round(o.reset_at > 1e12 ? o.reset_at / 1000 : o.reset_at) : undefined;
|
|
118
|
+
return { used_percent: used, ...(minutes !== undefined ? { window_minutes: minutes } : {}), ...(reset !== undefined ? { reset_at: reset } : {}) };
|
|
119
|
+
}
|
|
120
|
+
/** The kind a five-hour/weekly/other window takes, by its length in minutes. */
|
|
121
|
+
function windowKind(minutes) {
|
|
122
|
+
return minutes === 300 ? "five_hour" : minutes === 10080 ? "seven_day" : "window";
|
|
123
|
+
}
|
|
124
|
+
/** One Claude subscription account's plan windows: the two main windows and the scoped weeklies. */
|
|
125
|
+
function claudeLimitWindows(quota) {
|
|
126
|
+
const out = [];
|
|
127
|
+
const limits = quota.rate_limits;
|
|
128
|
+
for (const w of [limits?.primary, limits?.secondary]) {
|
|
129
|
+
const win = limitWindowFrom(w);
|
|
130
|
+
if (win)
|
|
131
|
+
out.push({ kind: windowKind(win.window_minutes), used_percent: win.used_percent, ...(win.window_minutes !== undefined ? { window_minutes: win.window_minutes } : {}), ...(win.reset_at !== undefined ? { reset_at: win.reset_at } : {}) });
|
|
132
|
+
}
|
|
133
|
+
const scoped = quota.scoped;
|
|
134
|
+
if (Array.isArray(scoped))
|
|
135
|
+
for (const s of scoped) {
|
|
136
|
+
if (!s || typeof s !== "object" || Array.isArray(s))
|
|
137
|
+
continue;
|
|
138
|
+
const entry = s;
|
|
139
|
+
const win = limitWindowFrom(entry);
|
|
140
|
+
if (!win || typeof entry.name !== "string" || !entry.name)
|
|
141
|
+
continue;
|
|
142
|
+
out.push({ kind: "scoped", label: entry.name, used_percent: win.used_percent, ...(win.window_minutes !== undefined ? { window_minutes: win.window_minutes } : {}), ...(win.reset_at !== undefined ? { reset_at: win.reset_at } : {}) });
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
/** The Claude Code login's plan windows as one account (the row shows Claude as a single group). */
|
|
147
|
+
async function anthropicLimitAccounts(deps) {
|
|
148
|
+
let usage;
|
|
149
|
+
try {
|
|
150
|
+
usage = (await deps.claudeUsage?.()) ?? {};
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
const quota = usage.current ?? Object.values(usage)[0];
|
|
156
|
+
if (!quota)
|
|
157
|
+
return [];
|
|
158
|
+
const windows = claudeLimitWindows(quota);
|
|
159
|
+
if (!windows.length)
|
|
160
|
+
return [];
|
|
161
|
+
const at = typeof quota.at === "number" ? quota.at : Date.now();
|
|
162
|
+
return [{ account: 0, windows, at }];
|
|
163
|
+
}
|
|
164
|
+
/** Every ChatGPT account's windows, paused and needs-login accounts left out and unnumbered. */
|
|
165
|
+
function chatgptLimitAccounts(accounts) {
|
|
166
|
+
const kept = accounts.filter((a) => a.state !== "paused" && a.state !== "needs-login");
|
|
167
|
+
const out = [];
|
|
168
|
+
kept.forEach((account, index) => {
|
|
169
|
+
const quota = account.quota;
|
|
170
|
+
if (!quota || typeof quota.at !== "number")
|
|
171
|
+
return;
|
|
172
|
+
const limits = quota.rate_limits;
|
|
173
|
+
const windows = [];
|
|
174
|
+
for (const w of [limits?.primary, limits?.secondary]) {
|
|
175
|
+
const win = limitWindowFrom(w);
|
|
176
|
+
if (win)
|
|
177
|
+
windows.push({ kind: windowKind(win.window_minutes), used_percent: win.used_percent, ...(win.window_minutes !== undefined ? { window_minutes: win.window_minutes } : {}), ...(win.reset_at !== undefined ? { reset_at: win.reset_at } : {}) });
|
|
178
|
+
}
|
|
179
|
+
if (windows.length)
|
|
180
|
+
out.push({ account: index, windows, at: quota.at });
|
|
181
|
+
});
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
/** Every Antigravity account's windows (Gemini's own plus the third-party ones), paused left out. */
|
|
185
|
+
async function googleLimitAccounts(deps, name) {
|
|
186
|
+
const google = deps.google?.();
|
|
187
|
+
if (!google)
|
|
188
|
+
return [];
|
|
189
|
+
const statuses = google.accounts()[name] ?? [];
|
|
190
|
+
const groups = (await google.usage?.(name)) ?? {};
|
|
191
|
+
const kept = statuses.filter((a) => a.state !== "paused" && a.state !== "needs-login");
|
|
192
|
+
const at = Date.now();
|
|
193
|
+
const out = [];
|
|
194
|
+
kept.forEach((account, index) => {
|
|
195
|
+
const quotient = groups[account.id];
|
|
196
|
+
if (!quotient || !quotient.length)
|
|
197
|
+
return;
|
|
198
|
+
const windows = googleWindowsFromGroups(quotient);
|
|
199
|
+
if (windows.length)
|
|
200
|
+
out.push({ account: index, windows, at });
|
|
201
|
+
});
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
/** Every provider whose quota can be read, in one shape, for `GET /api/limits`. */
|
|
205
|
+
async function buildLimits(deps) {
|
|
206
|
+
const providers = [];
|
|
207
|
+
const chatgpt = deps.chatgpt?.();
|
|
208
|
+
const accounts = chatgpt?.accounts?.() ?? {};
|
|
209
|
+
for (const [name, p] of Object.entries(deps.config().providers)) {
|
|
210
|
+
if (p.limits === false)
|
|
211
|
+
continue;
|
|
212
|
+
if (p.type === "anthropic" && p.auth === "claude-code") {
|
|
213
|
+
const rows = await anthropicLimitAccounts(deps);
|
|
214
|
+
if (rows.length)
|
|
215
|
+
providers.push({ provider: name, type: "anthropic", title: "Claude", accounts: rows });
|
|
216
|
+
}
|
|
217
|
+
else if (p.type === "chatgpt") {
|
|
218
|
+
const rows = chatgptLimitAccounts(accounts[name] ?? []);
|
|
219
|
+
if (rows.length)
|
|
220
|
+
providers.push({ provider: name, type: "chatgpt", title: "GPT", accounts: rows });
|
|
221
|
+
}
|
|
222
|
+
else if (p.type === "google" && p.auth === "antigravity") {
|
|
223
|
+
const rows = await googleLimitAccounts(deps, name);
|
|
224
|
+
if (rows.length)
|
|
225
|
+
providers.push({ provider: name, type: "google", title: "Gemini", accounts: rows });
|
|
226
|
+
}
|
|
227
|
+
else if (p.type === "openai-compatible" && isOpenCodeGo(p)) {
|
|
228
|
+
const windows = (await deps.opencodeUsage?.(name)) ?? [];
|
|
229
|
+
if (windows.length)
|
|
230
|
+
providers.push({ provider: name, type: "opencode-go", title: "Go", accounts: [{ account: 0, windows: windows.map((w) => ({ ...w })), at: Date.now() }] });
|
|
231
|
+
}
|
|
232
|
+
else if (p.type === "grok") {
|
|
233
|
+
const windows = (await deps.grokUsage?.(name)) ?? [];
|
|
234
|
+
if (windows.length)
|
|
235
|
+
providers.push({ provider: name, type: "grok", title: "Grok", accounts: [{ account: 0, windows: windows.map((w) => ({ ...w })), at: Date.now() }] });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return { providers };
|
|
239
|
+
}
|
|
103
240
|
// The CLI's `--effort` takes the same five; Opus, Sonnet and Haiku 5.5 answered `xhigh` (requests.jsonl, 2026-10-14, #56).
|
|
104
241
|
const ANTHROPIC_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
|
|
105
242
|
const CHATGPT_DEFAULT_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
|
|
@@ -1030,10 +1167,12 @@ async function probeProvider(body, probeFetch = fetchWithTimeout) {
|
|
|
1030
1167
|
const checkModel = presetProbeModel(body.preset) ?? models[0]?.id ?? body.probeModel ?? "test";
|
|
1031
1168
|
// 16 tokens, as on Responses: some vendors refuse a smaller cap outright (B.ai: "max_tokens must be
|
|
1032
1169
|
// greater than 2", #57), which read as a failed connection though the key and URL were right.
|
|
1170
|
+
// Streamed, as every translated turn is: the status arrives with the headers, while a reasoning
|
|
1171
|
+
// model answering whole can take past the timeout (Hive: 0.3s streamed, 3–23s whole, 2026-10-18).
|
|
1033
1172
|
const checkBody = responses
|
|
1034
|
-
? { model: checkModel, max_output_tokens: 16, stream:
|
|
1173
|
+
? { model: checkModel, max_output_tokens: 16, stream: true, store: false, input: "hi" }
|
|
1035
1174
|
: openai
|
|
1036
|
-
? { model: checkModel, max_tokens: 16, stream:
|
|
1175
|
+
? { model: checkModel, max_tokens: 16, stream: true, messages: [{ role: "user", content: "hi" }] }
|
|
1037
1176
|
: { model: checkModel, max_tokens: 16, messages: [{ role: "user", content: "hi" }] };
|
|
1038
1177
|
const label = responses ? "responses endpoint" : openai ? "chat completions endpoint" : "messages endpoint";
|
|
1039
1178
|
try {
|
|
@@ -1051,8 +1190,10 @@ async function probeProvider(body, probeFetch = fetchWithTimeout) {
|
|
|
1051
1190
|
const auth = response.status === 403 && refusedByPlan(refusal) ? "not-entitled" : "bad-key";
|
|
1052
1191
|
return { ok: false, auth, models, error: `${label} returned ${response.status}: ${refusal}` };
|
|
1053
1192
|
}
|
|
1054
|
-
if (response.ok)
|
|
1193
|
+
if (response.ok) {
|
|
1194
|
+
await response.body?.cancel().catch(() => { });
|
|
1055
1195
|
return { ok: true, auth: "ok", models, ...(modelsError ? { error: modelsError } : {}) };
|
|
1196
|
+
}
|
|
1056
1197
|
const detail = snippet(await response.text());
|
|
1057
1198
|
// A model-validation refusal still demonstrates that the endpoint reached the provider and the key was accepted.
|
|
1058
1199
|
if (refusedForModel(response.status, detail)) {
|
|
@@ -1441,6 +1582,28 @@ async function pickerModels(deps) {
|
|
|
1441
1582
|
}
|
|
1442
1583
|
return { models: await claudeModelList(deps), source: "fallback" };
|
|
1443
1584
|
}
|
|
1585
|
+
/**
|
|
1586
|
+
* Each model's context window, for a client that reads a session's context use without the CLI's own
|
|
1587
|
+
* figure (the phone, for a Claude app session). Claude's from Anthropic's catalog, the CLI's injected
|
|
1588
|
+
* models from their config (else `cli.autoCompactWindow`), providers' from theirs. Unknown is absent.
|
|
1589
|
+
*/
|
|
1590
|
+
async function contextWindows(deps) {
|
|
1591
|
+
const cfg = deps.config();
|
|
1592
|
+
const windows = {};
|
|
1593
|
+
const put = (id, value) => {
|
|
1594
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0 && windows[id] === undefined)
|
|
1595
|
+
windows[id] = Math.floor(value);
|
|
1596
|
+
};
|
|
1597
|
+
for (const model of await claudeModelList(deps))
|
|
1598
|
+
put(model.id, model.contextWindow);
|
|
1599
|
+
for (const model of cfg.cli.extraModels)
|
|
1600
|
+
put(model.model, model.contextWindow ?? cfg.cli.autoCompactWindow);
|
|
1601
|
+
for (const provider of Object.values(cfg.providers)) {
|
|
1602
|
+
for (const model of ("models" in provider && provider.models) || [])
|
|
1603
|
+
put(model.id, model.contextWindow);
|
|
1604
|
+
}
|
|
1605
|
+
return { windows };
|
|
1606
|
+
}
|
|
1444
1607
|
function tailLines(file, n) {
|
|
1445
1608
|
if (!fs.existsSync(file))
|
|
1446
1609
|
return "";
|
|
@@ -1535,6 +1698,28 @@ export function startAdmin(deps) {
|
|
|
1535
1698
|
sendJson(res, 403, { error: "cross-site request refused" });
|
|
1536
1699
|
return;
|
|
1537
1700
|
}
|
|
1701
|
+
if (pathname.startsWith("/api/phone/")) {
|
|
1702
|
+
res.setHeader("cache-control", "no-store");
|
|
1703
|
+
// Pairing is a credential read: guard GETs too, including DNS-rebinding Host values.
|
|
1704
|
+
if (crossSitePost(req) || req.headers["sec-fetch-site"] === "cross-site" ||
|
|
1705
|
+
!new Set([`127.0.0.1:${boundPort}`, `localhost:${boundPort}`]).has(req.headers.host ?? "")) {
|
|
1706
|
+
sendJson(res, 403, { error: "same-origin phone control required" });
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
let body;
|
|
1710
|
+
if (method === "POST" && pathname === "/api/phone/settings") {
|
|
1711
|
+
try {
|
|
1712
|
+
body = (await readBody(req, 4096, new Error("phone settings too large"))).toString("utf8");
|
|
1713
|
+
}
|
|
1714
|
+
catch {
|
|
1715
|
+
sendJson(res, 413, { error: "phone settings too large" });
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
const result = await phoneRequest(pathname.slice("/api/phone/".length), method, body, deps.phone);
|
|
1720
|
+
sendJson(res, result.status, result.body);
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1538
1723
|
if (pathname === "/api/shutdown" && method === "POST") {
|
|
1539
1724
|
if (!deps.shutdown) {
|
|
1540
1725
|
sendJson(res, 501, { error: "shutdown not available" });
|
|
@@ -1546,6 +1731,22 @@ export function startAdmin(deps) {
|
|
|
1546
1731
|
deps.shutdown();
|
|
1547
1732
|
return;
|
|
1548
1733
|
}
|
|
1734
|
+
if (pathname === "/api/remote" && method === "GET") {
|
|
1735
|
+
if (!deps.remote) {
|
|
1736
|
+
sendJson(res, 501, { error: "remote tunnels not available" });
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
sendJson(res, 200, { hosts: deps.remote.status() });
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
if (pathname === "/api/remote/reload" && method === "POST") {
|
|
1743
|
+
if (!deps.remote) {
|
|
1744
|
+
sendJson(res, 501, { error: "remote tunnels not available" });
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1747
|
+
sendJson(res, 200, { hosts: deps.remote.reload() });
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1549
1750
|
// Liveness is answering at all; readiness is 200 only when a request can actually go through.
|
|
1550
1751
|
if (pathname === "/readyz" && method === "GET") {
|
|
1551
1752
|
const readiness = (await buildStatus(deps)).readiness;
|
|
@@ -1559,6 +1760,11 @@ export function startAdmin(deps) {
|
|
|
1559
1760
|
sendJson(res, 200, await buildStatus(deps, { refresh }));
|
|
1560
1761
|
return;
|
|
1561
1762
|
}
|
|
1763
|
+
// One shape for every provider whose quota can be read, for the status bar's limits row.
|
|
1764
|
+
if (pathname === "/api/limits" && method === "GET") {
|
|
1765
|
+
sendJson(res, 200, await buildLimits(deps));
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1562
1768
|
if (pathname === "/api/mod-settings" && method === "GET") {
|
|
1563
1769
|
const snapshot = readModSettings();
|
|
1564
1770
|
// Filled here as well as on save, so a ladder with empty rungs (Auto migrated, or never set) reads whole to every surface.
|
|
@@ -1597,6 +1803,10 @@ export function startAdmin(deps) {
|
|
|
1597
1803
|
sendJson(res, 200, await pickerModels(deps));
|
|
1598
1804
|
return;
|
|
1599
1805
|
}
|
|
1806
|
+
if (pathname === "/api/context-windows" && method === "GET") {
|
|
1807
|
+
sendJson(res, 200, await contextWindows(deps));
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1600
1810
|
if (pathname === "/api/effort-levels" && method === "GET") {
|
|
1601
1811
|
sendJson(res, 200, effortLevels(deps.config()));
|
|
1602
1812
|
return;
|
|
@@ -2294,7 +2504,20 @@ export function startAdmin(deps) {
|
|
|
2294
2504
|
return;
|
|
2295
2505
|
}
|
|
2296
2506
|
if (pathname === "/api/requests/summary" && method === "GET") {
|
|
2297
|
-
const
|
|
2507
|
+
const query = new URL(url, "http://x").searchParams;
|
|
2508
|
+
const fromParam = query.get("from");
|
|
2509
|
+
if (fromParam !== null) {
|
|
2510
|
+
const from = Number(fromParam);
|
|
2511
|
+
const toParam = query.get("to");
|
|
2512
|
+
const to = toParam === null ? Date.now() : Number(toParam);
|
|
2513
|
+
if (!Number.isFinite(from) || !Number.isFinite(to) || from > to) {
|
|
2514
|
+
sendJson(res, 400, { error: "from and to must be epoch milliseconds with from <= to" });
|
|
2515
|
+
return;
|
|
2516
|
+
}
|
|
2517
|
+
sendJson(res, 200, { ...deps.requests.usage(from, to), from, to });
|
|
2518
|
+
return;
|
|
2519
|
+
}
|
|
2520
|
+
const seconds = Number(query.get("since") ?? 3600);
|
|
2298
2521
|
const safeSeconds = Number.isFinite(seconds) ? Math.min(31_536_000, Math.max(0, seconds)) : 3600;
|
|
2299
2522
|
sendJson(res, 200, deps.requests.summary(Date.now() - safeSeconds * 1000));
|
|
2300
2523
|
return;
|
|
@@ -7,6 +7,7 @@ import fs from "node:fs";
|
|
|
7
7
|
import os from "node:os";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { PRESETS } from "./presets.js";
|
|
10
|
+
import { hostProblem, portProblem } from "./remote-host.js";
|
|
10
11
|
/**
|
|
11
12
|
* The provider's own key, re-sent under the header another wire on the same account expects.
|
|
12
13
|
*
|
|
@@ -323,6 +324,8 @@ export function validate(c) {
|
|
|
323
324
|
errors.push(`provider ${name}: identity must be true or false`);
|
|
324
325
|
if (shared.instructionsAppend !== undefined && typeof shared.instructionsAppend !== "string")
|
|
325
326
|
errors.push(`provider ${name}: instructionsAppend must be a string`);
|
|
327
|
+
if (shared.limits !== undefined && typeof shared.limits !== "boolean")
|
|
328
|
+
errors.push(`provider ${name}: limits must be true or false`);
|
|
326
329
|
// Ids key the router's cooldown and quarantine state, so a duplicate would make two credentials
|
|
327
330
|
// share one health record and take each other down.
|
|
328
331
|
const pool = p.credentials;
|
|
@@ -465,6 +468,33 @@ export function validate(c) {
|
|
|
465
468
|
errors.push("cli.limitWorkerTools must be true or false");
|
|
466
469
|
if (c.listen.openaiPort !== undefined && !(c.listen.openaiPort >= 0 && c.listen.openaiPort < 65536))
|
|
467
470
|
errors.push("listen.openaiPort out of range");
|
|
471
|
+
if (c.remote !== undefined) {
|
|
472
|
+
const hosts = c.remote?.hosts;
|
|
473
|
+
if (!Array.isArray(hosts))
|
|
474
|
+
errors.push("remote.hosts must be a list");
|
|
475
|
+
else {
|
|
476
|
+
const seen = new Set();
|
|
477
|
+
hosts.forEach((entry, i) => {
|
|
478
|
+
const h = entry;
|
|
479
|
+
if (!h || typeof h !== "object") {
|
|
480
|
+
errors.push(`remote.hosts ${i} must be an object`);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
// Handed to ssh as an argument: the same check `remote add` applies, so a hand edit cannot
|
|
484
|
+
// slip an option such as -oProxyCommand into the router's own ssh.
|
|
485
|
+
const hostError = hostProblem(h.host);
|
|
486
|
+
if (hostError)
|
|
487
|
+
errors.push(`remote.hosts ${i}: ${hostError}`);
|
|
488
|
+
else if (seen.has(h.host))
|
|
489
|
+
errors.push(`remote.hosts ${i}: "${String(h.host)}" is listed twice`);
|
|
490
|
+
else
|
|
491
|
+
seen.add(h.host);
|
|
492
|
+
const portError = portProblem(h.port);
|
|
493
|
+
if (portError)
|
|
494
|
+
errors.push(`remote.hosts ${i}: ${portError}`);
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
}
|
|
468
498
|
return errors;
|
|
469
499
|
}
|
|
470
500
|
export class ConfigStore {
|
package/dist/router/src/index.js
CHANGED
|
@@ -21,6 +21,8 @@ import { OpenAiIngress } from "./ingress/server.js";
|
|
|
21
21
|
import { ObservedClaudeCodeAuth } from "./providers/anthropic-observed.js";
|
|
22
22
|
import { VERSION } from "./version.js";
|
|
23
23
|
import { allowLongSilences } from "./providers/retry.js";
|
|
24
|
+
import { assetsFingerprint, readLocalAssets, spawnSsh, syncRemoteAssets } from "./remote-host.js";
|
|
25
|
+
import { RemoteTunnels, proxyConnectHost } from "./remote-tunnel.js";
|
|
24
26
|
// Startup timing. Claude Desktop routes *all* its traffic through us in picker mode, so every
|
|
25
27
|
// second before the socket is open is a second the app cannot reach anything (ERR_PROXY_-
|
|
26
28
|
// CONNECTION_FAILED, observed 2026-09-14 after a reboot: 26s between exec and listening).
|
|
@@ -31,6 +33,8 @@ const since = (t) => Date.now() - t;
|
|
|
31
33
|
const home = homeDir();
|
|
32
34
|
const logFile = process.env.CLAUDERIPPLE_NO_LOGFILE ? null : path.join(home, "logs", "router.log");
|
|
33
35
|
let log = null;
|
|
36
|
+
// Created after listen(); the config callback below runs before that and finds it null.
|
|
37
|
+
let tunnels = null;
|
|
34
38
|
const store = new ConfigStore(undefined, (c, errors) => {
|
|
35
39
|
for (const e of errors)
|
|
36
40
|
(log ?? console).warn(`config: ${e}`);
|
|
@@ -51,6 +55,9 @@ const store = new ConfigStore(undefined, (c, errors) => {
|
|
|
51
55
|
catch (e) {
|
|
52
56
|
(log ?? console).warn(`agent files: ${e.message}`);
|
|
53
57
|
}
|
|
58
|
+
// Remote hosts get the regenerated agent files too, and a hand edit of `remote.hosts` takes effect.
|
|
59
|
+
tunnels?.reconcile(c.remote?.hosts ?? []);
|
|
60
|
+
tunnels?.assetsChanged();
|
|
54
61
|
});
|
|
55
62
|
const cfg0 = store.get();
|
|
56
63
|
log = new Logger(logFile, cfg0.log.maxBytes, cfg0.log.keep, !!process.env.CLAUDERIPPLE_ECHO || !logFile);
|
|
@@ -121,6 +128,10 @@ function beginDrain(reason) {
|
|
|
121
128
|
}
|
|
122
129
|
for (const sig of ["SIGINT", "SIGTERM"])
|
|
123
130
|
process.on(sig, () => beginDrain(sig));
|
|
131
|
+
// Tunnels go with the process, not with the start of a drain: a remote session's in-flight model
|
|
132
|
+
// call travels through one, and the drain exists to let it finish. Every exit path ends here
|
|
133
|
+
// (drain, the health self-exit, a fatal startup error); a SIGKILL is covered by the pid record.
|
|
134
|
+
process.on("exit", () => tunnels?.stopAll());
|
|
124
135
|
const statsEvery = 5 * 60 * 1000;
|
|
125
136
|
setInterval(() => {
|
|
126
137
|
const s = proxy.stats;
|
|
@@ -138,6 +149,24 @@ proxy
|
|
|
138
149
|
` (startup ${since(T_EXEC)}ms: node ${T_MODULE - T_EXEC}, config ${T_CONFIG - T_MODULE}, listen ${since(T_CONFIG)})`);
|
|
139
150
|
const ingressPort = await ingress.listen();
|
|
140
151
|
log.info(`clauderipple OpenAI ingress listening on 127.0.0.1:${ingressPort}`);
|
|
152
|
+
// Reverse tunnels to remote (SSH) hosts, after listen() so their far end has something to reach.
|
|
153
|
+
const assets = () => readLocalAssets({ home, agentDir: defaultAgentDir() });
|
|
154
|
+
tunnels = new RemoteTunnels({
|
|
155
|
+
log: log,
|
|
156
|
+
proxyTarget: () => ({ host: proxyConnectHost(store.get().listen.host), port: store.get().listen.port }),
|
|
157
|
+
pidFile: path.join(home, "remote-tunnels.json"),
|
|
158
|
+
sync: (host) => syncRemoteAssets(spawnSsh, host, assets()),
|
|
159
|
+
fingerprint: () => {
|
|
160
|
+
try {
|
|
161
|
+
return assetsFingerprint(assets());
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return "";
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
tunnels.cleanupStale();
|
|
169
|
+
tunnels.reconcile(c.remote?.hosts ?? []);
|
|
141
170
|
// Quota only arrives on GPT response headers, so a quiet day left the status 11 hours stale
|
|
142
171
|
// (2026-09-20). Ask once now, after listen(), and never wait for the answer.
|
|
143
172
|
proxy.chatgptRefreshAll();
|
|
@@ -161,6 +190,7 @@ proxy
|
|
|
161
190
|
google: () => ({
|
|
162
191
|
accounts: () => proxy.googleAccounts(),
|
|
163
192
|
models: (name) => proxy.googleFetchModels(name),
|
|
193
|
+
usage: (name) => proxy.googleUsage(name),
|
|
164
194
|
clearCooldown: (id) => proxy.googleClearCooldown(id),
|
|
165
195
|
recheckVerification: (id) => proxy.googleRecheckVerification(id),
|
|
166
196
|
}),
|
|
@@ -169,9 +199,18 @@ proxy
|
|
|
169
199
|
observedClaudeCodeAuth,
|
|
170
200
|
claudeUsage: () => proxy.claudeUsage.snapshot(),
|
|
171
201
|
claudeModels: () => proxy.claudeModels.list(),
|
|
202
|
+
opencodeUsage: (name) => proxy.opencodeLimits(name),
|
|
203
|
+
grokUsage: (name) => proxy.grokLimits(name),
|
|
172
204
|
image: (req, signal) => proxy.generateImage(req, signal),
|
|
173
205
|
modelCatalog: () => loadModelCatalog(),
|
|
174
206
|
shutdown: () => beginDrain("shutdown requested"),
|
|
207
|
+
remote: {
|
|
208
|
+
status: () => tunnels.status(),
|
|
209
|
+
reload: () => {
|
|
210
|
+
tunnels.reconcile(store.get().remote?.hosts ?? []);
|
|
211
|
+
return tunnels.status();
|
|
212
|
+
},
|
|
213
|
+
},
|
|
175
214
|
};
|
|
176
215
|
const admin = await startAdmin(adminDeps);
|
|
177
216
|
log.info(`clauderipple admin GUI on http://127.0.0.1:${admin.port}/`);
|
|
@@ -170,9 +170,13 @@ export function effortFromRequest(value) {
|
|
|
170
170
|
return effort ?? undefined;
|
|
171
171
|
}
|
|
172
172
|
/** Convert an OpenAI Responses request to a cache-friendly Anthropic Messages request. */
|
|
173
|
-
/**
|
|
173
|
+
/**
|
|
174
|
+
* Claude models that accept `output_config.effort` (measured 2026-09-13: Haiku 4.5 answers 400 "does
|
|
175
|
+
* not support the effort parameter"). Mythos 5 and 5.1 list `effort` among their capabilities in
|
|
176
|
+
* Claude Code 2.1.295's model catalogue (read 2026-10-14).
|
|
177
|
+
*/
|
|
174
178
|
export function claudeSupportsEffort(model) {
|
|
175
|
-
return /^claude-(opus|sonnet|fable)-(4-[6-9]|5)(-|$)/.test(model);
|
|
179
|
+
return /^claude-(opus|sonnet|fable|mythos)-(4-[6-9]|5)(-|$)/.test(model);
|
|
176
180
|
}
|
|
177
181
|
/**
|
|
178
182
|
* With a borrowed Claude Code login, Anthropic accepts the request only when the system prompt is
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Admin → host control bridge. Never forwards arbitrary URLs, headers or redirects.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { DEFAULT_PORT, remoteHome } from "../../remote/src/config.js";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
const INSTALL_HINT = "clauderipple remote install";
|
|
8
|
+
function target(dir) {
|
|
9
|
+
let port = DEFAULT_PORT;
|
|
10
|
+
try {
|
|
11
|
+
const config = JSON.parse(fs.readFileSync(path.join(dir, "host.json"), "utf8"));
|
|
12
|
+
if (config.port !== undefined) {
|
|
13
|
+
if (typeof config.port !== "number" || !Number.isInteger(config.port) || config.port < 1 || config.port > 65535)
|
|
14
|
+
throw new Error("invalid host port");
|
|
15
|
+
port = config.port;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (error.code !== "ENOENT")
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
const secret = fs.readFileSync(path.join(dir, "local.secret"), "utf8").trim();
|
|
23
|
+
if (secret.length < 32 || !/^[\w-]+$/.test(secret))
|
|
24
|
+
throw new Error("invalid local secret");
|
|
25
|
+
return { port, secret };
|
|
26
|
+
}
|
|
27
|
+
function execute(file, args) {
|
|
28
|
+
return new Promise((resolve, reject) => execFile(file, args, { timeout: 150_000, windowsHide: true }, error => error ? reject(error) : resolve()));
|
|
29
|
+
}
|
|
30
|
+
export async function phoneRequest(route, method, body, deps = {}) {
|
|
31
|
+
if (route === "restart" && method === "POST") {
|
|
32
|
+
const uid = deps.uid ?? process.getuid?.();
|
|
33
|
+
const command = uid === undefined ? "npm run remote -- install" : `launchctl kickstart -k gui/${uid}/com.clauderipple.host`;
|
|
34
|
+
if ((deps.platform ?? process.platform) !== "darwin") {
|
|
35
|
+
const script = fileURLToPath(new URL(`../../remote/src/main${path.extname(import.meta.url)}`, import.meta.url));
|
|
36
|
+
try {
|
|
37
|
+
await (deps.exec ?? execute)(process.execPath, [script, "install"]);
|
|
38
|
+
return { status: 200, body: { ok: true } };
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return { status: 502, body: { error: "host service install/restart failed", command: INSTALL_HINT } };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (uid === undefined)
|
|
45
|
+
return { status: 500, body: { error: "host restart unavailable" } };
|
|
46
|
+
try {
|
|
47
|
+
await (deps.exec ?? execute)("launchctl", ["kickstart", "-k", `gui/${uid}/com.clauderipple.host`]);
|
|
48
|
+
return { status: 200, body: { ok: true } };
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return { status: 502, body: { error: "host restart failed", command } };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (!((method === "GET" && (route === "status" || route === "pairing")) || (method === "POST" && (route === "settings" || route === "rotate")))) {
|
|
55
|
+
return { status: 404, body: { error: "unknown phone endpoint or method" } };
|
|
56
|
+
}
|
|
57
|
+
let response;
|
|
58
|
+
try {
|
|
59
|
+
const { port, secret } = target(deps.home ?? remoteHome());
|
|
60
|
+
response = await (deps.fetch ?? fetch)(`http://127.0.0.1:${port}/local/${route}`, {
|
|
61
|
+
method, headers: { "x-ripple-local": secret, "content-type": "application/json" },
|
|
62
|
+
...(body !== undefined ? { body } : {}), redirect: "error", signal: AbortSignal.timeout(5000),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return route === "status"
|
|
67
|
+
? { status: 200, body: { hostRunning: false, hint: INSTALL_HINT } }
|
|
68
|
+
: { status: 503, body: { error: "phone host unavailable", hint: INSTALL_HINT } };
|
|
69
|
+
}
|
|
70
|
+
// A live old host or rejected local secret is not a stopped host. Report the boundary failure.
|
|
71
|
+
if (!response.ok)
|
|
72
|
+
return { status: response.status === 400 ? 400 : 502, body: { error: `host control HTTP ${response.status}; update the host if its local API is unavailable` } };
|
|
73
|
+
try {
|
|
74
|
+
const data = await response.json();
|
|
75
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
76
|
+
throw new Error("invalid host response");
|
|
77
|
+
return { status: 200, body: route === "status" ? { ...data, hostRunning: true } : data };
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return { status: 502, body: { error: "invalid host control response" } };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -25,7 +25,39 @@ export function claudeUsageFromBody(body) {
|
|
|
25
25
|
const secondary = win(b?.seven_day, WEEK);
|
|
26
26
|
if (!primary && !secondary)
|
|
27
27
|
return null;
|
|
28
|
-
|
|
28
|
+
const scoped = scopedFromLimits(b?.limits);
|
|
29
|
+
return {
|
|
30
|
+
type: "claude.usage",
|
|
31
|
+
rate_limits: { primary: primary ?? secondary, secondary: primary ? secondary : null },
|
|
32
|
+
...(scoped.length ? { scoped } : {}),
|
|
33
|
+
at: Date.now(),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Per-model weekly windows from the same body: entries of `limits` with kind `weekly_scoped`
|
|
38
|
+
* carry the model in `scope.model.display_name` (measured 2026-10-15:
|
|
39
|
+
* `{"kind":"weekly_scoped","group":"weekly","percent":2,…,"scope":{"model":{"id":null,
|
|
40
|
+
* "display_name":"Fable"},"surface":null},"is_active":false}`). They ride beside
|
|
41
|
+
* `rate_limits` (which the GUI and the tray read) so those readers keep working unchanged.
|
|
42
|
+
*/
|
|
43
|
+
function scopedFromLimits(limits) {
|
|
44
|
+
if (!Array.isArray(limits))
|
|
45
|
+
return [];
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const entry of limits) {
|
|
48
|
+
if (!entry || typeof entry !== "object" || entry.kind !== "weekly_scoped")
|
|
49
|
+
continue;
|
|
50
|
+
const name = entry.scope?.model?.display_name;
|
|
51
|
+
const percent = entry.percent;
|
|
52
|
+
if (typeof name !== "string" || !name || typeof percent !== "number" || !Number.isFinite(percent))
|
|
53
|
+
continue;
|
|
54
|
+
const w = { name, used_percent: percent, window_minutes: WEEK };
|
|
55
|
+
const at = typeof entry.resets_at === "string" ? Date.parse(entry.resets_at) : NaN;
|
|
56
|
+
if (Number.isFinite(at))
|
|
57
|
+
w.reset_at = Math.round(at / 1000);
|
|
58
|
+
out.push(w);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
29
61
|
}
|
|
30
62
|
/**
|
|
31
63
|
* Per-account snapshots, fetched at most once per `ttlMs` per account. The GUI polls the status
|
|
@@ -25,6 +25,8 @@ import { ThoughtSignatureStore } from "./thoughtSignatures.js";
|
|
|
25
25
|
import { conversationKey, serverToolNames, toolNameRestoreMap } from "../chatgpt/translate.js";
|
|
26
26
|
import { GoogleAccountPool } from "./accounts.js";
|
|
27
27
|
import { fetchAvailableModels, staticAntigravityModels } from "./antigravity.js";
|
|
28
|
+
import { GoogleUsage } from "./usage.js";
|
|
29
|
+
import { ANTIGRAVITY_PROMPT_REFUSAL_MESSAGE, isAntigravityPromptRefusal } from "./prompt-refusal.js";
|
|
28
30
|
const PING_MS = 15_000;
|
|
29
31
|
/** How long the CCA model list is reused before asking again. */
|
|
30
32
|
const MODELS_CACHE_MS = 60 * 60 * 1000;
|
|
@@ -113,6 +115,8 @@ export class GoogleAdapter {
|
|
|
113
115
|
cfg;
|
|
114
116
|
log;
|
|
115
117
|
accounts;
|
|
118
|
+
/** Per-account quota, for the status bar's limits row (`GET /api/limits`). */
|
|
119
|
+
usage;
|
|
116
120
|
/** Cooldowns and conversation stickiness, shared with the proxy's other credential pools. */
|
|
117
121
|
pool;
|
|
118
122
|
lastInputByKey = new Map();
|
|
@@ -130,6 +134,7 @@ export class GoogleAdapter {
|
|
|
130
134
|
this.log = log;
|
|
131
135
|
this.pool = pool;
|
|
132
136
|
this.accounts = new GoogleAccountPool({ home, log, ...(fetchImpl ? { fetch: fetchImpl } : {}) });
|
|
137
|
+
this.usage = new GoogleUsage(this.accounts, { warn: (message) => log.warn(message), ...(fetchImpl ? { fetch: fetchImpl } : {}) });
|
|
133
138
|
}
|
|
134
139
|
describeAuth() {
|
|
135
140
|
const all = this.accounts.summaries();
|
|
@@ -161,6 +166,13 @@ export class GoogleAdapter {
|
|
|
161
166
|
};
|
|
162
167
|
});
|
|
163
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Each account's quota groups by owner id, for the status bar's limits row. Never a token; a
|
|
171
|
+
* failed lookup keeps that account's last good groups.
|
|
172
|
+
*/
|
|
173
|
+
usageSnapshot() {
|
|
174
|
+
return this.usage.snapshot();
|
|
175
|
+
}
|
|
164
176
|
/**
|
|
165
177
|
* Ask Google again whether an account still needs its verification page (dashboard action). The
|
|
166
178
|
* mark otherwise clears only when the account next answers a turn, so a person who had just
|
|
@@ -338,6 +350,8 @@ export class GoogleAdapter {
|
|
|
338
350
|
const sent = await this.sendToAnAccount({
|
|
339
351
|
conversation: key,
|
|
340
352
|
signal: controller.signal,
|
|
353
|
+
system: upstreamRequest.systemInstruction,
|
|
354
|
+
model,
|
|
341
355
|
build: (credential) => {
|
|
342
356
|
const built = transport.build(upstreamRequest, {
|
|
343
357
|
model,
|
|
@@ -352,6 +366,11 @@ export class GoogleAdapter {
|
|
|
352
366
|
res.off("close", onClose);
|
|
353
367
|
if (sent.kind === "aborted")
|
|
354
368
|
return { status: 0, bytes: 0, note: "client closed" };
|
|
369
|
+
if (sent.kind === "prompt-refused") {
|
|
370
|
+
const err = anthropicError(400, "invalid_request_error", ANTIGRAVITY_PROMPT_REFUSAL_MESSAGE);
|
|
371
|
+
return { kind: "prompt-incompatible", status: 400, bytes: 0, errorBody: err.body,
|
|
372
|
+
note: "upstream 429: suspected Antigravity SDK prompt refusal with remaining quota", refusal: "prompt-incompatible" };
|
|
373
|
+
}
|
|
355
374
|
if (sent.kind === "unreachable") {
|
|
356
375
|
const err = anthropicError(502, "api_error", `Google provider unreachable: ${sent.error.message}`);
|
|
357
376
|
if (!res.headersSent)
|
|
@@ -557,6 +576,13 @@ export class GoogleAdapter {
|
|
|
557
576
|
const safeText = redactErrorText(text, secrets);
|
|
558
577
|
const status = upstream.status;
|
|
559
578
|
this.log.warn(`google ${this.name}: account ${credential.ownerId.slice(0, 8)} answered ${status}: ${safeText.slice(0, 400)}`);
|
|
579
|
+
// The generic 429 is also real exhaustion. Only fresh quota evidence for this
|
|
580
|
+
// account/model permits a request-specific refusal; otherwise keep ordinary
|
|
581
|
+
// cooldown and account rotation. The proxy can still try a route fallback.
|
|
582
|
+
if (isAntigravityPromptRefusal(spec.system, status, text, upstream.headers.get("retry-after")) &&
|
|
583
|
+
this.usage.hasRemainingQuota(credential, spec.model)) {
|
|
584
|
+
return { kind: "prompt-refused" };
|
|
585
|
+
}
|
|
560
586
|
// Not a rest and not a refresh: the account works again the moment its person finishes
|
|
561
587
|
// Google's page, so it stays in rotation and the next turn finds out.
|
|
562
588
|
const verifyUrl = status === 403 ? googleValidationUrl(text) : undefined;
|