clauderipple 0.6.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.ko.md +43 -2
  3. package/README.md +60 -4
  4. package/dist/app/package.json +1 -1
  5. package/dist/cli/src/claude-auth.js +35 -1
  6. package/dist/cli/src/codex.js +23 -0
  7. package/dist/cli/src/hooks/agent-title.js +58 -29
  8. package/dist/cli/src/index.js +89 -1
  9. package/dist/cli/src/runtime.js +3 -0
  10. package/dist/cli/src/settings.js +49 -0
  11. package/dist/mod/.claude-plugin/plugin.json +1 -0
  12. package/dist/mod/hooks/hooks.json +1 -0
  13. package/dist/mod/hooks/register.tsx +309 -0
  14. package/dist/mod/hooks/worker.ts +49 -0
  15. package/dist/mod/tsconfig.json +3 -0
  16. package/dist/mod/types/index.d.ts +30 -0
  17. package/dist/router/src/admin.js +497 -34
  18. package/dist/router/src/agents.js +50 -9
  19. package/dist/router/src/catalog.js +80 -0
  20. package/dist/router/src/compat.js +27 -1
  21. package/dist/router/src/config.js +33 -0
  22. package/dist/router/src/index.js +13 -1
  23. package/dist/router/src/ingress/server.js +3 -2
  24. package/dist/router/src/picker.js +33 -2
  25. package/dist/router/src/presets.js +2 -1
  26. package/dist/router/src/providers/chatgpt/index.js +154 -15
  27. package/dist/router/src/providers/chatgpt/translate.js +34 -6
  28. package/dist/router/src/providers/google/accounts.js +345 -0
  29. package/dist/router/src/providers/google/antigravity.js +392 -0
  30. package/dist/router/src/providers/google/index.js +617 -0
  31. package/dist/router/src/providers/google/login.js +249 -0
  32. package/dist/router/src/providers/google/thoughtSignatures.js +47 -0
  33. package/dist/router/src/providers/google/translate.js +539 -0
  34. package/dist/router/src/providers/google/transport.js +118 -0
  35. package/dist/router/src/providers/openai/translate.js +3 -3
  36. package/dist/router/src/proxy.js +99 -1
  37. package/dist/router/src/requestlog.js +2 -0
  38. package/dist/router/src/routing.js +22 -0
  39. package/dist/router/src/version.js +1 -1
  40. package/dist/ui/app.js +436 -44
  41. package/dist/ui/i18n.js +88 -4
  42. package/dist/ui/index.html +10 -0
  43. package/dist/ui/style.css +4 -0
  44. package/docs/ARCHITECTURE.md +300 -3
  45. package/package.json +1 -1
@@ -8,32 +8,45 @@
8
8
  // GET /api/chatgpt-login ChatGPT browser login state and credential status
9
9
  // GET /api/chatgpt-accounts?provider= signed-in ChatGPT accounts, rotation state, quota (no tokens)
10
10
  // PATCH/DELETE /api/chatgpt-accounts/:id rename, pause/resume, clear a cooldown; remove
11
- // GET /* static files from packages/ui (the GUI itself)
11
+ // POST /api/google-login begin a Google (Antigravity) browser sign-in; GET polls the state
12
+ // POST /api/google-login/code paste the code when no loopback callback could be used
13
+ // GET /api/google-accounts?provider= signed-in Google accounts, rotation state (no tokens)
14
+ // PATCH/DELETE /api/google-accounts/:id rename, pause/resume, clear a cooldown; remove
15
+ // GET /api/update whether a newer version is published, and how this copy was installed
16
+ // POST /api/update start `clauderipple update` in the background (it restarts this router)
17
+ // GET /* static files from packages/ui (the GUI itself)
12
18
  import crypto from "node:crypto";
13
19
  import fs from "node:fs";
14
20
  import net from "node:net";
15
21
  import os from "node:os";
16
22
  import path from "node:path";
17
23
  import http from "node:http";
18
- import { execFile } from "node:child_process";
24
+ import { execFile, spawn } from "node:child_process";
19
25
  import { fileURLToPath } from "node:url";
20
26
  import { homeDir, validate } from "./config.js";
21
27
  import { PRESETS } from "./presets.js";
22
28
  import { resolveCompatibleCaps } from "./compat.js";
23
29
  import { CHATGPT_FALLBACK_MODELS } from "./providers/chatgpt/catalog.js";
24
30
  import { measureModel, refusedByPlan } from "./capabilities.js";
31
+ import { catalogEntry } from "./catalog.js";
25
32
  import { ClaudeCodeAuthStore, nativeAnthropicHeaders } from "./providers/anthropic.js";
26
33
  import { codexEnabled, codexHome } from "../../cli/src/codex.js";
27
34
  import { openBrowser } from "../../cli/src/browser.js";
28
35
  import { caTrusted, currentAppProxy } from "../../cli/src/picker.js";
29
36
  import { certPaths } from "../../cli/src/certs.js";
30
37
  import { desktopClaudeCodeDirs } from "../../cli/src/claude-auth.js";
31
- import { syncModelSlots } from "../../cli/src/settings.js";
38
+ import { statusModEnabled, syncModelSlots } from "../../cli/src/settings.js";
32
39
  import { ClaudeOAuthSession } from "./providers/claude-oauth.js";
33
40
  import { readClaudeAuthFile } from "./providers/anthropic-token-file.js";
34
41
  import { listClaudeAccounts, removeClaudeAccount, renameClaudeAccount } from "./providers/anthropic-accounts.js";
35
42
  import { readChatGptAccounts, removeChatGptAccount, summarize as summarizeChatGptAccount, updateChatGptAccount } from "./providers/chatgpt/accounts.js";
