clauderipple 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.ko.md +43 -2
  3. package/README.md +60 -4
  4. package/dist/app/package.json +1 -1
  5. package/dist/cli/src/claude-auth.js +35 -1
  6. package/dist/cli/src/codex.js +23 -0
  7. package/dist/cli/src/hooks/agent-title.js +58 -29
  8. package/dist/cli/src/index.js +89 -1
  9. package/dist/cli/src/runtime.js +3 -0
  10. package/dist/cli/src/settings.js +49 -0
  11. package/dist/mod/.claude-plugin/plugin.json +1 -0
  12. package/dist/mod/hooks/hooks.json +1 -0
  13. package/dist/mod/hooks/register.tsx +309 -0
  14. package/dist/mod/hooks/worker.ts +49 -0
  15. package/dist/mod/tsconfig.json +3 -0
  16. package/dist/mod/types/index.d.ts +30 -0
  17. package/dist/router/src/admin.js +497 -34
  18. package/dist/router/src/agents.js +50 -9
  19. package/dist/router/src/catalog.js +80 -0
  20. package/dist/router/src/compat.js +27 -1
  21. package/dist/router/src/config.js +33 -0
  22. package/dist/router/src/index.js +13 -1
  23. package/dist/router/src/ingress/server.js +3 -2
  24. package/dist/router/src/picker.js +33 -2
  25. package/dist/router/src/presets.js +2 -1
  26. package/dist/router/src/providers/chatgpt/index.js +154 -15
  27. package/dist/router/src/providers/chatgpt/translate.js +34 -6
  28. package/dist/router/src/providers/google/accounts.js +345 -0
  29. package/dist/router/src/providers/google/antigravity.js +392 -0
  30. package/dist/router/src/providers/google/index.js +617 -0
  31. package/dist/router/src/providers/google/login.js +249 -0
  32. package/dist/router/src/providers/google/thoughtSignatures.js +47 -0
  33. package/dist/router/src/providers/google/translate.js +539 -0
  34. package/dist/router/src/providers/google/transport.js +118 -0
  35. package/dist/router/src/providers/openai/translate.js +3 -3
  36. package/dist/router/src/proxy.js +99 -1
  37. package/dist/router/src/requestlog.js +2 -0
  38. package/dist/router/src/routing.js +22 -0
  39. package/dist/router/src/version.js +1 -1
  40. package/dist/ui/app.js +436 -44
  41. package/dist/ui/i18n.js +88 -4
  42. package/dist/ui/index.html +10 -0
  43. package/dist/ui/style.css +4 -0
  44. package/docs/ARCHITECTURE.md +300 -3
  45. package/package.json +1 -1
package/dist/ui/app.js CHANGED
@@ -56,6 +56,15 @@ async function configRequest(next) {
56
56
  return out;
57
57
  }
58
58
 
