clauderipple 0.7.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 +51 -0
  2. package/README.ko.md +15 -1
  3. package/README.md +23 -3
  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 +46 -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 +411 -32
  18. package/dist/router/src/agents.js +24 -8
  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 +31 -0
  22. package/dist/router/src/index.js +12 -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 +42 -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 +85 -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 +420 -45
  41. package/dist/ui/i18n.js +76 -4
  42. package/dist/ui/index.html +5 -0
  43. package/dist/ui/style.css +4 -0
  44. package/docs/ARCHITECTURE.md +287 -2
  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,13 @@ 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);
380
462
  const toolsLimited = Boolean(currentConfig.cli && currentConfig.cli.limitWorkerTools);
381
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") })]));
382
464
  const toolsButton = $("#client-worker-tools-toggle");
@@ -470,7 +552,7 @@ function renderModelSlots() {
470
552
 
471
553
  async function saveModelSlots() {
472
554
  if (!currentConfig) return;
473
- const next = clone(currentConfig);
555
+ const next = clone(await latestConfig());
474
556
  // Every slot is recorded, empty ones included. A select put back to "default (Claude)" has to
475
557
  // remove the env key, and the router tells "clear this" from "leave this alone" by whether the
476
558
  // slot is present at all — so dropping empty values here would make the choice a silent no-op.
@@ -499,7 +581,7 @@ function clientPickerSelections() {
499
581
  }
500
582
  async function saveClientPickerModels() {
501
583
  if (!currentConfig) return;
502
- const next = applyPickerSelections(clone(currentConfig), clientPickerSelections());
584
+ const next = applyPickerSelections(clone(await latestConfig()), clientPickerSelections());
503
585
  try { await configRequest(next); currentConfig = next; toast(t("slots.saved")); } catch (error) { toast(t("common.saveFailed"), true, error.message); }
504
586
  }
505
587
  function pickerModeOn() {
@@ -543,9 +625,22 @@ async function toggleAgentTitle(enabled) {
543
625
  void refreshHealth();
544
626
  }
545
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
+ }
546
641
  async function toggleWorkerTools(enabled) {
547
642
  if (!currentConfig) return;
548
- const next = clone(currentConfig);
643
+ const next = clone(await latestConfig());
549
644
  next.cli = { ...(next.cli || {}), limitWorkerTools: enabled };
550
645
  try {
551
646
  await configRequest(next);
@@ -756,7 +851,7 @@ function scheduleSlotsSave() {
756
851
  async function saveSlots() {
757
852
  if (!currentConfig || slotsSaving) return;
758
853
  slotsSaving = true;
759
- const next = clone(currentConfig);
854
+ const next = clone(await latestConfig());
760
855
  const routes = {};
761
856
  const seen = new Set();
762
857
  for (const row of $all("#slots-table tbody tr")) {
@@ -780,15 +875,6 @@ async function saveSlots() {
780
875
 
781
876
  // ---- Providers ----------------------------------------------------------------------
782
877
 
783
- function statusText(state) {
784
- if (!state) return t("providerStatus.checking");
785
- if (state.ok) return t("providerStatus.connected");
786
- // The key was accepted and the plan refused: saying "check your key" sends the operator to the
787
- // one thing that is not wrong. Measured 2026-09-22 against OpenCode's free-tier 403.
788
- if (state.auth === "not-entitled") return t("providerStatus.notEntitled");
789
- if (state.auth === "bad-key") return t("providerStatus.keyNeeded");
790
- return t("providerStatus.disconnected");
791
- }
792
878
  async function probeProvider(name, provider, onComplete) {
793
879
  probeStates.set(name, { pending: true });
794
880
  renderHealthProviders();
@@ -796,6 +882,8 @@ async function probeProvider(name, provider, onComplete) {
796
882
  const headers = provider.headers || {};
797
883
  const body = provider.type === "anthropic"
798
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 } : {}) }
799
887
  : {
800
888
  type: provider.type,
801
889
  url: provider.url,
@@ -805,7 +893,8 @@ async function probeProvider(name, provider, onComplete) {
805
893
  // The router reads the preset's fallback list to tag each discovered model with the wire it
806
894
  // speaks, since /models reports ids alone and one plan can serve several wires.
807
895
  preset: provider.preset,
808
- 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),
809
898
  // Some vendors refuse a request without it rather than merely losing the cache, so a test
810
899
  // that leaves it out reports a broken provider that works perfectly.
811
900
  sessionHeader: provider.sessionHeader || (provider.preset && presetById(provider.preset) && presetById(provider.preset).sessionHeader),
@@ -904,6 +993,7 @@ let providerDetailGeneration = 0;
904
993
  function providerKind(name, provider) {
905
994
  const preset = provider.preset && presetById(provider.preset);
906
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")}`;
907
997
  if (provider.type === "anthropic") return provider.auth === "claude-code"
908
998
  ? `${t("providers.anthropic")} · ${provider.accountPool ? t("providers.rotationOn") : t("providers.rotationOff")}`
909
999
  : `${t("providers.anthropic")} · ${t("providers.apiKey")}`;
@@ -945,7 +1035,7 @@ function renderProviderRail() {
945
1035
 
946
1036
  async function removeProvider(name, provider) {
947
1037
  if (!confirm(t("providers.removeConfirm", { name }))) return;
948
- const next = clone(currentConfig);
1038
+ const next = clone(await latestConfig());
949
1039
  delete next.providers[name];
950
1040
  next.routes = Object.fromEntries(Object.entries(next.routes || {}).filter(([, route]) => route.provider !== name));
951
1041
  next.direct = (next.direct || []).filter((rule) => rule.provider !== name);
@@ -964,7 +1054,7 @@ async function removeProvider(name, provider) {
964
1054
 
965
1055
  function providerTabs(name, provider) {
966
1056
  const tabs = [{ id: "overview", label: t("providers.overview") }];
967
- 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") });
968
1058
  tabs.push({ id: "models", label: t("providers.modelsTab") });
969
1059
  return el("div", { class: "provider-tabs", role: "tablist" }, tabs.map((tab) => {
970
1060
  const button = el("button", { class: providerDetailTab === tab.id ? "active" : "", type: "button", role: "tab", "aria-selected": String(providerDetailTab === tab.id), text: tab.label });
@@ -979,11 +1069,11 @@ function providerOverview(name, provider) {
979
1069
  const connection = el("section", { class: "detail-section" }, [
980
1070
  el("h3", { text: t("providers.connection") }),
981
1071
  el("div", { class: "detail-setting-row" }, [
982
- 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) })]),
983
1073
  providerState(name, provider),
984
1074
  ]),
985
1075
  state && !state.ok && state.error ? el("p", { class: "bad-text small provider-detail-error", text: shortError(state.error), title: state.error }) : null,
986
- live && live.needsLogin ? chatgptLoginButton(renderProviderDetail) : null,
1076
+ live && live.needsLogin && provider.type === "chatgpt" ? chatgptLoginButton(renderProviderDetail) : null,
987
1077
  ].filter(Boolean));
988
1078
  const models = modelsOf(provider);
989
1079
  const modelSummary = el("section", { class: "detail-section" }, [
@@ -1012,7 +1102,7 @@ function providerModelsPanel(name, provider) {
1012
1102
  async function saveAnthropicRotation(name, provider, enabled, control, message) {
1013
1103
  control.disabled = true;
1014
1104
  message.textContent = t("providers.rotationSaving");
1015
- const next = clone(currentConfig);
1105
+ const next = clone(await latestConfig());
1016
1106
  if (enabled) next.providers[name].accountPool = true;
1017
1107
  else delete next.providers[name].accountPool;
1018
1108
  try {
@@ -1145,6 +1235,7 @@ function durationText(seconds) {
1145
1235
  function chatgptStateBadge(account) {
1146
1236
  if (account.state === "paused") return el("span", { class: "badge", text: t("providers.chatgptPaused") });
1147
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") });
1148
1239
  if (account.state === "cooling") return el("span", { class: "badge warn", text: t("pool.coolingFor", { duration: durationText(account.cooldownSeconds) }) });
1149
1240
  return el("span", { class: "badge ok", text: account.active ? t("providers.chatgptInUse") : t("providers.chatgptStandby") });
1150
1241
  }
@@ -1208,6 +1299,133 @@ function renderChatgptAccountRows(target, data, name, generation) {
1208
1299
  if (countNode) countNode.textContent = t("providers.chatgptAccountCount", { count: accounts.length });
1209
1300
  }
1210
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
+
1211
1429
  async function refreshChatgptAccountPanel(name) {
1212
1430
  const target = $("#chatgpt-account-rows");
1213
1431
  if (!target || selectedProviderName !== name || providerDetailTab !== "accounts") return;
@@ -1253,6 +1471,7 @@ function renderProviderDetail() {
1253
1471
  let content;
1254
1472
  if (providerDetailTab === "accounts" && provider.type === "anthropic" && provider.auth === "claude-code") content = anthropicAccountsPanel(name, provider);
1255
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);
1256
1475
  else if (providerDetailTab === "models") content = providerModelsPanel(name, provider);
1257
1476
  else { providerDetailTab = "overview"; content = providerOverview(name, provider); }
1258
1477
  detail.replaceChildren(header, providerTabs(name, provider), content);
@@ -1269,7 +1488,7 @@ async function loadClients() {
1269
1488
  clientsLoaded = true;
1270
1489
  try {
1271
1490
  await loadCatalogs();
1272
- currentConfig = currentConfig || await api("/api/config");
1491
+ currentConfig = await api("/api/config");
1273
1492
  if (!status) status = await api("/api/status");
1274
1493
  renderClients();
1275
1494
  } catch (error) { toast(t("common.loadFailed"), true, error.message); }
@@ -1279,7 +1498,7 @@ async function loadProviders() {
1279
1498
  providersLoaded = true;
1280
1499
  try {
1281
1500
  await loadCatalogs();
1282
- currentConfig = currentConfig || await api("/api/config");
1501
+ currentConfig = await api("/api/config");
1283
1502
  renderProviderWorkspace();
1284
1503
  void Promise.all(Object.entries(currentConfig.providers || {}).map(([name, provider]) => probeProvider(name, provider, () => {
1285
1504
  if ($("#view-providers").classList.contains("active")) renderProviderWorkspace();
@@ -1409,6 +1628,9 @@ function openProviderChooser() {
1409
1628
  const chatgpt = el("button", { class: "chooser-tile", type: "button" }, [el("strong", { text: t("providers.chatgpt") }), el("span", { text: t("providers.chatgptHelp") })]);
1410
1629
  chatgpt.addEventListener("click", () => openProviderForm({ kind: "chatgpt" }));
1411
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);
1412
1634
  const native = presets.filter((preset) => (preset.kind || "anthropic-compatible") === "anthropic-compatible");
1413
1635
  for (const preset of native) {
1414
1636
  const tile = el("button", { class: "chooser-tile", type: "button" }, [el("strong", { text: preset.name }), el("span", { text: t("providers.presetHelp") })]);
@@ -1446,7 +1668,14 @@ function modelOverrideFields(model) {
1446
1668
  ...(model.authHeader ? { authHeader: model.authHeader } : {}),
1447
1669
  };
1448
1670
  }
1449
- 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];
1450
1679
  const selected = new Map();
1451
1680
  for (const model of models) if (checked.has(model.id)) selected.set(model.id, { id: model.id, name: labelOf(model), ...modelOverrideFields(model) });
1452
1681
  const wrap = el("div", { class: "model-picker" });
@@ -1480,6 +1709,26 @@ function modelChecklist(models, checked) {
1480
1709
  }
1481
1710
  if (search) { search.addEventListener("input", render); wrap.appendChild(search); }
1482
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
+ }
1483
1732
  wrap.selected = () => [...selected.values()];
1484
1733
  render();
1485
1734
  return wrap;
@@ -1504,7 +1753,7 @@ function openAnthropicProviderForm(options) {
1504
1753
  const probeButton = el("button", { class: "btn secondary", type: "button", text: t("providers.check") });
1505
1754
  const sourceLine = el("div", { class: "small" });
1506
1755
  let foundModels = modelsOf(existing).length ? modelsOf(existing) : claudeModels.map((model) => ({ id: model.id, name: labelOf(model) }));
1507
- 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));
1508
1757
  const modelArea = el("div", { class: "form-field" });
1509
1758
  function renderModels() {
1510
1759
  const modelsBox = modelChecklist(foundModels, selected);
@@ -1536,7 +1785,7 @@ function openAnthropicProviderForm(options) {
1536
1785
  ].filter(Boolean));
1537
1786
  if (Array.isArray(response.models) && response.models.length) {
1538
1787
  foundModels = response.models;
1539
- 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));
1540
1789
  renderModels();
1541
1790
  }
1542
1791
  } catch (error) { if (formActive) result.replaceChildren(el("span", { class: "bad-text", text: t("providers.probeFailed") }), el("div", { class: "small", text: error.message })); }
@@ -1556,9 +1805,9 @@ function openAnthropicProviderForm(options) {
1556
1805
  saveButton.addEventListener("click", async () => {
1557
1806
  const typedName = nameInput.value.trim();
1558
1807
  if (!typedName) { toast(t("providers.nameRequired"), true); return; }
1559
- const next = clone(currentConfig);
1808
+ const next = clone(await latestConfig());
1560
1809
  const providerName = existing ? options.name : uniqueName(typedName, next.providers);
1561
- const checkedModels = form.querySelector(".model-picker").selected();
1810
+ const checkedModels = withSavedModelFields(form.querySelector(".model-picker").selected(), existing && next.providers[options.name]);
1562
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 };
1563
1812
  next.providers[providerName] = provider;
1564
1813
  // accountPool makes these models native Claude routing targets. Without it this remains the
@@ -1572,6 +1821,115 @@ function openAnthropicProviderForm(options) {
1572
1821
  showModal(form, () => { formActive = false; });
1573
1822
  if (auth.value === "claude-code") void runProbe();
1574
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
+ }
1575
1933
  function openProviderForm(options) {
1576
1934
  const existing = options.provider;
1577
1935
  const preset = options.preset || (existing && existing.preset && presetById(existing.preset));
@@ -1581,6 +1939,7 @@ function openProviderForm(options) {
1581
1939
  const isCustom = options.kind === "custom";
1582
1940
  const displayName = options.name || (preset && preset.name) || (isChatgpt ? t("providers.chatgpt") : isAnthropic ? t("providers.anthropic") : t("providers.customName"));
1583
1941
  if (isAnthropic) { openAnthropicProviderForm(options); return; }
1942
+ if (options.kind === "google" || (existing && existing.type === "google")) { openGoogleProviderForm(options); return; }
1584
1943
  const nameInput = el("input", { value: displayName, maxlength: "60" });
1585
1944
  const keyInput = el("input", { type: "password", autocomplete: "off", placeholder: t("providers.keyPlaceholder") });
1586
1945
  const showKey = el("button", { class: "eye-button", type: "button", text: t("common.show") });
@@ -1599,7 +1958,11 @@ function openProviderForm(options) {
1599
1958
  const initialModels = modelsOf(existing).length ? modelsOf(existing) : (preset ? (preset.fallbackModels || []) : (isChatgpt ? CHATGPT_MODELS : []));
1600
1959
  let foundModels = initialModels;
1601
1960
  const currentChecked = new Set(modelsOf(existing).map((model) => model.id));
1602
- 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);
1603
1966
  const providerEffortLevels = isChatgpt
1604
1967
  ? effortLevelsFor(options.name || "chatgpt", "gpt-5.6-terra")
1605
1968
  : (existing ? effortLevelsFor(options.name, initialModels[0] && initialModels[0].id) : fallbackEffortLevels({ ...(preset ? { preset: preset.id } : {}), ...(isCustom ? { caps: {} } : {}) }, initialModels[0] && initialModels[0].id));
@@ -1715,6 +2078,12 @@ function openProviderForm(options) {
1715
2078
  function probeDraft() {
1716
2079
  const d = draftProvider();
1717
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
+ }
1718
2087
  return d;
1719
2088
  }
1720
2089
  async function runProbe() {
@@ -1731,12 +2100,17 @@ function openProviderForm(options) {
1731
2100
  el("span", { class: response.ok && !noCredits ? "ok-text" : noCredits ? "warn-text" : "bad-text", text: headline }),
1732
2101
  response.error ? el("div", { class: "small", text: response.error.replace(/^no-credits:\s*/, "") }) : null,
1733
2102
  ].filter(Boolean));
1734
- foundModels = response.models && response.models.length ? response.models.map((model) => typeof model === "string" ? { id: model, name: model } : model) : (preset ? (preset.fallbackModels || []) : foundModels);
1735
- 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));
1736
2107
  // Saved picks that the provider no longer lists stay visible and ticked so nothing is dropped silently.
1737
2108
  for (const saved of modelsOf(existing)) if (keep.has(saved.id) && !foundModels.some((m) => m.id === saved.id)) foundModels = [saved, ...foundModels];
1738
- modelArea.querySelector(".model-picker").replaceWith(modelChecklist(foundModels, keep));
1739
- 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(""));
1740
2114
  }
1741
2115
  probeButton && probeButton.addEventListener("click", runProbe);
1742
2116
  // Editing a provider that can list models: fetch the full list right away so the saved picks are
@@ -1780,10 +2154,11 @@ function openProviderForm(options) {
1780
2154
  if (!typedName) { toast(t("providers.nameRequired"), true); return; }
1781
2155
  const provider = draftProvider();
1782
2156
  if (!isChatgpt && (!provider.url || !/^https?:\/\//.test(provider.url))) { toast(t("providers.urlRequired"), true); return; }
1783
- const next = clone(currentConfig);
2157
+ const next = clone(await latestConfig());
1784
2158
  const providerName = existing ? options.name : uniqueName(typedName, next.providers);
2159
+ const saved = existing && (next.providers[options.name] || existing);
1785
2160
  if (existing && providerName !== options.name) delete next.providers[options.name];
1786
- const checkedModels = form.querySelector(".model-picker").selected();
2161
+ const checkedModels = withSavedModelFields(form.querySelector(".model-picker").selected(), saved);
1787
2162
  provider.models = checkedModels;
1788
2163
  // The form rebuilds the provider from its own fields, so anything it has no field for
1789
2164
  // (debugDump, a hand-written setting) is carried over from the saved one rather than dropped.
@@ -1791,7 +2166,7 @@ function openProviderForm(options) {
1791
2166
  const formKeys = new Set(isChatgpt
1792
2167
  ? ["type", "auth", "defaultEffort", "identity", "instructionsAppend", "models"]
1793
2168
  : ["type", "url", "identity", "instructionsAppend", "preset", "sessionHeader", "wire", "caps", "headers", "models"]);
1794
- 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))) : {};
1795
2170
  next.providers[providerName] = { ...kept, ...provider };
1796
2171
  // Read after `next.providers` has been updated, so an unticked model is already undeclared here
1797
2172
  // and falls out on its own, and a renamed provider answers to its new name.