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.
- package/CHANGELOG.md +51 -0
- package/README.ko.md +15 -1
- package/README.md +23 -3
- package/dist/app/package.json +1 -1
- package/dist/cli/src/claude-auth.js +35 -1
- package/dist/cli/src/codex.js +23 -0
- package/dist/cli/src/hooks/agent-title.js +58 -29
- package/dist/cli/src/index.js +46 -1
- package/dist/cli/src/runtime.js +3 -0
- package/dist/cli/src/settings.js +49 -0
- package/dist/mod/.claude-plugin/plugin.json +1 -0
- package/dist/mod/hooks/hooks.json +1 -0
- package/dist/mod/hooks/register.tsx +309 -0
- package/dist/mod/hooks/worker.ts +49 -0
- package/dist/mod/tsconfig.json +3 -0
- package/dist/mod/types/index.d.ts +30 -0
- package/dist/router/src/admin.js +411 -32
- package/dist/router/src/agents.js +24 -8
- package/dist/router/src/catalog.js +80 -0
- package/dist/router/src/compat.js +27 -1
- package/dist/router/src/config.js +31 -0
- package/dist/router/src/index.js +12 -1
- package/dist/router/src/ingress/server.js +3 -2
- package/dist/router/src/picker.js +33 -2
- package/dist/router/src/presets.js +2 -1
- package/dist/router/src/providers/chatgpt/index.js +42 -15
- package/dist/router/src/providers/chatgpt/translate.js +34 -6
- package/dist/router/src/providers/google/accounts.js +345 -0
- package/dist/router/src/providers/google/antigravity.js +392 -0
- package/dist/router/src/providers/google/index.js +617 -0
- package/dist/router/src/providers/google/login.js +249 -0
- package/dist/router/src/providers/google/thoughtSignatures.js +47 -0
- package/dist/router/src/providers/google/translate.js +539 -0
- package/dist/router/src/providers/google/transport.js +118 -0
- package/dist/router/src/providers/openai/translate.js +3 -3
- package/dist/router/src/proxy.js +85 -1
- package/dist/router/src/requestlog.js +2 -0
- package/dist/router/src/routing.js +22 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +420 -45
- package/dist/ui/i18n.js +76 -4
- package/dist/ui/index.html +5 -0
- package/dist/ui/style.css +4 -0
- package/docs/ARCHITECTURE.md +287 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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 ?
|
|
318
|
-
if (state) return
|
|
319
|
-
return
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1735
|
-
const
|
|
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
|
-
|
|
1739
|
-
|
|
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(
|
|
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 =
|
|
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.
|