59
+ // A save sends the whole config, so it starts from what the router holds now rather than the copy
60
+ // this screen loaded: the router writes config.json too (measured wires and effort ladders, hand
61
+ // edits), and a save built on an old copy put all of that back. Seen 2026-10-02: adding a provider
62
+ // restored three OpenCode ladders realigned five hours earlier, so the app's effort menus never narrowed.
63
+ async function latestConfig() {
64
+ try { currentConfig = await api("/api/config"); } catch { /* the save that follows reports an unreachable router */ }
65
+ return currentConfig;
66
+ }
67
+
59
68
  function modelsOf(provider) {
60
69
  const value = (provider && provider.models) || [];
61
70
  return value.map((model) => typeof model === "string" ? { id: model, name: model } : model).filter((model) => model && model.id);
@@ -201,7 +210,7 @@ async function refreshHealth() {
201
210
  if (clientsLoaded) renderClients();
202
211
  const details = $("#health-details");
203
212
  details.replaceChildren(
204
- el("div", { class: "row" }, [el("span", { class: "k", text: t("health.version") }), el("span", { class: "v", text: status.version })]),
213
+ versionRow(),
205
214
  // Which files answer, and since when: the only way to see that an update actually replaced the router.
206
215
  ...(status.runtime ? [el("div", { class: "row" }, [el("span", { class: "k", text: t("health.runtime") }), el("span", { class: "v", text: `${status.runtime.router || "?"} · ${new Date(status.runtime.startedAt).toLocaleString()}` })])] : []),
207
216
  el("div", { class: "row" }, [el("span", { class: "k", text: t("health.routes") }), el("span", { class: "v", text: String(status.routes) })]),
@@ -209,6 +218,57 @@ async function refreshHealth() {
209
218
  );
210
219
  }
211
220
 
221
+ // The update check runs on a click, not with the 5-second refresh: it asks the npm registry. What an
222
+ // update means depends on how this copy was installed, so the router's CLI decides and runs it; the
223
+ // router restarts on the new version and the served-files check above reloads this page.
224
+ let updateState = { busy: false, check: null };
225
+ function versionRow() {
226
+ const check = updateState.check;
227
+ const children = [el("span", { class: "k", text: t("health.version") }), el("span", { class: "v", text: status.version })];
228
+ if (check && check.newer && check.kind === "checkout") {
229
+ children.push(el("span", { class: "small", text: t("update.checkout", { latest: check.latest }) }));
230
+ } else {
231
+ const action = check && check.newer;
232
+ const label = updateState.busy ? t("update.checking")
233
+ : check && check.running ? t("update.running")
234
+ : action ? (check.kind === "packaged" ? t("update.download", { latest: check.latest }) : t("update.install", { latest: check.latest }))
235
+ : t("update.check");
236
+ const button = el("button", { class: "btn secondary compact", type: "button", text: label });
237
+ button.disabled = updateState.busy || Boolean(check && check.running);
238
+ button.addEventListener("click", () => void (action ? startUpdate() : checkForUpdate()));
239
+ children.push(button);
240
+ if (check && !check.newer && !updateState.busy) children.push(el("span", { class: "small", text: t("update.latest") }));
241
+ }
242
+ return el("div", { class: "row", id: "health-version-row" }, children);
243
+ }
244
+ function renderVersionRow() {
245
+ const row = $("#health-version-row");
246
+ if (row) row.replaceWith(versionRow());
247
+ }
248
+ async function checkForUpdate() {
249
+ updateState = { busy: true, check: updateState.check };
250
+ renderVersionRow();
251
+ try { updateState = { busy: false, check: await api("/api/update") }; }
252
+ catch (error) { updateState = { busy: false, check: null }; toast(t("update.checkFailed"), true, error.message); }
253
+ renderVersionRow();
254
+ }
255
+ async function startUpdate() {
256
+ const check = updateState.check;
257
+ if (check.kind === "packaged") { window.open("https://github.com/PBJ-2/clauderipple/releases/latest", "_blank", "noreferrer"); return; }
258
+ if (!confirm(t("update.confirm", { latest: check.latest, current: check.current }))) return;
259
+ updateState = { busy: true, check };
260
+ renderVersionRow();
261
+ try {
262
+ await api("/api/update", { method: "POST" });
263
+ updateState = { busy: false, check: { ...check, running: true } };
264
+ toast(t("update.started"));
265
+ } catch (error) {
266
+ updateState = { busy: false, check };
267
+ toast(t("update.failed"), true, error.message);
268
+ }
269
+ renderVersionRow();
270
+ }
271
+
212
272
  function quotaLine(name) {
213
273
  const quota = status && status.chatgpt && status.chatgpt.quota && status.chatgpt.quota[name];
214
274
  const primary = quota && quota.rate_limits && quota.rate_limits.primary;
@@ -299,24 +359,38 @@ function credentialLine(name) {
299
359
  return el("div", { class: `small ${resting ? "bad-text" : ""}`.trim(), text: parts.join(" · ") });
300
360
  }
301
361
 
302
- function providerState(name, provider) {
362
+ /**
363
+ * One verdict on a provider's connection, which the badge and the detail heading both show. They
364
+ * used to decide separately: the heading kept a failure from a restart long after the badge had
365
+ * healed, and both read a check still running as "disconnected" (Gemini, 2026-10-02).
366
+ */
367
+ function providerConnection(name) {
303
368
  const live = status && status.providers && status.providers[name];
304
369
  // Reaching the host is not the same as being able to use it. A ChatGPT provider with no
305
370
  // credentials would otherwise read "Connected" and send the user off believing it works.
306
- if (live && live.needsLogin) return badge("warn", t("providerStatus.loginNeeded"));
371
+ if (live && live.needsLogin) return { level: "warn", text: t("providerStatus.loginNeeded") };
372
+ if (live && live.needsVerification) return { level: "warn", text: t("providers.googleVerifyNeeded") };
307
373
  const state = stateFor(name);
374
+ if (state && state.pending) return { level: "warn", text: t("providerStatus.checking") };
308
375
  // A probe is the better evidence — it actually called the provider — but only while it is fresh.
309
376
  // One caught mid-restart used to sit there in red for the rest of the session, while the poll
310
377
  // every five seconds said the provider was fine and a fresh probe agreed. After PROBE_TTL_MS the
311
378
  // live reading takes over, so a stale failure heals itself instead of needing a manual re-check.
312
379
  if (state && !probeIsStale(state)) {
313
- return state.ok ? badge("ok", t("providerStatus.connected"))
314
- : state.auth === "bad-key" ? badge("bad", t("providerStatus.keyNeeded"))
315
- : badge("bad", t("providerStatus.disconnected"));
380
+ if (state.ok) return { level: "ok", text: t("providerStatus.connected") };
381
+ // The key was accepted and the plan refused: saying "check your key" sends the operator to the
382
+ // one thing that is not wrong. Measured 2026-09-22 against OpenCode's free-tier 403.
383
+ if (state.auth === "not-entitled") return { level: "bad", text: t("providerStatus.notEntitled") };
384
+ if (state.auth === "bad-key") return { level: "bad", text: t("providerStatus.keyNeeded") };
385
+ return { level: "bad", text: t("providerStatus.disconnected") };
316
386
  }
317
- if (live) return live.reachable ? badge("ok", t("providerStatus.connected")) : badge("bad", t("providerStatus.disconnected"));
318
- if (state) return badge("bad", t("providerStatus.disconnected"));
319
- return badge("warn", t("providerStatus.checking"));
387
+ if (live) return live.reachable ? { level: "ok", text: t("providerStatus.connected") } : { level: "bad", text: t("providerStatus.disconnected") };
388
+ if (state) return { level: "bad", text: t("providerStatus.disconnected") };
389
+ return { level: "warn", text: t("providerStatus.checking") };
390
+ }
391
+ function providerState(name) {
392
+ const verdict = providerConnection(name);
393
+ return badge(verdict.level, verdict.text);
320
394
  }
321
395
  function renderHealthProviders() {
322
396
  const box = $("#health-providers");
@@ -330,8 +404,8 @@ function renderHealthProviders() {
330
404
  const line = el("div", { class: "provider-status" }, [
331
405
  el("strong", { text: name }),
332
406
  providerState(name, provider),
333
- live && live.needsLogin ? chatgptLoginButton(() => renderHealthProviders()) : null,
334
- live && live.needsLogin && chatgptLoginMessage ? el("span", { class: "small", text: chatgptLoginMessage }) : null,
407
+ live && live.needsLogin && provider.type === "chatgpt" ? chatgptLoginButton(() => renderHealthProviders()) : null,
408
+ live && live.needsLogin && provider.type === "chatgpt" && chatgptLoginMessage ? el("span", { class: "small", text: chatgptLoginMessage }) : null,
335
409
  ].filter(Boolean));
336
410
  const quota = quotaLine(name);
337
411
  if (quota) line.appendChild(el("div", { class: "small", text: quota }));
@@ -352,6 +426,7 @@ function renderHealthProviders() {
352
426
  }
353
427
 
354
428
  let agentTitleBusy = false;
429
+ let statusModBusy = false;
355
430
  let codexBusy = false;
356
431
  function renderClients() {
357
432
  if (!currentConfig || !status) return;
@@ -377,6 +452,19 @@ function renderClients() {
377
452
  agentButton.className = agentEnabled ? "btn secondary" : "btn";
378
453
  agentButton.disabled = agentTitleBusy;
379
454
  agentButton.onclick = () => toggleAgentTitle(!agentEnabled);
455
+ const modEnabled = Boolean(status.statusMod);
456
+ $("#client-status-mod-rows").replaceChildren(el("div", { class: "row" }, [el("span", { class: "k", text: t("picker.state") }), modEnabled ? badge("ok", t("agentTitle.on")) : el("span", { class: "small", text: t("agentTitle.off") })]));
457
+ const modButton = $("#client-status-mod-toggle");
458
+ modButton.textContent = modEnabled ? t("statusMod.turnOff") : t("statusMod.turnOn");
459
+ modButton.className = modEnabled ? "btn secondary" : "btn";
460
+ modButton.disabled = statusModBusy;
461
+ modButton.onclick = () => toggleStatusMod(!modEnabled);
462
+ const toolsLimited = Boolean(currentConfig.cli && currentConfig.cli.limitWorkerTools);
463
+ $("#client-worker-tools-rows").replaceChildren(el("div", { class: "row" }, [el("span", { class: "k", text: t("picker.state") }), toolsLimited ? badge("ok", t("workerTools.on")) : el("span", { class: "small", text: t("workerTools.off") })]));
464
+ const toolsButton = $("#client-worker-tools-toggle");
465
+ toolsButton.textContent = toolsLimited ? t("workerTools.turnOff") : t("workerTools.turnOn");
466
+ toolsButton.className = toolsLimited ? "btn secondary" : "btn";
467
+ toolsButton.onclick = () => toggleWorkerTools(!toolsLimited);
380
468
  void renderCodexClient();
381
469
  const mapped = Object.entries(currentConfig.routes || {}).map(([source, route]) => `${labelOf(claudeModels.find((model) => model.id === source) || { id: source })} → ${labelOf((groupedModels(currentConfig).find((group) => group.name === route.provider) || { models: [] }).models.find((model) => model.id === route.model) || { id: route.model })}`);
382
470
  $("#client-claude-code-rows").replaceChildren(el("div", { class: "small", text: mapped.join(" · ") || t("slots.noChanges") }));
@@ -464,7 +552,7 @@ function renderModelSlots() {
464
552
 
465
553
  async function saveModelSlots() {
466
554
  if (!currentConfig) return;
467
- const next = clone(currentConfig);
555
+ const next = clone(await latestConfig());
468
556
  // Every slot is recorded, empty ones included. A select put back to "default (Claude)" has to
469
557
  // remove the env key, and the router tells "clear this" from "leave this alone" by whether the
470
558
  // slot is present at all — so dropping empty values here would make the choice a silent no-op.
@@ -493,7 +581,7 @@ function clientPickerSelections() {
493
581
  }
494
582
  async function saveClientPickerModels() {
495
583
  if (!currentConfig) return;
496
- const next = applyPickerSelections(clone(currentConfig), clientPickerSelections());
584
+ const next = applyPickerSelections(clone(await latestConfig()), clientPickerSelections());
497
585
  try { await configRequest(next); currentConfig = next; toast(t("slots.saved")); } catch (error) { toast(t("common.saveFailed"), true, error.message); }
498
586
  }
499
587
  function pickerModeOn() {
@@ -537,6 +625,30 @@ async function toggleAgentTitle(enabled) {
537
625
  void refreshHealth();
538
626
  }
539
627
  }
628
+ async function toggleStatusMod(enabled) {
629
+ if (statusModBusy) return;
630
+ statusModBusy = true;
631
+ try {
632
+ await api("/api/status-mod", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled }) });
633
+ toast(t("statusMod.saved"));
634
+ } catch (error) {
635
+ toast(t("common.actionFailed"), true, error.message);
636
+ } finally {
637
+ statusModBusy = false;
638
+ void refreshHealth();
639
+ }
640
+ }
641
+ async function toggleWorkerTools(enabled) {
642
+ if (!currentConfig) return;
643
+ const next = clone(await latestConfig());
644
+ next.cli = { ...(next.cli || {}), limitWorkerTools: enabled };
645
+ try {
646
+ await configRequest(next);
647
+ currentConfig = next;
648
+ toast(t("common.saved"));
649
+ } catch (error) { toast(t("common.saveFailed"), true, error.message); }
650
+ renderClients();
651
+ }
540
652
  async function renderCodexClient() {
541
653
  const rows = $("#client-codex-rows");
542
654
  const button = $("#client-codex-toggle");
@@ -739,7 +851,7 @@ function scheduleSlotsSave() {
739
851
  async function saveSlots() {
740
852
  if (!currentConfig || slotsSaving) return;
741
853
  slotsSaving = true;
742
- const next = clone(currentConfig);
854
+ const next = clone(await latestConfig());
743
855
  const routes = {};
744
856
  const seen = new Set();
745
857
  for (const row of $all("#slots-table tbody tr")) {
@@ -763,15 +875,6 @@ async function saveSlots() {
763
875
 
764
876
  // ---- Providers ----------------------------------------------------------------------
765
877
 
766
- function statusText(state) {
767
- if (!state) return t("providerStatus.checking");
768
- if (state.ok) return t("providerStatus.connected");
769
- // The key was accepted and the plan refused: saying "check your key" sends the operator to the
770
- // one thing that is not wrong. Measured 2026-09-22 against OpenCode's free-tier 403.
771
- if (state.auth === "not-entitled") return t("providerStatus.notEntitled");
772
- if (state.auth === "bad-key") return t("providerStatus.keyNeeded");
773
- return t("providerStatus.disconnected");
774
- }
775
878
  async function probeProvider(name, provider, onComplete) {
776
879
  probeStates.set(name, { pending: true });
777
880
  renderHealthProviders();
@@ -779,6 +882,8 @@ async function probeProvider(name, provider, onComplete) {
779
882
  const headers = provider.headers || {};
780
883
  const body = provider.type === "anthropic"
781
884
  ? { type: "anthropic", auth: provider.auth, ...(provider.auth === "api-key" && provider.apiKey ? { apiKey: provider.apiKey } : {}) }
885
+ : provider.type === "google"
886
+ ? { type: "google", auth: provider.auth, ...(provider.apiKey ? { apiKey: provider.apiKey } : {}), ...(provider.url ? { url: provider.url } : {}) }
782
887
  : {
783
888
  type: provider.type,
784
889
  url: provider.url,
@@ -788,7 +893,8 @@ async function probeProvider(name, provider, onComplete) {
788
893
  // The router reads the preset's fallback list to tag each discovered model with the wire it
789
894
  // speaks, since /models reports ids alone and one plan can serve several wires.
790
895
  preset: provider.preset,
791
- probeModel: provider.probeModel || (provider.preset && presetById(provider.preset) && (presetById(provider.preset).fallbackModels || [])[0] && presetById(provider.preset).fallbackModels[0].id),
896
+ // Without a preset the provider's own first model is the only id known to exist there.
897
+ probeModel: provider.probeModel || (provider.preset && presetById(provider.preset) && (presetById(provider.preset).fallbackModels || [])[0] && presetById(provider.preset).fallbackModels[0].id) || (modelsOf(provider)[0] && modelsOf(provider)[0].id),
792
898
  // Some vendors refuse a request without it rather than merely losing the cache, so a test
793
899
  // that leaves it out reports a broken provider that works perfectly.
794
900
  sessionHeader: provider.sessionHeader || (provider.preset && presetById(provider.preset) && presetById(provider.preset).sessionHeader),
@@ -887,6 +993,7 @@ let providerDetailGeneration = 0;
887
993
  function providerKind(name, provider) {
888
994
  const preset = provider.preset && presetById(provider.preset);
889
995
  if (provider.type === "chatgpt") return t("providers.chatgpt");
996
+ if (provider.type === "google") return `${t("providers.google")} · ${provider.auth === "antigravity" ? t("providers.googleAntigravity") : t("providers.apiKey")}`;
890
997
  if (provider.type === "anthropic") return provider.auth === "claude-code"
891
998
  ? `${t("providers.anthropic")} · ${provider.accountPool ? t("providers.rotationOn") : t("providers.rotationOff")}`
892
999
  : `${t("providers.anthropic")} · ${t("providers.apiKey")}`;
@@ -928,7 +1035,7 @@ function renderProviderRail() {
928
1035
 
929
1036
  async function removeProvider(name, provider) {
930
1037
  if (!confirm(t("providers.removeConfirm", { name }))) return;
931
- const next = clone(currentConfig);
1038
+ const next = clone(await latestConfig());
932
1039
  delete next.providers[name];
933
1040
  next.routes = Object.fromEntries(Object.entries(next.routes || {}).filter(([, route]) => route.provider !== name));
934
1041
  next.direct = (next.direct || []).filter((rule) => rule.provider !== name);
@@ -947,7 +1054,7 @@ async function removeProvider(name, provider) {
947
1054
 
948
1055
  function providerTabs(name, provider) {
949
1056
  const tabs = [{ id: "overview", label: t("providers.overview") }];
950
- if ((provider.type === "anthropic" && provider.auth === "claude-code") || provider.type === "chatgpt") tabs.push({ id: "accounts", label: t("providers.accounts") });
1057
+ if ((provider.type === "anthropic" && provider.auth === "claude-code") || provider.type === "chatgpt" || (provider.type === "google" && provider.auth === "antigravity")) tabs.push({ id: "accounts", label: t("providers.accounts") });
951
1058
  tabs.push({ id: "models", label: t("providers.modelsTab") });
952
1059
  return el("div", { class: "provider-tabs", role: "tablist" }, tabs.map((tab) => {
953
1060
  const button = el("button", { class: providerDetailTab === tab.id ? "active" : "", type: "button", role: "tab", "aria-selected": String(providerDetailTab === tab.id), text: tab.label });
@@ -962,11 +1069,11 @@ function providerOverview(name, provider) {
962
1069
  const connection = el("section", { class: "detail-section" }, [
963
1070
  el("h3", { text: t("providers.connection") }),
964
1071
  el("div", { class: "detail-setting-row" }, [
965
- el("div", { class: "setting-copy" }, [el("strong", { text: statusText(state) }), el("span", { text: providerKind(name, provider) })]),
1072
+ el("div", { class: "setting-copy" }, [el("strong", { text: providerConnection(name).text }), el("span", { text: providerKind(name, provider) })]),
966
1073
  providerState(name, provider),
967
1074
  ]),
968
1075
  state && !state.ok && state.error ? el("p", { class: "bad-text small provider-detail-error", text: shortError(state.error), title: state.error }) : null,
969
- live && live.needsLogin ? chatgptLoginButton(renderProviderDetail) : null,
1076
+ live && live.needsLogin && provider.type === "chatgpt" ? chatgptLoginButton(renderProviderDetail) : null,
970
1077
  ].filter(Boolean));
971
1078
  const models = modelsOf(provider);
972
1079
  const modelSummary = el("section", { class: "detail-section" }, [
@@ -995,7 +1102,7 @@ function providerModelsPanel(name, provider) {
995
1102
  async function saveAnthropicRotation(name, provider, enabled, control, message) {
996
1103
  control.disabled = true;
997
1104
  message.textContent = t("providers.rotationSaving");
998
- const next = clone(currentConfig);
1105
+ const next = clone(await latestConfig());
999
1106
  if (enabled) next.providers[name].accountPool = true;
1000
1107
  else delete next.providers[name].accountPool;
1001
1108
  try {
@@ -1128,6 +1235,7 @@ function durationText(seconds) {
1128
1235
  function chatgptStateBadge(account) {
1129
1236
  if (account.state === "paused") return el("span", { class: "badge", text: t("providers.chatgptPaused") });
1130
1237
  if (account.state === "needs-login" || account.state === "quarantined") return el("span", { class: "badge bad", text: t("providers.anthropicReauth") });
1238
+ if (account.state === "needs-verification") return el("span", { class: "badge warn", text: t("providers.googleVerifyNeeded") });
1131
1239
  if (account.state === "cooling") return el("span", { class: "badge warn", text: t("pool.coolingFor", { duration: durationText(account.cooldownSeconds) }) });
1132
1240
  return el("span", { class: "badge ok", text: account.active ? t("providers.chatgptInUse") : t("providers.chatgptStandby") });
1133
1241
  }
@@ -1191,6 +1299,133 @@ function renderChatgptAccountRows(target, data, name, generation) {
1191
1299
  if (countNode) countNode.textContent = t("providers.chatgptAccountCount", { count: accounts.length });
1192
1300
  }
1193
1301
 
1302
+ let googleLoginBusy = false;
1303
+ /**
1304
+ * The Antigravity sign-in. Google's terms name this use as a breach and suspended accounts for it,
1305
+ * so the warning is put in front of the user and the sign-in starts only on an explicit yes.
1306
+ */
1307
+ async function startGoogleLogin(onChange) {
1308
+ if (googleLoginBusy) return false;
1309
+ if (!confirm(t("providers.googleWarning"))) return false;
1310
+ googleLoginBusy = true;
1311
+ onChange && onChange();
1312
+ try {
1313
+ const started = await api("/api/google-login", { method: "POST" });
1314
+ if (started.opened === false && started.url) toast(t("providers.googleOpenManually"), false, started.url);
1315
+ const deadline = Date.now() + 6 * 60 * 1000;
1316
+ while (Date.now() < deadline) {
1317
+ await new Promise((resolve) => setTimeout(resolve, 2000));
1318
+ const login = await api("/api/google-login");
1319
+ if (login.running === false && login.ok === true) { toast(t("providers.googleAdded")); await refreshHealth(); return true; }
1320
+ if (login.running === false && login.ok === false) { toast(t("common.actionFailed"), true, login.error); return false; }
1321
+ }
1322
+ return false;
1323
+ } catch (error) {
1324
+ toast(t("common.actionFailed"), true, error.message);
1325
+ return false;
1326
+ } finally {
1327
+ googleLoginBusy = false;
1328
+ onChange && onChange();
1329
+ }
1330
+ }
1331
+
1332
+ async function patchGoogleAccount(name, id, change) {
1333
+ try {
1334
+ await api(`/api/google-accounts/${encodeURIComponent(id)}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(change) });
1335
+ await refreshGoogleAccountPanel(name);
1336
+ } catch (error) { toast(t("common.actionFailed"), true, error.message); }
1337
+ }
1338
+ async function recheckGoogleVerification(name, id) {
1339
+ try {
1340
+ const result = await api(`/api/google-accounts/${encodeURIComponent(id)}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ recheck: true }) });
1341
+ if (result.verification === "verified") toast(t("providers.googleVerified"));
1342
+ else if (result.verification === "still-required") toast(t("providers.googleStillUnverified"), true);
1343
+ else toast(t("providers.googleVerifyUnknown"), true);
1344
+ await refreshGoogleAccountPanel(name);
1345
+ } catch (error) { toast(t("common.actionFailed"), true, error.message); }
1346
+ }
1347
+
1348
+ function renderGoogleAccountRows(target, data, name, generation) {
1349
+ if (generation !== providerDetailGeneration) return;
1350
+ const accounts = Array.isArray(data.accounts) ? data.accounts : [];
1351
+ const rows = accounts.map((account) => {
1352
+ const actions = [];
1353
+ if (account.state === "needs-login" || account.state === "quarantined") {
1354
+ const reauth = el("button", { class: "btn secondary compact", type: "button", text: t("providers.reauthAction") });
1355
+ reauth.addEventListener("click", () => void startGoogleLogin(() => void refreshGoogleAccountPanel(name)));
1356
+ actions.push(reauth);
1357
+ }
1358
+ // Only the person can finish Google's check, so the page opens in their browser; the account
1359
+ // answers again on the next turn after that.
1360
+ if (account.state === "needs-verification" && account.verifyUrl) {
1361
+ const link = el("a", { class: "btn secondary compact", href: account.verifyUrl, target: "_blank", rel: "noreferrer", text: t("providers.googleVerifyAction") });
1362
+ // Coming back to this window is the likeliest moment the page has been finished: ask then.
1363
+ link.addEventListener("click", () => window.addEventListener("focus", () => void recheckGoogleVerification(name, account.id), { once: true }));
1364
+ const recheck = el("button", { class: "btn secondary compact", type: "button", text: t("providers.googleRecheck") });
1365
+ recheck.addEventListener("click", () => { recheck.disabled = true; void recheckGoogleVerification(name, account.id); });
1366
+ actions.push(link, recheck);
1367
+ }
1368
+ if (account.state === "cooling") {
1369
+ const now = el("button", { class: "btn secondary compact", type: "button", text: t("providers.chatgptClearCooldown") });
1370
+ now.addEventListener("click", () => void patchGoogleAccount(name, account.id, { clearCooldown: true }));
1371
+ actions.push(now);
1372
+ }
1373
+ const pause = el("button", { class: "btn secondary compact", type: "button", text: account.paused ? t("providers.chatgptResume") : t("providers.chatgptPause") });
1374
+ pause.addEventListener("click", () => void patchGoogleAccount(name, account.id, { paused: !account.paused }));
1375
+ const rename = el("button", { class: "btn secondary compact", type: "button", text: t("common.edit") });
1376
+ rename.addEventListener("click", () => {
1377
+ const label = prompt(t("providers.anthropicRenamePrompt"), account.label);
1378
+ if (!label || !label.trim() || label.trim() === account.label) return;
1379
+ void patchGoogleAccount(name, account.id, { label });
1380
+ });
1381
+ const remove = el("button", { class: "btn danger compact", type: "button", text: t("common.remove") });
1382
+ remove.addEventListener("click", async () => {
1383
+ if (!confirm(t("providers.googleRemoveConfirm", { name: account.label }))) return;
1384
+ try { await api(`/api/google-accounts/${encodeURIComponent(account.id)}`, { method: "DELETE" }); await refreshGoogleAccountPanel(name); }
1385
+ catch (error) { toast(t("common.actionFailed"), true, error.message); }
1386
+ });
1387
+ actions.push(pause, rename, remove);
1388
+ const detail = [account.email && account.email !== account.label ? account.email : null, account.projectId ? t("providers.googleProject", { id: account.projectId }) : null].filter(Boolean).join(" · ");
1389
+ return el("article", { class: `account-card stacked${account.active ? " current" : ""}` }, [
1390
+ el("div", { class: "account-card-copy" }, [
1391
+ el("strong", { text: account.label }),
1392
+ detail ? el("span", { class: "small", text: detail }) : null,
1393
+ hint(account.state === "needs-login" || account.state === "quarantined" ? t("providers.chatgptReauthHelp") : account.state === "needs-verification" ? t("providers.googleVerifyHelp") : t("providers.googleOwnHelp")),
1394
+ ].filter(Boolean)),
1395
+ chatgptStateBadge(account),
1396
+ el("div", { class: "account-card-actions" }, actions),
1397
+ ].filter(Boolean));
1398
+ });
1399
+ target.replaceChildren(...(rows.length ? rows : [el("div", { class: "empty-card", text: t("providers.googleNoAccounts") })]));
1400
+ const countNode = $("#google-account-count");
1401
+ if (countNode) countNode.textContent = t("providers.chatgptAccountCount", { count: accounts.length });
1402
+ }
1403
+
1404
+ async function refreshGoogleAccountPanel(name) {
1405
+ const target = $("#google-account-rows");
1406
+ if (!target || selectedProviderName !== name || providerDetailTab !== "accounts") return;
1407
+ const generation = providerDetailGeneration;
1408
+ try { renderGoogleAccountRows(target, await api(`/api/google-accounts?provider=${encodeURIComponent(name)}`), name, generation); }
1409
+ catch (error) { if (generation === providerDetailGeneration) target.replaceChildren(el("div", { class: "bad-text small", text: error.message })); }
1410
+ }
1411
+
1412
+ function googleAccountsPanel(name) {
1413
+ const add = el("button", { class: "btn", type: "button", text: t("providers.addGoogleAccount") });
1414
+ add.disabled = googleLoginBusy;
1415
+ add.addEventListener("click", () => void startGoogleLogin(() => { renderProviderDetail(); void refreshGoogleAccountPanel(name); }));
1416
+ const rows = el("div", { id: "google-account-rows", class: "account-card-list" }, [el("div", { class: "small", text: t("providers.checking") })]);
1417
+ const panel = el("div", { class: "provider-panel" }, [
1418
+ el("section", { class: "detail-section account-summary" }, [
1419
+ el("div", { class: "section-heading" }, [el("div", {}, [el("h3", { text: t("providers.googleAccountsTitle") }), el("p", { id: "google-account-count", class: "account-count", text: t("providers.chatgptAccountCount", { count: 0 }) }), hint(t("providers.googleAccountsSubtitle"))]), add]),
1420
+ googleLoginBusy ? el("p", { class: "small", text: t("providers.googleLoginWaiting") }) : null,
1421
+ el("p", { class: "small warn-text", text: t("providers.googleWarningShort") }),
1422
+ ].filter(Boolean)),
1423
+ el("section", { class: "detail-section" }, [rows]),
1424
+ ]);
1425
+ queueMicrotask(() => void refreshGoogleAccountPanel(name));
1426
+ return panel;
1427
+ }
1428
+
1194
1429
  async function refreshChatgptAccountPanel(name) {
1195
1430
  const target = $("#chatgpt-account-rows");
1196
1431
  if (!target || selectedProviderName !== name || providerDetailTab !== "accounts") return;
@@ -1236,6 +1471,7 @@ function renderProviderDetail() {
1236
1471
  let content;
1237
1472
  if (providerDetailTab === "accounts" && provider.type === "anthropic" && provider.auth === "claude-code") content = anthropicAccountsPanel(name, provider);
1238
1473
  else if (providerDetailTab === "accounts" && provider.type === "chatgpt") content = chatgptAccountsPanel(name);
1474
+ else if (providerDetailTab === "accounts" && provider.type === "google" && provider.auth === "antigravity") content = googleAccountsPanel(name);
1239
1475
  else if (providerDetailTab === "models") content = providerModelsPanel(name, provider);
1240
1476
  else { providerDetailTab = "overview"; content = providerOverview(name, provider); }
1241
1477
  detail.replaceChildren(header, providerTabs(name, provider), content);
@@ -1252,7 +1488,7 @@ async function loadClients() {
1252
1488
  clientsLoaded = true;
1253
1489
  try {
1254
1490
  await loadCatalogs();
1255
- currentConfig = currentConfig || await api("/api/config");
1491
+ currentConfig = await api("/api/config");
1256
1492
  if (!status) status = await api("/api/status");
1257
1493
  renderClients();
1258
1494
  } catch (error) { toast(t("common.loadFailed"), true, error.message); }
@@ -1262,7 +1498,7 @@ async function loadProviders() {
1262
1498
  providersLoaded = true;
1263
1499
  try {
1264
1500
  await loadCatalogs();
1265
- currentConfig = currentConfig || await api("/api/config");
1501
+ currentConfig = await api("/api/config");
1266
1502
  renderProviderWorkspace();
1267
1503
  void Promise.all(Object.entries(currentConfig.providers || {}).map(([name, provider]) => probeProvider(name, provider, () => {
1268
1504
  if ($("#view-providers").classList.contains("active")) renderProviderWorkspace();
@@ -1392,6 +1628,9 @@ function openProviderChooser() {
1392
1628
  const chatgpt = el("button", { class: "chooser-tile", type: "button" }, [el("strong", { text: t("providers.chatgpt") }), el("span", { text: t("providers.chatgptHelp") })]);
1393
1629
  chatgpt.addEventListener("click", () => openProviderForm({ kind: "chatgpt" }));
1394
1630
  grid.appendChild(chatgpt);
1631
+ const google = el("button", { class: "chooser-tile", type: "button" }, [el("strong", { text: t("providers.google") }), el("span", { text: t("providers.googleHelp") })]);
1632
+ google.addEventListener("click", () => openProviderForm({ kind: "google" }));
1633
+ grid.appendChild(google);
1395
1634
  const native = presets.filter((preset) => (preset.kind || "anthropic-compatible") === "anthropic-compatible");
1396
1635
  for (const preset of native) {
1397
1636
  const tile = el("button", { class: "chooser-tile", type: "button" }, [el("strong", { text: preset.name }), el("span", { text: t("providers.presetHelp") })]);
@@ -1429,7 +1668,14 @@ function modelOverrideFields(model) {
1429
1668
  ...(model.authHeader ? { authHeader: model.authHeader } : {}),
1430
1669
  };
1431
1670
  }
1432
- function modelChecklist(models, checked) {
1671
+ // A provider form holds the per-model fields from when it opened, and has no control for them; a
1672
+ // measurement may have landed since. So a model the router already has keeps the router's values.
1673
+ function withSavedModelFields(models, saved) {
1674
+ const known = new Map(modelsOf(saved).map((model) => [model.id, model]));
1675
+ return models.map((model) => known.has(model.id) ? { ...model, ...modelOverrideFields(known.get(model.id)) } : model);
1676
+ }
1677
+ function modelChecklist(models, checked, options = {}) {
1678
+ models = [...models];
1433
1679
  const selected = new Map();
1434
1680
  for (const model of models) if (checked.has(model.id)) selected.set(model.id, { id: model.id, name: labelOf(model), ...modelOverrideFields(model) });
1435
1681
  const wrap = el("div", { class: "model-picker" });
@@ -1463,6 +1709,26 @@ function modelChecklist(models, checked) {
1463
1709
  }
1464
1710
  if (search) { search.addEventListener("input", render); wrap.appendChild(search); }
1465
1711
  wrap.append(grid, note);
1712
+ // A provider without a model list (or one that leaves a model off it) is otherwise unusable: the
1713
+ // only ids on offer are the ones fetched, and abliteration.ai lists none (issue #42).
1714
+ if (options.allowCustom) {
1715
+ const idInput = el("input", { placeholder: t("providers.customModelPlaceholder"), autocomplete: "off", spellcheck: "false" });
1716
+ const addButton = el("button", { class: "btn secondary", type: "button", text: t("providers.customModelAdd") });
1717
+ function addTyped() {
1718
+ const id = idInput.value.trim();
1719
+ if (!id) return;
1720
+ if (!models.some((model) => model.id === id)) models.unshift({ id, name: id });
1721
+ const model = models.find((entry) => entry.id === id);
1722
+ selected.set(id, { id, name: labelOf(model), ...modelOverrideFields(model) });
1723
+ idInput.value = "";
1724
+ if (search) search.value = "";
1725
+ render();
1726
+ }
1727
+ addButton.addEventListener("click", addTyped);
1728
+ // Enter here adds the id; left to bubble, the modal's own Enter handler would save the form too.
1729
+ idInput.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.isComposing) { event.preventDefault(); event.stopPropagation(); addTyped(); } });
1730
+ wrap.appendChild(el("div", { class: "key-control model-add" }, [idInput, addButton]));
1731
+ }
1466
1732
  wrap.selected = () => [...selected.values()];
1467
1733
  render();
1468
1734
  return wrap;
@@ -1487,7 +1753,7 @@ function openAnthropicProviderForm(options) {
1487
1753
  const probeButton = el("button", { class: "btn secondary", type: "button", text: t("providers.check") });
1488
1754
  const sourceLine = el("div", { class: "small" });
1489
1755
  let foundModels = modelsOf(existing).length ? modelsOf(existing) : claudeModels.map((model) => ({ id: model.id, name: labelOf(model) }));
1490
- let selected = new Set(modelsOf(existing).length ? modelsOf(existing).map((model) => model.id) : foundModels.map((model) => model.id));
1756
+ let selected = new Set(existing ? modelsOf(existing).map((model) => model.id) : foundModels.map((model) => model.id));
1491
1757
  const modelArea = el("div", { class: "form-field" });
1492
1758
  function renderModels() {
1493
1759
  const modelsBox = modelChecklist(foundModels, selected);
@@ -1519,7 +1785,7 @@ function openAnthropicProviderForm(options) {
1519
1785
  ].filter(Boolean));
1520
1786
  if (Array.isArray(response.models) && response.models.length) {
1521
1787
  foundModels = response.models;
1522
- selected = new Set(modelsOf(existing).length ? modelsOf(existing).map((model) => model.id) : foundModels.map((model) => model.id));
1788
+ selected = new Set(existing ? modelsOf(existing).map((model) => model.id) : foundModels.map((model) => model.id));
1523
1789
  renderModels();
1524
1790
  }
1525
1791
  } catch (error) { if (formActive) result.replaceChildren(el("span", { class: "bad-text", text: t("providers.probeFailed") }), el("div", { class: "small", text: error.message })); }
@@ -1539,9 +1805,9 @@ function openAnthropicProviderForm(options) {
1539
1805
  saveButton.addEventListener("click", async () => {
1540
1806
  const typedName = nameInput.value.trim();
1541
1807
  if (!typedName) { toast(t("providers.nameRequired"), true); return; }
1542
- const next = clone(currentConfig);
1808
+ const next = clone(await latestConfig());
1543
1809
  const providerName = existing ? options.name : uniqueName(typedName, next.providers);
1544
- const checkedModels = form.querySelector(".model-picker").selected();
1810
+ const checkedModels = withSavedModelFields(form.querySelector(".model-picker").selected(), existing && next.providers[options.name]);
1545
1811
  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 };
1546
1812
  next.providers[providerName] = provider;
1547
1813
  // accountPool makes these models native Claude routing targets. Without it this remains the
@@ -1555,6 +1821,115 @@ function openAnthropicProviderForm(options) {
1555
1821
  showModal(form, () => { formActive = false; });
1556
1822
  if (auth.value === "claude-code") void runProbe();
1557
1823
  }
1824
+ function openGoogleProviderForm(options) {
1825
+ let formActive = true;
1826
+ const existing = options.provider;
1827
+ const nameInput = el("input", { value: options.name || t("providers.google"), maxlength: "60" });
1828
+ const keyInput = el("input", { type: "password", autocomplete: "off", placeholder: existing && existing.apiKey ? t("providers.keySaved") : t("providers.keyPlaceholder") });
1829
+ const showKey = el("button", { class: "eye-button", type: "button", text: t("common.show") });
1830
+ showKey.addEventListener("click", () => { const show = keyInput.type === "password"; keyInput.type = show ? "text" : "password"; showKey.textContent = show ? t("common.hide") : t("common.show"); });
1831
+ 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.googleKeyHelp") })]);
1832
+ const auth = el("select", {}, [selectOption("api-key", t("providers.googleAuthApiKey")), selectOption("antigravity", t("providers.googleAntigravity"))]);
1833
+ auth.value = (existing && existing.auth) || "api-key";
1834
+ const authField = inputRow(t("providers.credentials"), auth, t("providers.googleAuthHelp"));
1835
+ const login = el("button", { class: "btn secondary", type: "button", text: t("providers.addGoogleAccount") });
1836
+ login.addEventListener("click", async () => {
1837
+ login.disabled = true;
1838
+ if (await startGoogleLogin()) await runProbe();
1839
+ if (formActive) login.disabled = false;
1840
+ });
1841
+ const loginField = el("div", { class: "form-field" }, [el("span", { text: t("providers.googleLogin") }), login, el("small", { class: "warn-text", text: t("providers.googleWarningShort") })]);
1842
+ function syncAuth() {
1843
+ keyField.hidden = auth.value !== "api-key";
1844
+ loginField.hidden = auth.value !== "antigravity";
1845
+ }
1846
+ auth.addEventListener("change", syncAuth);
1847
+ syncAuth();
1848
+ const result = el("div", { class: "probe-result" });
1849
+ const probeButton = el("button", { class: "btn secondary", type: "button", text: t("providers.check") });
1850
+ let foundModels = modelsOf(existing);
1851
+ const currentChecked = new Set(foundModels.map((model) => model.id));
1852
+ const modelArea = el("div", { class: "form-field" });
1853
+ function renderModels(checked, help) {
1854
+ modelArea.replaceChildren(el("span", { text: t("providers.models") }), hint(help || t("providers.modelsHelp")), modelChecklist(foundModels, checked, { allowCustom: true }));
1855
+ }
1856
+ renderModels(currentChecked);
1857
+ async function runProbe() {
1858
+ probeButton.disabled = true;
1859
+ result.textContent = t("providers.checking");
1860
+ try {
1861
+ const body = auth.value === "antigravity" ? { type: "google", auth: "antigravity" } : { type: "google", auth: "api-key", apiKey: keyInput.value.trim() || (existing && existing.apiKey) };
1862
+ const response = await api("/api/providers/probe", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
1863
+ if (!formActive) return;
1864
+ const failed = response.auth === "bad-key" ? t("providers.probeBadKey") : response.auth === "missing" && auth.value === "antigravity" ? t("providers.googleSignInFirst") : t("providers.probeFailed");
1865
+ result.replaceChildren(...[
1866
+ el("span", { class: response.ok ? "ok-text" : "bad-text", text: response.ok ? t("providers.probeOk") : failed }),
1867
+ response.error ? el("div", { class: "small", text: response.error }) : null,
1868
+ ].filter(Boolean));
1869
+ if (Array.isArray(response.models) && response.models.length) {
1870
+ const ticked = modelArea.querySelector(".model-picker").selected();
1871
+ // The catalogue lists dozens of Gemini variants, so a new provider starts with none ticked
1872
+ // rather than all of them; saved and typed picks stay ticked and visible.
1873
+ const keep = new Set([...currentChecked, ...ticked.map((model) => model.id)]);
1874
+ foundModels = [...ticked.filter((model) => !response.models.some((m) => m.id === model.id)), ...response.models];
1875
+ renderModels(keep, t("providers.modelsFoundMany"));
1876
+ }
1877
+ } catch (error) { if (formActive) result.replaceChildren(el("span", { class: "bad-text", text: t("providers.probeFailed") }), el("div", { class: "small", text: error.message })); }
1878
+ finally { if (formActive) probeButton.disabled = false; }
1879
+ }
1880
+ probeButton.addEventListener("click", () => void runProbe());
1881
+ const advanced = el("details", { class: "details" }, [el("summary", { text: t("common.advanced") })]);
1882
+ const identity = el("input", { type: "checkbox", checked: !(existing && existing.identity === false) });
1883
+ const append = el("textarea", { rows: "2", value: (existing && existing.instructionsAppend) || "" });
1884
+ advanced.appendChild(el("div", { class: "advanced-content" }, [
1885
+ el("div", { class: "form-field" }, [el("label", { class: "check" }, [identity, el("span", { text: t("providers.identity") })]), el("small", { text: t("providers.identityHelp") })]),
1886
+ inputRow(t("providers.append"), append, t("providers.appendHelp")),
1887
+ ]));
1888
+ const pickerInput = el("input", { type: "checkbox", checked: Boolean(existing && modelsOf(existing).some((model) => ((currentConfig.cli && currentConfig.cli.extraModels) || []).some((extra) => extra.model === model.id))) });
1889
+ const form = el("div", { class: "provider-form" }, [
1890
+ el("h1", { id: "modal-title", text: existing ? t("providers.edit") : t("providers.addTitle") }),
1891
+ inputRow(t("providers.name"), nameInput, t("providers.nameHelp")), authField, keyField, loginField, probeButton, result, modelArea,
1892
+ el("label", { class: "check picker-check" }, [pickerInput, el("span", { text: t("providers.showInPicker") })]),
1893
+ pickerModeOn() ? null : hint(t("providers.pickerOffHint")),
1894
+ advanced,
1895
+ ]);
1896
+ const saveButton = el("button", { class: "btn", type: "button", "data-default-action": "", text: existing ? t("common.save") : t("providers.add") });
1897
+ saveButton.addEventListener("click", async () => {
1898
+ const typedName = nameInput.value.trim();
1899
+ if (!typedName) { toast(t("providers.nameRequired"), true); return; }
1900
+ const next = clone(await latestConfig());
1901
+ const providerName = existing ? options.name : uniqueName(typedName, next.providers);
1902
+ const apiKey = auth.value === "api-key" ? keyInput.value.trim() || (existing && existing.apiKey) : undefined;
1903
+ const saved = existing && (next.providers[options.name] || existing);
1904
+ const checkedModels = withSavedModelFields(form.querySelector(".model-picker").selected(), saved);
1905
+ // Fields this form has no control for (defaultEffort, a hand-written setting) survive the save.
1906
+ const formKeys = new Set(["type", "auth", "apiKey", "identity", "instructionsAppend", "models"]);
1907
+ const kept = saved ? Object.fromEntries(Object.entries(saved).filter(([key]) => !formKeys.has(key))) : {};
1908
+ next.providers[providerName] = {
1909
+ ...kept,
1910
+ type: "google",
1911
+ auth: auth.value,
1912
+ ...(apiKey ? { apiKey } : {}),
1913
+ ...(existing && existing.url ? { url: existing.url } : {}),
1914
+ identity: identity.checked,
1915
+ ...(append.value.trim() ? { instructionsAppend: append.value.trim() } : {}),
1916
+ models: checkedModels,
1917
+ };
1918
+ // Same rule as the other provider forms: read after the provider is updated, so an unticked
1919
+ // model is already undeclared and falls out of the picker on its own.
1920
+ const existingSelections = pickerSelectionsExcept(next, providerName);
1921
+ applyPickerSelections(next, pickerInput.checked ? [...existingSelections, ...checkedModels.map((model) => ({ ...model, provider: providerName }))] : existingSelections);
1922
+ saveButton.disabled = true;
1923
+ try { await configRequest(next); currentConfig = next; selectedProviderName = providerName; providerDetailTab = auth.value === "antigravity" ? "accounts" : "overview"; slotsLoaded = false; clientsLoaded = false; providersLoaded = false; closeModal(); await loadProviders(); toast(t("common.saved")); await offerPickerOn(pickerInput.checked && checkedModels.length > 0); }
1924
+ catch (error) { toast(t("common.saveFailed"), true, error.message); }
1925
+ finally { saveButton.disabled = false; }
1926
+ });
1927
+ form.appendChild(el("div", { class: "actions end" }, [el("button", { class: "btn secondary", type: "button", text: t("common.cancel"), onclick: closeModal }), saveButton]));
1928
+ showModal(form, () => { formActive = false; });
1929
+ // An existing provider can already reach its catalogue (a stored key or a signed-in account), so
1930
+ // the full list is shown straight away.
1931
+ if (existing && (existing.apiKey || existing.auth === "antigravity")) void runProbe();
1932
+ }
1558
1933
  function openProviderForm(options) {
1559
1934
  const existing = options.provider;
1560
1935
  const preset = options.preset || (existing && existing.preset && presetById(existing.preset));
@@ -1564,6 +1939,7 @@ function openProviderForm(options) {
1564
1939
  const isCustom = options.kind === "custom";
1565
1940
  const displayName = options.name || (preset && preset.name) || (isChatgpt ? t("providers.chatgpt") : isAnthropic ? t("providers.anthropic") : t("providers.customName"));
1566
1941
  if (isAnthropic) { openAnthropicProviderForm(options); return; }
1942
+ if (options.kind === "google" || (existing && existing.type === "google")) { openGoogleProviderForm(options); return; }
1567
1943
  const nameInput = el("input", { value: displayName, maxlength: "60" });
1568
1944
  const keyInput = el("input", { type: "password", autocomplete: "off", placeholder: t("providers.keyPlaceholder") });
1569
1945
  const showKey = el("button", { class: "eye-button", type: "button", text: t("common.show") });
@@ -1582,7 +1958,11 @@ function openProviderForm(options) {
1582
1958
  const initialModels = modelsOf(existing).length ? modelsOf(existing) : (preset ? (preset.fallbackModels || []) : (isChatgpt ? CHATGPT_MODELS : []));
1583
1959
  let foundModels = initialModels;
1584
1960
  const currentChecked = new Set(modelsOf(existing).map((model) => model.id));
1585
- const modelsBox = modelChecklist(foundModels, currentChecked.size ? currentChecked : new Set(foundModels.map((model) => model.id)));
1961
+ // Everything starts ticked only for a new provider. An existing one keeps its saved picks even
1962
+ // when that is none: treating "no models" as "not decided yet" re-ticked the whole fallback list
1963
+ // every time the form opened, and saving then declared all of them (2026-09-29, opencode-zen).
1964
+ const checklistOptions = { allowCustom: !isChatgpt };
1965
+ const modelsBox = modelChecklist(foundModels, existing ? currentChecked : new Set(foundModels.map((model) => model.id)), checklistOptions);
1586
1966
  const providerEffortLevels = isChatgpt
1587
1967
  ? effortLevelsFor(options.name || "chatgpt", "gpt-5.6-terra")
1588
1968
  : (existing ? effortLevelsFor(options.name, initialModels[0] && initialModels[0].id) : fallbackEffortLevels({ ...(preset ? { preset: preset.id } : {}), ...(isCustom ? { caps: {} } : {}) }, initialModels[0] && initialModels[0].id));
@@ -1698,6 +2078,12 @@ function openProviderForm(options) {
1698
2078
  function probeDraft() {
1699
2079
  const d = draftProvider();
1700
2080
  if (preset) { d.modelsUrl = preset.modelsUrl; d.modelsAuthHeader = preset.modelsAuthHeader; d.probeModel = (preset.fallbackModels || [])[0] && preset.fallbackModels[0].id; }
2081
+ // With nothing to list models from, test with a model the user ticked rather than a made-up id
2082
+ // the provider is bound to refuse.
2083
+ else {
2084
+ const ticked = modelArea.querySelector(".model-picker").selected();
2085
+ if (ticked.length) d.probeModel = ticked[0].id;
2086
+ }
1701
2087
  return d;
1702
2088
  }
1703
2089
  async function runProbe() {
@@ -1714,12 +2100,17 @@ function openProviderForm(options) {
1714
2100
  el("span", { class: response.ok && !noCredits ? "ok-text" : noCredits ? "warn-text" : "bad-text", text: headline }),
1715
2101
  response.error ? el("div", { class: "small", text: response.error.replace(/^no-credits:\s*/, "") }) : null,
1716
2102
  ].filter(Boolean));
1717
- foundModels = response.models && response.models.length ? response.models.map((model) => typeof model === "string" ? { id: model, name: model } : model) : (preset ? (preset.fallbackModels || []) : foundModels);
1718
- const keep = foundModels.length > 12 ? new Set([...currentChecked, ...modelArea.querySelector(".model-picker").selected().map((m) => m.id)]) : new Set(foundModels.map((model) => model.id));
2103
+ const listed = Boolean(response.models && response.models.length);
2104
+ const ticked = modelArea.querySelector(".model-picker").selected();
2105
+ foundModels = listed ? response.models.map((model) => typeof model === "string" ? { id: model, name: model } : model) : (preset ? (preset.fallbackModels || []) : foundModels);
2106
+ const keep = existing || foundModels.length > 12 ? new Set([...currentChecked, ...ticked.map((m) => m.id)]) : new Set(foundModels.map((model) => model.id));
1719
2107
  // Saved picks that the provider no longer lists stay visible and ticked so nothing is dropped silently.
1720
2108
  for (const saved of modelsOf(existing)) if (keep.has(saved.id) && !foundModels.some((m) => m.id === saved.id)) foundModels = [saved, ...foundModels];
1721
- modelArea.querySelector(".model-picker").replaceWith(modelChecklist(foundModels, keep));
1722
- modelArea.replaceChildren(el("span", { text: t("providers.models") }), hint(response.ok ? (foundModels.length > 12 ? t("providers.modelsFoundMany") : t("providers.modelsFound")) : t("providers.modelsFallback")), modelArea.querySelector(".model-picker") || document.createTextNode(""));
2109
+ // So do ids typed in before the test: the provider's list replacing them would undo the typing.
2110
+ for (const typed of ticked) if (!foundModels.some((m) => m.id === typed.id)) { foundModels = [typed, ...foundModels]; keep.add(typed.id); }
2111
+ modelArea.querySelector(".model-picker").replaceWith(modelChecklist(foundModels, keep, checklistOptions));
2112
+ const listHint = listed ? (foundModels.length > 12 ? t("providers.modelsFoundMany") : t("providers.modelsFound")) : preset ? t("providers.modelsFallback") : t("providers.customModelHint");
2113
+ modelArea.replaceChildren(el("span", { text: t("providers.models") }), hint(listHint), modelArea.querySelector(".model-picker") || document.createTextNode(""));
1723
2114
  }
1724
2115
  probeButton && probeButton.addEventListener("click", runProbe);
1725
2116
  // Editing a provider that can list models: fetch the full list right away so the saved picks are
@@ -1763,10 +2154,11 @@ function openProviderForm(options) {
1763
2154
  if (!typedName) { toast(t("providers.nameRequired"), true); return; }
1764
2155
  const provider = draftProvider();
1765
2156
  if (!isChatgpt && (!provider.url || !/^https?:\/\//.test(provider.url))) { toast(t("providers.urlRequired"), true); return; }
1766
- const next = clone(currentConfig);
2157
+ const next = clone(await latestConfig());
1767
2158
  const providerName = existing ? options.name : uniqueName(typedName, next.providers);
2159
+ const saved = existing && (next.providers[options.name] || existing);
1768
2160
  if (existing && providerName !== options.name) delete next.providers[options.name];
1769
- const checkedModels = form.querySelector(".model-picker").selected();
2161
+ const checkedModels = withSavedModelFields(form.querySelector(".model-picker").selected(), saved);
1770
2162
  provider.models = checkedModels;
1771
2163
  // The form rebuilds the provider from its own fields, so anything it has no field for
1772
2164
  // (debugDump, a hand-written setting) is carried over from the saved one rather than dropped.
@@ -1774,7 +2166,7 @@ function openProviderForm(options) {
1774
2166
  const formKeys = new Set(isChatgpt
1775
2167
  ? ["type", "auth", "defaultEffort", "identity", "instructionsAppend", "models"]
1776
2168
  : ["type", "url", "identity", "instructionsAppend", "preset", "sessionHeader", "wire", "caps", "headers", "models"]);
1777
- const kept = existing ? Object.fromEntries(Object.entries(existing).filter(([key]) => !formKeys.has(key))) : {};
2169
+ const kept = saved ? Object.fromEntries(Object.entries(saved).filter(([key]) => !formKeys.has(key))) : {};
1778
2170
  next.providers[providerName] = { ...kept, ...provider };
1779
2171
  // Read after `next.providers` has been updated, so an unticked model is already undeclared here
1780
2172
  // and falls out on its own, and a renamed provider answers to its new name.