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
package/dist/ui/app.js CHANGED
@@ -9,6 +9,9 @@ function el(tag, attrs, children) {
9
9
  if (key === "class") node.className = value;
10
10
  else if (key === "text") node.textContent = value;
11
11
  else if (key === "checked") node.checked = Boolean(value);
12
+ // A textarea ignores the `value` attribute, so a form reopened on a saved provider showed an
13
+ // empty box and its save wiped what was there (instructionsAppend, 2026-09-23).
14
+ else if (key === "value" && tag === "textarea") node.value = value;
12
15
  else if (key.startsWith("on") && typeof value === "function") node.addEventListener(key.slice(2), value);
13
16
  else if (value !== undefined && value !== null) node.setAttribute(key, value);
14
17
  }
@@ -44,8 +47,13 @@ async function api(path, opts) {
44
47
  return body;
45
48
  }
46
49
 
47
- function configRequest(next) {
48
- return api("/api/config", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(next) });
50
+ // The router writes `cli.models` to settings.json on this save (they only take effect there). If
51
+ // that write could not happen it says so here a slot that silently does nothing is the bug this
52
+ // call used to be, so a failure must reach the screen rather than the router log.
53
+ async function configRequest(next) {
54
+ const out = await api("/api/config", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(next) });
55
+ if (out && out.warning) toast(out.warning, true);
56
+ return out;
49
57
  }
50
58
 
