clauderipple 0.2.0 → 0.3.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 (43) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.ko.md +48 -4
  3. package/README.md +58 -4
  4. package/dist/cli/src/claude-auth.js +3 -2
  5. package/dist/cli/src/codex.js +20 -1
  6. package/dist/cli/src/hooks/agent-title.js +1 -1
  7. package/dist/cli/src/index.js +4 -4
  8. package/dist/cli/src/schtasks.js +43 -1
  9. package/dist/cli/src/settings.js +73 -6
  10. package/dist/router/src/admin.js +489 -56
  11. package/dist/router/src/agents.js +250 -0
  12. package/dist/router/src/bootstrap.js +24 -8
  13. package/dist/router/src/capabilities.js +214 -0
  14. package/dist/router/src/compat.js +5 -1
  15. package/dist/router/src/config.js +264 -11
  16. package/dist/router/src/index.js +14 -1
  17. package/dist/router/src/ingress/server.js +24 -14
  18. package/dist/router/src/picker.js +14 -6
  19. package/dist/router/src/pool.js +233 -0
  20. package/dist/router/src/presets.js +156 -1
  21. package/dist/router/src/providers/anthropic-account-pool.js +139 -0
  22. package/dist/router/src/providers/anthropic-accounts.js +281 -0
  23. package/dist/router/src/providers/chatgpt/catalog.js +97 -0
  24. package/dist/router/src/providers/chatgpt/index.js +343 -12
  25. package/dist/router/src/providers/chatgpt/sse.js +4 -0
  26. package/dist/router/src/providers/chatgpt/translate.js +156 -14
  27. package/dist/router/src/providers/claude-oauth.js +61 -19
  28. package/dist/router/src/providers/openai/index.js +55 -11
  29. package/dist/router/src/providers/openai/translate.js +82 -14
  30. package/dist/router/src/providers/retry.js +88 -0
  31. package/dist/router/src/proxy.js +697 -82
  32. package/dist/router/src/requestlog.js +5 -2
  33. package/dist/router/src/routing.js +151 -17
  34. package/dist/router/src/version.js +1 -1
  35. package/dist/router/src/websearch.js +307 -0
  36. package/dist/router/src/x509.js +7 -2
  37. package/dist/ui/app.js +740 -160
  38. package/dist/ui/i18n.js +14 -6
  39. package/dist/ui/index.html +18 -5
  40. package/dist/ui/presets-fallback.js +2 -0
  41. package/dist/ui/style.css +133 -9
  42. package/docs/ARCHITECTURE.md +381 -20
  43. package/package.json +5 -1
@@ -7,6 +7,7 @@
7
7
  // POST /api/chatgpt-login begin ChatGPT browser login without holding the request open
8
8
  // GET /api/chatgpt-login ChatGPT browser login state and credential status
9
9
  // GET /* static files from packages/ui (the GUI itself)
10
+ import crypto from "node:crypto";
10
11
  import fs from "node:fs";
11
12
  import net from "node:net";
12
13
  import os from "node:os";
@@ -17,12 +18,17 @@ import { fileURLToPath } from "node:url";
17
18
  import { homeDir, validate } from "./config.js";
18
19
  import { PRESETS } from "./presets.js";
19
20
  import { resolveCompatibleCaps } from "./compat.js";
21
+ import { CHATGPT_FALLBACK_MODELS } from "./providers/chatgpt/catalog.js";
22
+ import { measureModel, refusedByPlan } from "./capabilities.js";
20
23
  import { ClaudeCodeAuthStore, nativeAnthropicHeaders } from "./providers/anthropic.js";
21
24
  import { codexEnabled, codexHome } from "../../cli/src/codex.js";
22
25
  import { openBrowser } from "../../cli/src/browser.js";
23
26
  import { caTrusted, currentAppProxy } from "../../cli/src/picker.js";
27
+ import { certPaths } from "../../cli/src/certs.js";
28
+ import { syncModelSlots } from "../../cli/src/settings.js";
24
29
  import { ClaudeOAuthSession } from "./providers/claude-oauth.js";
25
30
  import { readClaudeAuthFile } from "./providers/anthropic-token-file.js";
31
+ import { listClaudeAccounts, removeClaudeAccount, renameClaudeAccount } from "./providers/anthropic-accounts.js";
26
32
  const MAX_BODY = 1024 * 1024;
27
33
  const here = path.dirname(fileURLToPath(import.meta.url));
28
34
  const STARTED_AT = new Date().toISOString();
@@ -40,7 +46,6 @@ const MIME = {
40
46
  };
41
47
  const ANTHROPIC_EFFORT_LEVELS = ["low", "medium", "high", "max"];
42
48
  const CHATGPT_DEFAULT_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
43
- const CHATGPT_LUNA_EFFORT_LEVELS = [...CHATGPT_DEFAULT_EFFORT_LEVELS, "ultra"];
44
49
  let chatgptLogin = { running: false };
45
50
  /** The Claude subscription sign-in in progress (or the last one), for GET /api/claude-oauth. */
46
51
  let claudeOAuth = null;
@@ -51,15 +56,15 @@ export function effortLevels(cfg) {
51
56
  };