43
+ import { IMAGE_ASPECTS, IMAGE_BACKGROUNDS, IMAGE_FORMATS } from "./providers/chatgpt/index.js";
44
+ import { ANTIGRAVITY_WARNING, staticAntigravityModels } from "./providers/google/antigravity.js";
45
+ import { readGoogleAccounts, removeGoogleAccount, summarize as summarizeGoogleAccount, updateGoogleAccount } from "./providers/google/accounts.js";
46
+ import { GoogleOAuthSession } from "./providers/google/login.js";
36
47
  const MAX_BODY = 1024 * 1024;
48
+ /** `/api/image` carries reference images inline, so it gets more room than a config save. */
49
+ const MAX_IMAGE_BODY = 32 * 1024 * 1024;
37
50
  const here = path.dirname(fileURLToPath(import.meta.url));
38
51
  const STARTED_AT = new Date().toISOString();
39
52
  // packages/router/src → packages/ui in a checkout; dist/router/src → dist/ui in an npm install,
@@ -79,6 +92,23 @@ const CHATGPT_DEFAULT_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
79
92
  let chatgptLogin = { running: false };
80
93
  /** The Claude subscription sign-in in progress (or the last one), for GET /api/claude-oauth. */
81
94
  let claudeOAuth = null;
95
+ /** The Google (Antigravity) sign-in in progress (or the last one), for GET /api/google-login. */
96
+ let googleOAuth = null;
97
+ /** When the GUI last started an update. It restarts this router, so a fresh process starts at 0. */
98
+ let updateStartedAt = 0;
99
+ const UPDATE_RUN_MS = 10 * 60 * 1000;
100
+ /** The last line of `clauderipple update --check --json`, or null when it said something else. */
101
+ export function parseUpdateCheck(output) {
102
+ try {
103
+ const check = JSON.parse(output.trim().split("\n").pop() ?? "");
104
+ if (typeof check.current !== "string" || typeof check.newer !== "boolean" || typeof check.kind !== "string")
105
+ return null;
106
+ return { current: check.current, latest: typeof check.latest === "string" ? check.latest : null, newer: check.newer, kind: check.kind };
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }
82
112
  /** Shared catalog for the GUI: model-specific values override provider defaults. */
83
113
  export function effortLevels(cfg) {
84
114
  const providers = {
@@ -107,6 +137,13 @@ export function effortLevels(cfg) {
107
137
  };
108
138
  continue;
109
139
  }
140
+ if (provider.type === "google") {
141
+ // Gemini exposes its ladder per model (the probe fills `effortLevels`); there is no
142
+ // provider-wide default, so the union of what the models declared is the fallback.
143
+ const levels = [...new Set((provider.models ?? []).flatMap((model) => model.effortLevels ?? []))];
144
+ providers[name] = { default: levels, ...(Object.keys(modelLevels).length ? { models: modelLevels } : {}) };
145
+ continue;
146
+ }
110
147
  if (provider.type === "anthropic") {
111
148
  providers[name] = { default: ANTHROPIC_EFFORT_LEVELS };
112
149
  continue;
@@ -119,6 +156,21 @@ export function effortLevels(cfg) {
119
156
  }
120
157
  return { providers };
121
158
  }
159
+ /** One routed model's ladder, from the provider that declares it — what the picker's effort menu
160
+ * should offer for it. Undefined when no provider declares the id: the menu is then left alone. */
161
+ export function effortLevelsForModel(cfg, modelId) {
162
+ const catalog = effortLevels(cfg).providers;
163
+ for (const [name, provider] of Object.entries(cfg.providers)) {
164
+ if (provider.type === "anthropic")
165
+ continue;
166
+ const own = catalog[name];
167
+ if (own?.models?.[modelId])
168
+ return own.models[modelId];
169
+ if (own && provider.models?.some((model) => model.id === modelId))
170
+ return own.default;
171
+ }
172
+ return undefined;
173
+ }
122
174
  const CLAUDE_MODEL_FALLBACK = [
123
175
  { id: "claude-fable-5-1", name: "Fable 5.1" },
124
176
  { id: "claude-opus-5-5", name: "Opus 5.5" },
@@ -225,13 +277,23 @@ export function chatgptSignedIn(mode) {
225
277
  const borrowed = fs.existsSync(path.join(os.homedir(), ".codex", "auth.json"));
226
278
  return mode === "own" ? own : mode === "borrow-codex" ? borrowed : own || borrowed;
227
279
  }
280
+ /**
281
+ * An Antigravity provider is only as usable as its accounts: none signed in, or every one waiting on
282
+ * Google's verification page, answers nothing however reachable the host is.
283
+ */
284
+ export function googleHealth(accounts) {
285
+ if (accounts.every((a) => a.state === "needs-login"))
286
+ return { needsLogin: true };
287
+ const inRotation = accounts.filter((a) => a.state !== "paused" && a.state !== "needs-login");
288
+ return inRotation.length > 0 && inRotation.every((a) => a.state === "needs-verification") ? { needsLogin: false, needsVerification: true } : { needsLogin: false };
289
+ }
228
290
  async function buildStatus(deps, opts = {}) {
229
291
  const cfg = deps.config();
230
292
  const providers = {};
231
293
  // Keyed in the order the config lists them. Assigning inside the Promise.all callbacks ordered
232
294
  // them by whichever TCP check answered first, so the Health list reshuffled on every poll.
233
295
  const checked = await Promise.all(Object.entries(cfg.providers).map(async ([name, p]) => {
234
- const url = p.type === "anthropic" ? "https://api.anthropic.com" : p.type === "chatgpt" ? (p.url ?? "https://chatgpt.com/backend-api") : p.url;
296
+ const url = p.type === "anthropic" ? "https://api.anthropic.com" : p.type === "chatgpt" ? (p.url ?? "https://chatgpt.com/backend-api") : p.type === "google" ? (p.url ?? "https://generativelanguage.googleapis.com") : p.url;
235
297
  let reachable = false;
236
298
  try {
237
299
  const u = new URL(url);
@@ -259,6 +321,7 @@ async function buildStatus(deps, opts = {}) {
259
321
  // Reaching the host says nothing about being able to use it: a chatgpt provider with no
260
322
  // credentials is not "connected", and calling it that sends the user off believing it works.
261
323
  ...(p.type === "chatgpt" ? { needsLogin: !chatgptSignedIn(p.auth) } : {}),
324
+ ...(p.type === "google" && p.auth === "antigravity" ? googleHealth(deps.google?.().accounts?.()[name] ?? []) : {}),
262
325
  ...(p.type === "anthropic"
263
326
  ? {
264
327
  authSource: p.auth === "claude-code" ? claudeAuthStore(deps).describeSource() : null,
@@ -349,6 +412,7 @@ async function buildStatus(deps, opts = {}) {
349
412
  credentials: deps.credentials?.() ?? {},
350
413
  picker,
351
414
  agentTitle: agentTitleHookEnabled(),
415
+ statusMod: statusModEnabled(),
352
416
  pickerModels: cfg.cli.extraModels.map((m) => m.name || m.model),
353
417
  uiRevision: uiRevision(),
354
418
  };
@@ -369,30 +433,50 @@ function pickerTrust() {
369
433
  pickerTrustMemo = { at: now, value };
370
434
  return value;
371
435
  }
372
- /** Run the ClaudeRipple CLI with the Node that installed us (`<home>/paths.json`, written by `install`). */
436
+ /** The ClaudeRipple CLI and the Node that installed us (`<home>/paths.json`, written by `install`). */
437
+ function cliInvocation() {
438
+ let node = process.execPath;
439
+ // Same walk either way; the extension says which layout this file was loaded from.
440
+ let cli = path.resolve(here, `../../cli/src/index${path.extname(fileURLToPath(import.meta.url))}`);
441
+ let runtimeEnv = {};
442
+ try {
443
+ const p = JSON.parse(fs.readFileSync(path.join(homeDir(), "paths.json"), "utf8"));
444
+ if (p.node)
445
+ node = p.node;
446
+ if (p.env)
447
+ runtimeEnv = p.env;
448
+ if (p.cli)
449
+ cli = p.cli;
450
+ }
451
+ catch {
452
+ /* not installed via the CLI: fall back to our own node + repo layout */
453
+ }
454
+ return { node, cli, env: { ...process.env, ...runtimeEnv, CLAUDERIPPLE_HOME: homeDir() } };
455
+ }
373
456
  function runCli(args, timeout = 180_000) {
374
457
  return new Promise((resolveP) => {
375
- let node = process.execPath;
376
- // Same walk either way; the extension says which layout this file was loaded from.
377
- let cli = path.resolve(here, `../../cli/src/index${path.extname(fileURLToPath(import.meta.url))}`);
378
- let runtimeEnv = {};
379
- try {
380
- const p = JSON.parse(fs.readFileSync(path.join(homeDir(), "paths.json"), "utf8"));
381
- if (p.node)
382
- node = p.node;
383
- if (p.env)
384
- runtimeEnv = p.env;
385
- if (p.cli)
386
- cli = p.cli;
387
- }
388
- catch {
389
- /* not installed via the CLI: fall back to our own node + repo layout */
390
- }
391
- execFile(node, [cli, ...args], { env: { ...process.env, ...runtimeEnv, CLAUDERIPPLE_HOME: homeDir() }, timeout }, (err, stdout, stderr) => {
458
+ const { node, cli, env } = cliInvocation();
459
+ execFile(node, [cli, ...args], { env, timeout }, (err, stdout, stderr) => {
392
460
  resolveP({ ok: !err, output: `${stdout}${stderr}${err ? `\n${err.message}` : ""}`.trim() });
393
461
  });
394
462
  });
395
463
  }
464
+ /**
465
+ * Start the CLI in its own process group, writing to `log`. An update ends by restarting this
466
+ * router, and launchd stops a job by killing its whole process group — an ordinary child would die
467
+ * with the router halfway through the install that restarted it.
468
+ */
469
+ function startCliDetached(args, log) {
470
+ const { node, cli, env } = cliInvocation();
471
+ fs.mkdirSync(path.dirname(log), { recursive: true });
472
+ const out = fs.openSync(log, "w");
473
+ try {
474
+ spawn(node, [cli, ...args], { env, detached: true, stdio: ["ignore", out, out], windowsHide: true }).unref();
475
+ }
476
+ finally {
477
+ fs.closeSync(out);
478
+ }
479
+ }
396
480
  /**
397
481
  * Whether this provider can actually run a web search — measured capability, not an assumption from
398
482
  * the vendor name. The rule is the router's own (`Proxy.canRunServerTools`, mirrored here because
@@ -444,13 +528,51 @@ function syncModelSlotsForGui(cfg) {
444
528
  return `model slots were not written to settings.json: ${message}`;
445
529
  }
446
530
  }
447
- function readBody(req) {
531
+ /**
532
+ * The body of `POST /api/image`, checked: `{prompt, aspect?, format?, background?, images?}`,
533
+ * `images` being `data:image/...;base64,` URLs. A string is the reason it was refused.
534
+ */
535
+ export function parseImageRequest(raw) {
536
+ if (!raw || typeof raw !== "object")
537
+ return "expected a JSON object";
538
+ const body = raw;
539
+ if (typeof body.prompt !== "string" || !body.prompt.trim())
540
+ return "prompt is required";
541
+ const out = { prompt: body.prompt };
542
+ const pick = (key, allowed) => {
543
+ const value = body[key];
544
+ if (value === undefined)
545
+ return null;
546
+ if (typeof value !== "string" || !allowed.includes(value))
547
+ return `${key} must be one of ${allowed.join(", ")}`;
548
+ out[key] = value;
549
+ return null;
550
+ };
551
+ const refused = pick("aspect", IMAGE_ASPECTS) ?? pick("format", IMAGE_FORMATS) ?? pick("background", IMAGE_BACKGROUNDS);
552
+ if (refused)
553
+ return refused;
554
+ if (body.images !== undefined) {
555
+ if (!Array.isArray(body.images))
556
+ return "images must be a list of data: URLs";
557
+ const images = [];
558
+ for (const url of body.images) {
559
+ const m = typeof url === "string" ? /^data:(image\/[a-z0-9.+-]+);base64,([A-Za-z0-9+/=\s]+)$/i.exec(url) : null;
560
+ if (!m)
561
+ return "each image must be a data:image/...;base64, URL";
562
+ images.push({ mediaType: m[1].toLowerCase(), data: m[2].replace(/\s/g, "") });
563
+ }
564
+ if (images.length)
565
+ out.images = images;
566
+ }
567
+ return out;
568
+ }
569
+ function readBody(req, limit = MAX_BODY) {
448
570
  return new Promise((resolveP, reject) => {
449
571
  const chunks = [];
450
572
  let n = 0;
451
573
  req.on("data", (c) => {
452
574
  n += c.length;
453
- if (n > MAX_BODY) {
575
+ if (n > limit) {
454
576
  reject(new Error("body too large"));
455
577
  req.destroy();
456
578
  return;
@@ -524,7 +646,7 @@ async function probeAnthropicApiKey(apiKey, probeFetch = fetchWithTimeout) {
524
646
  if (response.ok)
525
647
  return { ok: true, auth: "ok", models };
526
648
  const detail = snippet(await response.text());
527
- if (response.status === 400 && /model.{0,80}(not.?found|invalid|unsupported|does not exist)|unknown.{0,20}model/i.test(detail))
649
+ if (refusedForModel(response.status, detail))
528
650
  return { ok: true, auth: "ok", models };
529
651
  if (response.status === 402 || /insufficient|balance|credit|quota|billing/i.test(detail))
530
652
  return { ok: true, auth: "ok", models, error: `no-credits: ${response.status} ${detail}` };
@@ -543,6 +665,86 @@ function probeClaudeCodeAuth(deps) {
543
665
  const signedIn = accounts.length > 0 ? "oauth" : readClaudeAuthFile(homeDir())?.source ?? null;
544
666
  return { ok: source !== null || hasUsableAccount, auth: source !== null || hasUsableAccount ? "ok" : "missing", source, signedIn, accountCount: accounts.length, models: CLAUDE_MODEL_FALLBACK };
545
667
  }
668
+ /** The reasoning levels a `GET /v1beta/models` entry says the model takes, or none to offer. */
669
+ function googleEffortLevels(entry) {
670
+ // `thinking: true` is the listing's own flag that a model reasons. The Generative Language API
671
+ // documents no per-model effort ladder, so a thinking model is offered the Gemini-3 levels this
672
+ // router maps onto (translate.ts): a model that only knows a subset clamps server-side.
673
+ return entry.thinking === true ? ["minimal", "low", "medium", "high"] : [];
674
+ }
675
+ /**
676
+ * `GET {base}/v1beta/models` with the key on `x-goog-api-key`, following `nextPageToken`.
677
+ *
678
+ * The auth check is the listing itself: Google answers a bad key with `400 API_KEY_INVALID` (or a
679
+ * 401/403), so there is no separate one-token request to make.
680
+ */
681
+ async function probeGoogle(body, probeFetch = fetchWithTimeout) {
682
+ const apiKey = typeof body.apiKey === "string" && body.apiKey.length > 0 ? body.apiKey : (process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY);
683
+ if (!apiKey)
684
+ return { ok: false, auth: "missing", models: [], error: "no API key; set one or GEMINI_API_KEY" };
685
+ const base = (typeof body.url === "string" && body.url ? body.url : "https://generativelanguage.googleapis.com").replace(/\/+$/, "");
686
+ const models = [];
687
+ let pageToken;
688
+ try {
689
+ // Bounded: a catalogue that keeps handing back a token must not spin the admin request forever.
690
+ for (let page = 0; page < 20; page++) {
691
+ const url = `${base}/v1beta/models?pageSize=1000${pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : ""}`;
692
+ const response = await probeFetch(url, { headers: { "x-goog-api-key": apiKey } });
693
+ if (response.status === 401 || response.status === 403)
694
+ return { ok: false, auth: "bad-key", models: [], error: `models endpoint returned ${response.status}` };
695
+ if (!response.ok) {
696
+ const detail = snippet(await response.text());
697
+ // A wrong key comes back 400 with `status: API_KEY_INVALID`; that is a bad key, not a bad base.
698
+ const badKey = /API_KEY_INVALID|API key not valid|invalid api key/i.test(detail);
699
+ return { ok: false, auth: badKey ? "bad-key" : response.status >= 500 ? "unreachable" : "unknown", models: [], error: `models endpoint returned ${response.status}: ${detail}` };
700
+ }
701
+ const parsed = await response.json();
702
+ for (const raw of Array.isArray(parsed.models) ? parsed.models : []) {
703
+ if (!raw || typeof raw !== "object")
704
+ continue;
705
+ const entry = raw;
706
+ if (typeof entry.name !== "string")
707
+ continue;
708
+ const id = entry.name.replace(/^models\//, "");
709
+ // Only models that can actually answer `generateContent` are worth listing; embedding and
710
+ // prediction models share the catalogue but cannot serve a turn.
711
+ const methods = Array.isArray(entry.supportedGenerationMethods) ? entry.supportedGenerationMethods : [];
712
+ if (!methods.some((method) => method === "generateContent"))
713
+ continue;
714
+ const levels = googleEffortLevels(entry);
715
+ models.push({
716
+ id,
717
+ ...(typeof entry.displayName === "string" ? { name: entry.displayName } : {}),
718
+ ...(typeof entry.inputTokenLimit === "number" && entry.inputTokenLimit > 0 ? { contextWindow: Math.floor(entry.inputTokenLimit) } : {}),
719
+ ...(levels.length > 0 ? { effortLevels: levels } : {}),
720
+ });
721
+ }
722
+ pageToken = typeof parsed.nextPageToken === "string" && parsed.nextPageToken ? parsed.nextPageToken : undefined;
723
+ if (!pageToken)
724
+ break;
725
+ }
726
+ if (models.length === 0)
727
+ return { ok: true, auth: "ok", models, error: "no generateContent models listed" };
728
+ return { ok: true, auth: "ok", models };
729
+ }
730
+ catch (e) {
731
+ return { ok: false, auth: "unreachable", models, error: `models endpoint: ${errorText(e)}` };
732
+ }
733
+ }
734
+ /**
735
+ * `{type:"google", auth:"antigravity"}`: whether any Google account is signed in, and the model list
736
+ * Cloud Code Assist returns for it (falling back to the static list). No network call is made here
737
+ * beyond the catalogue the provider already serves; a signed-out state is the common answer.
738
+ */
739
+ async function probeGoogleAntigravity(deps) {
740
+ const accounts = deps.google?.().accounts?.() ?? {};
741
+ const signedIn = Object.values(accounts).some((list) => list.length > 0) || readGoogleAccounts(homeDir()).length > 0;
742
+ if (!signedIn)
743
+ return { ok: false, auth: "missing", models: staticAntigravityModels(), error: "no Google account; run `clauderipple google-login`" };
744
+ const named = Object.keys(accounts)[0];
745
+ const models = named ? await deps.google?.().models?.(named).catch(() => null) ?? null : null;
746
+ return { ok: true, auth: "ok", models: models ?? staticAntigravityModels() };
747
+ }
546
748
  function chatCompletionsUrl(base) {
547
749
  return `${base.replace(/\/+$/, "")}/chat/completions`;
548
750
  }
@@ -576,6 +778,15 @@ function parsedModels(value) {
576
778
  function snippet(text) {
577
779
  return text.replace(/\s+/g, " ").trim().slice(0, 200);
578
780
  }
781
+ /**
782
+ * Whether a refused test request was refused for its model alone, which still shows the endpoint is
783
+ * right and the key was accepted. Anthropic's own wire says so with 404 not_found_error rather than
784
+ * 400 — abliteration.ai answers "Requested model was not found." (issue #42) — so 404 counts too,
785
+ * but only with a model named in the body: a bare 404 is a wrong URL.
786
+ */
787
+ function refusedForModel(status, detail) {
788
+ return (status === 400 || status === 404) && /model.{0,80}(not.?found|invalid|unsupported|does not exist)|unknown.{0,20}model/i.test(detail);
789
+ }
579
790
  /**
580
791
  * Fill each discovered model with the wire, endpoint and auth convention its preset declares for it.
581
792
  *
@@ -668,8 +879,8 @@ async function probeProvider(body, probeFetch = fetchWithTimeout) {
668
879
  if (response.ok)
669
880
  return { ok: true, auth: "ok", models, ...(modelsError ? { error: modelsError } : {}) };
670
881
  const detail = snippet(await response.text());
671
- // A model-validation 400 still demonstrates that the endpoint reached the provider and the key was accepted.
672
- if (response.status === 400 && /model.{0,80}(not.?found|invalid|unsupported|does not exist)|unknown.{0,20}model/i.test(detail)) {
882
+ // A model-validation refusal still demonstrates that the endpoint reached the provider and the key was accepted.
883
+ if (refusedForModel(response.status, detail)) {
673
884
  return { ok: true, auth: "ok", models, ...(modelsError ? { error: modelsError } : {}) };
674
885
  }
675
886
  // Key accepted but the account cannot pay: report auth ok so the user tops up instead of re-checking the key.
@@ -689,6 +900,8 @@ const MEASURE_JOBS = new Map();
689
900
  * (and its error strings) in memory for the life of the process. */
690
901
  const MEASURE_JOB_TTL_MS = 10 * 60 * 1000;
691
902
  const MEASURE_TIMEOUT_MS = 20_000;
903
+ /** Models measured at once. One job is one provider and one key, so this is also its burst. */
904
+ const MEASURE_CONCURRENCY = 4;
692
905
  async function measureFetchWithTimeout(url, init) {
693
906
  return fetch(url, { ...init, signal: AbortSignal.timeout(MEASURE_TIMEOUT_MS) });
694
907
  }
@@ -708,8 +921,12 @@ function sweepMeasureJobs() {
708
921
  * trying the list in catalogue order made it measure as `chat` and quietly overrule the `anthropic`
709
922
  * the preset recorded for it. A recorded wire is still only a starting point: it has to answer like
710
923
  * any other candidate, and the rest of the list is tried when it does not.
924
+ *
925
+ * `catalogWire` is the wire the public catalogue gives this model (catalog.ts). It goes first when
926
+ * the preset's own table says nothing about the model, so a model newer than that table is still
927
+ * settled in one request instead of walking the list.
711
928
  */
712
- function measureCandidates(p, modelId) {
929
+ function measureCandidates(p, modelId, catalogWire) {
713
930
  const defaultWire = p.type === "anthropic-compatible" ? "anthropic"
714
931
  : p.type === "openai-compatible" ? (p.wire ?? "chat")
715
932
  : "chat";
@@ -749,6 +966,11 @@ function measureCandidates(p, modelId) {
749
966
  if (model.wire)
750
967
  add({ wire: model.wire, url: model.url ?? p.url, ...(model.authHeader ? { authHeader: model.authHeader } : {}) });
751
968
  }
969
+ if (!known?.wire && catalogWire) {
970
+ const at = candidates.findIndex((c) => c.wire === catalogWire);
971
+ if (at > 0)
972
+ candidates.unshift(...candidates.splice(at, 1));
973
+ }
752
974
  return candidates;
753
975
  }
754
976
  /** The effort ladder this provider offers, before any per-model override narrows it. */
@@ -817,14 +1039,31 @@ async function runMeasureJob(jobId, providerName, models, deps) {
817
1039
  const fetchImpl = deps.measureFetch ?? measureFetchWithTimeout;
818
1040
  const headers = "headers" in provider ? provider.headers : undefined;
819
1041
  const sessionHeader = "sessionHeader" in provider ? provider.sessionHeader : undefined;
820
- for (const id of models) {
1042
+ const preset = "preset" in provider ? provider.preset : undefined;
1043
+ const catalog = deps.modelCatalog ? await deps.modelCatalog().catch(() => undefined) : undefined;
1044
+ const measureOne = async (id) => {
1045
+ const listed = catalogEntry(catalog, preset, id);
821
1046
  // Per model, not once per job: the order depends on what the preset recorded for this id.
822
- const candidates = measureCandidates(provider, id);
823
- const measured = await measureModel(id, candidates, ladder, { fetch: fetchImpl, ...(sessionHeader ? { sessionHeader } : {}), ...(headers ? { headers } : {}) });
1047
+ const candidates = measureCandidates(provider, id, listed?.wire);
1048
+ // A ladder the catalogue states is taken as stated; the wire is still asked, once, because that
1049
+ // one request is also what finds a model the plan refuses (403 FreeTierError, DataPolicyError).
1050
+ const stated = ladder.length > 0 ? listed?.effortLevels : undefined;
1051
+ const known = stated !== undefined;
1052
+ const measured = await measureModel(id, candidates, known ? [] : ladder, { fetch: fetchImpl, ...(sessionHeader ? { sessionHeader } : {}), ...(headers ? { headers } : {}) });
1053
+ if (stated && measured.wire)
1054
+ measured.effortLevels = stated;
824
1055
  job.results.push(measured);
825
1056
  job.done++;
826
- deps.log.info(`admin: measured ${providerName}/${id} -> ${measured.error ? `error ${measured.error}` : `wire ${measured.wire}${measured.effortLevels ? ` effort ${measured.effortLevels.join(",")}` : ""}`}`);
827
- }
1057
+ deps.log.info(`admin: measured ${providerName}/${id} -> ${measured.error ? `error ${measured.error}` : `wire ${measured.wire}${measured.effortLevels ? ` effort ${measured.effortLevels.join(",")}${known ? " (catalogue)" : ""}` : ""}`}`);
1058
+ };
1059
+ // Models are independent, so a few run at once and a slow upstream answer holds up only its own
1060
+ // model. Levels inside one model stay sequential: a burst there invites 429s, and a level that
1061
+ // meets one is kept rather than measured, which would quietly widen the ladder.
1062
+ const queue = [...models];
1063
+ await Promise.all(Array.from({ length: Math.min(MEASURE_CONCURRENCY, queue.length) }, async () => {
1064
+ for (let id = queue.shift(); id !== undefined; id = queue.shift())
1065
+ await measureOne(id);
1066
+ }));
828
1067
  try {
829
1068
  const outcome = applyMeasurements(deps.configFile, job.results);
830
1069
  if ("error" in outcome) {
@@ -1051,6 +1290,14 @@ export function startAdmin(deps) {
1051
1290
  });
1052
1291
  return;
1053
1292
  }
1293
+ if (probe.type === "google") {
1294
+ if (probe.auth === "antigravity") {
1295
+ sendJson(res, 200, await probeGoogleAntigravity(deps));
1296
+ return;
1297
+ }
1298
+ sendJson(res, 200, await probeGoogle({ apiKey: probe.apiKey, url: probe.url }, deps.probeFetch));
1299
+ return;
1300
+ }
1054
1301
  if ((probe.type !== "anthropic-compatible" && probe.type !== "openai-compatible") ||
1055
1302
  typeof probe.url !== "string" ||
1056
1303
  !/^https?:\/\//.test(probe.url) ||
@@ -1381,6 +1628,126 @@ export function startAdmin(deps) {
1381
1628
  sendJson(res, r.ok ? 200 : 500, { ok: r.ok, output: r.output });
1382
1629
  return;
1383
1630
  }
1631
+ // Google (Antigravity) sign-in of our own (browser, PKCE, loopback callback). The GUI starts it
1632
+ // and polls the state; tokens never leave the router and never appear in any response.
1633
+ if (pathname === "/api/google-login" && method === "GET") {
1634
+ const state = googleOAuth?.snapshot ?? { running: false, url: null, startedAt: null, finishedAt: null, ok: null, error: null, account: null, termsWarning: ANTIGRAVITY_WARNING };
1635
+ sendJson(res, 200, state);
1636
+ return;
1637
+ }
1638
+ if (pathname === "/api/google-login" && method === "POST") {
1639
+ if (googleOAuth?.snapshot.running) {
1640
+ sendJson(res, 200, googleOAuth.snapshot);
1641
+ return;
1642
+ }
1643
+ const session = new GoogleOAuthSession({ home: homeDir(), ...(deps.googleOAuthFetch ? { fetch: deps.googleOAuthFetch } : {}), ...(deps.googleOAuthPort !== undefined ? { port: deps.googleOAuthPort } : {}) }, ANTIGRAVITY_WARNING);
1644
+ googleOAuth = session;
1645
+ let started;
1646
+ try {
1647
+ started = await session.start();
1648
+ }
1649
+ catch (error) {
1650
+ sendJson(res, 500, { ...session.snapshot, error: error.message });
1651
+ return;
1652
+ }
1653
+ const opened = deps.openBrowser ? deps.openBrowser(started.url) : openBrowser(started.url);
1654
+ deps.log.info(`admin: google sign-in via GUI -> started (browser ${opened ? "opened" : "not opened"})`);
1655
+ void session.result.then(() => deps.log.info("admin: google sign-in via GUI -> ok"), (error) => deps.log.info(`admin: google sign-in via GUI -> failed: ${error.message}`));
1656
+ sendJson(res, 200, { ...session.snapshot, opened, termsWarning: ANTIGRAVITY_WARNING });
1657
+ return;
1658
+ }
1659
+ if (pathname === "/api/google-login/code" && method === "POST") {
1660
+ const session = googleOAuth;
1661
+ if (!session?.snapshot.running) {
1662
+ sendJson(res, 409, { error: "no Google sign-in is waiting for a code" });
1663
+ return;
1664
+ }
1665
+ let code = "";
1666
+ try {
1667
+ code = String(JSON.parse((await readBody(req)).toString("utf8")).code ?? "");
1668
+ }
1669
+ catch {
1670
+ sendJson(res, 400, { error: "invalid JSON" });
1671
+ return;
1672
+ }
1673
+ await session.submitCode(code);
1674
+ await session.result.catch(() => { });
1675
+ sendJson(res, session.snapshot.ok ? 200 : 400, session.snapshot);
1676
+ return;
1677
+ }
1678
+ if (pathname === "/api/google-login/cancel" && method === "POST") {
1679
+ googleOAuth?.cancel();
1680
+ sendJson(res, 200, googleOAuth?.snapshot ?? { running: false });
1681
+ return;
1682
+ }
1683
+ if (pathname === "/api/google-logout" && method === "POST") {
1684
+ const r = await (deps.runCli ?? runCli)(["google-logout"]);
1685
+ deps.log.info(`admin: google-logout via GUI -> ${r.ok ? "ok" : "failed"}`);
1686
+ sendJson(res, r.ok ? 200 : 500, { ok: r.ok, output: r.output });
1687
+ return;
1688
+ }
1689
+ // Safe account metadata only: labels, emails, project ids and rotation state. Tokens stay in
1690
+ // the router and are never serialized.
1691
+ if (pathname === "/api/google-accounts" && method === "GET") {
1692
+ const all = deps.google?.().accounts?.() ?? {};
1693
+ const wanted = new URL(url, "http://127.0.0.1").searchParams.get("provider");
1694
+ const name = wanted && all[wanted] ? wanted : Object.keys(all)[0];
1695
+ const accounts = name ? all[name] : readGoogleAccounts(homeDir()).map(summarizeGoogleAccount);
1696
+ sendJson(res, 200, { provider: name ?? null, accounts });
1697
+ return;
1698
+ }
1699
+ if (pathname.startsWith("/api/google-accounts/") && (method === "PATCH" || method === "DELETE")) {
1700
+ let id;
1701
+ try {
1702
+ id = decodeURIComponent(pathname.slice("/api/google-accounts/".length));
1703
+ }
1704
+ catch {
1705
+ sendJson(res, 400, { error: "invalid account id" });
1706
+ return;
1707
+ }
1708
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id)) {
1709
+ sendJson(res, 400, { error: "invalid account id" });
1710
+ return;
1711
+ }
1712
+ if (method === "DELETE") {
1713
+ if (!removeGoogleAccount(homeDir(), id)) {
1714
+ sendJson(res, 404, { error: "Google account not found" });
1715
+ return;
1716
+ }
1717
+ deps.log.info(`admin: removed Google account ${id.slice(0, 8)}`);
1718
+ sendJson(res, 200, { ok: true });
1719
+ return;
1720
+ }
1721
+ let change;
1722
+ try {
1723
+ change = JSON.parse((await readBody(req)).toString("utf8"));
1724
+ }
1725
+ catch {
1726
+ sendJson(res, 400, { error: "invalid JSON" });
1727
+ return;
1728
+ }
1729
+ if ((change.label !== undefined && (typeof change.label !== "string" || !change.label.trim())) || (change.paused !== undefined && typeof change.paused !== "boolean")) {
1730
+ sendJson(res, 400, { error: "expected {label?: non-empty string, paused?: boolean, clearCooldown?: true, recheck?: true}" });
1731
+ return;
1732
+ }
1733
+ if (change.recheck === true) {
1734
+ const result = (await deps.google?.().recheckVerification?.(id)) ?? "unknown";
1735
+ deps.log.info(`admin: rechecked Google verification for ${id.slice(0, 8)} -> ${result}`);
1736
+ sendJson(res, 200, { ok: true, verification: result });
1737
+ return;
1738
+ }
1739
+ if (change.clearCooldown === true)
1740
+ deps.google?.().clearCooldown?.(id);
1741
+ if (change.label !== undefined || change.paused !== undefined) {
1742
+ if (!updateGoogleAccount(homeDir(), id, { ...(typeof change.label === "string" ? { label: change.label } : {}), ...(typeof change.paused === "boolean" ? { paused: change.paused } : {}) })) {
1743
+ sendJson(res, 404, { error: "Google account not found" });
1744
+ return;
1745
+ }
1746
+ }
1747
+ deps.log.info(`admin: updated Google account ${id.slice(0, 8)}`);
1748
+ sendJson(res, 200, { ok: true });
1749
+ return;
1750
+ }
1384
1751
  if (pathname === "/api/picker" && method === "POST") {
1385
1752
  // Runs `clauderipple picker on|off` (keychain trust, app Config Library, config flag).
1386
1753
  // macOS shows its keychain password dialog in the user's session; we never see the password.
@@ -1409,6 +1776,82 @@ export function startAdmin(deps) {
1409
1776
  sendJson(res, r.ok ? 200 : 500, { ok: r.ok, output: r.output });
1410
1777
  return;
1411
1778
  }
1779
+ if (pathname === "/api/update" && (method === "GET" || method === "POST")) {
1780
+ // The CLI answers both: it knows how this copy was installed, which decides what an update is
1781
+ // (the tray asks it the same way). A POST checks again rather than trusting the page.
1782
+ const r = await (deps.runCli ?? runCli)(["update", "--check", "--json"], 30_000);
1783
+ const check = parseUpdateCheck(r.output);
1784
+ if (!check) {
1785
+ sendJson(res, 502, { error: "could not check for updates", output: r.output.slice(-2000) });
1786
+ return;
1787
+ }
1788
+ const running = updateStartedAt > 0 && Date.now() - updateStartedAt < UPDATE_RUN_MS;
1789
+ if (method === "GET") {
1790
+ sendJson(res, 200, { ...check, running });
1791
+ return;
1792
+ }
1793
+ if (check.kind !== "script" && check.kind !== "npm") {
1794
+ sendJson(res, 409, { error: `this copy (${check.kind}) is not updated from here`, ...check });
1795
+ return;
1796
+ }
1797
+ if (!check.newer) {
1798
+ sendJson(res, 409, { error: "already the latest version", ...check });
1799
+ return;
1800
+ }
1801
+ if (running) {
1802
+ sendJson(res, 409, { error: "an update is already running", ...check, running });
1803
+ return;
1804
+ }
1805
+ updateStartedAt = Date.now();
1806
+ const log = path.join(homeDir(), "logs", "update.log");
1807
+ (deps.startCliDetached ?? startCliDetached)(["update"], log);
1808
+ deps.log.info(`admin: update ${check.current} -> ${check.latest ?? "?"} started via GUI (output in ${log})`);
1809
+ sendJson(res, 202, { started: true, ...check, log });
1810
+ return;
1811
+ }
1812
+ if (pathname === "/api/image" && method === "POST") {
1813
+ // The image itself is the response body, so `curl -o out.png` is the whole client. An error
1814
+ // is JSON with a non-2xx status (`curl --fail-with-body` writes it to the same file).
1815
+ if (!deps.image) {
1816
+ sendJson(res, 501, { error: "image generation not available" });
1817
+ return;
1818
+ }
1819
+ let parsed;
1820
+ try {
1821
+ parsed = parseImageRequest(JSON.parse((await readBody(req, MAX_IMAGE_BODY)).toString("utf8")));
1822
+ }
1823
+ catch (e) {
1824
+ sendJson(res, 400, { error: /too large/.test(e.message) ? "body too large (reference images over 32 MB)" : "invalid JSON" });
1825
+ return;
1826
+ }
1827
+ if (typeof parsed === "string") {
1828
+ sendJson(res, 400, { error: parsed });
1829
+ return;
1830
+ }
1831
+ // A caller that gives up should not leave the generation running on the subscription.
1832
+ const abort = new AbortController();
1833
+ res.on("close", () => { if (!res.writableFinished)
1834
+ abort.abort(); });
1835
+ const started = Date.now();
1836
+ try {
1837
+ const image = await deps.image(parsed, abort.signal);
1838
+ deps.log.info(`admin: image ${image.size ?? "?"} ${image.quality ?? "?"} ${image.format}, ${parsed.images?.length ?? 0} reference(s), ${image.data.length} bytes in ${Date.now() - started} ms`);
1839
+ res.writeHead(200, {
1840
+ "content-type": `image/${image.format}`,
1841
+ "content-length": String(image.data.length),
1842
+ ...(image.size ? { "x-image-size": image.size } : {}),
1843
+ ...(image.quality ? { "x-image-quality": image.quality } : {}),
1844
+ ...(image.revisedPrompt ? { "x-revised-prompt": encodeURIComponent(image.revisedPrompt) } : {}),
1845
+ });
1846
+ res.end(image.data);
1847
+ }
1848
+ catch (e) {
1849
+ deps.log.warn(`admin: image failed after ${Date.now() - started} ms: ${e.message}`);
1850
+ if (!res.headersSent)
1851
+ sendJson(res, 502, { error: e.message });
1852
+ }
1853
+ return;
1854
+ }
1412
1855
  if (pathname === "/api/agent-title" && method === "POST") {
1413
1856
  // Registers/removes the PreToolUse hook that prefixes subagent titles with the real model.
1414
1857
  let enabled;
@@ -1428,14 +1871,34 @@ export function startAdmin(deps) {
1428
1871
  sendJson(res, r.ok ? 200 : 500, { ok: r.ok, output: r.output });
1429
1872
  return;
1430
1873
  }
1874
+ if (pathname === "/api/status-mod" && method === "POST") {
1875
+ // Through the CLI, like agent-title: it knows where this install keeps packages/mod.
1876
+ let enabled;
1877
+ try {
1878
+ enabled = JSON.parse((await readBody(req)).toString("utf8")).enabled;
1879
+ }
1880
+ catch {
1881
+ sendJson(res, 400, { error: "invalid JSON" });
1882
+ return;
1883
+ }
1884
+ if (typeof enabled !== "boolean") {
1885
+ sendJson(res, 400, { error: "expected {enabled: boolean}" });
1886
+ return;
1887
+ }
1888
+ const r = await runCli(["mod", enabled ? "on" : "off"]);
1889
+ deps.log.info(`admin: status mod ${enabled ? "on" : "off"} via GUI -> ${r.ok ? "ok" : "failed"}`);
1890
+ sendJson(res, r.ok ? 200 : 500, { ok: r.ok, output: r.output });
1891
+ return;
1892
+ }
1431
1893
  if (pathname === "/api/requests" && method === "GET") {
1432
1894
  const query = new URL(url, "http://x").searchParams;
1433
1895
  const requested = Number(query.get("n") ?? 200);
1434
1896
  const n = Number.isFinite(requested) ? Math.min(2000, Math.max(1, Math.floor(requested))) : 200;
1435
1897
  const provider = query.get("provider") || undefined;
1898
+ const session = query.get("session") || undefined;
1436
1899
  const kindValue = query.get("kind");
1437
1900
  const kind = kindValue === "messages" || kindValue === "count_tokens" || kindValue === "other" ? kindValue : undefined;
1438
- sendJson(res, 200, { requests: deps.requests.list(n, { ...(provider ? { provider } : {}), ...(kind ? { kind } : {}) }) });
1901
+ sendJson(res, 200, { requests: deps.requests.list(n, { ...(provider ? { provider } : {}), ...(kind ? { kind } : {}), ...(session ? { session } : {}) }) });
1439
1902
  return;
1440
1903
  }
1441
1904
  if (pathname === "/api/requests/summary" && method === "GET") {