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