52
57
  for (const [name, provider] of Object.entries(cfg.providers)) {
53
58
  if (provider.type === "chatgpt") {
54
- providers[name] = {
55
- default: CHATGPT_DEFAULT_EFFORT_LEVELS,
56
- models: {
57
- "gpt-5.6-luna": CHATGPT_LUNA_EFFORT_LEVELS,
58
- "gpt-5.6-terra": CHATGPT_DEFAULT_EFFORT_LEVELS,
59
- "gpt-5.6-sol": CHATGPT_DEFAULT_EFFORT_LEVELS,
60
- "gpt-6-astra": CHATGPT_DEFAULT_EFFORT_LEVELS,
61
- },
62
- };
59
+ // The backend's catalogue is the truth about which models exist and what each supports, but
60
+ // it lives behind a login and a network call, so the measured fallback list carries the
61
+ // per-model ladders (ultra on astra/sol/5.6 terra+sol, not on either Luna) and any config
62
+ // entries the user has saved override them.
63
+ const models = Object.fromEntries(CHATGPT_FALLBACK_MODELS.filter((model) => model.effortLevels !== undefined).map((model) => [model.id, [...model.effortLevels]]));
64
+ for (const model of provider.models ?? [])
65
+ if (model.effortLevels !== undefined)
66
+ models[model.id] = [...model.effortLevels];
67
+ providers[name] = { default: CHATGPT_DEFAULT_EFFORT_LEVELS, ...(Object.keys(models).length ? { models } : {}) };
63
68
  continue;
64
69
  }
65
70
  const modelLevels = Object.fromEntries((provider.models ?? [])
@@ -78,7 +83,7 @@ export function effortLevels(cfg) {
78
83
  }
79
84
  const preset = provider.preset ? PRESETS.find((entry) => entry.id === provider.preset) : undefined;
80
85
  providers[name] = {
81
- default: resolveCompatibleCaps(preset ? { effortLevels: preset.effortLevels, thinking: preset.thinking } : undefined, provider.caps).effortLevels,
86
+ default: resolveCompatibleCaps(preset ? { effortLevels: preset.effortLevels, thinking: preset.thinking, ...(preset.serverTools ? { serverTools: true } : {}) } : undefined, provider.caps).effortLevels,
82
87
  ...(Object.keys(modelLevels).length ? { models: modelLevels } : {}),
83
88
  };
84
89
  }
@@ -86,6 +91,7 @@ export function effortLevels(cfg) {
86
91
  }
87
92
  const CLAUDE_MODEL_FALLBACK = [
88
93
  { id: "claude-fable-5-1", name: "Fable 5.1" },
94
+ { id: "claude-opus-5-5", name: "Opus 5.5" },
89
95
  { id: "claude-opus-5", name: "Opus 5" },
90
96
  { id: "claude-sonnet-5", name: "Sonnet 5" },
91
97
  { id: "claude-haiku-4-5", name: "Haiku 4.5" },
@@ -184,10 +190,12 @@ export function chatgptSignedIn(mode) {
184
190
  const borrowed = fs.existsSync(path.join(os.homedir(), ".codex", "auth.json"));
185
191
  return mode === "own" ? own : mode === "borrow-codex" ? borrowed : own || borrowed;
186
192
  }
187
- async function buildStatus(deps) {
193
+ async function buildStatus(deps, opts = {}) {
188
194
  const cfg = deps.config();
189
195
  const providers = {};
190
- await Promise.all(Object.entries(cfg.providers).map(async ([name, p]) => {
196
+ // Keyed in the order the config lists them. Assigning inside the Promise.all callbacks ordered
197
+ // them by whichever TCP check answered first, so the Health list reshuffled on every poll.
198
+ const checked = await Promise.all(Object.entries(cfg.providers).map(async ([name, p]) => {
191
199
  const url = p.type === "anthropic" ? "https://api.anthropic.com" : p.type === "chatgpt" ? (p.url ?? "https://chatgpt.com/backend-api") : p.url;
192
200
  let reachable = false;
193
201
  try {
@@ -197,22 +205,59 @@ async function buildStatus(deps) {
197
205
  catch {
198
206
  reachable = false;
199
207
  }
200
- providers[name] = {
201
- url,
202
- type: p.type,
203
- reachable,
204
- // Reaching the host says nothing about being able to use it: a chatgpt provider with no
205
- // credentials is not "connected", and calling it that sends the user off believing it works.
206
- ...(p.type === "chatgpt" ? { needsLogin: !chatgptSignedIn(p.auth) } : {}),
207
- ...(p.type === "anthropic"
208
- ? {
209
- authSource: p.auth === "claude-code" ? claudeAuthStore(deps).describeSource() : null,
210
- signedIn: p.auth === "claude-code" ? (readClaudeAuthFile(homeDir())?.source ?? null) : null,
211
- }
212
- : {}),
213
- };
208
+ return [name, {
209
+ url,
210
+ type: p.type,
211
+ reachable,
212
+ // Whether the router could run a `WebSearch` side request through this provider, so the
213
+ // Clients screen can say so beside a model name. Nothing on that screen said which ones
214
+ // those are, and a `smallFast` slot pointed at one turns every search into a visible failure
215
+ // (2026-09-21: it was set to OpenCode Go's DeepSeek, which accepts the search request and
216
+ // answers with a fabricated one).
217
+ //
218
+ // It answers "can the router search here", not "can this model search". Those differ:
219
+ // measured 2026-09-21, OpenCode Go's Responses endpoint runs OpenAI's hosted `web_search`
220
+ // and cites real pages, but the wire is OpenAI-shaped while its hits come back as Responses
221
+ // events — a backend the router does not have, so a search still cannot go through it.
222
+ // Naming the provider's own capability instead would be a promise the router cannot keep.
223
+ ...(providerCanSearch(name, cfg) ? { webSearch: true } : {}),
224
+ // Reaching the host says nothing about being able to use it: a chatgpt provider with no
225
+ // credentials is not "connected", and calling it that sends the user off believing it works.
226
+ ...(p.type === "chatgpt" ? { needsLogin: !chatgptSignedIn(p.auth) } : {}),
227
+ ...(p.type === "anthropic"
228
+ ? {
229
+ authSource: p.auth === "claude-code" ? claudeAuthStore(deps).describeSource() : null,
230
+ signedIn: p.auth === "claude-code" ? (listClaudeAccounts(homeDir()).length > 0 ? "oauth" : readClaudeAuthFile(homeDir())?.source ?? null) : null,
231
+ accountCount: p.auth === "claude-code" ? listClaudeAccounts(homeDir()).length : 0,
232
+ }
233
+ : {}),
234
+ }];
214
235
  }));
236
+ for (const [name, entry] of checked)
237
+ providers[name] = entry;
215
238
  const chatgpt = deps.chatgpt?.() ?? { quota: {}, auth: {} };
239
+ // A snapshot from response headers ages the moment GPT traffic stops: measured 2026-09-20, the
240
+ // status route showed 1% from 11 hours earlier while the account was at 40%. Past ten minutes,
241
+ // ask the backend directly. The lookup shares one in-flight promise per provider, so concurrent
242
+ // status polls (the GUI does one every 5s) collapse into a single request. A failed lookup keeps
243
+ // the last good numbers and says why, rather than blanking the read the budget decision uses.
244
+ const STALE_MS = 10 * 60 * 1000;
245
+ const staleReasons = {};
246
+ const refresh = deps.chatgpt?.().refresh;
247
+ if (refresh) {
248
+ const now = Date.now();
249
+ for (const [name, q] of Object.entries(chatgpt.quota)) {
250
+ const at = q && typeof q.at === "number" ? q.at : 0;
251
+ if (!opts.refresh && at && now - at < STALE_MS)
252
+ continue;
253
+ const fresh = await refresh(name);
254
+ if (fresh)
255
+ chatgpt.quota[name] = fresh;
256
+ else
257
+ staleReasons[name] = opts.refresh ? "refresh requested but lookup failed" : "lookup failed; showing last known value";
258
+ }
259
+ }
260
+ const stale = Object.keys(staleReasons).length > 0;
216
261
  const signedIn = {};
217
262
  for (const [name, p] of Object.entries(cfg.providers)) {
218
263
  if (p.type !== "chatgpt")
@@ -260,7 +305,10 @@ async function buildStatus(deps) {
260
305
  pointsAtRouter: env.HTTPS_PROXY === wantProxy,
261
306
  },
262
307
  cliVersion: cliVersion(),
263
- chatgpt: { ...chatgpt, signedIn },
308
+ chatgpt: { ...chatgpt, signedIn, ...(stale ? { stale: true, staleReason: staleReasons } : {}) },
309
+ // Only present for providers that declare a pool; a provider with one credential has nothing
310
+ // to report and would only add a row that never changes.
311
+ credentials: deps.credentials?.() ?? {},
264
312
  picker,
265
313
  agentTitle: agentTitleHookEnabled(),
266
314
  pickerModels: cfg.cli.extraModels.map((m) => m.name || m.model),
@@ -307,6 +355,57 @@ function runCli(args, timeout = 180_000) {
307
355
  });
308
356
  });
309
357
  }
358
+ /**
359
+ * Whether this provider can actually run a web search — measured capability, not an assumption from
360
+ * the vendor name. The rule is the router's own (`Proxy.canRunServerTools`, mirrored here because
361
+ * that one is private to a live request): Anthropic runs its own server tools; an
362
+ * `anthropic-compatible` provider only when its preset says it was measured doing so; a `chatgpt`
363
+ * provider through its hosted search; an `openai-compatible` provider never — its web plugin is a
364
+ * vendor extension, and OpenCode Go's silently ignores it, leaving the model to answer with invented
365
+ * results. A configured `webSearch` backend is deliberately not counted here: it serves the search
366
+ * for the session, but a model still cannot search by itself, which is what a slot points at.
367
+ */
368
+ function providerCanSearch(name, cfg) {
369
+ const p = cfg.providers[name];
370
+ if (!p)
371
+ return false;
372
+ if (p.type === "chatgpt")
373
+ return true;
374
+ if (p.type === "anthropic")
375
+ return p.accountPool === true && p.auth === "claude-code";
376
+ if (p.type !== "anthropic-compatible")
377
+ return false;
378
+ const preset = p.preset ? PRESETS.find((entry) => entry.id === p.preset) : undefined;
379
+ return resolveCompatibleCaps(preset ? { ...(preset.serverTools ? { serverTools: true } : {}) } : undefined, p.caps).serverTools;
380
+ }
381
+ /**
382
+ * Write `cli.models` into `~/.claude/settings.json` right after a GUI save, so a slot chosen on the
383
+ * Clients screen takes effect in the next session instead of waiting for an `install`.
384
+ *
385
+ * Returns a human-readable warning when the write could not happen, or null. A slot that silently
386
+ * does nothing is the exact bug this closes, so a failure has to come back to the screen rather
387
+ * than being swallowed into the router log.
388
+ */
389
+ function syncModelSlotsForGui(cfg) {
390
+ try {
391
+ const home = homeDir();
392
+ syncModelSlots({
393
+ proxyUrl: `http://127.0.0.1:${cfg.listen.port}`,
394
+ caPath: certPaths(home).caPem,
395
+ force: false,
396
+ // Always an object, never omitted: `cli.models` absent means every slot should be back on
397
+ // Claude, and `syncModelSlots` reads an absent `models` as "do not touch these".
398
+ models: cfg.cli.models ?? {},
399
+ });
400
+ return null;
401
+ }
402
+ catch (e) {
403
+ const message = e.message;
404
+ if (/not ours/.test(message))
405
+ return "settings.json has another proxy's HTTPS_PROXY; the model slots were not written. Run `clauderipple install --force` to take it over.";
406
+ return `model slots were not written to settings.json: ${message}`;
407
+ }
408
+ }
310
409
  function readBody(req) {
311
410
  return new Promise((resolveP, reject) => {
312
411
  const chunks = [];
@@ -329,6 +428,14 @@ function sendJson(res, status, body) {
329
428
  res.writeHead(status, { "content-type": "application/json; charset=utf-8", "content-length": String(Buffer.byteLength(out)) });
330
429
  res.end(out);
331
430
  }
431
+ /** Write the config file atomically. The one save path: the GUI's PUT and the capability
432
+ * measurement both go through it, so the router never picks up a half-written file either way. */
433
+ function writeConfigFile(configFile, config) {
434
+ const tmp = `${configFile}.tmp-${process.pid}-${Date.now()}`;
435
+ fs.mkdirSync(path.dirname(configFile), { recursive: true });
436
+ fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n");
437
+ fs.renameSync(tmp, configFile);
438
+ }
332
439
  function headerValue(headers) {
333
440
  if (!headers)
334
441
  return null;
@@ -391,9 +498,12 @@ async function probeAnthropicApiKey(apiKey, probeFetch = fetchWithTimeout) {
391
498
  }
392
499
  function probeClaudeCodeAuth(deps) {
393
500
  const source = claudeAuthStore(deps).describeSource();
394
- // Our own sign-in is reported separately: a Claude Desktop session outranks it, and without this
395
- // the screen would answer a finished sign-in with the source it was already showing.
396
- return { ok: source !== null, auth: source ? "ok" : "missing", source, signedIn: readClaudeAuthFile(homeDir())?.source ?? null, models: CLAUDE_MODEL_FALLBACK };
501
+ // Our own accounts are reported separately: a Claude Desktop session can outrank them, and without
502
+ // this the screen would answer a finished sign-in with the source it was already showing.
503
+ const accounts = listClaudeAccounts(homeDir());
504
+ const hasUsableAccount = accounts.some((account) => !account.needsReauth && account.expiresAt > Date.now());
505
+ const signedIn = accounts.length > 0 ? "oauth" : readClaudeAuthFile(homeDir())?.source ?? null;
506
+ return { ok: source !== null || hasUsableAccount, auth: source !== null || hasUsableAccount ? "ok" : "missing", source, signedIn, accountCount: accounts.length, models: CLAUDE_MODEL_FALLBACK };
397
507
  }
398
508
  function chatCompletionsUrl(base) {
399
509
  return `${base.replace(/\/+$/, "")}/chat/completions`;
@@ -412,30 +522,82 @@ function parsedModels(value) {
412
522
  const supported = Array.isArray(r.supported_parameters) && r.supported_parameters.every((value) => typeof value === "string")
413
523
  ? r.supported_parameters
414
524
  : undefined;
525
+ // OpenRouter and several other OpenAI-compatible vendors report each model's real context
526
+ // window here. Taking it means a routed model gets its own window without anyone typing it.
527
+ const contextWindow = typeof r.context_length === "number" && Number.isFinite(r.context_length) && r.context_length > 0
528
+ ? Math.floor(r.context_length)
529
+ : undefined;
415
530
  return [{
416
531
  id: r.id,
417
532
  ...(typeof r.name === "string" ? { name: r.name } : {}),
418
533
  ...(supported ? { effortLevels: supported.includes("reasoning_effort") ? ["low", "medium", "high"] : [] } : {}),
534
+ ...(contextWindow ? { contextWindow } : {}),
419
535
  }];
420
536
  });
421
537
  }
422
538
  function snippet(text) {
423
539
  return text.replace(/\s+/g, " ").trim().slice(0, 200);
424
540
  }
541
+ /**
542
+ * Fill each discovered model with the wire, endpoint and auth convention its preset declares for it.
543
+ *
544
+ * `/models` reports ids alone, and one plan can serve several wires on one catalog (OpenCode Go's
545
+ * Responses, Chat and Anthropic groups). A discovered model left bare would be routed on the
546
+ * provider's default wire — the wrong shape on the wrong path for two of the three groups. The
547
+ * preset's fallback list is the only place that mapping is written down, so it is read here; an id
548
+ * not on it stays bare and gets the provider's own wire.
549
+ */
550
+ function withPresetOverrides(models, presetId) {
551
+ if (!presetId)
552
+ return models;
553
+ const preset = PRESETS.find((entry) => entry.id === presetId);
554
+ if (!preset)
555
+ return models;
556
+ return models.map((model) => {
557
+ const known = preset.fallbackModels.find((fallback) => fallback.id === model.id);
558
+ if (!known)
559
+ return model;
560
+ return {
561
+ ...model,
562
+ ...(known.wire !== undefined ? { wire: known.wire } : {}),
563
+ ...(known.url !== undefined ? { url: known.url } : {}),
564
+ ...(known.authHeader !== undefined ? { authHeader: known.authHeader } : {}),
565
+ };
566
+ });
567
+ }
425
568
  async function fetchWithTimeout(url, init) {
426
569
  return fetch(url, { ...init, signal: AbortSignal.timeout(8_000) });
427
570
  }
428
- async function probeProvider(body) {
571
+ /**
572
+ * The model a connection test should use, when the preset names one that speaks the wire the test
573
+ * speaks (Chat Completions).
574
+ *
575
+ * The catalogue's first entry is whatever the vendor happened to list first, and OpenCode lists its
576
+ * free tier there. Measured 2026-09-22: `big-pickle`, the head of Zen's catalogue, answers 403
577
+ * FreeTierError — "OpenCode's free tier can only be used from within OpenCode" — while
578
+ * `claude-fable-5` on the same key answers 402. Testing with the head therefore reported a paying
579
+ * account as refused, and pointed the operator at a key that was never the problem.
580
+ *
581
+ * The preset's own list is picked from deliberately, and only its Chat entries: the test posts to
582
+ * Chat Completions, so a Responses-only model like Muse Spark would answer 503 and look broken.
583
+ */
584
+ function presetProbeModel(presetId) {
585
+ const preset = presetId ? PRESETS.find((entry) => entry.id === presetId) : undefined;
586
+ if (!preset)
587
+ return undefined;
588
+ return preset.fallbackModels.find((model) => (model.wire ?? preset.wire ?? "chat") === "chat")?.id;
589
+ }
590
+ async function probeProvider(body, probeFetch = fetchWithTimeout) {
429
591
  const source = headerValue(body.headers);
430
592
  let models = [];
431
593
  let modelsError;
432
594
  if (body.modelsUrl) {
433
595
  try {
434
- const response = await fetchWithTimeout(body.modelsUrl, { headers: authHeaders(source, body.modelsAuthHeader) });
596
+ const response = await probeFetch(body.modelsUrl, { headers: authHeaders(source, body.modelsAuthHeader) });
435
597
  if (response.status === 401 || response.status === 403)
436
598
  return { ok: false, auth: "bad-key", models: [], error: `models endpoint returned ${response.status}` };
437
599
  if (response.ok)
438
- models = parsedModels(await response.json());
600
+ models = withPresetOverrides(parsedModels(await response.json()), body.preset);
439
601
  else
440
602
  modelsError = `models endpoint returned ${response.status}`;
441
603
  }
@@ -445,18 +607,26 @@ async function probeProvider(body) {
445
607
  }
446
608
  const openai = body.type === "openai-compatible";
447
609
  const checkUrl = openai ? chatCompletionsUrl(body.url) : messagesUrl(body.url);
610
+ const checkModel = presetProbeModel(body.preset) ?? models[0]?.id ?? body.probeModel ?? "test";
448
611
  const checkBody = openai
449
- ? { model: models[0]?.id ?? body.probeModel ?? "test", max_tokens: 1, stream: false, messages: [{ role: "user", content: "hi" }] }
450
- : { model: models[0]?.id ?? body.probeModel ?? "test", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
612
+ ? { model: checkModel, max_tokens: 1, stream: false, messages: [{ role: "user", content: "hi" }] }
613
+ : { model: checkModel, max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
451
614
  const label = openai ? "chat completions endpoint" : "messages endpoint";
452
615
  try {
453
- const response = await fetchWithTimeout(checkUrl, {
616
+ const response = await probeFetch(checkUrl, {
454
617
  method: "POST",
455
- headers: { "content-type": "application/json", ...authHeaders(source) },
618
+ headers: {
619
+ "content-type": "application/json",
620
+ ...(body.sessionHeader ? { [body.sessionHeader]: `probe-${crypto.randomBytes(8).toString("hex")}` } : {}),
621
+ ...authHeaders(source),
622
+ },
456
623
  body: JSON.stringify(checkBody),
457
624
  });
458
- if (response.status === 401 || response.status === 403)
459
- return { ok: false, auth: "bad-key", models, error: `${label} returned ${response.status}: ${snippet(await response.text())}` };
625
+ if (response.status === 401 || response.status === 403) {
626
+ const refusal = snippet(await response.text());
627
+ const auth = response.status === 403 && refusedByPlan(refusal) ? "not-entitled" : "bad-key";
628
+ return { ok: false, auth, models, error: `${label} returned ${response.status}: ${refusal}` };
629
+ }
460
630
  if (response.ok)
461
631
  return { ok: true, auth: "ok", models, ...(modelsError ? { error: modelsError } : {}) };
462
632
  const detail = snippet(await response.text());
@@ -475,6 +645,162 @@ async function probeProvider(body) {
475
645
  return { ok: false, auth: "unreachable", models, error: modelsError ? `${modelsError}; ${message}` : message };
476
646
  }
477
647
  }
648
+ const MEASURE_JOBS = new Map();
649
+ /** Measured capability does not change minute to minute, so a finished job is served for a while
650
+ * and then dropped: a test or a GUI that polls late should re-measure rather than hold the result
651
+ * (and its error strings) in memory for the life of the process. */
652
+ const MEASURE_JOB_TTL_MS = 10 * 60 * 1000;
653
+ const MEASURE_TIMEOUT_MS = 20_000;
654
+ async function measureFetchWithTimeout(url, init) {
655
+ return fetch(url, { ...init, signal: AbortSignal.timeout(MEASURE_TIMEOUT_MS) });
656
+ }
657
+ /** Drop finished jobs past their TTL. Run on each measure request, so no timer keeps the process up. */
658
+ function sweepMeasureJobs() {
659
+ const cutoff = Date.now() - MEASURE_JOB_TTL_MS;
660
+ for (const [id, job] of MEASURE_JOBS)
661
+ if (job.state !== "running" && job.finishedAt !== undefined && job.finishedAt < cutoff)
662
+ MEASURE_JOBS.delete(id);
663
+ }
664
+ /**
665
+ * The wires a model of this provider might speak, in the order they are worth trying.
666
+ *
667
+ * The preset's own entry for this exact model goes first when there is one. Order decides the
668
+ * answer, because the first wire to reply wins — and a plan can serve one model on two of them:
669
+ * measured 2026-09-22, `minimax-m3` answers on both Chat Completions and Anthropic Messages, so
670
+ * trying the list in catalogue order made it measure as `chat` and quietly overrule the `anthropic`
671
+ * the preset recorded for it. A recorded wire is still only a starting point: it has to answer like
672
+ * any other candidate, and the rest of the list is tried when it does not.
673
+ */
674
+ function measureCandidates(p, modelId) {
675
+ const defaultWire = p.type === "anthropic-compatible" ? "anthropic"
676
+ : p.type === "openai-compatible" ? (p.wire ?? "chat")
677
+ : "chat";
678
+ const ownUrl = "url" in p ? p.url : "";
679
+ const ownHeader = "authHeader" in p ? p.authHeader : undefined;
680
+ const own = { wire: defaultWire, url: ownUrl, ...(ownHeader ? { authHeader: ownHeader } : {}) };
681
+ const candidates = [];
682
+ const seen = new Set();
683
+ const add = (c) => {
684
+ const key = `${c.wire}|${c.url}|${c.authHeader ?? ""}`;
685
+ if (seen.has(key))
686
+ return;
687
+ seen.add(key);
688
+ candidates.push(c);
689
+ };
690
+ const known = "preset" in p && p.preset
691
+ ? PRESETS.find((entry) => entry.id === p.preset)?.fallbackModels?.find((m) => m.id === modelId)
692
+ : undefined;
693
+ if (known?.wire)
694
+ add({ wire: known.wire, url: known.url ?? ownUrl, ...(known.authHeader ? { authHeader: known.authHeader } : {}) });
695
+ add(own);
696
+ if (p.type === "openai-compatible" && p.preset) {
697
+ // One plan can serve several wires on one catalog. The preset's fallback list is the only place
698
+ // any of that mapping is written down, so it is what the remaining candidates are read from.
699
+ const preset = PRESETS.find((entry) => entry.id === p.preset);
700
+ for (const model of preset?.fallbackModels ?? [])
701
+ add({ wire: model.wire ?? defaultWire, url: model.url ?? p.url, ...(model.authHeader ? { authHeader: model.authHeader } : {}) });
702
+ }
703
+ else if (p.type === "openai-compatible") {
704
+ // No table to read: the two OpenAI wires this kind of provider could be serving.
705
+ add({ wire: "responses", url: p.url });
706
+ add({ wire: "chat", url: p.url });
707
+ }
708
+ else if (p.type === "anthropic-compatible" && p.preset) {
709
+ const preset = PRESETS.find((entry) => entry.id === p.preset);
710
+ for (const model of preset?.fallbackModels ?? [])
711
+ if (model.wire)
712
+ add({ wire: model.wire, url: model.url ?? p.url, ...(model.authHeader ? { authHeader: model.authHeader } : {}) });
713
+ }
714
+ return candidates;
715
+ }
716
+ /** The effort ladder this provider offers, before any per-model override narrows it. */
717
+ function providerLadder(p) {
718
+ if (p.type === "openai-compatible")
719
+ return p.caps?.reasoning === "effort" ? [...(p.caps.effortLevels ?? [])] : [];
720
+ if (p.type === "anthropic-compatible")
721
+ return [...(p.caps?.effortLevels ?? [])];
722
+ return [];
723
+ }
724
+ /**
725
+ * Fold measurements into the config on disk.
726
+ *
727
+ * Read again here rather than reusing what the job started with: the user may have saved from the
728
+ * GUI while the measurements ran, and writing back a stale copy would silently revert that save.
729
+ * Only a model that is still present is touched, and only into a blank field — a `wire` or
730
+ * `effortLevels` already on the model is the user's, and an explicitly empty array is a decision
731
+ * (it disables the provider fallback), not an empty slot. A model whose measurement errored is
732
+ * left entirely alone. The result is validated with the same `validate` the GUI's save uses, so a
733
+ * bad edit is refused rather than written.
734
+ */
735
+ function applyMeasurements(configFile, results) {
736
+ let current;
737
+ try {
738
+ current = JSON.parse(fs.readFileSync(configFile, "utf8"));
739
+ }
740
+ catch (e) {
741
+ return { error: `could not re-read config: ${errorText(e)}` };
742
+ }
743
+ let applied = 0;
744
+ for (const result of results) {
745
+ if (result.error || !result.wire)
746
+ continue;
747
+ for (const provider of Object.values(current.providers)) {
748
+ if (!("models" in provider) || !provider.models)
749
+ continue;
750
+ const model = provider.models.find((entry) => entry.id === result.id);
751
+ if (!model)
752
+ continue;
753
+ const before = `${model.wire ?? ""}|${model.effortLevels ? model.effortLevels.join(",") : "∅"}`;
754
+ if (model.wire === undefined)
755
+ model.wire = result.wire;
756
+ if (model.effortLevels === undefined && result.effortLevels !== undefined)
757
+ model.effortLevels = result.effortLevels;
758
+ if (`${model.wire ?? ""}|${model.effortLevels ? model.effortLevels.join(",") : "∅"}` !== before)
759
+ applied++;
760
+ }
761
+ }
762
+ const errors = validate(current);
763
+ if (errors.length > 0)
764
+ return { error: `measured config did not validate: ${errors[0]}` };
765
+ writeConfigFile(configFile, current);
766
+ return { applied };
767
+ }
768
+ /** Run one provider measurement job to completion. Detached: the request that started it has already answered. */
769
+ async function runMeasureJob(jobId, providerName, models, deps) {
770
+ const job = MEASURE_JOBS.get(jobId);
771
+ if (!job)
772
+ return;
773
+ const provider = deps.config().providers[providerName];
774
+ if (!provider || (provider.type !== "openai-compatible" && provider.type !== "anthropic-compatible")) {
775
+ MEASURE_JOBS.set(jobId, { ...job, state: "failed", error: `provider "${providerName}" is not openai-compatible or anthropic-compatible` });
776
+ return;
777
+ }
778
+ const ladder = providerLadder(provider);
779
+ const fetchImpl = deps.measureFetch ?? measureFetchWithTimeout;
780
+ const headers = "headers" in provider ? provider.headers : undefined;
781
+ const sessionHeader = "sessionHeader" in provider ? provider.sessionHeader : undefined;
782
+ for (const id of models) {
783
+ // Per model, not once per job: the order depends on what the preset recorded for this id.
784
+ const candidates = measureCandidates(provider, id);
785
+ const measured = await measureModel(id, candidates, ladder, { fetch: fetchImpl, ...(sessionHeader ? { sessionHeader } : {}), ...(headers ? { headers } : {}) });
786
+ job.results.push(measured);
787
+ job.done++;
788
+ deps.log.info(`admin: measured ${providerName}/${id} -> ${measured.error ? `error ${measured.error}` : `wire ${measured.wire}${measured.effortLevels ? ` effort ${measured.effortLevels.join(",")}` : ""}`}`);
789
+ }
790
+ try {
791
+ const outcome = applyMeasurements(deps.configFile, job.results);
792
+ if ("error" in outcome) {
793
+ MEASURE_JOBS.set(jobId, { ...job, state: "failed", error: outcome.error, finishedAt: Date.now() });
794
+ deps.log.warn(`admin: measurement for ${providerName} was not applied: ${outcome.error}`);
795
+ return;
796
+ }
797
+ deps.log.info(`admin: measurement for ${providerName} applied to ${outcome.applied} model(s)`);
798
+ MEASURE_JOBS.set(jobId, { ...job, state: "done", finishedAt: Date.now() });
799
+ }
800
+ catch (e) {
801
+ MEASURE_JOBS.set(jobId, { ...job, state: "failed", error: errorText(e), finishedAt: Date.now() });
802
+ }
803
+ }
478
804
  function pickerModels(deps) {
479
805
  const last = deps.picker?.().last;
480
806
  if (last && typeof last === "object" && Array.isArray(last.surfaces)) {
@@ -582,10 +908,14 @@ export function startAdmin(deps) {
582
908
  }
583
909
  async function handle(req, res) {
584
910
  const url = req.url ?? "/";
585
- const method = req.method ?? "GET";
911
+ // A HEAD is a GET whose body is discarded, and Node discards it for us: measured 2026-09-22,
912
+ // the response goes out with its `content-length` and no bytes after the headers. Normalising
913
+ // it here lets every read-only route answer one, where matching on "GET" alone fell through to
914
+ // 404 — which reads as a dead router to anything that checks with a HEAD.
915
+ const method = req.method === "HEAD" ? "GET" : (req.method ?? "GET");
586
916
  const pathname = url.split("?")[0] ?? "/";
587
917
  try {
588
- if (method !== "GET" && method !== "HEAD" && crossSitePost(req)) {
918
+ if (method !== "GET" && crossSitePost(req)) {
589
919
  deps.log.warn(`admin: refused ${method} ${pathname} from origin ${String(req.headers.origin)}`);
590
920
  sendJson(res, 403, { error: "cross-site request refused" });
591
921
  return;
@@ -610,7 +940,8 @@ export function startAdmin(deps) {
610
940
  return;
611
941
  }
612
942
  if (pathname === "/api/status" && method === "GET") {
613
- sendJson(res, 200, await buildStatus(deps));
943
+ const refresh = new URL(url, "http://127.0.0.1").searchParams.get("refresh") === "1";
944
+ sendJson(res, 200, await buildStatus(deps, { refresh }));
614
945
  return;
615
946
  }
616
947
  if (pathname === "/api/presets" && method === "GET") {
@@ -665,16 +996,20 @@ export function startAdmin(deps) {
665
996
  if (probe.type === "chatgpt") {
666
997
  const statuses = Object.values(deps.chatgpt?.().auth ?? {});
667
998
  const signed = chatgptSignedIn(typeof probe.auth === "string" ? probe.auth : undefined);
999
+ // Which provider to ask: the form knows the one it is editing, and a provider merely
1000
+ // configured by hand is the only other candidate. Unknown name → ask nobody, show the
1001
+ // measured fallback, which is also what an unreachable catalogue yields.
1002
+ const providers = deps.config().providers;
1003
+ const named = typeof probe.name === "string" && providers[probe.name]?.type === "chatgpt" ? probe.name : undefined;
1004
+ const providerName = named ?? Object.entries(providers).find(([, p]) => p.type === "chatgpt")?.[0];
1005
+ const live = providerName ? await deps.chatgpt?.().models?.(providerName) : undefined;
1006
+ const models = live?.length ? live : CHATGPT_FALLBACK_MODELS;
668
1007
  sendJson(res, 200, {
669
1008
  ok: signed,
670
1009
  auth: signed ? (statuses[0] ?? "ok") : "missing",
671
1010
  ...(signed ? {} : { error: "no ChatGPT credentials: sign in from the tray menu, or install and sign in to the Codex CLI" }),
672
- models: [
673
- { id: "gpt-5.6-terra", name: "GPT-5.6 Terra" },
674
- { id: "gpt-5.6-sol", name: "GPT-5.6 Sol" },
675
- { id: "gpt-5.6-luna", name: "GPT-5.6 Luna" },
676
- { id: "gpt-6-astra", name: "GPT-6 Astra" },
677
- ],
1011
+ models,
1012
+ modelsSource: live?.length ? "catalog" : "fallback",
678
1013
  });
679
1014
  return;
680
1015
  }
@@ -692,12 +1027,56 @@ export function startAdmin(deps) {
692
1027
  url: probe.url,
693
1028
  ...(probe.headers ? { headers: probe.headers } : {}),
694
1029
  ...(probe.modelsUrl ? { modelsUrl: probe.modelsUrl } : {}),
1030
+ ...(typeof probe.sessionHeader === "string" && probe.sessionHeader ? { sessionHeader: probe.sessionHeader } : {}),
695
1031
  ...(probe.modelsAuthHeader ? { modelsAuthHeader: probe.modelsAuthHeader } : {}),
1032
+ ...(typeof probe.preset === "string" && probe.preset ? { preset: probe.preset } : {}),
696
1033
  ...(typeof probe.probeModel === "string" ? { probeModel: probe.probeModel } : {}),
697
- });
1034
+ }, deps.probeModelFetch ?? fetchWithTimeout);
698
1035
  sendJson(res, 200, result);
699
1036
  return;
700
1037
  }
1038
+ // Measure what each model's wire and effort ladder actually is, in the background: a job is
1039
+ // several small upstream requests, and the caller polls /api/providers/measure/<jobId> rather
1040
+ // than holding a request open for all of them.
1041
+ if (pathname === "/api/providers/measure" && method === "POST") {
1042
+ let body;
1043
+ try {
1044
+ body = JSON.parse((await readBody(req)).toString("utf8"));
1045
+ }
1046
+ catch {
1047
+ sendJson(res, 400, { error: "invalid JSON" });
1048
+ return;
1049
+ }
1050
+ if (typeof body.provider !== "string" || !Array.isArray(body.models) || body.models.some((m) => typeof m !== "string")) {
1051
+ sendJson(res, 400, { error: "expected {provider: string, models: string[]}" });
1052
+ return;
1053
+ }
1054
+ const provider = deps.config().providers[body.provider];
1055
+ if (!provider || (provider.type !== "openai-compatible" && provider.type !== "anthropic-compatible")) {
1056
+ sendJson(res, 400, { error: `provider "${body.provider}" is not openai-compatible or anthropic-compatible` });
1057
+ return;
1058
+ }
1059
+ sweepMeasureJobs();
1060
+ const jobId = crypto.randomBytes(12).toString("hex");
1061
+ const job = { state: "running", done: 0, total: body.models.length, results: [] };
1062
+ MEASURE_JOBS.set(jobId, job);
1063
+ deps.log.info(`admin: measuring ${body.provider} (${body.models.length} model(s)) -> ${jobId.slice(0, 8)}`);
1064
+ void runMeasureJob(jobId, body.provider, body.models, deps);
1065
+ sendJson(res, 202, { jobId, total: job.total });
1066
+ return;
1067
+ }
1068
+ if (pathname.startsWith("/api/providers/measure/") && method === "GET") {
1069
+ const jobId = pathname.slice("/api/providers/measure/".length).split("/")[0] ?? "";
1070
+ const job = MEASURE_JOBS.get(jobId);
1071
+ if (!job) {
1072
+ sendJson(res, 404, { error: "unknown or expired measure job" });
1073
+ return;
1074
+ }
1075
+ // The polled shape, without the sweep's own bookkeeping field.
1076
+ const { finishedAt: _finishedAt, ...view } = job;
1077
+ sendJson(res, 200, view);
1078
+ return;
1079
+ }
701
1080
  if (pathname === "/api/config" && method === "GET") {
702
1081
  sendJson(res, 200, deps.config());
703
1082
  return;
@@ -724,12 +1103,13 @@ export function startAdmin(deps) {
724
1103
  sendJson(res, 400, { errors });
725
1104
  return;
726
1105
  }
727
- const tmp = `${deps.configFile}.tmp-${process.pid}-${Date.now()}`;
728
- fs.mkdirSync(path.dirname(deps.configFile), { recursive: true });
729
- fs.writeFileSync(tmp, JSON.stringify(parsed, null, 2) + "\n");
730
- fs.renameSync(tmp, deps.configFile);
1106
+ writeConfigFile(deps.configFile, parsed);
731
1107
  deps.log.info(`admin: config saved via GUI (${Object.keys(parsed.routes).length} routes, ${Object.keys(parsed.providers).length} providers)`);
732
- sendJson(res, 200, { ok: true });
1108
+ // `cli.models` cannot take effect through the router at all: Claude Code reads the slots
1109
+ // before a request exists. So a GUI save has to write them to settings.json itself;
1110
+ // otherwise the Clients screen shows a choice that only a later `install` would honour.
1111
+ const slotWarning = syncModelSlotsForGui(parsed);
1112
+ sendJson(res, 200, { ok: true, ...(slotWarning ? { warning: slotWarning } : {}) });
733
1113
  return;
734
1114
  }
735
1115
  if (pathname === "/api/codex" && method === "GET") {
@@ -776,10 +1156,63 @@ export function startAdmin(deps) {
776
1156
  sendJson(res, 200, { started: true });
777
1157
  return;
778
1158
  }
1159
+ // Safe account metadata only. The current Claude Code/Desktop source remains externally owned;
1160
+ // ClaudeRipple accounts can be renamed or removed by their local opaque id.
1161
+ if (pathname === "/api/claude-accounts" && method === "GET") {
1162
+ const source = claudeAuthStore(deps).describeSource();
1163
+ sendJson(res, 200, {
1164
+ current: source ? { id: "current", label: "Current Claude login", source, external: true } : null,
1165
+ accounts: listClaudeAccounts(homeDir()),
1166
+ });
1167
+ return;
1168
+ }
1169
+ if (pathname.startsWith("/api/claude-accounts/") && (method === "PATCH" || method === "DELETE")) {
1170
+ let id;
1171
+ try {
1172
+ id = decodeURIComponent(pathname.slice("/api/claude-accounts/".length));
1173
+ }
1174
+ catch {
1175
+ sendJson(res, 400, { error: "invalid account id" });
1176
+ return;
1177
+ }
1178
+ const validId = id === "legacy" || /^[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);
1179
+ if (!validId || id === "current") {
1180
+ sendJson(res, id === "current" ? 409 : 400, { error: id === "current" ? "the current Claude login is managed by Claude Code or Claude Desktop" : "invalid account id" });
1181
+ return;
1182
+ }
1183
+ if (method === "DELETE") {
1184
+ if (!removeClaudeAccount(homeDir(), id)) {
1185
+ sendJson(res, 404, { error: "Claude account not found" });
1186
+ return;
1187
+ }
1188
+ deps.log.info(`admin: removed Claude account ${id.slice(0, 8)}`);
1189
+ sendJson(res, 200, { ok: true });
1190
+ return;
1191
+ }
1192
+ let label;
1193
+ try {
1194
+ label = JSON.parse((await readBody(req)).toString("utf8")).label;
1195
+ }
1196
+ catch {
1197
+ sendJson(res, 400, { error: "invalid JSON" });
1198
+ return;
1199
+ }
1200
+ if (typeof label !== "string" || !label.trim()) {
1201
+ sendJson(res, 400, { error: "expected {label: non-empty string}" });
1202
+ return;
1203
+ }
1204
+ if (!renameClaudeAccount(homeDir(), id, label)) {
1205
+ sendJson(res, 404, { error: "Claude account not found" });
1206
+ return;
1207
+ }
1208
+ deps.log.info(`admin: renamed Claude account ${id.slice(0, 8)}`);
1209
+ sendJson(res, 200, { ok: true });
1210
+ return;
1211
+ }
779
1212
  // Claude subscription sign-in of our own (browser, PKCE). The GUI starts it, polls the state,
780
1213
  // and pastes the code when the loopback port could not be used. Tokens never leave the router.
781
1214
  if (pathname === "/api/claude-oauth" && method === "GET") {
782
- const state = { ...(claudeOAuth?.snapshot ?? { running: false, url: null, manual: false, startedAt: null, finishedAt: null, ok: null, error: null }), source: claudeAuthStore(deps).describeSource() };
1215
+ const state = { ...(claudeOAuth?.snapshot ?? { running: false, url: null, manual: false, startedAt: null, finishedAt: null, ok: null, error: null, account: null }), source: claudeAuthStore(deps).describeSource() };
783
1216
  sendJson(res, 200, state);
784
1217
  return;
785
1218
  }