51
59
  function modelsOf(provider) {
@@ -65,9 +73,9 @@ function uniqueName(name, providers) {
65
73
  function groupedModels(config) {
66
74
  const groups = [];
67
75
  for (const [name, provider] of Object.entries(config.providers || {})) {
68
- // A native Claude provider serves the OpenAI ingress (Codex) only, so it is not a mapping
69
- // target: the router ignores a slot pointed at it and passes the request through instead.
70
- if (provider.type === "anthropic") continue;
76
+ // A native Claude provider becomes a mapping target only after the explicit account-pool opt-in.
77
+ // API-key and legacy login providers remain OpenAI-ingress-only.
78
+ if (provider.type === "anthropic" && !provider.accountPool) continue;
71
79
  let models = modelsOf(provider);
72
80
  if (!models.length && provider.type === "chatgpt") models = CHATGPT_MODELS;
73
81
  if (models.length) groups.push({ name, provider, models });
@@ -79,11 +87,15 @@ function modelEffortLevels(provider, model) {
79
87
  const entry = modelsOf(provider).find((item) => item.id === model);
80
88
  return entry && Array.isArray(entry.effortLevels) ? entry.effortLevels : undefined;
81
89
  }
90
+ // The ChatGPT ladders the Codex catalogue reports (measured 2026-09-23): every model takes
91
+ // low..max, and only these add ultra. The live probe replaces this table when it can be reached.
92
+ const CHATGPT_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
93
+ const CHATGPT_ULTRA_MODELS = new Set(["gpt-5.6-terra", "gpt-5.6-sol", "gpt-6-astra", "gpt-6-sol"]);
82
94
  function fallbackEffortLevels(provider, model) {
83
95
  if (!provider) return [];
84
96
  const explicit = modelEffortLevels(provider, model);
85
97
  if (explicit !== undefined) return explicit;
86
- if (provider.type === "chatgpt") return model === "gpt-5.6-luna" ? ["low", "medium", "high", "xhigh", "max", "ultra"] : ["low", "medium", "high", "xhigh", "max"];
98
+ if (provider.type === "chatgpt") return CHATGPT_ULTRA_MODELS.has(model) ? [...CHATGPT_EFFORT_LEVELS, "ultra"] : [...CHATGPT_EFFORT_LEVELS];
87
99
  const preset = provider.preset && presetById(provider.preset);
88
100
  if (provider.type === "openai-compatible") return provider.caps && provider.caps.reasoning === "effort" && Array.isArray(provider.caps.effortLevels) ? provider.caps.effortLevels : [];
89
101
  return provider.caps && Array.isArray(provider.caps.effortLevels) ? provider.caps.effortLevels : (preset && preset.effortLevels) || [];
@@ -104,6 +116,18 @@ function modelEffortTag(model) {
104
116
  if (!Array.isArray(model.effortLevels)) return null;
105
117
  return model.effortLevels.length ? t("providers.modelEffort") : t("providers.modelNoEffort");
106
118
  }
119
+ // Routed models do not share a context window, so each picker entry carries its own. What the user
120
+ // typed wins; otherwise whatever the vendor's /models reported (`context_length`); otherwise the
121
+ // global cli.autoCompactWindow fallback. This is the model's real window, not a compaction point.
122
+ function discoveredContextWindow(providerName, modelId) {
123
+ const provider = currentConfig && currentConfig.providers && currentConfig.providers[providerName];
124
+ const entry = provider && modelsOf(provider).find((item) => item.id === modelId);
125
+ return entry && Number.isFinite(entry.contextWindow) ? entry.contextWindow : undefined;
126
+ }
127
+ function savedContextWindow(modelId) {
128
+ const entry = ((currentConfig && currentConfig.cli && currentConfig.cli.extraModels) || []).find((item) => item.model === modelId);
129
+ return entry && Number.isFinite(entry.contextWindow) ? entry.contextWindow : undefined;
130
+ }
107
131
 
108
132
  let currentConfig = null;
109
133
  let status = null;
@@ -189,9 +213,35 @@ function quotaLine(name) {
189
213
  const quota = status && status.chatgpt && status.chatgpt.quota && status.chatgpt.quota[name];
190
214
  const primary = quota && quota.rate_limits && quota.rate_limits.primary;
191
215
  if (!primary) return null;
192
- const reset = primary.reset_after_seconds ? t("health.resetsIn", { hours: Math.max(1, Math.round(primary.reset_after_seconds / 3600)) }) : "";
193
- return t("health.quota", { percent: primary.used_percent, reset });
216
+ // The usage figure is what anyone acts on. The reset countdown was a second number beside it
217
+ // that nobody does anything with, so it is left out.
218
+ return t("health.quota", { percent: primary.used_percent, reset: "" });
219
+ }
220
+ /**
221
+ * One line a person can read. Vendors answer with a JSON body and sometimes an escaped one inside
222
+ * it; the useful part is the status and the vendor's own message, not the envelope around them.
223
+ * The full text stays on the element's title.
224
+ */
225
+ function shortError(text) {
226
+ const raw = String(text || "").trim();
227
+ if (!raw) return "";
228
+ const status = raw.match(/\b(\d{3})\b/);
229
+ let message = "";
230
+ try {
231
+ const json = raw.slice(raw.indexOf("{"));
232
+ const parsed = JSON.parse(json);
233
+ const err = parsed.error || parsed;
234
+ message = String(err.message || err.error || "").trim();
235
+ } catch { /* not JSON, or truncated: fall back to the raw head */ }
236
+ if (!message) message = raw.replace(/\s+/g, " ").slice(0, 120);
237
+ const head = status ? `HTTP ${status[1]}` : "";
238
+ const line = [head, message].filter(Boolean).join(" · ");
239
+ return line.length > 140 ? `${line.slice(0, 139)}…` : line;
194
240
  }
241
+
242
+ /** How long a probe result speaks for the provider before the live reading takes over. */
243
+ const PROBE_TTL_MS = 90_000;
244
+ function probeIsStale(state) { return !state.pending && typeof state.at === "number" && Date.now() - state.at > PROBE_TTL_MS; }
195
245
  function stateFor(name) { return probeStates.get(name); }
196
246
  function chatgptLoginButton(onChange) {
197
247
  const button = el("button", { class: "btn secondary", type: "button", text: t("providers.chatgptLogin") });
@@ -228,14 +278,40 @@ async function startChatgptLogin(onChange) {
228
278
  onChange && onChange();
229
279
  }
230
280
  }
281
+ /**
282
+ * Which of a provider's credentials are resting, and until when. Absent for a provider with one
283
+ * credential: a row that can only ever say "ready" is noise. Ids and labels only — never a key.
284
+ */
285
+ function credentialLine(name) {
286
+ const pool = (status && status.credentials && status.credentials[name]) || [];
287
+ if (!pool.length) return null;
288
+ const parts = pool.map((c) => {
289
+ const who = c.label || c.id;
290
+ if (c.state === "quarantined") return `${who}: ${t("pool.quarantined")}`;
291
+ if (c.state === "cooling") return `${who}: ${t("pool.cooling", { seconds: c.cooldownSeconds ?? 0 })}`;
292
+ return `${who}: ${t("pool.ready")}`;
293
+ });
294
+ const resting = pool.filter((c) => c.state !== "ready").length;
295
+ return el("div", { class: `small ${resting ? "bad-text" : ""}`.trim(), text: parts.join(" · ") });
296
+ }
297
+
231
298
  function providerState(name, provider) {
232
299
  const live = status && status.providers && status.providers[name];
233
300
  // Reaching the host is not the same as being able to use it. A ChatGPT provider with no
234
301
  // credentials would otherwise read "Connected" and send the user off believing it works.
235
302
  if (live && live.needsLogin) return badge("warn", t("providerStatus.loginNeeded"));
236
303
  const state = stateFor(name);
237
- if (state) return state.ok ? badge("ok", t("providerStatus.connected")) : state.auth === "bad-key" ? badge("bad", t("providerStatus.keyNeeded")) : badge("bad", t("providerStatus.disconnected"));
304
+ // A probe is the better evidence it actually called the provider but only while it is fresh.
305
+ // One caught mid-restart used to sit there in red for the rest of the session, while the poll
306
+ // every five seconds said the provider was fine and a fresh probe agreed. After PROBE_TTL_MS the
307
+ // live reading takes over, so a stale failure heals itself instead of needing a manual re-check.
308
+ if (state && !probeIsStale(state)) {
309
+ return state.ok ? badge("ok", t("providerStatus.connected"))
310
+ : state.auth === "bad-key" ? badge("bad", t("providerStatus.keyNeeded"))
311
+ : badge("bad", t("providerStatus.disconnected"));
312
+ }
238
313
  if (live) return live.reachable ? badge("ok", t("providerStatus.connected")) : badge("bad", t("providerStatus.disconnected"));
314
+ if (state) return badge("bad", t("providerStatus.disconnected"));
239
315
  return badge("warn", t("providerStatus.checking"));
240
316
  }
241
317
  function renderHealthProviders() {
@@ -255,6 +331,8 @@ function renderHealthProviders() {
255
331
  ].filter(Boolean));
256
332
  const quota = quotaLine(name);
257
333
  if (quota) line.appendChild(el("div", { class: "small", text: quota }));
334
+ const pool = credentialLine(name);
335
+ if (pool) line.appendChild(pool);
258
336
  return line;
259
337
  }));
260
338
  }
@@ -277,6 +355,7 @@ function renderClients() {
277
355
  pickerButton.disabled = pickerBusy;
278
356
  pickerButton.onclick = () => togglePicker(!picker.enabled);
279
357
  renderClientPickerModels(picker.enabled);
358
+ renderModelSlots();
280
359
  const agentEnabled = Boolean(status.agentTitle);
281
360
  $("#client-agent-title-rows").replaceChildren(el("div", { class: "row" }, [el("span", { class: "k", text: t("picker.state") }), agentEnabled ? badge("ok", t("agentTitle.on")) : el("span", { class: "small", text: t("agentTitle.off") })]));
282
361
  const agentButton = $("#client-agent-title-toggle");
@@ -300,13 +379,103 @@ function renderClientPickerModels(enabled) {
300
379
  input.dataset.model = model.id;
301
380
  input.dataset.provider = group.name;
302
381
  input.dataset.name = labelOf(model);
303
- input.addEventListener("change", () => void saveClientPickerModels());
304
- box.appendChild(el("label", { class: "model-check" }, [input, el("span", { text: labelOf(model) }), el("small", { text: group.name })]));
382
+ const discovered = discoveredContextWindow(group.name, model.id);
383
+ const saved = savedContextWindow(model.id);
384
+ // Blank means "use the global fallback"; the placeholder says what that would be.
385
+ const windowField = el("input", {
386
+ type: "number", class: "model-window", min: "1", step: "1000",
387
+ value: saved !== undefined ? String(saved) : "",
388
+ placeholder: discovered !== undefined ? String(discovered) : t("slots.windowGlobal"),
389
+ title: t("slots.windowHelp"),
390
+ });
391
+ windowField.hidden = !input.checked;
392
+ windowField.dataset.model = model.id;
393
+ windowField.addEventListener("change", () => void saveClientPickerModels());
394
+ input.addEventListener("change", () => { windowField.hidden = !input.checked; void saveClientPickerModels(); });
395
+ // Two lines: the name owns the first — on one line it was the thing that collapsed, down to
396
+ // "GPT…" — and the provider and the window share the second. The field sits outside the
397
+ // <label>, because inside it every click toggles the checkbox instead of reaching the field.
398
+ box.appendChild(el("div", { class: "picker-model", title: model.id }, [
399
+ el("label", { class: "pm-main" }, [input, el("span", { text: labelOf(model) })]),
400
+ el("small", { text: group.name }),
401
+ windowField,
402
+ ]));
305
403
  }
306
404
  if (!box.childElementCount) box.appendChild(hint(t("slots.noProviderModels")));
307
405
  }
406
+ // Claude Code picks these before a request exists, so routing cannot reach them: a search, a title
407
+ // or a subagent goes wherever the CLI already decided. Left empty, Claude answers them — which is
408
+ // why a routed session still searches on Claude quota until `smallFast` is pointed somewhere.
409
+ const MODEL_SLOTS = ["smallFast", "subagent", "main"];
410
+
411
+ // A provider the router measured as unable to search. Only the router can know this — it comes from
412
+ // the status snapshot, never from the model's name — and the `smallFast` slot is exactly where it
413
+ // matters, because a search sent to such a model comes back as invented prose or a visible failure.
414
+ function providerCannotSearch(name) {
415
+ return Boolean(status && status.providers && status.providers[name] && status.providers[name].webSearch !== true);
416
+ }
417
+
418
+ function renderModelSlots() {
419
+ const rows = $("#client-model-slots");
420
+ if (!rows) return;
421
+ const chosen = (currentConfig.cli && currentConfig.cli.models) || {};
422
+ rows.replaceChildren(...MODEL_SLOTS.map((slot) => {
423
+ const select = el("select", {});
424
+ select.appendChild(el("option", { value: "", text: t("slots.slotDefault") }));
425
+ for (const group of groupedModels(currentConfig)) for (const model of group.models) {
426
+ const option = el("option", { value: model.id, text: `${labelOf(model)} · ${group.name}` });
427
+ if (chosen[slot] === model.id) option.selected = true;
428
+ // `smallFast` is what a WebSearch runs on, so an inability to search is fatal there and
429
+ // merely worth knowing everywhere else.
430
+ if (slot === "smallFast" && providerCannotSearch(group.name)) {
431
+ option.textContent += ` — ${t("slots.noWebSearch")}`;
432
+ }
433
+ select.appendChild(option);
434
+ }
435
+ // A model the config names but no provider offers any more would otherwise vanish silently.
436
+ if (chosen[slot] && !allKnownModelIds(currentConfig).has(chosen[slot])) {
437
+ const orphan = el("option", { value: chosen[slot], text: `${chosen[slot]} (?)` });
438
+ orphan.selected = true;
439
+ select.appendChild(orphan);
440
+ }
441
+ select.onchange = () => void saveModelSlots();
442
+ select.dataset.slot = slot;
443
+ return el("div", { class: "slot-row" }, [
444
+ el("span", { class: "k", text: t(`slots.slot.${slot}`) }),
445
+ select,
446
+ el("span", { class: "hint", text: t(`slots.slotHelp.${slot}`) }),
447
+ ]);
448
+ }));
449
+ }
450
+
451
+ async function saveModelSlots() {
452
+ if (!currentConfig) return;
453
+ const next = clone(currentConfig);
454
+ // Every slot is recorded, empty ones included. A select put back to "default (Claude)" has to
455
+ // remove the env key, and the router tells "clear this" from "leave this alone" by whether the
456
+ // slot is present at all — so dropping empty values here would make the choice a silent no-op.
457
+ const models = {};
458
+ for (const select of $all("#client-model-slots select")) models[select.dataset.slot] = select.value;
459
+ next.cli = { ...(next.cli || {}), models };
460
+ try {
461
+ await configRequest(next);
462
+ currentConfig = next;
463
+ // Saying "saved" over a choice that will make every search fail is the same silent no-op this
464
+ // screen was already guilty of once. The label on the option warns before the fact; this catches
465
+ // a session that already had it selected.
466
+ const owners = models.smallFast ? providersOffering(next, models.smallFast) : [];
467
+ if (owners.length && providerCannotSearch(owners[0])) toast(t("slots.noWebSearchWarn"), true);
468
+ else toast(t("slots.slotSaved"));
469
+ } catch (error) { toast(t("common.saveFailed"), true, error.message); }
470
+ }
471
+
308
472
  function clientPickerSelections() {
309
- return $all("#client-picker-models input:checked").map((input) => ({ id: input.dataset.model, name: input.dataset.name, provider: input.dataset.provider }));
473
+ return $all("#client-picker-models input[type=checkbox]:checked").map((input) => {
474
+ const field = $(`#client-picker-models input.model-window[data-model="${CSS.escape(input.dataset.model)}"]`);
475
+ const typed = field && field.value.trim() ? Number(field.value) : NaN;
476
+ const contextWindow = Number.isFinite(typed) && typed > 0 ? Math.floor(typed) : discoveredContextWindow(input.dataset.provider, input.dataset.model);
477
+ return { id: input.dataset.model, name: input.dataset.name, provider: input.dataset.provider, contextWindow };
478
+ });
310
479
  }
311
480
  async function saveClientPickerModels() {
312
481
  if (!currentConfig) return;
@@ -466,6 +635,33 @@ $("#slots-add").addEventListener("click", () => {
466
635
  updateSlotSummary();
467
636
  });
468
637
  function allKnownModelIds(config) { return new Set(groupedModels(config).flatMap((group) => group.models.map((model) => model.id))); }
638
+ // Which providers carry this exact id. One is what lets the router route it with no rule at all;
639
+ // two is the ambiguity it refuses to guess through, and the only case a rule is still needed.
640
+ function providersOffering(config, id) { return groupedModels(config).filter((group) => group.models.some((model) => model.id === id)).map((group) => group.name); }
641
+ // Whose entry this is. A `direct` rule used to answer it, but since 2026-09-19 a model routes by
642
+ // the provider that declares it and most models have no rule at all — so the answer is the
643
+ // providers, with the rule kept only for the ambiguity they cannot settle: two offering one id.
644
+ function pickerOwner(config, id) {
645
+ const offering = providersOffering(config, id);
646
+ if (offering.length < 2) return offering[0];
647
+ const rule = (config.direct || []).filter((entry) => id.startsWith(entry.prefix)).sort((a, b) => b.prefix.length - a.prefix.length)[0];
648
+ return rule && offering.includes(rule.provider) ? rule.provider : offering[0];
649
+ }
650
+ /**
651
+ * The picker entries belonging to every provider but the one being saved — the ones no checkbox on
652
+ * this form can speak for, and which the rebuild must therefore be told to keep.
653
+ *
654
+ * Asking `direct` who owned them dropped all of them: the rules stopped being written in 2026-09-19
655
+ * and only the legacy `gpt-` prefix still matched anything, so one save of any provider emptied the
656
+ * picker of every non-GPT model (measured 2026-09-22 — deepseek and five more went that way).
657
+ */
658
+ function pickerSelectionsExcept(config, providerName) {
659
+ return ((config.cli && config.cli.extraModels) || [])
660
+ .filter((entry) => entry.model !== null && entry.model !== undefined)
661
+ // The window is part of the entry; rebuilding without it reset every other provider's.
662
+ .map((entry) => ({ id: entry.model, name: entry.name, contextWindow: entry.contextWindow, provider: pickerOwner(config, entry.model) }))
663
+ .filter((entry) => entry.provider && entry.provider !== providerName);
664
+ }
469
665
  function applyPickerSelections(next, selections) {
470
666
  const known = allKnownModelIds(next);
471
667
  // An entry no provider offers any more has no checkbox — the list is built from the providers —
@@ -479,12 +675,27 @@ function applyPickerSelections(next, selections) {
479
675
  const extras = new Map();
480
676
  const direct = new Map();
481
677
  for (const entry of selected) {
482
- extras.set(entry.id, { model: entry.id, name: entry.name || entry.id });
483
- direct.set(entry.id, { prefix: entry.id, provider: entry.provider });
678
+ // Without this the rebuild would drop the window on every checkbox click.
679
+ extras.set(entry.id, { model: entry.id, name: entry.name || entry.id, ...(Number.isFinite(entry.contextWindow) && entry.contextWindow > 0 ? { contextWindow: entry.contextWindow } : {}) });
680
+ // A rule per ticked model is no longer what makes it route: since 2026-09-19 the router sends a
681
+ // model to the one provider whose own `models` list carries it (routing.ts, "declared models").
682
+ // Writing one anyway restated the same fact in a second place and piled up — one config reached
683
+ // eighteen. It is still written for the case the router deliberately refuses to guess: an id
684
+ // that MORE THAN ONE provider offers, where only the operator knows which deal is meant. Keep
685
+ // this condition and the router's in step; the router is the one that decides.
686
+ if (providersOffering(next, entry.id).length > 1) direct.set(entry.id, { prefix: entry.id, provider: entry.provider });
484
687
  }
485
688
  next.cli = { ...(next.cli || {}), extraModels: [...extras.values()] };
486
- const preservedDirect = (next.direct || []).filter((rule) => !known.has(rule.prefix) && !orphans.has(rule.prefix));
487
- next.direct = [...preservedDirect, ...direct.values()];
689
+ // A rule survives when it is not a model id at all (the legacy `gpt-` prefix), or when it names an
690
+ // id two providers offer, which the router will not resolve on its own. Dropping that second kind
691
+ // because the model happens not to be ticked in the picker would stop it routing: the config here
692
+ // has `deepseek-v4-pro` on a direct mapping and on OpenCode Go, and it is in no picker list.
693
+ // An orphan goes either way — no provider offers it, and nothing in the interface can uncheck it.
694
+ const preservedDirect = (next.direct || []).filter((rule) =>
695
+ !orphans.has(rule.prefix) && (!known.has(rule.prefix) || providersOffering(next, rule.prefix).length > 1));
696
+ const byPrefix = new Map(preservedDirect.map((rule) => [rule.prefix, rule]));
697
+ for (const [prefix, rule] of direct) if (!byPrefix.has(prefix)) byPrefix.set(prefix, rule);
698
+ next.direct = [...byPrefix.values()];
488
699
  // A legacy gpt- prefix rule is intentionally retained by the filter above.
489
700
  return next;
490
701
  }
@@ -541,6 +752,9 @@ async function saveSlots() {
541
752
  function statusText(state) {
542
753
  if (!state) return t("providerStatus.checking");
543
754
  if (state.ok) return t("providerStatus.connected");
755
+ // The key was accepted and the plan refused: saying "check your key" sends the operator to the
756
+ // one thing that is not wrong. Measured 2026-09-22 against OpenCode's free-tier 403.
757
+ if (state.auth === "not-entitled") return t("providerStatus.notEntitled");
544
758
  if (state.auth === "bad-key") return t("providerStatus.keyNeeded");
545
759
  return t("providerStatus.disconnected");
546
760
  }
@@ -557,79 +771,339 @@ async function probeProvider(name, provider, onComplete) {
557
771
  headers,
558
772
  modelsUrl: provider.modelsUrl || (provider.preset && presetById(provider.preset) && presetById(provider.preset).modelsUrl),
559
773
  modelsAuthHeader: provider.modelsAuthHeader || (provider.preset && presetById(provider.preset) && presetById(provider.preset).modelsAuthHeader),
774
+ // The router reads the preset's fallback list to tag each discovered model with the wire it
775
+ // speaks, since /models reports ids alone and one plan can serve several wires.
776
+ preset: provider.preset,
560
777
  probeModel: provider.probeModel || (provider.preset && presetById(provider.preset) && (presetById(provider.preset).fallbackModels || [])[0] && presetById(provider.preset).fallbackModels[0].id),
778
+ // Some vendors refuse a request without it rather than merely losing the cache, so a test
779
+ // that leaves it out reports a broken provider that works perfectly.
780
+ sessionHeader: provider.sessionHeader || (provider.preset && presetById(provider.preset) && presetById(provider.preset).sessionHeader),
561
781
  };
562
782
  const result = await api("/api/providers/probe", {
563
783
  method: "POST",
564
784
  headers: { "content-type": "application/json" },
565
785
  body: JSON.stringify(body),
566
786
  });
567
- probeStates.set(name, result);
787
+ probeStates.set(name, { ...result, at: Date.now() });
568
788
  onComplete && onComplete(result);
569
789
  return result;
570
790
  } catch (error) {
571
791
  const unavailable = error.status === 404;
572
792
  const result = { ok: false, auth: unavailable ? "unknown" : "unreachable", models: [], error: unavailable ? t("providers.apiSoon") : error.message, unavailable };
573
- probeStates.set(name, result);
793
+ probeStates.set(name, { ...result, at: Date.now() });
574
794
  onComplete && onComplete(result);
575
795
  return result;
576
796
  } finally {
577
797
  renderHealthProviders();
578
798
  }
579
799
  }
580
- function providerCard(name, provider) {
581
- const card = el("article", { class: "card provider-card" });
582
- const title = el("h2", { text: name });
583
- const stateLine = el("div", { class: "provider-state" });
584
- const modelText = el("p", { class: "small" });
585
- const effortText = el("p", { class: "small" });
586
- const check = el("button", { class: "btn secondary", type: "button", text: t("providers.check") });
587
- const edit = el("button", { class: "btn secondary", type: "button", text: t("common.edit") });
588
- const remove = el("button", { class: "btn danger", type: "button", text: t("common.remove") });
589
- const draw = () => {
590
- const state = stateFor(name);
591
- const preset = provider.preset && presetById(provider.preset);
592
- let kind = provider.type === "chatgpt" ? t("providers.chatgpt") : provider.type === "anthropic" ? `${t("providers.anthropic")} · ${provider.auth === "claude-code" ? t("providers.anthropicLoginReuse") : t("providers.apiKey")}` : preset ? preset.name : "";
593
- if (provider.type !== "chatgpt" && provider.type !== "anthropic") { try { kind = `${kind ? kind + " · " : ""}${new URL(provider.url).host}`; } catch { /* keep */ } }
594
- const live = status && status.providers && status.providers[name];
595
- stateLine.replaceChildren(...[
596
- providerState(name, provider),
597
- live && live.needsLogin ? chatgptLoginButton(draw) : null,
598
- live && live.needsLogin && chatgptLoginMessage ? el("span", { class: "small", text: chatgptLoginMessage }) : null,
599
- el("span", { class: "small", text: kind }),
600
- state && !state.ok && state.error ? el("span", { class: "small bad-text", text: state.error }) : null,
601
- ].filter(Boolean));
602
- const models = modelsOf(provider);
603
- modelText.replaceChildren(...(models.length
604
- ? models.flatMap((model, index) => [
605
- index ? document.createTextNode(", ") : null,
606
- document.createTextNode(labelOf(model)),
607
- modelEffortTag(model) ? el("span", { class: `model-effort-tag ${model.effortLevels.length ? "has-effort" : "no-effort"}`, text: modelEffortTag(model) }) : null,
608
- ].filter(Boolean))
609
- : [document.createTextNode(t("providers.noModels"))]));
610
- if (hasModelEffortData(provider)) effortText.hidden = true;
611
- else {
612
- const levels = effortLevelsFor(name, models[0] && models[0].id);
613
- effortText.textContent = t("providers.effortLevels", { levels: levels.length ? levels.join(" · ") : t("providers.effortNone") });
614
- effortText.hidden = false;
800
+ // ---- Capability measurement ---------------------------------------------------------
801
+ //
802
+ // A model's wire and its effort ladder are measured by asking it, because a vendor's `/models`
803
+ // reports ids and nothing else. That takes several small requests per model, far too long to hold a
804
+ // save behind, so the save returns at once and this runs after it and settles the config when it
805
+ // finishes. Provider name { state, done, total }, for the line the detail view shows meanwhile.
806
+ const measureStates = new Map();
807
+
808
+ /** What the operator wants to know from a finished job: what was settled, and what would not answer. */
809
+ function measureSummary(results) {
810
+ const measured = results.filter((entry) => entry.wire);
811
+ if (measured.length === 0) return "";
812
+ const failed = results.length - measured.length;
813
+ const parts = measured.map((entry) => `${entry.id} ${entry.wire}${entry.effortLevels ? ` (${entry.effortLevels.length ? entry.effortLevels.join("/") : t("providers.effortNone")})` : ""}`);
814
+ return failed > 0 ? `${parts.join(", ")} · +${failed}` : parts.join(", ");
815
+ }
816
+
817
+ /**
818
+ * Measure the models of one provider, in the background, and show the config the result settled.
819
+ *
820
+ * Only models missing a wire or a ladder are sent: one the operator set is their decision, and the
821
+ * router will not overwrite it either, so measuring it again would spend requests to change nothing.
822
+ */
823
+ async function startMeasurement(providerName, provider) {
824
+ if (!provider || (provider.type !== "openai-compatible" && provider.type !== "anthropic-compatible")) return;
825
+ const models = modelsOf(provider).filter((model) => !model.wire || !model.effortLevels).map((model) => model.id);
826
+ if (models.length === 0) return;
827
+ let jobId;
828
+ try {
829
+ const started = await api("/api/providers/measure", {
830
+ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ provider: providerName, models }),
831
+ });
832
+ jobId = started.jobId;
833
+ measureStates.set(providerName, { state: "running", done: 0, total: started.total });
834
+ renderProviderWorkspace();
835
+ } catch { return; } // The router is older than this screen, or said no: the save itself stands.
836
+ try {
837
+ for (;;) {
838
+ await new Promise((resolve) => setTimeout(resolve, 1500));
839
+ const job = await api(`/api/providers/measure/${jobId}`);
840
+ measureStates.set(providerName, { state: job.state, done: job.done, total: job.total });
841
+ renderProviderWorkspace();
842
+ if (job.state === "running") continue;
843
+ measureStates.delete(providerName);
844
+ if (job.state === "failed") { toast(t("measure.failed"), true, job.error); renderProviderWorkspace(); return; }
845
+ const results = job.results || [];
846
+ // A model the plan refuses is not merely unmeasured: no wire will ever answer for it, so
847
+ // saying so once here is the only chance the operator gets before a session fails on it.
848
+ // Measured 2026-09-22: OpenCode answers every `-free` model with 403 FreeTierError.
849
+ const barred = results.filter((entry) => typeof entry.error === "string" && entry.error.startsWith("not-entitled"));
850
+ if (barred.length > 0) toast(t("measure.notEntitled", { models: barred.map((entry) => entry.id).join(", ") }), true);
851
+ const summary = measureSummary(results);
852
+ // An auth failure is reported as itself: it says nothing about any model, and leaving it as
853
+ // "nothing was measured" would send the operator looking at the wrong thing.
854
+ const refused = results.some((entry) => entry.error === "auth");
855
+ // The barred models already had their own line; do not also call the round a failure when
856
+ // every other model measured fine.
857
+ if (summary || barred.length === 0) toast(summary ? t("measure.done", { summary }) : refused ? t("measure.authFailed") : t("measure.nothing"), !summary);
858
+ await loadProviders();
859
+ return;
615
860
  }
616
- };
617
- check.addEventListener("click", async () => { check.disabled = true; await probeProvider(name, provider); check.disabled = false; draw(); });
618
- edit.addEventListener("click", () => openProviderForm({ name, provider }));
619
- remove.addEventListener("click", async () => {
620
- if (!confirm(t("providers.removeConfirm", { name }))) return;
621
- const next = clone(currentConfig);
622
- delete next.providers[name];
623
- next.routes = Object.fromEntries(Object.entries(next.routes || {}).filter(([, route]) => route.provider !== name));
624
- next.direct = (next.direct || []).filter((rule) => rule.provider !== name);
625
- next.cli.extraModels = (next.cli.extraModels || []).filter((entry) => !modelsOf(provider).some((model) => model.id === entry.model));
626
- try { await configRequest(next); currentConfig = next; providersLoaded = false; slotsLoaded = false; await loadProviders(); toast(t("common.saved")); } catch (error) { toast(t("common.saveFailed"), true, error.message); }
861
+ } catch (error) {
862
+ measureStates.delete(providerName);
863
+ toast(t("measure.failed"), true, error.message);
864
+ renderProviderWorkspace();
865
+ }
866
+ }
867
+
868
+ let selectedProviderName = null;
869
+ let providerDetailTab = "overview";
870
+ let providerSearch = "";
871
+ let providerDetailGeneration = 0;
872
+
873
+ function providerKind(name, provider) {
874
+ const preset = provider.preset && presetById(provider.preset);
875
+ if (provider.type === "chatgpt") return t("providers.chatgpt");
876
+ if (provider.type === "anthropic") return provider.auth === "claude-code"
877
+ ? `${t("providers.anthropic")} · ${provider.accountPool ? t("providers.rotationOn") : t("providers.rotationOff")}`
878
+ : `${t("providers.anthropic")} · ${t("providers.apiKey")}`;
879
+ if (preset) return preset.name;
880
+ try { return new URL(provider.url).host; } catch { return provider.type || name; }
881
+ }
882
+
883
+ function providerRailItem(name, provider) {
884
+ const selected = name === selectedProviderName;
885
+ const button = el("button", {
886
+ class: `provider-rail-item${selected ? " selected" : ""}`,
887
+ type: "button",
888
+ role: "option",
889
+ "aria-selected": String(selected),
890
+ }, [
891
+ el("span", { class: "provider-rail-copy" }, [
892
+ el("strong", { text: name }),
893
+ el("span", { text: t("providers.modelsCountShort", { count: modelsOf(provider).length }) }),
894
+ ]),
895
+ providerState(name, provider),
896
+ ]);
897
+ button.addEventListener("click", () => {
898
+ selectedProviderName = name;
899
+ providerDetailTab = provider.type === "anthropic" && provider.auth === "claude-code" ? "accounts" : "overview";
900
+ renderProviderWorkspace();
627
901
  });
902
+ return button;
903
+ }
904
+
905
+ function renderProviderRail() {
906
+ const list = $("#providers-list");
907
+ const entries = Object.entries((currentConfig && currentConfig.providers) || {});
908
+ const query = providerSearch.trim().toLowerCase();
909
+ const visible = entries.filter(([name, provider]) => !query || name.toLowerCase().includes(query) || providerKind(name, provider).toLowerCase().includes(query));
910
+ list.replaceChildren(...visible.map(([name, provider]) => providerRailItem(name, provider)));
911
+ if (!entries.length) list.appendChild(el("div", { class: "provider-rail-empty", text: t("providers.empty") }));
912
+ else if (!visible.length) list.appendChild(el("div", { class: "provider-rail-empty", text: t("providers.noSelection") }));
913
+ }
914
+
915
+ async function removeProvider(name, provider) {
916
+ if (!confirm(t("providers.removeConfirm", { name }))) return;
917
+ const next = clone(currentConfig);
918
+ delete next.providers[name];
919
+ next.routes = Object.fromEntries(Object.entries(next.routes || {}).filter(([, route]) => route.provider !== name));
920
+ next.direct = (next.direct || []).filter((rule) => rule.provider !== name);
921
+ next.cli.extraModels = (next.cli.extraModels || []).filter((entry) => !modelsOf(provider).some((model) => model.id === entry.model));
922
+ try {
923
+ await configRequest(next);
924
+ currentConfig = next;
925
+ selectedProviderName = Object.keys(next.providers)[0] || null;
926
+ providerDetailTab = "overview";
927
+ slotsLoaded = false;
928
+ clientsLoaded = false;
929
+ renderProviderWorkspace();
930
+ toast(t("common.saved"));
931
+ } catch (error) { toast(t("common.saveFailed"), true, error.message); }
932
+ }
933
+
934
+ function providerTabs(name, provider) {
935
+ const tabs = [{ id: "overview", label: t("providers.overview") }];
936
+ if (provider.type === "anthropic" && provider.auth === "claude-code") tabs.push({ id: "accounts", label: t("providers.accounts") });
937
+ tabs.push({ id: "models", label: t("providers.modelsTab") });
938
+ return el("div", { class: "provider-tabs", role: "tablist" }, tabs.map((tab) => {
939
+ const button = el("button", { class: providerDetailTab === tab.id ? "active" : "", type: "button", role: "tab", "aria-selected": String(providerDetailTab === tab.id), text: tab.label });
940
+ button.addEventListener("click", () => { providerDetailTab = tab.id; renderProviderDetail(); });
941
+ return button;
942
+ }));
943
+ }
944
+
945
+ function providerOverview(name, provider) {
946
+ const state = stateFor(name);
947
+ const live = status && status.providers && status.providers[name];
948
+ const connection = el("section", { class: "detail-section" }, [
949
+ el("h3", { text: t("providers.connection") }),
950
+ el("div", { class: "detail-setting-row" }, [
951
+ el("div", { class: "setting-copy" }, [el("strong", { text: statusText(state) }), el("span", { text: providerKind(name, provider) })]),
952
+ providerState(name, provider),
953
+ ]),
954
+ state && !state.ok && state.error ? el("p", { class: "bad-text small provider-detail-error", text: shortError(state.error), title: state.error }) : null,
955
+ live && live.needsLogin ? chatgptLoginButton(renderProviderDetail) : null,
956
+ ].filter(Boolean));
957
+ const models = modelsOf(provider);
958
+ const modelSummary = el("section", { class: "detail-section" }, [
959
+ el("h3", { text: t("providers.selectedModels") }),
960
+ models.length ? el("div", { class: "model-chip-list" }, models.map((model) => el("span", { class: "model-chip", text: labelOf(model) }))) : hint(t("providers.noModels")),
961
+ ]);
962
+ const measuring = measureStates.get(name);
963
+ if (measuring && measuring.state === "running") {
964
+ modelSummary.appendChild(el("p", { class: "small", text: t("measure.running", { done: measuring.done, total: measuring.total }) }));
965
+ modelSummary.appendChild(hint(t("measure.help")));
966
+ }
628
967
  const quota = quotaLine(name);
629
- card.append(el("div", { class: "toolbar" }, [title, el("div", { class: "right" }, [check, edit, remove])]), stateLine, modelText, effortText, quota ? el("div", { class: "small", text: quota }) : document.createTextNode(""));
630
- draw();
631
- return card;
968
+ return el("div", { class: "provider-panel" }, [connection, quota ? el("section", { class: "detail-section" }, [el("h3", { text: t("health.quota", { percent: "", reset: "" }).trim() }), el("p", { text: quota })]) : null, modelSummary].filter(Boolean));
969
+ }
970
+
971
+ function providerModelsPanel(name, provider) {
972
+ const models = modelsOf(provider);
973
+ return el("div", { class: "provider-panel" }, [
974
+ el("section", { class: "detail-section" }, [
975
+ el("div", { class: "section-heading" }, [el("div", {}, [el("h3", { text: t("providers.selectedModels") }), hint(t("providers.modelsHelp"))]), el("button", { class: "btn secondary", type: "button", text: t("common.edit"), onclick: () => openProviderForm({ name, provider }) })]),
976
+ models.length ? el("div", { class: "provider-model-list" }, models.map((model) => el("div", { class: "provider-model-row" }, [el("strong", { text: labelOf(model) }), el("span", { class: "small", text: model.id }), modelEffortTag(model) ? el("span", { class: `model-effort-tag ${model.effortLevels.length ? "has-effort" : "no-effort"}`, text: modelEffortTag(model) }) : null].filter(Boolean)))) : hint(t("providers.noModels")),
977
+ ]),
978
+ ]);
979
+ }
980
+
981
+ async function saveAnthropicRotation(name, provider, enabled, control, message) {
982
+ control.disabled = true;
983
+ message.textContent = t("providers.rotationSaving");
984
+ const next = clone(currentConfig);
985
+ if (enabled) next.providers[name].accountPool = true;
986
+ else delete next.providers[name].accountPool;
987
+ try {
988
+ await configRequest(next);
989
+ currentConfig = next;
990
+ slotsLoaded = false;
991
+ clientsLoaded = false;
992
+ renderProviderWorkspace();
993
+ toast(t("providers.rotationSaved"));
994
+ } catch (error) {
995
+ control.checked = !enabled;
996
+ message.textContent = "";
997
+ toast(t("common.saveFailed"), true, error.message);
998
+ } finally { control.disabled = false; }
632
999
  }
1000
+
1001
+ function renderClaudeAccountRows(target, data, name, generation) {
1002
+ if (generation !== providerDetailGeneration) return;
1003
+ const rows = [];
1004
+ if (data.current) rows.push(el("article", { class: "account-card current" }, [
1005
+ el("div", { class: "account-card-copy" }, [el("strong", { text: data.current.label }), el("span", { class: "small", text: anthropicSourceText(data.current.source) }), hint(t("providers.currentAccountHelp"))]),
1006
+ el("span", { class: "badge ok", text: t("providers.anthropicCurrent") }),
1007
+ ]));
1008
+ for (const account of Array.isArray(data.accounts) ? data.accounts : []) {
1009
+ const unavailable = account.needsReauth || account.expiresAt <= Date.now();
1010
+ const rename = el("button", { class: "btn secondary compact", type: "button", text: t("common.edit") });
1011
+ rename.addEventListener("click", async () => {
1012
+ const label = prompt(t("providers.anthropicRenamePrompt"), account.label);
1013
+ if (!label || !label.trim() || label.trim() === account.label) return;
1014
+ try { await api(`/api/claude-accounts/${encodeURIComponent(account.id)}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ label }) }); await refreshClaudeAccountPanel(name); }
1015
+ catch (error) { toast(t("common.actionFailed"), true, error.message); }
1016
+ });
1017
+ const remove = el("button", { class: "btn danger compact", type: "button", text: t("common.remove") });
1018
+ remove.addEventListener("click", async () => {
1019
+ if (!confirm(t("providers.anthropicRemoveConfirm", { name: account.label }))) return;
1020
+ try { await api(`/api/claude-accounts/${encodeURIComponent(account.id)}`, { method: "DELETE" }); await refreshClaudeAccountPanel(name); }
1021
+ catch (error) { toast(t("common.actionFailed"), true, error.message); }
1022
+ });
1023
+ const actions = [rename, remove];
1024
+ if (unavailable) {
1025
+ const reauth = el("button", { class: "btn secondary compact", type: "button", text: t("providers.reauthAction") });
1026
+ reauth.addEventListener("click", () => openClaudeAccountConsent(name));
1027
+ actions.unshift(reauth);
1028
+ }
1029
+ rows.push(el("article", { class: "account-card" }, [
1030
+ el("div", { class: "account-card-copy" }, [el("strong", { text: account.label }), account.email && account.email !== account.label ? el("span", { class: "small", text: account.email }) : null, hint(t("providers.addedAccountHelp"))]),
1031
+ el("span", { class: `badge ${unavailable ? "bad" : "ok"}`, text: unavailable ? t("providers.anthropicReauth") : t("pool.ready") }),
1032
+ el("div", { class: "account-card-actions" }, actions),
1033
+ ]));
1034
+ }
1035
+ target.replaceChildren(...(rows.length ? rows : [el("div", { class: "empty-card", text: t("providers.anthropicNoAccounts") })]));
1036
+ const count = (data.current ? 1 : 0) + (Array.isArray(data.accounts) ? data.accounts.length : 0);
1037
+ const countNode = $("#claude-account-count");
1038
+ if (countNode) countNode.textContent = t("providers.accountCount", { count });
1039
+ }
1040
+
1041
+ async function refreshClaudeAccountPanel(name) {
1042
+ const target = $("#claude-account-rows");
1043
+ if (!target || selectedProviderName !== name || providerDetailTab !== "accounts") return;
1044
+ const generation = providerDetailGeneration;
1045
+ try { renderClaudeAccountRows(target, await api("/api/claude-accounts"), name, generation); }
1046
+ catch (error) { if (generation === providerDetailGeneration) target.replaceChildren(el("div", { class: "bad-text small", text: error.message })); }
1047
+ }
1048
+
1049
+ function anthropicAccountsPanel(name, provider) {
1050
+ const rotation = el("input", { type: "checkbox", checked: Boolean(provider.accountPool) });
1051
+ const rotationMessage = el("span", { class: "small" });
1052
+ rotation.addEventListener("change", () => void saveAnthropicRotation(name, provider, rotation.checked, rotation, rotationMessage));
1053
+ const add = el("button", { class: "btn", type: "button", text: t("providers.addClaudeAccount") });
1054
+ add.addEventListener("click", () => openClaudeAccountConsent(name));
1055
+ const removeAll = el("button", { class: "btn danger", type: "button", text: t("providers.anthropicLogoutAll") });
1056
+ removeAll.addEventListener("click", async () => {
1057
+ if (!confirm(t("providers.anthropicLogoutAllConfirm"))) return;
1058
+ try { await api("/api/claude-logout", { method: "POST" }); await refreshClaudeAccountPanel(name); }
1059
+ catch (error) { toast(t("common.actionFailed"), true, error.message); }
1060
+ });
1061
+ const rows = el("div", { id: "claude-account-rows", class: "account-card-list" }, [el("div", { class: "small", text: t("providers.checking") })]);
1062
+ const panel = el("div", { class: "provider-panel" }, [
1063
+ el("section", { class: "detail-section account-summary" }, [
1064
+ el("div", { class: "section-heading" }, [el("div", {}, [el("h3", { text: t("providers.accountPoolTitle") }), el("p", { id: "claude-account-count", class: "account-count", text: t("providers.accountCount", { count: 0 }) }), hint(t("providers.accountCountHelp"))]), add]),
1065
+ el("div", { class: "detail-setting-row rotation-row" }, [el("div", { class: "setting-copy" }, [el("strong", { text: t("providers.anthropicPool") }), el("span", { text: t("providers.accountPoolSubtitle") })]), el("label", { class: "switch" }, [rotation, el("span")])]),
1066
+ !provider.accountPool ? el("div", { class: "notice warn" }, [el("strong", { text: t("providers.rotationOff") }), el("span", { text: t("providers.rotationRequired") })]) : null,
1067
+ rotationMessage,
1068
+ ]),
1069
+ el("section", { class: "detail-section" }, [rows]),
1070
+ el("section", { class: "detail-section danger-section" }, [el("h3", { text: t("providers.dangerZone") }), hint(t("providers.anthropicLogoutAllConfirm")), removeAll]),
1071
+ ]);
1072
+ queueMicrotask(() => void refreshClaudeAccountPanel(name));
1073
+ return panel;
1074
+ }
1075
+
1076
+ function renderProviderDetail() {
1077
+ const detail = $("#provider-detail");
1078
+ providerDetailGeneration += 1;
1079
+ const provider = currentConfig && currentConfig.providers && currentConfig.providers[selectedProviderName];
1080
+ if (!provider) {
1081
+ detail.replaceChildren(el("div", { class: "provider-detail-empty" }, [el("strong", { text: t("providers.noSelection") })]));
1082
+ return;
1083
+ }
1084
+ const name = selectedProviderName;
1085
+ const check = el("button", { class: "btn secondary", type: "button", text: t("providers.check") });
1086
+ check.addEventListener("click", async () => { check.disabled = true; await probeProvider(name, provider); check.disabled = false; renderProviderWorkspace(); });
1087
+ const edit = el("button", { class: "btn secondary", type: "button", text: t("common.edit"), onclick: () => openProviderForm({ name, provider }) });
1088
+ const remove = el("button", { class: "btn danger", type: "button", text: t("common.remove"), onclick: () => void removeProvider(name, provider) });
1089
+ const header = el("header", { class: "provider-detail-header" }, [
1090
+ el("div", {}, [el("div", { class: "provider-title-line" }, [el("h2", { text: name }), providerState(name, provider)]), el("p", { class: "small", text: providerKind(name, provider) })]),
1091
+ el("div", { class: "provider-detail-actions" }, [check, edit, remove]),
1092
+ ]);
1093
+ let content;
1094
+ if (providerDetailTab === "accounts" && provider.type === "anthropic" && provider.auth === "claude-code") content = anthropicAccountsPanel(name, provider);
1095
+ else if (providerDetailTab === "models") content = providerModelsPanel(name, provider);
1096
+ else { providerDetailTab = "overview"; content = providerOverview(name, provider); }
1097
+ detail.replaceChildren(header, providerTabs(name, provider), content);
1098
+ }
1099
+
1100
+ function renderProviderWorkspace() {
1101
+ const entries = Object.entries((currentConfig && currentConfig.providers) || {});
1102
+ if (!selectedProviderName || !currentConfig.providers[selectedProviderName]) selectedProviderName = entries[0] ? entries[0][0] : null;
1103
+ renderProviderRail();
1104
+ renderProviderDetail();
1105
+ }
1106
+
633
1107
  async function loadClients() {
634
1108
  clientsLoaded = true;
635
1109
  try {
@@ -645,32 +1119,41 @@ async function loadProviders() {
645
1119
  try {
646
1120
  await loadCatalogs();
647
1121
  currentConfig = currentConfig || await api("/api/config");
648
- const list = $("#providers-list");
649
- list.replaceChildren(...Object.entries(currentConfig.providers || {}).map(([name, provider]) => providerCard(name, provider)));
650
- if (!currentConfig.providers || !Object.keys(currentConfig.providers).length) list.appendChild(el("div", { class: "empty-card", text: t("providers.empty") }));
1122
+ renderProviderWorkspace();
651
1123
  void Promise.all(Object.entries(currentConfig.providers || {}).map(([name, provider]) => probeProvider(name, provider, () => {
652
- const existing = $all(".provider-card").find((card) => card.querySelector("h2").textContent === name);
653
- if (existing) { existing.remove(); list.appendChild(providerCard(name, provider)); }
1124
+ if ($("#view-providers").classList.contains("active")) renderProviderWorkspace();
654
1125
  })));
655
1126
  } catch (error) { toast(t("common.loadFailed"), true, error.message); }
656
1127
  }
1128
+ $("#providers-search").addEventListener("input", (event) => { providerSearch = event.target.value; renderProviderRail(); });
657
1129
  $("#providers-add").addEventListener("click", openProviderChooser);
658
1130
  $("#providers-refresh").addEventListener("click", async () => {
659
1131
  if (!currentConfig) return;
1132
+ const button = $("#providers-refresh");
1133
+ button.disabled = true;
660
1134
  await Promise.all(Object.entries(currentConfig.providers).map(([name, provider]) => probeProvider(name, provider)));
661
- providersLoaded = false;
662
- await loadProviders();
1135
+ button.disabled = false;
1136
+ renderProviderWorkspace();
663
1137
  });
664
1138
 
665
1139
  // ---- Provider modal -----------------------------------------------------------------
666
1140
 
667
- function showModal(content) {
1141
+ let modalCleanup = null;
1142
+ function showModal(content, cleanup) {
1143
+ if (modalCleanup) modalCleanup();
1144
+ modalCleanup = cleanup || null;
668
1145
  $("#modal-content").replaceChildren(content);
669
1146
  $("#modal-backdrop").hidden = false;
670
1147
  const first = $("#modal-content input, #modal-content button, #modal-content select");
671
1148
  if (first) setTimeout(() => first.focus(), 0);
672
1149
  }
673
- function closeModal() { $("#modal-backdrop").hidden = true; $("#modal-content").replaceChildren(); }
1150
+ function closeModal() {
1151
+ const cleanup = modalCleanup;
1152
+ modalCleanup = null;
1153
+ if (cleanup) cleanup();
1154
+ $("#modal-backdrop").hidden = true;
1155
+ $("#modal-content").replaceChildren();
1156
+ }
674
1157
  $("#modal-close").addEventListener("click", closeModal);
675
1158
  $("#modal-backdrop").addEventListener("click", (event) => { if (event.target === $("#modal-backdrop")) closeModal(); });
676
1159
  window.addEventListener("keydown", (event) => {
@@ -680,6 +1163,83 @@ window.addEventListener("keydown", (event) => {
680
1163
  if (action && !action.disabled) { event.preventDefault(); action.click(); }
681
1164
  }
682
1165
  });
1166
+
1167
+ function openClaudeAccountConsent(providerName) {
1168
+ const accepted = el("input", { type: "checkbox" });
1169
+ const proceed = el("button", { class: "btn", type: "button", "data-default-action": "", text: t("providers.anthropicOAuthContinue") });
1170
+ proceed.disabled = true;
1171
+ accepted.addEventListener("change", () => { proceed.disabled = !accepted.checked; });
1172
+ proceed.addEventListener("click", () => void openClaudeAccountSignIn(providerName, false));
1173
+ showModal(el("div", { class: "oauth-consent" }, [
1174
+ el("h1", { id: "modal-title", text: t("providers.anthropicOAuthTitle") }),
1175
+ el("div", { class: "notice warn" }, [el("strong", { text: t("providers.anthropicOAuthTitle") }), el("span", { text: t("providers.anthropicOAuthWarning") })]),
1176
+ el("label", { class: "check oauth-accept" }, [accepted, el("span", { text: t("providers.anthropicOAuthAccept") })]),
1177
+ el("div", { class: "actions end" }, [el("button", { class: "btn secondary", type: "button", text: t("common.cancel"), onclick: closeModal }), proceed]),
1178
+ ]));
1179
+ }
1180
+
1181
+ async function openClaudeAccountSignIn(providerName, manual) {
1182
+ let active = true;
1183
+ let poll = null;
1184
+ const body = el("div", { class: "oauth-progress" });
1185
+ function stopPolling() { if (poll) clearInterval(poll); poll = null; }
1186
+ function showError(state) {
1187
+ stopPolling();
1188
+ if (!active) return;
1189
+ const retry = el("button", { class: "btn secondary", type: "button", text: t("providers.anthropicSignInManual") });
1190
+ retry.addEventListener("click", () => void openClaudeAccountSignIn(providerName, true));
1191
+ body.replaceChildren(el("span", { class: "bad-text", text: t("providers.anthropicSignInFailed") }), state.error ? el("p", { class: "small", text: state.error }) : null, retry);
1192
+ }
1193
+ async function finish(state) {
1194
+ stopPolling();
1195
+ if (!active) return;
1196
+ if (!state.ok) { showError(state); return; }
1197
+ active = false;
1198
+ closeModal();
1199
+ if (selectedProviderName === providerName) {
1200
+ providerDetailTab = "accounts";
1201
+ renderProviderDetail();
1202
+ }
1203
+ toast(t("providers.anthropicLoginDone"));
1204
+ }
1205
+ function renderState(state) {
1206
+ const controls = [
1207
+ el("p", { text: state.manual ? t("providers.anthropicSignInPaste") : t("providers.anthropicSignInBrowser") }),
1208
+ state.url ? el("a", { class: "login-link", href: state.url, target: "_blank", rel: "noreferrer", text: t("providers.anthropicSignInLink") }) : null,
1209
+ ];
1210
+ if (state.manual) {
1211
+ const code = el("input", { type: "text", autocomplete: "off", placeholder: "code#state" });
1212
+ const submit = el("button", { class: "btn", type: "button", text: t("providers.anthropicSignInSubmit") });
1213
+ submit.addEventListener("click", async () => {
1214
+ submit.disabled = true;
1215
+ try { await finish(await api("/api/claude-oauth/code", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ code: code.value }) })); }
1216
+ catch (error) { showError((error.body && { ok: false, error: error.body.error }) || { ok: false, error: error.message }); }
1217
+ });
1218
+ controls.push(el("div", { class: "key-control" }, [code, submit]));
1219
+ } else controls.push(el("p", { class: "small", text: t("providers.anthropicSignInWaiting") }));
1220
+ controls.push(el("button", { class: "btn secondary", type: "button", text: t("common.cancel"), onclick: closeModal }));
1221
+ body.replaceChildren(...controls.filter(Boolean));
1222
+ }
1223
+ showModal(el("div", {}, [el("h1", { id: "modal-title", text: t("providers.anthropicOAuthTitle") }), body]), () => {
1224
+ const wasActive = active;
1225
+ active = false;
1226
+ stopPolling();
1227
+ if (wasActive) void api("/api/claude-oauth/cancel", { method: "POST" }).catch(() => {});
1228
+ });
1229
+ body.replaceChildren(el("p", { class: "small", text: t("providers.checking") }));
1230
+ try {
1231
+ const state = await api("/api/claude-oauth", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ manual: Boolean(manual) }) });
1232
+ if (!active) return;
1233
+ renderState(state);
1234
+ poll = setInterval(async () => {
1235
+ try {
1236
+ const next = await api("/api/claude-oauth");
1237
+ if (!next.running) await finish(next);
1238
+ } catch { /* keep the last useful state while the router is busy */ }
1239
+ }, 2000);
1240
+ } catch (error) { showError({ ok: false, error: error.message }); }
1241
+ }
1242
+
683
1243
  function openProviderChooser() {
684
1244
  const grid = el("div", { class: "chooser-grid" });
685
1245
  const anthropic = el("button", { class: "chooser-tile", type: "button" }, [el("strong", { text: t("providers.anthropic") }), el("span", { text: t("providers.anthropicHelp") })]);
@@ -713,10 +1273,21 @@ function inputRow(label, control, helpText) {
713
1273
  }
714
1274
  // Checklist that stays usable with hundreds of models (OpenRouter lists 400+): a search box, checked
715
1275
  // entries pinned first, at most 36 visible rows, and the selection kept in a Set so filtering never
716
- // loses ticks. `box.selected()` returns the chosen {id,name} entries.
1276
+ // loses ticks. `box.selected()` returns the chosen entries, each carrying whatever per-model override
1277
+ // it arrived with (effortLevels, contextWindow, and the wire/url/authHeader a preset gave it) so a
1278
+ // save does not strip the endpoint the model speaks.
1279
+ function modelOverrideFields(model) {
1280
+ return {
1281
+ ...(Array.isArray(model.effortLevels) ? { effortLevels: [...model.effortLevels] } : {}),
1282
+ ...(typeof model.contextWindow === "number" ? { contextWindow: model.contextWindow } : {}),
1283
+ ...(model.wire ? { wire: model.wire } : {}),
1284
+ ...(model.url ? { url: model.url } : {}),
1285
+ ...(model.authHeader ? { authHeader: model.authHeader } : {}),
1286
+ };
1287
+ }
717
1288
  function modelChecklist(models, checked) {
718
1289
  const selected = new Map();
719
- for (const model of models) if (checked.has(model.id)) selected.set(model.id, { id: model.id, name: labelOf(model), ...(Array.isArray(model.effortLevels) ? { effortLevels: [...model.effortLevels] } : {}) });
1290
+ for (const model of models) if (checked.has(model.id)) selected.set(model.id, { id: model.id, name: labelOf(model), ...modelOverrideFields(model) });
720
1291
  const wrap = el("div", { class: "model-picker" });
721
1292
  const grid = el("div", { class: "model-checklist modal-checklist" });
722
1293
  const note = el("div", { class: "small", text: "" });
@@ -733,7 +1304,7 @@ function modelChecklist(models, checked) {
733
1304
  input.dataset.model = model.id;
734
1305
  input.dataset.name = labelOf(model);
735
1306
  input.addEventListener("change", () => {
736
- if (input.checked) selected.set(model.id, { id: model.id, name: labelOf(model), ...(Array.isArray(model.effortLevels) ? { effortLevels: [...model.effortLevels] } : {}) });
1307
+ if (input.checked) selected.set(model.id, { id: model.id, name: labelOf(model), ...modelOverrideFields(model) });
737
1308
  else selected.delete(model.id);
738
1309
  note.textContent = summary(matches.length);
739
1310
  });
@@ -759,6 +1330,7 @@ function anthropicSourceText(source) {
759
1330
  return t("providers.anthropicSourceMissing");
760
1331
  }
761
1332
  function openAnthropicProviderForm(options) {
1333
+ let formActive = true;
762
1334
  const existing = options.provider;
763
1335
  const displayName = options.name || t("providers.anthropic");
764
1336
  const nameInput = el("input", { value: displayName, maxlength: "60" });
@@ -770,16 +1342,6 @@ function openAnthropicProviderForm(options) {
770
1342
  const result = el("div", { class: "probe-result" });
771
1343
  const probeButton = el("button", { class: "btn secondary", type: "button", text: t("providers.check") });
772
1344
  const sourceLine = el("div", { class: "small" });
773
- // Our own sign-in is stored even while a Claude Desktop session outranks it. Saying so is the
774
- // only way a finished sign-in shows up on a screen whose source line does not change.
775
- const signedInLine = el("div", { class: "small" });
776
- function showSignedIn(response) {
777
- const stored = response && response.signedIn;
778
- signedInLine.hidden = !stored;
779
- if (!stored) return;
780
- signedInLine.textContent =
781
- response.source === "token-file" ? t("providers.anthropicSignedInActive") : t("providers.anthropicSignedInStandby");
782
- }
783
1345
  let foundModels = modelsOf(existing).length ? modelsOf(existing) : claudeModels.map((model) => ({ id: model.id, name: labelOf(model) }));
784
1346
  let selected = new Set(modelsOf(existing).length ? modelsOf(existing).map((model) => model.id) : foundModels.map((model) => model.id));
785
1347
  const modelArea = el("div", { class: "form-field" });
@@ -790,13 +1352,11 @@ function openAnthropicProviderForm(options) {
790
1352
  renderModels();
791
1353
  const authField = inputRow(t("providers.credentials"), auth, t("providers.anthropicCredentialsHelp"));
792
1354
  const keyField = el("div", { class: "form-field key-field" }, [el("span", { text: t("providers.apiKey") }), el("div", { class: "key-control" }, [keyInput, showKey]), el("small", { text: t("providers.keyHelp") })]);
793
- const subscriptionActions = el("div", { class: "actions" }, [el("button", { class: "btn secondary", type: "button", text: t("providers.anthropicLogin") }), el("button", { class: "btn secondary", type: "button", text: t("providers.anthropicLogout") })]);
1355
+ const accountPool = existing ? Boolean(existing.accountPool) : true;
794
1356
  function syncAuthFields() {
795
1357
  const reused = auth.value === "claude-code";
796
1358
  keyField.hidden = reused;
797
- subscriptionActions.hidden = !reused;
798
1359
  sourceLine.hidden = !reused;
799
- signedInLine.hidden = !reused || !signedInLine.textContent;
800
1360
  }
801
1361
  auth.addEventListener("change", syncAuthFields);
802
1362
  syncAuthFields();
@@ -806,11 +1366,11 @@ function openAnthropicProviderForm(options) {
806
1366
  try {
807
1367
  const body = auth.value === "claude-code" ? { type: "anthropic", auth: "claude-code" } : { type: "anthropic", auth: "api-key", apiKey: keyInput.value || (existing && existing.apiKey) };
808
1368
  const response = await api("/api/providers/probe", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
1369
+ if (!formActive) return;
809
1370
  sourceLine.textContent = anthropicSourceText(response.source);
810
- showSignedIn(response);
811
1371
  const noCredits = response.ok && /^no-credits:/.test(response.error || "");
812
1372
  result.replaceChildren(...[
813
- el("span", { class: response.ok && !noCredits ? "ok-text" : noCredits ? "warn-text" : "bad-text", text: noCredits ? t("providers.probeNoCredits") : response.ok ? t("providers.probeOk") : response.auth === "bad-key" ? t("providers.probeBadKey") : auth.value === "claude-code" ? anthropicSourceText(response.source) : t("providers.probeFailed") }),
1373
+ el("span", { class: response.ok && !noCredits ? "ok-text" : noCredits ? "warn-text" : "bad-text", text: noCredits ? t("providers.probeNoCredits") : response.ok ? t("providers.probeOk") : response.auth === "not-entitled" ? t("providers.probeNotEntitled") : response.auth === "bad-key" ? t("providers.probeBadKey") : auth.value === "claude-code" ? anthropicSourceText(response.source) : t("providers.probeFailed") }),
814
1374
  response.error ? el("div", { class: "small", text: response.error.replace(/^no-credits:\s*/, "") }) : null,
815
1375
  ].filter(Boolean));
816
1376
  if (Array.isArray(response.models) && response.models.length) {
@@ -818,60 +1378,18 @@ function openAnthropicProviderForm(options) {
818
1378
  selected = new Set(modelsOf(existing).length ? modelsOf(existing).map((model) => model.id) : foundModels.map((model) => model.id));
819
1379
  renderModels();
820
1380
  }
821
- } catch (error) { result.replaceChildren(el("span", { class: "bad-text", text: t("providers.probeFailed") }), el("div", { class: "small", text: error.message })); }
822
- finally { probeButton.disabled = false; }
1381
+ } catch (error) { if (formActive) result.replaceChildren(el("span", { class: "bad-text", text: t("providers.probeFailed") }), el("div", { class: "small", text: error.message })); }
1382
+ finally { if (formActive) probeButton.disabled = false; }
823
1383
  }
824
1384
  probeButton.addEventListener("click", () => void runProbe());
825
- // Our own browser sign-in (PKCE): start it, show the link in case no window opened, poll until the
826
- // router has the credential. When the loopback port is taken the code is pasted here instead.
827
- let signInPoll = null;
828
- function renderSignIn(state) {
829
- const parts = [el("div", { text: state.manual ? t("providers.anthropicSignInPaste") : t("providers.anthropicSignInBrowser") })];
830
- if (state.url) parts.push(el("a", { href: state.url, target: "_blank", rel: "noreferrer", text: t("providers.anthropicSignInLink") }));
831
- if (state.manual) {
832
- const codeInput = el("input", { type: "text", autocomplete: "off", placeholder: "code#state" });
833
- const submit = el("button", { class: "btn secondary", type: "button", text: t("providers.anthropicSignInSubmit") });
834
- submit.addEventListener("click", async () => {
835
- submit.disabled = true;
836
- try { const done = await api("/api/claude-oauth/code", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ code: codeInput.value }) }); finishSignIn(done); }
837
- catch (error) { finishSignIn({ ok: false, error: (error.body && error.body.error) || error.message }); }
838
- });
839
- parts.push(el("div", { class: "key-control" }, [codeInput, submit]));
840
- } else parts.push(el("div", { class: "small", text: t("providers.anthropicSignInWaiting") }));
841
- const cancel = el("button", { class: "btn secondary", type: "button", text: t("common.cancel") });
842
- cancel.addEventListener("click", async () => { clearInterval(signInPoll); await api("/api/claude-oauth/cancel", { method: "POST" }).catch(() => {}); result.replaceChildren(); });
843
- parts.push(cancel);
844
- result.replaceChildren(el("div", { class: "sign-in" }, parts));
845
- }
846
- function finishSignIn(state) {
847
- clearInterval(signInPoll);
848
- // The probe refreshes the source and the stored-sign-in line, then the outcome is put back:
849
- // the probe's own wording would otherwise erase the answer to the button that was just pressed.
850
- if (state.ok) { void runProbe().then(() => result.replaceChildren(el("span", { class: "ok-text", text: t("providers.anthropicLoginDone") }))); return; }
851
- const retry = el("button", { class: "btn secondary", type: "button", text: t("providers.anthropicSignInManual") });
852
- retry.addEventListener("click", () => void startSignIn(true));
853
- result.replaceChildren(el("span", { class: "bad-text", text: t("providers.anthropicSignInFailed") }), el("div", { class: "small", text: state.error || "" }), retry);
854
- }
855
- async function startSignIn(manual) {
856
- clearInterval(signInPoll);
857
- try {
858
- const state = await api("/api/claude-oauth", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ manual: Boolean(manual) }) });
859
- renderSignIn(state);
860
- signInPoll = setInterval(async () => {
861
- try { const now = await api("/api/claude-oauth"); if (!now.running) finishSignIn(now); }
862
- catch { /* the router may be busy; keep polling */ }
863
- }, 2000);
864
- } catch (error) { result.replaceChildren(el("span", { class: "bad-text", text: t("common.actionFailed") }), el("div", { class: "small", text: error.message })); }
865
- }
866
- subscriptionActions.children[0].addEventListener("click", () => void startSignIn(false));
867
- subscriptionActions.children[1].addEventListener("click", async () => {
868
- try { const response = await api("/api/claude-logout", { method: "POST" }); result.replaceChildren(el("span", { class: "ok-text", text: response.output })); await runProbe(); }
869
- catch (error) { toast(t("common.actionFailed"), true, error.message); }
870
- });
1385
+ const modeHint = hint("");
1386
+ function syncModeHint() { modeHint.textContent = auth.value === "claude-code" && accountPool ? t("providers.anthropicPoolRouting") : t("providers.anthropicIngressOnly"); }
1387
+ auth.addEventListener("change", syncModeHint);
1388
+ syncModeHint();
871
1389
  const form = el("div", { class: "provider-form" }, [
872
1390
  el("h1", { id: "modal-title", text: existing ? t("providers.edit") : t("providers.addTitle") }),
873
- inputRow(t("providers.name"), nameInput, t("providers.nameHelp")), authField, keyField, probeButton, sourceLine, signedInLine, result, subscriptionActions, modelArea,
874
- hint(t("providers.anthropicIngressOnly")),
1391
+ inputRow(t("providers.name"), nameInput, t("providers.nameHelp")), authField, keyField, probeButton, sourceLine, result, modelArea,
1392
+ modeHint,
875
1393
  ]);
876
1394
  const saveButton = el("button", { class: "btn", type: "button", "data-default-action": "", text: existing ? t("common.save") : t("providers.add") });
877
1395
  saveButton.addEventListener("click", async () => {
@@ -880,17 +1398,17 @@ function openAnthropicProviderForm(options) {
880
1398
  const next = clone(currentConfig);
881
1399
  const providerName = existing ? options.name : uniqueName(typedName, next.providers);
882
1400
  const checkedModels = form.querySelector(".model-picker").selected();
883
- const provider = { type: "anthropic", auth: auth.value, ...(auth.value === "api-key" && (keyInput.value || (existing && existing.apiKey)) ? { apiKey: keyInput.value || existing.apiKey } : {}), models: checkedModels };
1401
+ const provider = { type: "anthropic", auth: auth.value, ...(auth.value === "claude-code" && accountPool ? { accountPool: true } : {}), ...(auth.value === "api-key" && (keyInput.value || (existing && existing.apiKey)) ? { apiKey: keyInput.value || existing.apiKey } : {}), models: checkedModels };
884
1402
  next.providers[providerName] = provider;
885
- // No picker/direct entries: these models are served to Codex through the OpenAI ingress, not
886
- // routed from the Claude app, so a rule naming this provider would never fire.
1403
+ // accountPool makes these models native Claude routing targets. Without it this remains the
1404
+ // historical OpenAI ingress provider, and routing deliberately ignores it.
887
1405
  saveButton.disabled = true;
888
- try { await configRequest(next); currentConfig = next; slotsLoaded = false; clientsLoaded = false; providersLoaded = false; closeModal(); await loadProviders(); toast(t("common.saved")); }
1406
+ try { await configRequest(next); currentConfig = next; selectedProviderName = providerName; providerDetailTab = auth.value === "claude-code" ? "accounts" : "overview"; slotsLoaded = false; clientsLoaded = false; providersLoaded = false; closeModal(); await loadProviders(); toast(t("common.saved")); }
889
1407
  catch (error) { toast(t("common.saveFailed"), true, error.message); }
890
1408
  finally { saveButton.disabled = false; }
891
1409
  });
892
1410
  form.appendChild(el("div", { class: "actions end" }, [el("button", { class: "btn secondary", type: "button", text: t("common.cancel"), onclick: closeModal }), saveButton]));
893
- showModal(form);
1411
+ showModal(form, () => { formActive = false; });
894
1412
  if (auth.value === "claude-code") void runProbe();
895
1413
  }
896
1414
  function openProviderForm(options) {
@@ -1026,6 +1544,9 @@ function openProviderForm(options) {
1026
1544
  ...(prompt ? { identity: prompt.identity.checked } : {}),
1027
1545
  ...(prompt && prompt.append.value.trim() ? { instructionsAppend: prompt.append.value.trim() } : {}),
1028
1546
  ...(preset ? { preset: preset.id } : {}),
1547
+ // A vendor that asks for a session header keys its prompt cache on it, so carry it from the
1548
+ // preset rather than leaving the user to discover the bill.
1549
+ ...(preset && preset.sessionHeader ? { sessionHeader: preset.sessionHeader } : {}),
1029
1550
  ...(isOpenAi ? { wire: wireSelect.value, caps: { effortLevels: (preset && preset.effortLevels) || [], reasoning: preset && preset.effortLevels && preset.effortLevels.length ? "effort" : "none" } } : {}),
1030
1551
  ...(Object.keys(readHeaders()).length ? { headers: readHeaders() } : {}),
1031
1552
  };
@@ -1044,7 +1565,7 @@ function openProviderForm(options) {
1044
1565
  const response = await probeProvider(temporary, draft);
1045
1566
  probeButton.disabled = false;
1046
1567
  const noCredits = response.ok && /^no-credits:/.test(response.error || "");
1047
- const headline = noCredits ? t("providers.probeNoCredits") : response.ok ? t("providers.probeOk") : response.auth === "bad-key" ? t("providers.probeBadKey") : response.unavailable ? t("providers.apiSoon") : t("providers.probeFailed");
1568
+ const headline = noCredits ? t("providers.probeNoCredits") : response.ok ? t("providers.probeOk") : response.auth === "not-entitled" ? t("providers.probeNotEntitled") : response.auth === "bad-key" ? t("providers.probeBadKey") : response.unavailable ? t("providers.apiSoon") : t("providers.probeFailed");
1048
1569
  result.replaceChildren(...[
1049
1570
  el("span", { class: response.ok && !noCredits ? "ok-text" : noCredits ? "warn-text" : "bad-text", text: headline }),
1050
1571
  response.error ? el("div", { class: "small", text: response.error.replace(/^no-credits:\s*/, "") }) : null,
@@ -1060,6 +1581,38 @@ function openProviderForm(options) {
1060
1581
  // Editing a provider that can list models: fetch the full list right away so the saved picks are
1061
1582
  // shown among everything available, not as a two-entry list.
1062
1583
  if (existing && !isChatgpt && preset && preset.modelsUrl && existingKey) setTimeout(() => void runProbe(), 0);
1584
+ // ChatGPT has no key field and no probe button, so without this the list stayed the built-in
1585
+ // fallback and a model OpenAI shipped after this release never appeared. Ask the backend for its
1586
+ // catalogue in the background: ticks survive, new models arrive unticked, and a failure just
1587
+ // leaves the fallback list standing.
1588
+ let formActive = true;
1589
+ if (isChatgpt) {
1590
+ const chat = advancedContent._chatgpt;
1591
+ void (async () => {
1592
+ let response;
1593
+ try {
1594
+ response = await api("/api/providers/probe", {
1595
+ method: "POST",
1596
+ headers: { "content-type": "application/json" },
1597
+ body: JSON.stringify({ type: "chatgpt", auth: chat.auth.value, name: options.name }),
1598
+ });
1599
+ } catch { return; }
1600
+ if (!formActive || !Array.isArray(response.models) || !response.models.length) return;
1601
+ // Captured while the fetched list is checked, not against the fallback it is about to replace.
1602
+ const keep = new Set([...currentChecked, ...modelArea.querySelector(".model-picker").selected().map((model) => model.id)]);
1603
+ // A model the catalogue no longer lists but the config still names stays visible and ticked,
1604
+ // so a save cannot drop it silently.
1605
+ const named = new Set(response.models.map((model) => model.id));
1606
+ foundModels = [...response.models, ...foundModels.filter((saved) => !named.has(saved.id))];
1607
+ modelArea.querySelector(".model-picker").replaceWith(modelChecklist(foundModels, keep));
1608
+ modelArea.replaceChildren(
1609
+ el("span", { text: t("providers.models") }),
1610
+ hint(t("providers.modelsHelp")),
1611
+ hint(t("providers.modelsFoundLive", { n: response.models.length })),
1612
+ modelArea.querySelector(".model-picker"),
1613
+ );
1614
+ })();
1615
+ }
1063
1616
  const saveButton = el("button", { class: "btn", type: "button", "data-default-action": "", text: existing ? t("common.save") : t("providers.add") });
1064
1617
  saveButton.addEventListener("click", async () => {
1065
1618
  const typedName = nameInput.value.trim();
@@ -1071,11 +1624,17 @@ function openProviderForm(options) {
1071
1624
  if (existing && providerName !== options.name) delete next.providers[options.name];
1072
1625
  const checkedModels = form.querySelector(".model-picker").selected();
1073
1626
  provider.models = checkedModels;
1074
- next.providers[providerName] = provider;
1075
- const existingSelections = ((next.cli && next.cli.extraModels) || []).filter((entry) => entry.model !== null && entry.model !== undefined).map((entry) => {
1076
- const direct = (next.direct || []).filter((rule) => entry.model.startsWith(rule.prefix)).sort((a, b) => b.prefix.length - a.prefix.length)[0];
1077
- return { id: entry.model, name: entry.name, provider: direct && direct.provider };
1078
- }).filter((entry) => entry.provider && entry.provider !== options.name);
1627
+ // The form rebuilds the provider from its own fields, so anything it has no field for
1628
+ // (debugDump, a hand-written setting) is carried over from the saved one rather than dropped.
1629
+ // A field the form owns is left to the draft, so clearing it in the form still clears it.
1630
+ const formKeys = new Set(isChatgpt
1631
+ ? ["type", "auth", "defaultEffort", "identity", "instructionsAppend", "models"]
1632
+ : ["type", "url", "identity", "instructionsAppend", "preset", "sessionHeader", "wire", "caps", "headers", "models"]);
1633
+ const kept = existing ? Object.fromEntries(Object.entries(existing).filter(([key]) => !formKeys.has(key))) : {};
1634
+ next.providers[providerName] = { ...kept, ...provider };
1635
+ // Read after `next.providers` has been updated, so an unticked model is already undeclared here
1636
+ // and falls out on its own, and a renamed provider answers to its new name.
1637
+ const existingSelections = pickerSelectionsExcept(next, providerName);
1079
1638
  if (pickerInput.checked) {
1080
1639
  applyPickerSelections(next, [...existingSelections, ...checkedModels.map((model) => ({ ...model, provider: providerName }))]);
1081
1640
  } else {
@@ -1085,18 +1644,24 @@ function openProviderForm(options) {
1085
1644
  try {
1086
1645
  await configRequest(next);
1087
1646
  currentConfig = next;
1647
+ selectedProviderName = providerName;
1648
+ providerDetailTab = "overview";
1088
1649
  slotsLoaded = false;
1089
1650
  providersLoaded = false;
1090
1651
  closeModal();
1091
1652
  await loadProviders();
1092
1653
  toast(t("common.saved"));
1093
1654
  await offerPickerOn(pickerInput.checked && checkedModels.length > 0);
1655
+ // Detached on purpose. Measuring asks every ticked model several questions, which is far
1656
+ // longer than a save should take, and a model whose wire is still unknown works exactly as it
1657
+ // did before — it is only unmeasured. The result lands in the config when it arrives.
1658
+ void startMeasurement(providerName, next.providers[providerName]);
1094
1659
  } catch (error) {
1095
1660
  toast(t("common.saveFailed"), true, error.message);
1096
1661
  } finally { saveButton.disabled = false; }
1097
1662
  });
1098
1663
  form.appendChild(el("div", { class: "actions end" }, [el("button", { class: "btn secondary", type: "button", text: t("common.cancel"), onclick: closeModal }), saveButton]));
1099
- showModal(form);
1664
+ showModal(form, () => { if (isChatgpt) formActive = false; });
1100
1665
  }
1101
1666
 
1102
1667
  // ---- Logs ---------------------------------------------------------------------------
@@ -1121,7 +1686,7 @@ function timeOf(iso) {
1121
1686
  return `${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}:${two(at.getSeconds())}`;
1122
1687
  }
1123
1688
  function providerClass(name) { return name === "anthropic" ? "provider-anthropic" : name === "chatgpt" ? "provider-chatgpt" : "provider-default"; }
1124
- function summaryChip(label, value) { return el("div", { class: "logs-chip" }, [el("span", { class: "label", text: label }), el("span", { class: "value", text: value })]); }
1689
+ function summaryChip(label, value, sub) { return el("div", { class: "logs-chip" }, [el("span", { class: "label", text: label }), el("span", { class: "value", text: value }), sub ? el("span", { class: "sub", text: sub }) : null]); }
1125
1690
 
1126
1691
  function showLogsPanel(name) {
1127
1692
  logsPanel = name;
@@ -1144,7 +1709,9 @@ function renderSummary(summary) {
1144
1709
  $("#logs-summary").replaceChildren(
1145
1710
  summaryChip(t("logs.summary.requests"), formatNumber(total.count)),
1146
1711
  summaryChip(t("logs.summary.success"), `${formatNumber(total.ok)} / ${formatNumber(total.failed)}`),
1147
- summaryChip(t("logs.summary.input"), `${formatNumber(total.input)} · ${t("logs.cacheHit", { percent: total.cacheHitPercent || 0 })}`),
1712
+ // On its own line: with the whole input counted the number runs to eight digits, and the
1713
+ // ellipsis on one line cut the cache rate off entirely.
1714
+ summaryChip(t("logs.summary.input"), formatNumber(totalInputOf(total)), t("logs.cacheHit", { percent: total.cacheHitPercent || 0 })),
1148
1715
  summaryChip(t("logs.summary.output"), formatNumber(total.output)),
1149
1716
  summaryChip(t("logs.summary.duration"), formatSeconds(total.avgMs)),
1150
1717
  );
@@ -1160,11 +1727,17 @@ function renderProviderFilter(records) {
1160
1727
  if (!names.includes(requestProvider)) requestProvider = "";
1161
1728
  if (before && before !== select.value) select.value = requestProvider;
1162
1729
  }
1730
+ /** Everything the model read: uncached, read from the cache, and written to it. */
1731
+ function totalInputOf(usage) {
1732
+ return (usage.input || 0) + (usage.cached || 0) + (usage.cacheWrite || 0);
1733
+ }
1163
1734
  function requestDetail(record) {
1164
1735
  const details = [
1165
1736
  [t("logs.detail.id"), record.id],
1166
1737
  [t("logs.detail.kind"), record.kind],
1167
1738
  [t("logs.detail.stop"), record.stopReason || t("logs.none")],
1739
+ [t("logs.detail.uncached"), record.usage ? formatNumber(record.usage.input) : t("logs.none")],
1740
+ [t("logs.detail.cacheRead"), record.usage && record.usage.cached ? formatNumber(record.usage.cached) : t("logs.none")],
1168
1741
  [t("logs.detail.cacheWrite"), record.usage && record.usage.cacheWrite ? formatNumber(record.usage.cacheWrite) : t("logs.none")],
1169
1742
  ].map(([label, value]) => el("div", {}, [el("span", { class: "detail-label", text: label }), el("span", { class: "detail-value", text: value })]));
1170
1743
  if (record.note) details.push(el("div", { class: "request-note" }, [el("span", { class: "detail-label", text: t("logs.detail.note") }), el("span", { class: "detail-value", text: record.note })]));
@@ -1172,13 +1745,20 @@ function requestDetail(record) {
1172
1745
  }
1173
1746
  function requestRow(record) {
1174
1747
  const row = el("tr", { class: "request-row", title: record.note || "", onclick: () => { expandedRequestId = expandedRequestId === record.id ? null : record.id; renderRequests(requestRows); } });
1748
+ // The requested name's `@effort` is the agent file's default, not what was sent: a marker can
1749
+ // override it, and `gpt-6-sol@medium` beside an effort of high read as a contradiction
1750
+ // (2026-09-23). The effort column is the sent value, so the source is shown without it.
1751
+ const source = record.source ? record.source.replace(/@[^@]*$/, "") : "";
1175
1752
  const model = el("div", { class: "request-models" }, [
1176
1753
  el("span", { class: "model", text: record.target }),
1177
- record.source && record.source !== record.target ? el("span", { class: "small", text: `(${record.source})` }) : null,
1754
+ source && source !== record.target ? el("span", { class: "small", text: `(${source})` }) : null,
1178
1755
  el("span", { class: `provider-badge ${providerClass(record.provider)}`, text: record.provider }),
1179
1756
  ].filter(Boolean));
1757
+ // The whole input, not the uncached remainder: a fully cached Anthropic turn reports `input: 2`,
1758
+ // which read as a two-token request (2026-09-23). The split is in the detail row.
1759
+ const totalInput = record.usage ? totalInputOf(record.usage) : 0;
1180
1760
  const input = record.usage
1181
- ? el("span", { class: "token-cell", text: formatNumber(record.usage.input) }, [el("span", { class: "cache-pill", text: t("logs.cacheHit", { percent: Math.round(record.usage.cached / Math.max(1, record.usage.input + record.usage.cached) * 100) }) })])
1761
+ ? el("span", { class: "token-cell", text: formatNumber(totalInput) }, [el("span", { class: "cache-pill", text: t("logs.cacheHit", { percent: Math.round(record.usage.cached / Math.max(1, totalInput) * 100) }) })])
1182
1762
  : el("span", { class: "no-usage", text: t("common.notAvailable") });
1183
1763
  const status = el("span", { class: `status-text ${record.ok ? "ok" : "bad"}`, text: `${record.ok ? t("logs.status.ok") : t("logs.status.error")} ${record.status}` });
1184
1764
  row.append(