clauderipple 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/LICENSE +674 -0
  3. package/README.ko.md +328 -0
  4. package/README.md +372 -0
  5. package/bin/clauderipple.js +12 -0
  6. package/dist/app/assets/trayDownTemplate.png +0 -0
  7. package/dist/app/assets/trayDownTemplate@2x.png +0 -0
  8. package/dist/app/assets/trayTemplate.png +0 -0
  9. package/dist/app/assets/trayTemplate@2x.png +0 -0
  10. package/dist/app/assets/trayWarnTemplate.png +0 -0
  11. package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
  12. package/dist/app/assets/trayWin.png +0 -0
  13. package/dist/app/assets/trayWin@2x.png +0 -0
  14. package/dist/app/assets/trayWinDown.png +0 -0
  15. package/dist/app/assets/trayWinDown@2x.png +0 -0
  16. package/dist/app/assets/trayWinWarn.png +0 -0
  17. package/dist/app/assets/trayWinWarn@2x.png +0 -0
  18. package/dist/app/dist/main.js +518 -0
  19. package/dist/cli/src/browser.js +21 -0
  20. package/dist/cli/src/bundle.js +51 -0
  21. package/dist/cli/src/certs.js +33 -0
  22. package/dist/cli/src/claude-auth.js +112 -0
  23. package/dist/cli/src/codex.js +172 -0
  24. package/dist/cli/src/gen-certs.js +7 -0
  25. package/dist/cli/src/hooks/agent-title.js +160 -0
  26. package/dist/cli/src/index.js +489 -0
  27. package/dist/cli/src/launchd.js +183 -0
  28. package/dist/cli/src/picker.js +166 -0
  29. package/dist/cli/src/probe.js +55 -0
  30. package/dist/cli/src/runtime.js +62 -0
  31. package/dist/cli/src/schtasks.js +134 -0
  32. package/dist/cli/src/settings.js +142 -0
  33. package/dist/cli/src/supervisor.js +100 -0
  34. package/dist/cli/src/tray.js +85 -0
  35. package/dist/router/src/admin.js +945 -0
  36. package/dist/router/src/bootstrap.js +80 -0
  37. package/dist/router/src/certs.js +65 -0
  38. package/dist/router/src/compat.js +172 -0
  39. package/dist/router/src/config.js +179 -0
  40. package/dist/router/src/health.js +45 -0
  41. package/dist/router/src/identity.js +51 -0
  42. package/dist/router/src/index.js +144 -0
  43. package/dist/router/src/ingress/models.js +29 -0
  44. package/dist/router/src/ingress/server.js +400 -0
  45. package/dist/router/src/ingress/translate.js +457 -0
  46. package/dist/router/src/log.js +81 -0
  47. package/dist/router/src/picker.js +74 -0
  48. package/dist/router/src/presets.js +267 -0
  49. package/dist/router/src/providers/anthropic-observed.js +88 -0
  50. package/dist/router/src/providers/anthropic-token-file.js +48 -0
  51. package/dist/router/src/providers/anthropic.js +203 -0
  52. package/dist/router/src/providers/chatgpt/auth.js +226 -0
  53. package/dist/router/src/providers/chatgpt/index.js +274 -0
  54. package/dist/router/src/providers/chatgpt/sse.js +28 -0
  55. package/dist/router/src/providers/chatgpt/translate.js +393 -0
  56. package/dist/router/src/providers/claude-oauth.js +252 -0
  57. package/dist/router/src/providers/openai/index.js +193 -0
  58. package/dist/router/src/providers/openai/translate.js +504 -0
  59. package/dist/router/src/proxy.js +724 -0
  60. package/dist/router/src/redact.js +43 -0
  61. package/dist/router/src/requestlog.js +346 -0
  62. package/dist/router/src/routing.js +113 -0
  63. package/dist/router/src/version.js +8 -0
  64. package/dist/router/src/x509.js +203 -0
  65. package/dist/ui/app.js +1228 -0
  66. package/dist/ui/i18n.js +95 -0
  67. package/dist/ui/index.html +104 -0
  68. package/dist/ui/presets-fallback.js +61 -0
  69. package/dist/ui/style.css +347 -0
  70. package/docs/ARCHITECTURE.md +441 -0
  71. package/package.json +66 -0
package/dist/ui/app.js ADDED
@@ -0,0 +1,1228 @@
1
+ "use strict";
2
+
3
+
4
+ function $(sel, root) { return (root || document).querySelector(sel); }
5
+ function $all(sel, root) { return Array.from((root || document).querySelectorAll(sel)); }
6
+ function el(tag, attrs, children) {
7
+ const node = document.createElement(tag);
8
+ for (const [key, value] of Object.entries(attrs || {})) {
9
+ if (key === "class") node.className = value;
10
+ else if (key === "text") node.textContent = value;
11
+ else if (key === "checked") node.checked = Boolean(value);
12
+ else if (key.startsWith("on") && typeof value === "function") node.addEventListener(key.slice(2), value);
13
+ else if (value !== undefined && value !== null) node.setAttribute(key, value);
14
+ }
15
+ for (const child of children || []) {
16
+ if (child !== null && child !== undefined) node.appendChild(child instanceof Node ? child : document.createTextNode(String(child)));
17
+ }
18
+ return node;
19
+ }
20
+
21
+ let toastTimer = null;
22
+ function toast(message, isError, raw) {
23
+ const box = $("#toast");
24
+ box.replaceChildren(el("div", { text: message }));
25
+ if (raw) box.appendChild(el("div", { class: "toast-raw", text: raw }));
26
+ box.classList.toggle("error", Boolean(isError));
27
+ box.classList.add("show");
28
+ clearTimeout(toastTimer);
29
+ toastTimer = setTimeout(() => box.classList.remove("show"), isError ? 6500 : 3200);
30
+ }
31
+
32
+ async function api(path, opts) {
33
+ const res = await fetch(path, opts);
34
+ const text = await res.text();
35
+ let body;
36
+ try { body = text ? JSON.parse(text) : {}; } catch { body = { raw: text }; }
37
+ if (!res.ok) {
38
+ const raw = (body.errors || [body.error || body.raw || `HTTP ${res.status}`]).join("; ");
39
+ const error = new Error(raw);
40
+ error.body = body;
41
+ error.status = res.status;
42
+ throw error;
43
+ }
44
+ return body;
45
+ }
46
+
47
+ function configRequest(next) {
48
+ return api("/api/config", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(next) });
49
+ }
50
+
51
+ function modelsOf(provider) {
52
+ const value = (provider && provider.models) || [];
53
+ return value.map((model) => typeof model === "string" ? { id: model, name: model } : model).filter((model) => model && model.id);
54
+ }
55
+ function labelOf(model) { return model.name || model.id; }
56
+ function clone(value) { return JSON.parse(JSON.stringify(value)); }
57
+ function safeName(value) { return value.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "provider"; }
58
+ function uniqueName(name, providers) {
59
+ const base = safeName(name);
60
+ let value = base;
61
+ let number = 2;
62
+ while (providers[value]) value = `${base}-${number++}`;
63
+ return value;
64
+ }
65
+ function groupedModels(config) {
66
+ const groups = [];
67
+ for (const [name, provider] of Object.entries(config.providers || {})) {
68
+ // A native Claude provider serves the OpenAI ingress (Codex) only, so it is not a mapping
69
+ // target: the router ignores a slot pointed at it and passes the request through instead.
70
+ if (provider.type === "anthropic") continue;
71
+ let models = modelsOf(provider);
72
+ if (!models.length && provider.type === "chatgpt") models = CHATGPT_MODELS;
73
+ if (models.length) groups.push({ name, provider, models });
74
+ }
75
+ return groups;
76
+ }
77
+ function presetById(id) { return presets.find((preset) => preset.id === id); }
78
+ function modelEffortLevels(provider, model) {
79
+ const entry = modelsOf(provider).find((item) => item.id === model);
80
+ return entry && Array.isArray(entry.effortLevels) ? entry.effortLevels : undefined;
81
+ }
82
+ function fallbackEffortLevels(provider, model) {
83
+ if (!provider) return [];
84
+ const explicit = modelEffortLevels(provider, model);
85
+ if (explicit !== undefined) return explicit;
86
+ if (provider.type === "chatgpt") return model === "gpt-5.6-luna" ? ["low", "medium", "high", "xhigh", "max", "ultra"] : ["low", "medium", "high", "xhigh", "max"];
87
+ const preset = provider.preset && presetById(provider.preset);
88
+ if (provider.type === "openai-compatible") return provider.caps && provider.caps.reasoning === "effort" && Array.isArray(provider.caps.effortLevels) ? provider.caps.effortLevels : [];
89
+ return provider.caps && Array.isArray(provider.caps.effortLevels) ? provider.caps.effortLevels : (preset && preset.effortLevels) || [];
90
+ }
91
+ function effortLevelsFor(providerName, model) {
92
+ const provider = currentConfig && currentConfig.providers && currentConfig.providers[providerName];
93
+ const catalog = effortCatalog && effortCatalog.providers && effortCatalog.providers[providerName];
94
+ if (catalog) return (catalog.models && catalog.models[model]) || catalog.default || [];
95
+ return fallbackEffortLevels(provider, model);
96
+ }
97
+ function providerSupportsEffort(provider, model) {
98
+ return fallbackEffortLevels(provider, model).length > 0;
99
+ }
100
+ function hasModelEffortData(provider) {
101
+ return modelsOf(provider).some((model) => Array.isArray(model.effortLevels));
102
+ }
103
+ function modelEffortTag(model) {
104
+ if (!Array.isArray(model.effortLevels)) return null;
105
+ return model.effortLevels.length ? t("providers.modelEffort") : t("providers.modelNoEffort");
106
+ }
107
+
108
+ let currentConfig = null;
109
+ let status = null;
110
+ let loadedUiRevision = null;
111
+ let claudeModels = FALLBACK_CLAUDE_MODELS;
112
+ let presets = FALLBACK_PRESETS;
113
+ let effortCatalog = null;
114
+ let slotsLoaded = false;
115
+ let providersLoaded = false;
116
+ let clientsLoaded = false;
117
+ let pickerBusy = false;
118
+ let probeStates = new Map();
119
+ let chatgptLoginBusy = false;
120
+ let chatgptLoginMessage = "";
121
+
122
+ function selectOption(value, text) { return el("option", { value }, [text]); }
123
+ function badge(kind, text) { return el("span", { class: `badge ${kind} dot`, text }, []); }
124
+ function hint(text) { return el("p", { class: "hint", text }, []); }
125
+
126
+ function showView(name) {
127
+ for (const button of $all(".nav-btn")) button.classList.toggle("active", button.dataset.view === name);
128
+ for (const view of $all(".view")) view.classList.toggle("active", view.id === `view-${name}`);
129
+ if (location.hash !== `#${name}`) history.replaceState(null, "", `#${name}`);
130
+ if (name === "slots" && !slotsLoaded) void loadSlots();
131
+ if (name === "providers" && !providersLoaded) void loadProviders();
132
+ if (name === "clients" && !clientsLoaded) void loadClients();
133
+ if (name === "logs") queueMicrotask(() => void refreshRequests());
134
+ }
135
+ for (const button of $all(".nav-btn")) button.addEventListener("click", () => showView(button.dataset.view));
136
+ window.addEventListener("hashchange", () => showView((location.hash || "#health").slice(1)));
137
+ showView((location.hash || "#health").slice(1) || "health");
138
+
139
+ (function setupLanguage() {
140
+ const button = $("#lang-toggle");
141
+ const lang = (typeof CURRENT_LANG !== "undefined" && CURRENT_LANG) || "en";
142
+ button.textContent = lang === "ko" ? t("lang.toggleEn") : t("lang.toggleKo");
143
+ button.addEventListener("click", () => {
144
+ try { localStorage.setItem("clauderipple_lang", lang === "ko" ? "en" : "ko"); } catch { /* unavailable */ }
145
+ location.reload();
146
+ });
147
+ })();
148
+
149
+ async function loadCatalogs() {
150
+ const [modelResult, presetResult, effortResult] = await Promise.allSettled([api("/api/claude-models"), api("/api/presets"), api("/api/effort-levels")]);
151
+ if (modelResult.status === "fulfilled" && Array.isArray(modelResult.value.models) && modelResult.value.models.length) claudeModels = modelResult.value.models;
152
+ if (presetResult.status === "fulfilled" && Array.isArray(presetResult.value.presets) && presetResult.value.presets.length) presets = presetResult.value.presets;
153
+ if (effortResult.status === "fulfilled" && effortResult.value && effortResult.value.providers) effortCatalog = effortResult.value;
154
+ }
155
+
156
+ // ---- Status -------------------------------------------------------------------------
157
+
158
+ async function refreshHealth() {
159
+ try {
160
+ status = await api("/api/status");
161
+ } catch (error) {
162
+ $("#health-desktop").replaceChildren(el("div", { class: "row" }, [el("span", { class: "k", text: t("health.desktop") }), badge("bad", t("health.disconnected"))]));
163
+ return;
164
+ }
165
+ // Served files changed underneath an open window (e.g. after an update): reload, unless a form is open.
166
+ if (status.uiRevision) {
167
+ if (loadedUiRevision === null) loadedUiRevision = status.uiRevision;
168
+ else if (loadedUiRevision !== status.uiRevision && $("#modal-backdrop").hidden) location.reload();
169
+ }
170
+ const desktop = $("#health-desktop");
171
+ desktop.replaceChildren(
172
+ el("div", { class: "row" }, [el("span", { class: "k", text: t("health.connection") }), status.settings.pointsAtRouter ? badge("ok", t("health.connected")) : badge("bad", t("health.notConnected"))]),
173
+ hint(status.settings.pointsAtRouter ? t("health.connectedHelp") : t("health.notConnectedHelp")),
174
+ );
175
+ $("#health-requests").replaceChildren(el("div", { class: "row" }, [el("span", { class: "k", text: t("health.requests") }), el("span", { class: "v", text: t("health.requestsFmt", status.stats) })]));
176
+ renderHealthProviders();
177
+ if (clientsLoaded) renderClients();
178
+ const details = $("#health-details");
179
+ details.replaceChildren(
180
+ el("div", { class: "row" }, [el("span", { class: "k", text: t("health.version") }), el("span", { class: "v", text: status.version })]),
181
+ // Which files answer, and since when: the only way to see that an update actually replaced the router.
182
+ ...(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()}` })])] : []),
183
+ el("div", { class: "row" }, [el("span", { class: "k", text: t("health.routes") }), el("span", { class: "v", text: String(status.routes) })]),
184
+ el("div", { class: "row" }, [el("span", { class: "k", text: t("health.cli") }), el("span", { class: "v", text: status.cliVersion })]),
185
+ );
186
+ }
187
+
188
+ function quotaLine(name) {
189
+ const quota = status && status.chatgpt && status.chatgpt.quota && status.chatgpt.quota[name];
190
+ const primary = quota && quota.rate_limits && quota.rate_limits.primary;
191
+ if (!primary) return null;
192
+ const reset = primary.reset_after_seconds ? t("health.resetsIn", { hours: Math.max(1, Math.round(primary.reset_after_seconds / 3600)) }) : "";
193
+ return t("health.quota", { percent: primary.used_percent, reset });
194
+ }
195
+ function stateFor(name) { return probeStates.get(name); }
196
+ function chatgptLoginButton(onChange) {
197
+ const button = el("button", { class: "btn secondary", type: "button", text: t("providers.chatgptLogin") });
198
+ button.disabled = chatgptLoginBusy;
199
+ button.addEventListener("click", () => void startChatgptLogin(onChange));
200
+ return button;
201
+ }
202
+ async function startChatgptLogin(onChange) {
203
+ if (chatgptLoginBusy) return;
204
+ chatgptLoginBusy = true;
205
+ chatgptLoginMessage = t("providers.chatgptLoginWaiting");
206
+ onChange && onChange();
207
+ try {
208
+ await api("/api/chatgpt-login", { method: "POST" });
209
+ const deadline = Date.now() + 6 * 60 * 1000;
210
+ while (Date.now() < deadline) {
211
+ await new Promise((resolve) => setTimeout(resolve, 2000));
212
+ const login = await api("/api/chatgpt-login");
213
+ if (login.signedIn) {
214
+ toast(t("providers.chatgptLoginDone"));
215
+ await refreshHealth();
216
+ return;
217
+ }
218
+ if (login.running === false && login.ok === false) {
219
+ toast(t("common.actionFailed"), true, login.output);
220
+ return;
221
+ }
222
+ }
223
+ } catch (error) {
224
+ toast(t("common.actionFailed"), true, error.message);
225
+ } finally {
226
+ chatgptLoginBusy = false;
227
+ chatgptLoginMessage = "";
228
+ onChange && onChange();
229
+ }
230
+ }
231
+ function providerState(name, provider) {
232
+ const live = status && status.providers && status.providers[name];
233
+ // Reaching the host is not the same as being able to use it. A ChatGPT provider with no
234
+ // credentials would otherwise read "Connected" and send the user off believing it works.
235
+ if (live && live.needsLogin) return badge("warn", t("providerStatus.loginNeeded"));
236
+ const state = stateFor(name);
237
+ if (state) return state.ok ? badge("ok", t("providerStatus.connected")) : state.auth === "bad-key" ? badge("bad", t("providerStatus.keyNeeded")) : badge("bad", t("providerStatus.disconnected"));
238
+ if (live) return live.reachable ? badge("ok", t("providerStatus.connected")) : badge("bad", t("providerStatus.disconnected"));
239
+ return badge("warn", t("providerStatus.checking"));
240
+ }
241
+ function renderHealthProviders() {
242
+ const box = $("#health-providers");
243
+ const providers = Object.entries((currentConfig && currentConfig.providers) || (status && status.providers) || {});
244
+ if (!providers.length) {
245
+ box.replaceChildren(hint(t("health.noProviders")));
246
+ return;
247
+ }
248
+ box.replaceChildren(...providers.map(([name, provider]) => {
249
+ const live = status && status.providers && status.providers[name];
250
+ const line = el("div", { class: "provider-status" }, [
251
+ el("strong", { text: name }),
252
+ providerState(name, provider),
253
+ live && live.needsLogin ? chatgptLoginButton(() => renderHealthProviders()) : null,
254
+ live && live.needsLogin && chatgptLoginMessage ? el("span", { class: "small", text: chatgptLoginMessage }) : null,
255
+ ].filter(Boolean));
256
+ const quota = quotaLine(name);
257
+ if (quota) line.appendChild(el("div", { class: "small", text: quota }));
258
+ return line;
259
+ }));
260
+ }
261
+
262
+ let agentTitleBusy = false;
263
+ let codexBusy = false;
264
+ function renderClients() {
265
+ if (!currentConfig || !status) return;
266
+ const picker = status.picker || { enabled: false, last: null };
267
+ const last = picker.last || null;
268
+ const names = status.pickerModels || [];
269
+ $("#client-picker-rows").replaceChildren(...[
270
+ el("div", { class: "row" }, [el("span", { class: "k", text: t("picker.state") }), picker.enabled ? badge("ok", t("picker.on")) : el("span", { class: "small", text: t("picker.off") })]),
271
+ picker.enabled ? el("div", { class: "small", text: t("picker.models", { count: names.length, names: names.join(", ") || "—" }) }) : null,
272
+ picker.enabled ? (last && last.at ? el("div", { class: "small", text: t("picker.lastAt", { at: new Date(last.at).toLocaleString() }) }) : hint(t("picker.never"))) : null,
273
+ ].filter(Boolean));
274
+ const pickerButton = $("#client-picker-toggle");
275
+ pickerButton.textContent = picker.enabled ? t("picker.turnOff") : t("picker.turnOn");
276
+ pickerButton.className = picker.enabled ? "btn secondary" : "btn";
277
+ pickerButton.disabled = pickerBusy;
278
+ pickerButton.onclick = () => togglePicker(!picker.enabled);
279
+ renderClientPickerModels(picker.enabled);
280
+ const agentEnabled = Boolean(status.agentTitle);
281
+ $("#client-agent-title-rows").replaceChildren(el("div", { class: "row" }, [el("span", { class: "k", text: t("picker.state") }), agentEnabled ? badge("ok", t("agentTitle.on")) : el("span", { class: "small", text: t("agentTitle.off") })]));
282
+ const agentButton = $("#client-agent-title-toggle");
283
+ agentButton.textContent = agentEnabled ? t("agentTitle.turnOff") : t("agentTitle.turnOn");
284
+ agentButton.className = agentEnabled ? "btn secondary" : "btn";
285
+ agentButton.disabled = agentTitleBusy;
286
+ agentButton.onclick = () => toggleAgentTitle(!agentEnabled);
287
+ void renderCodexClient();
288
+ 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 })}`);
289
+ $("#client-claude-code-rows").replaceChildren(el("div", { class: "small", text: mapped.join(" · ") || t("slots.noChanges") }));
290
+ }
291
+ function renderClientPickerModels(enabled) {
292
+ const card = $("#client-picker-models-card");
293
+ card.hidden = !enabled;
294
+ if (!enabled) return;
295
+ const box = $("#client-picker-models");
296
+ const checked = new Set(((currentConfig.cli && currentConfig.cli.extraModels) || []).map((item) => item.model));
297
+ box.replaceChildren();
298
+ for (const group of groupedModels(currentConfig)) for (const model of group.models) {
299
+ const input = el("input", { type: "checkbox", checked: checked.has(model.id) });
300
+ input.dataset.model = model.id;
301
+ input.dataset.provider = group.name;
302
+ input.dataset.name = labelOf(model);
303
+ input.addEventListener("change", () => void saveClientPickerModels());
304
+ box.appendChild(el("label", { class: "model-check" }, [input, el("span", { text: labelOf(model) }), el("small", { text: group.name })]));
305
+ }
306
+ if (!box.childElementCount) box.appendChild(hint(t("slots.noProviderModels")));
307
+ }
308
+ function clientPickerSelections() {
309
+ return $all("#client-picker-models input:checked").map((input) => ({ id: input.dataset.model, name: input.dataset.name, provider: input.dataset.provider }));
310
+ }
311
+ async function saveClientPickerModels() {
312
+ if (!currentConfig) return;
313
+ const next = applyPickerSelections(clone(currentConfig), clientPickerSelections());
314
+ try { await configRequest(next); currentConfig = next; toast(t("slots.saved")); } catch (error) { toast(t("common.saveFailed"), true, error.message); }
315
+ }
316
+ function pickerModeOn() {
317
+ return Boolean(status && status.picker && status.picker.enabled);
318
+ }
319
+ // Ticking "show in the Claude app picker" only records which models to show; nothing appears until
320
+ // picker mode itself is on. Ask right after saving so the user is not left with a silent no-op
321
+ // (the box was ticked, picker mode stayed off, and the picker never changed — seen on Windows, 2026-09-14).
322
+ async function offerPickerOn(wanted) {
323
+ if (!wanted || pickerModeOn()) return;
324
+ if (!confirm(t("providers.pickerOffPrompt"))) return;
325
+ await togglePicker(true, true);
326
+ }
327
+ async function togglePicker(enabled, confirmed = false) {
328
+ if (pickerBusy) return;
329
+ if (enabled && !confirmed && !confirm(t("picker.confirmOn"))) return;
330
+ pickerBusy = true;
331
+ $("#client-picker-msg").textContent = t("picker.working");
332
+ try {
333
+ await api("/api/picker", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled }) });
334
+ toast(enabled ? t("picker.doneOn") : t("picker.doneOff"));
335
+ if (currentConfig) currentConfig.picker = { ...(currentConfig.picker || {}), enabled };
336
+ } catch (error) {
337
+ toast(t("common.actionFailed"), true, error.message);
338
+ } finally {
339
+ pickerBusy = false;
340
+ $("#client-picker-msg").textContent = "";
341
+ void refreshHealth();
342
+ }
343
+ }
344
+ async function toggleAgentTitle(enabled) {
345
+ if (agentTitleBusy) return;
346
+ agentTitleBusy = true;
347
+ try {
348
+ await api("/api/agent-title", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled }) });
349
+ toast(t("common.saved"));
350
+ } catch (error) {
351
+ toast(t("common.actionFailed"), true, error.message);
352
+ } finally {
353
+ agentTitleBusy = false;
354
+ void refreshHealth();
355
+ }
356
+ }
357
+ async function renderCodexClient() {
358
+ const rows = $("#client-codex-rows");
359
+ const button = $("#client-codex-toggle");
360
+ try {
361
+ const codex = await api("/api/codex");
362
+ rows.replaceChildren(el("div", { class: "row" }, [el("span", { class: "k", text: t("picker.state") }), codex.enabled ? badge("ok", t("picker.on")) : el("span", { class: "small", text: t("picker.off") })]), el("div", { class: "small", text: codex.configPath }));
363
+ button.textContent = codex.enabled ? t("clients.codex.turnOff") : t("clients.codex.turnOn");
364
+ button.className = codex.enabled ? "btn secondary" : "btn";
365
+ button.disabled = codexBusy;
366
+ button.onclick = () => toggleCodex(!codex.enabled);
367
+ } catch (error) {
368
+ rows.replaceChildren(el("div", { class: "small bad-text", text: error.message }));
369
+ }
370
+ }
371
+ async function toggleCodex(enabled) {
372
+ if (codexBusy) return;
373
+ codexBusy = true;
374
+ $("#client-codex-msg").textContent = t("picker.working");
375
+ try { const result = await api("/api/codex", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled }) }); toast(t("common.saved"), false, result.output); }
376
+ catch (error) { toast(t("common.actionFailed"), true, error.message); }
377
+ finally { codexBusy = false; $("#client-codex-msg").textContent = ""; void renderCodexClient(); }
378
+ }
379
+ void Promise.all([loadCatalogs(), refreshHealth()]);
380
+ setInterval(refreshHealth, 5000);
381
+
382
+ // ---- Model links --------------------------------------------------------------------
383
+
384
+ function claudeSelect(selected) {
385
+ const select = el("select", {});
386
+ for (const model of claudeModels) select.appendChild(selectOption(model.id, labelOf(model)));
387
+ select.value = selected || claudeModels[0].id;
388
+ return select;
389
+ }
390
+ function targetSelect(route) {
391
+ const select = el("select", {});
392
+ select.appendChild(selectOption("", t("slots.passthrough")));
393
+ for (const group of groupedModels(currentConfig)) {
394
+ const optgroup = el("optgroup", { label: group.name });
395
+ for (const model of group.models) optgroup.appendChild(selectOption(JSON.stringify([group.name, model.id]), labelOf(model)));
396
+ select.appendChild(optgroup);
397
+ }
398
+ select.value = route ? JSON.stringify([route.provider, route.model]) : "";
399
+ return select;
400
+ }
401
+ function effortSelect(value, levels) {
402
+ const select = el("select", {});
403
+ for (const effort of levels) select.appendChild(selectOption(effort, effort));
404
+ select.value = levels.includes(value) ? value : (levels.includes("high") ? "high" : levels[0]);
405
+ return select;
406
+ }
407
+ function routeFromTarget(value) {
408
+ if (!value) return null;
409
+ try {
410
+ const [provider, model] = JSON.parse(value);
411
+ return provider && model ? { provider, model } : null;
412
+ } catch { return null; }
413
+ }
414
+ function slotRow(id, route) {
415
+ const row = el("tr", {});
416
+ const source = claudeSelect(id);
417
+ const target = targetSelect(route);
418
+ const initialTarget = routeFromTarget(target.value);
419
+ const effort = effortSelect(route && route.effort, initialTarget ? effortLevelsFor(initialTarget.provider, initialTarget.model) : ["high"]);
420
+ const effortCell = el("td", {}, [effort]);
421
+ const remove = el("button", { class: "icon-btn", type: "button", title: t("common.remove"), text: "×", onclick: () => { row.remove(); updateSlotSummary(); scheduleSlotsSave(); } });
422
+ function sync() {
423
+ const selected = routeFromTarget(target.value);
424
+ const levels = selected ? effortLevelsFor(selected.provider, selected.model) : [];
425
+ const supported = levels.length > 0;
426
+ const prior = effort.value;
427
+ effort.replaceChildren(...levels.map((level) => selectOption(level, level)));
428
+ if (supported) effort.value = levels.includes(prior) ? prior : (levels.includes("high") ? "high" : levels[0]);
429
+ effort.disabled = !supported;
430
+ effortCell.classList.toggle("muted-cell", !supported);
431
+ if (!supported) {
432
+ effortCell.dataset.empty = t("common.notAvailable");
433
+ effortCell.title = t("slots.noEffort");
434
+ } else {
435
+ delete effortCell.dataset.empty;
436
+ effortCell.removeAttribute("title");
437
+ }
438
+ updateSlotSummary();
439
+ }
440
+ target.addEventListener("change", () => { sync(); scheduleSlotsSave(); });
441
+ source.addEventListener("change", () => { updateSlotSummary(); scheduleSlotsSave(); });
442
+ effort.addEventListener("change", scheduleSlotsSave);
443
+ row.append(el("td", {}, [source]), el("td", {}, [target]), effortCell, el("td", { class: "icon-cell" }, [remove]));
444
+ row._get = () => ({ id: source.value, route: routeFromTarget(target.value), effort: effort.value });
445
+ sync();
446
+ return row;
447
+ }
448
+ async function loadSlots() {
449
+ slotsLoaded = true;
450
+ try {
451
+ await loadCatalogs();
452
+ currentConfig = await api("/api/config");
453
+ const rows = $("#slots-table tbody");
454
+ rows.replaceChildren();
455
+ const routes = currentConfig.routes || {};
456
+ const ids = [...claudeModels.map((model) => model.id), ...Object.keys(routes).filter((id) => !claudeModels.some((model) => model.id === id))];
457
+ for (const id of ids) rows.appendChild(slotRow(id, routes[id]));
458
+ updateSlotSummary();
459
+ } catch (error) {
460
+ toast(t("common.loadFailed"), true, error.message);
461
+ }
462
+ }
463
+ $("#slots-add").addEventListener("click", () => {
464
+ if (!currentConfig) return;
465
+ $("#slots-table tbody").appendChild(slotRow(claudeModels[0].id, null));
466
+ updateSlotSummary();
467
+ });
468
+ function allKnownModelIds(config) { return new Set(groupedModels(config).flatMap((group) => group.models.map((model) => model.id))); }
469
+ function applyPickerSelections(next, selections) {
470
+ const known = allKnownModelIds(next);
471
+ // An entry no provider offers any more has no checkbox — the list is built from the providers —
472
+ // so keeping it here left removed models in the app picker forever, twice over once the same id
473
+ // had been written twice. Orphans go, the rest is deduplicated by model id, and the direct rules
474
+ // that served only an orphan go with it. A prefix rule that is not a model id (the legacy gpt-
475
+ // one) is not an orphan and stays.
476
+ const currentExtras = (next.cli && next.cli.extraModels) || [];
477
+ const orphans = new Set(currentExtras.filter((entry) => entry.model && !known.has(entry.model)).map((entry) => entry.model));
478
+ const selected = selections.filter((entry) => entry.id && entry.provider && known.has(entry.id));
479
+ const extras = new Map();
480
+ const direct = new Map();
481
+ for (const entry of selected) {
482
+ extras.set(entry.id, { model: entry.id, name: entry.name || entry.id });
483
+ direct.set(entry.id, { prefix: entry.id, provider: entry.provider });
484
+ }
485
+ next.cli = { ...(next.cli || {}), extraModels: [...extras.values()] };
486
+ const preservedDirect = (next.direct || []).filter((rule) => !known.has(rule.prefix) && !orphans.has(rule.prefix));
487
+ next.direct = [...preservedDirect, ...direct.values()];
488
+ // A legacy gpt- prefix rule is intentionally retained by the filter above.
489
+ return next;
490
+ }
491
+ function updateSlotSummary() {
492
+ const summaries = $all("#slots-table tbody tr").map((row) => row._get()).filter((item) => item.route).slice(0, 3).map((item) => {
493
+ const source = claudeModels.find((model) => model.id === item.id);
494
+ const group = groupedModels(currentConfig).find((itemGroup) => itemGroup.name === item.route.provider);
495
+ const target = group && group.models.find((model) => model.id === item.route.model);
496
+ return `${labelOf(source || { id: item.id })} → ${labelOf(target || { id: item.route.model })}${effortLevelsFor(item.route.provider, item.route.model).length ? ` (${item.effort})` : ""}`;
497
+ });
498
+ $("#slots-summary").textContent = summaries.length ? summaries.join(" · ") : t("slots.noChanges");
499
+ }
500
+ // Changes save themselves (debounced); there is no Save button. `slotsStatus` shows saving/saved/error.
501
+ let slotsSaveTimer = null;
502
+ let slotsSaving = false;
503
+ function slotsStatus(text, isError) {
504
+ const box = $("#slots-status");
505
+ box.textContent = text;
506
+ box.classList.toggle("bad-text", Boolean(isError));
507
+ }
508
+ function scheduleSlotsSave() {
509
+ if (!currentConfig || !slotsLoaded) return;
510
+ clearTimeout(slotsSaveTimer);
511
+ slotsStatus(t("slots.saving"));
512
+ slotsSaveTimer = setTimeout(() => void saveSlots(), 500);
513
+ }
514
+ async function saveSlots() {
515
+ if (!currentConfig || slotsSaving) return;
516
+ slotsSaving = true;
517
+ const next = clone(currentConfig);
518
+ const routes = {};
519
+ const seen = new Set();
520
+ for (const row of $all("#slots-table tbody tr")) {
521
+ const item = row._get();
522
+ if (seen.has(item.id)) { slotsStatus(t("slots.duplicate"), true); slotsSaving = false; return; }
523
+ seen.add(item.id);
524
+ if (item.route) routes[item.id] = { ...item.route, ...(effortLevelsFor(item.route.provider, item.route.model).length ? { effort: item.effort } : {}) };
525
+ }
526
+ next.routes = routes;
527
+ try {
528
+ await configRequest(next);
529
+ currentConfig = next;
530
+ slotsStatus(t("slots.saved"));
531
+ updateSlotSummary();
532
+ } catch (error) {
533
+ slotsStatus(`${t("common.saveFailed")} ${error.message}`, true);
534
+ } finally {
535
+ slotsSaving = false;
536
+ }
537
+ }
538
+
539
+ // ---- Providers ----------------------------------------------------------------------
540
+
541
+ function statusText(state) {
542
+ if (!state) return t("providerStatus.checking");
543
+ if (state.ok) return t("providerStatus.connected");
544
+ if (state.auth === "bad-key") return t("providerStatus.keyNeeded");
545
+ return t("providerStatus.disconnected");
546
+ }
547
+ async function probeProvider(name, provider, onComplete) {
548
+ probeStates.set(name, { pending: true });
549
+ renderHealthProviders();
550
+ try {
551
+ const headers = provider.headers || {};
552
+ const body = provider.type === "anthropic"
553
+ ? { type: "anthropic", auth: provider.auth, ...(provider.auth === "api-key" && provider.apiKey ? { apiKey: provider.apiKey } : {}) }
554
+ : {
555
+ type: provider.type,
556
+ url: provider.url,
557
+ headers,
558
+ modelsUrl: provider.modelsUrl || (provider.preset && presetById(provider.preset) && presetById(provider.preset).modelsUrl),
559
+ modelsAuthHeader: provider.modelsAuthHeader || (provider.preset && presetById(provider.preset) && presetById(provider.preset).modelsAuthHeader),
560
+ probeModel: provider.probeModel || (provider.preset && presetById(provider.preset) && (presetById(provider.preset).fallbackModels || [])[0] && presetById(provider.preset).fallbackModels[0].id),
561
+ };
562
+ const result = await api("/api/providers/probe", {
563
+ method: "POST",
564
+ headers: { "content-type": "application/json" },
565
+ body: JSON.stringify(body),
566
+ });
567
+ probeStates.set(name, result);
568
+ onComplete && onComplete(result);
569
+ return result;
570
+ } catch (error) {
571
+ const unavailable = error.status === 404;
572
+ const result = { ok: false, auth: unavailable ? "unknown" : "unreachable", models: [], error: unavailable ? t("providers.apiSoon") : error.message, unavailable };
573
+ probeStates.set(name, result);
574
+ onComplete && onComplete(result);
575
+ return result;
576
+ } finally {
577
+ renderHealthProviders();
578
+ }
579
+ }
580
+ function providerCard(name, provider) {
581
+ const card = el("article", { class: "card provider-card" });
582
+ const title = el("h2", { text: name });
583
+ const stateLine = el("div", { class: "provider-state" });
584
+ const modelText = el("p", { class: "small" });
585
+ const effortText = el("p", { class: "small" });
586
+ const check = el("button", { class: "btn secondary", type: "button", text: t("providers.check") });
587
+ const edit = el("button", { class: "btn secondary", type: "button", text: t("common.edit") });
588
+ const remove = el("button", { class: "btn danger", type: "button", text: t("common.remove") });
589
+ const draw = () => {
590
+ const state = stateFor(name);
591
+ const preset = provider.preset && presetById(provider.preset);
592
+ let kind = provider.type === "chatgpt" ? t("providers.chatgpt") : provider.type === "anthropic" ? `${t("providers.anthropic")} · ${provider.auth === "claude-code" ? t("providers.anthropicLoginReuse") : t("providers.apiKey")}` : preset ? preset.name : "";
593
+ if (provider.type !== "chatgpt" && provider.type !== "anthropic") { try { kind = `${kind ? kind + " · " : ""}${new URL(provider.url).host}`; } catch { /* keep */ } }
594
+ const live = status && status.providers && status.providers[name];
595
+ stateLine.replaceChildren(...[
596
+ providerState(name, provider),
597
+ live && live.needsLogin ? chatgptLoginButton(draw) : null,
598
+ live && live.needsLogin && chatgptLoginMessage ? el("span", { class: "small", text: chatgptLoginMessage }) : null,
599
+ el("span", { class: "small", text: kind }),
600
+ state && !state.ok && state.error ? el("span", { class: "small bad-text", text: state.error }) : null,
601
+ ].filter(Boolean));
602
+ const models = modelsOf(provider);
603
+ modelText.replaceChildren(...(models.length
604
+ ? models.flatMap((model, index) => [
605
+ index ? document.createTextNode(", ") : null,
606
+ document.createTextNode(labelOf(model)),
607
+ modelEffortTag(model) ? el("span", { class: `model-effort-tag ${model.effortLevels.length ? "has-effort" : "no-effort"}`, text: modelEffortTag(model) }) : null,
608
+ ].filter(Boolean))
609
+ : [document.createTextNode(t("providers.noModels"))]));
610
+ if (hasModelEffortData(provider)) effortText.hidden = true;
611
+ else {
612
+ const levels = effortLevelsFor(name, models[0] && models[0].id);
613
+ effortText.textContent = t("providers.effortLevels", { levels: levels.length ? levels.join(" · ") : t("providers.effortNone") });
614
+ effortText.hidden = false;
615
+ }
616
+ };
617
+ check.addEventListener("click", async () => { check.disabled = true; await probeProvider(name, provider); check.disabled = false; draw(); });
618
+ edit.addEventListener("click", () => openProviderForm({ name, provider }));
619
+ remove.addEventListener("click", async () => {
620
+ if (!confirm(t("providers.removeConfirm", { name }))) return;
621
+ const next = clone(currentConfig);
622
+ delete next.providers[name];
623
+ next.routes = Object.fromEntries(Object.entries(next.routes || {}).filter(([, route]) => route.provider !== name));
624
+ next.direct = (next.direct || []).filter((rule) => rule.provider !== name);
625
+ next.cli.extraModels = (next.cli.extraModels || []).filter((entry) => !modelsOf(provider).some((model) => model.id === entry.model));
626
+ try { await configRequest(next); currentConfig = next; providersLoaded = false; slotsLoaded = false; await loadProviders(); toast(t("common.saved")); } catch (error) { toast(t("common.saveFailed"), true, error.message); }
627
+ });
628
+ const quota = quotaLine(name);
629
+ card.append(el("div", { class: "toolbar" }, [title, el("div", { class: "right" }, [check, edit, remove])]), stateLine, modelText, effortText, quota ? el("div", { class: "small", text: quota }) : document.createTextNode(""));
630
+ draw();
631
+ return card;
632
+ }
633
+ async function loadClients() {
634
+ clientsLoaded = true;
635
+ try {
636
+ await loadCatalogs();
637
+ currentConfig = currentConfig || await api("/api/config");
638
+ if (!status) status = await api("/api/status");
639
+ renderClients();
640
+ } catch (error) { toast(t("common.loadFailed"), true, error.message); }
641
+ }
642
+
643
+ async function loadProviders() {
644
+ providersLoaded = true;
645
+ try {
646
+ await loadCatalogs();
647
+ currentConfig = currentConfig || await api("/api/config");
648
+ const list = $("#providers-list");
649
+ list.replaceChildren(...Object.entries(currentConfig.providers || {}).map(([name, provider]) => providerCard(name, provider)));
650
+ if (!currentConfig.providers || !Object.keys(currentConfig.providers).length) list.appendChild(el("div", { class: "empty-card", text: t("providers.empty") }));
651
+ void Promise.all(Object.entries(currentConfig.providers || {}).map(([name, provider]) => probeProvider(name, provider, () => {
652
+ const existing = $all(".provider-card").find((card) => card.querySelector("h2").textContent === name);
653
+ if (existing) { existing.remove(); list.appendChild(providerCard(name, provider)); }
654
+ })));
655
+ } catch (error) { toast(t("common.loadFailed"), true, error.message); }
656
+ }
657
+ $("#providers-add").addEventListener("click", openProviderChooser);
658
+ $("#providers-refresh").addEventListener("click", async () => {
659
+ if (!currentConfig) return;
660
+ await Promise.all(Object.entries(currentConfig.providers).map(([name, provider]) => probeProvider(name, provider)));
661
+ providersLoaded = false;
662
+ await loadProviders();
663
+ });
664
+
665
+ // ---- Provider modal -----------------------------------------------------------------
666
+
667
+ function showModal(content) {
668
+ $("#modal-content").replaceChildren(content);
669
+ $("#modal-backdrop").hidden = false;
670
+ const first = $("#modal-content input, #modal-content button, #modal-content select");
671
+ if (first) setTimeout(() => first.focus(), 0);
672
+ }
673
+ function closeModal() { $("#modal-backdrop").hidden = true; $("#modal-content").replaceChildren(); }
674
+ $("#modal-close").addEventListener("click", closeModal);
675
+ $("#modal-backdrop").addEventListener("click", (event) => { if (event.target === $("#modal-backdrop")) closeModal(); });
676
+ window.addEventListener("keydown", (event) => {
677
+ if (event.key === "Escape" && !$("#modal-backdrop").hidden) { closeModal(); return; }
678
+ if (event.key === "Enter" && !$("#modal-backdrop").hidden && event.target.tagName !== "TEXTAREA") {
679
+ const action = $("#modal-content [data-default-action]");
680
+ if (action && !action.disabled) { event.preventDefault(); action.click(); }
681
+ }
682
+ });
683
+ function openProviderChooser() {
684
+ const grid = el("div", { class: "chooser-grid" });
685
+ const anthropic = el("button", { class: "chooser-tile", type: "button" }, [el("strong", { text: t("providers.anthropic") }), el("span", { text: t("providers.anthropicHelp") })]);
686
+ anthropic.addEventListener("click", () => openProviderForm({ kind: "anthropic" }));
687
+ grid.appendChild(anthropic);
688
+ const chatgpt = el("button", { class: "chooser-tile", type: "button" }, [el("strong", { text: t("providers.chatgpt") }), el("span", { text: t("providers.chatgptHelp") })]);
689
+ chatgpt.addEventListener("click", () => openProviderForm({ kind: "chatgpt" }));
690
+ grid.appendChild(chatgpt);
691
+ const native = presets.filter((preset) => (preset.kind || "anthropic-compatible") === "anthropic-compatible");
692
+ for (const preset of native) {
693
+ const tile = el("button", { class: "chooser-tile", type: "button" }, [el("strong", { text: preset.name }), el("span", { text: t("providers.presetHelp") })]);
694
+ tile.addEventListener("click", () => openProviderForm({ preset }));
695
+ grid.appendChild(tile);
696
+ }
697
+ const openai = presets.filter((preset) => preset.kind === "openai-compatible");
698
+ if (openai.length) {
699
+ grid.appendChild(el("div", { class: "chooser-group" }, [el("strong", { text: t("providers.openaiGroup") }), el("span", { text: t("providers.openaiGroupHelp") })]));
700
+ for (const preset of openai) {
701
+ const tile = el("button", { class: "chooser-tile", type: "button" }, [el("strong", { text: preset.name }), el("span", { text: t("providers.presetHelp") })]);
702
+ tile.addEventListener("click", () => openProviderForm({ preset }));
703
+ grid.appendChild(tile);
704
+ }
705
+ }
706
+ const custom = el("button", { class: "chooser-tile", type: "button" }, [el("strong", { text: t("providers.custom") }), el("span", { text: t("providers.customHelp") })]);
707
+ custom.addEventListener("click", () => openProviderForm({ kind: "custom" }));
708
+ grid.appendChild(custom);
709
+ showModal(el("div", {}, [el("h1", { id: "modal-title", text: t("providers.choose") }), hint(t("providers.chooseHelp")), grid]));
710
+ }
711
+ function inputRow(label, control, helpText) {
712
+ return el("label", { class: "form-field" }, [el("span", { text: label }), control, helpText ? el("small", { text: helpText }) : null]);
713
+ }
714
+ // Checklist that stays usable with hundreds of models (OpenRouter lists 400+): a search box, checked
715
+ // entries pinned first, at most 36 visible rows, and the selection kept in a Set so filtering never
716
+ // loses ticks. `box.selected()` returns the chosen {id,name} entries.
717
+ function modelChecklist(models, checked) {
718
+ const selected = new Map();
719
+ for (const model of models) if (checked.has(model.id)) selected.set(model.id, { id: model.id, name: labelOf(model), ...(Array.isArray(model.effortLevels) ? { effortLevels: [...model.effortLevels] } : {}) });
720
+ const wrap = el("div", { class: "model-picker" });
721
+ const grid = el("div", { class: "model-checklist modal-checklist" });
722
+ const note = el("div", { class: "small", text: "" });
723
+ const search = models.length > 12 ? el("input", { type: "search", placeholder: t("providers.searchModels"), class: "model-search" }) : null;
724
+ const LIMIT = 36;
725
+ function render() {
726
+ const q = (search ? search.value : "").trim().toLowerCase();
727
+ const matches = models.filter((m) => !q || m.id.toLowerCase().includes(q) || labelOf(m).toLowerCase().includes(q));
728
+ const pinned = matches.filter((m) => selected.has(m.id));
729
+ const rest = matches.filter((m) => !selected.has(m.id)).slice(0, Math.max(0, LIMIT - pinned.length));
730
+ grid.replaceChildren();
731
+ for (const model of [...pinned, ...rest]) {
732
+ const input = el("input", { type: "checkbox", checked: selected.has(model.id) });
733
+ input.dataset.model = model.id;
734
+ input.dataset.name = labelOf(model);
735
+ input.addEventListener("change", () => {
736
+ if (input.checked) selected.set(model.id, { id: model.id, name: labelOf(model), ...(Array.isArray(model.effortLevels) ? { effortLevels: [...model.effortLevels] } : {}) });
737
+ else selected.delete(model.id);
738
+ note.textContent = summary(matches.length);
739
+ });
740
+ const tag = modelEffortTag(model);
741
+ grid.appendChild(el("label", { class: "model-check", title: model.id }, [input, el("span", { text: labelOf(model) }), tag ? el("span", { class: `model-effort-tag ${model.effortLevels.length ? "has-effort" : "no-effort"}`, text: tag }) : null]));
742
+ }
743
+ note.textContent = summary(matches.length);
744
+ }
745
+ function summary(matchCount) {
746
+ const hidden = Math.max(0, matchCount - Math.min(matchCount, LIMIT));
747
+ return t("providers.modelsSummary", { selected: selected.size, total: models.length }) + (hidden > 0 ? " · " + t("providers.modelsHidden", { hidden }) : "");
748
+ }
749
+ if (search) { search.addEventListener("input", render); wrap.appendChild(search); }
750
+ wrap.append(grid, note);
751
+ wrap.selected = () => [...selected.values()];
752
+ render();
753
+ return wrap;
754
+ }
755
+ function anthropicSourceText(source) {
756
+ if (source === "observed") return t("providers.anthropicSourceObserved");
757
+ if (source === "keychain" || source === "credentials-file" || source === "env") return t("providers.anthropicSourceClaudeCode");
758
+ if (source === "token-file") return t("providers.anthropicSourceTokenFile");
759
+ return t("providers.anthropicSourceMissing");
760
+ }
761
+ function openAnthropicProviderForm(options) {
762
+ const existing = options.provider;
763
+ const displayName = options.name || t("providers.anthropic");
764
+ const nameInput = el("input", { value: displayName, maxlength: "60" });
765
+ const auth = el("select", {}, [selectOption("claude-code", t("providers.anthropicAuthClaudeCode")), selectOption("api-key", t("providers.anthropicAuthApiKey"))]);
766
+ auth.value = (existing && existing.auth) || "claude-code";
767
+ const keyInput = el("input", { type: "password", autocomplete: "off", placeholder: existing && existing.apiKey ? t("providers.keySaved") : t("providers.keyPlaceholder") });
768
+ const showKey = el("button", { class: "eye-button", type: "button", text: t("common.show") });
769
+ showKey.addEventListener("click", () => { const show = keyInput.type === "password"; keyInput.type = show ? "text" : "password"; showKey.textContent = show ? t("common.hide") : t("common.show"); });
770
+ const result = el("div", { class: "probe-result" });
771
+ const probeButton = el("button", { class: "btn secondary", type: "button", text: t("providers.check") });
772
+ const sourceLine = el("div", { class: "small" });
773
+ // Our own sign-in is stored even while a Claude Desktop session outranks it. Saying so is the
774
+ // only way a finished sign-in shows up on a screen whose source line does not change.
775
+ const signedInLine = el("div", { class: "small" });
776
+ function showSignedIn(response) {
777
+ const stored = response && response.signedIn;
778
+ signedInLine.hidden = !stored;
779
+ if (!stored) return;
780
+ signedInLine.textContent =
781
+ response.source === "token-file" ? t("providers.anthropicSignedInActive") : t("providers.anthropicSignedInStandby");
782
+ }
783
+ let foundModels = modelsOf(existing).length ? modelsOf(existing) : claudeModels.map((model) => ({ id: model.id, name: labelOf(model) }));
784
+ let selected = new Set(modelsOf(existing).length ? modelsOf(existing).map((model) => model.id) : foundModels.map((model) => model.id));
785
+ const modelArea = el("div", { class: "form-field" });
786
+ function renderModels() {
787
+ const modelsBox = modelChecklist(foundModels, selected);
788
+ modelArea.replaceChildren(el("span", { text: t("providers.models") }), hint(t("providers.modelsHelp")), el("small", { text: t("providers.effortLevels", { levels: "low · medium · high · max" }) }), modelsBox);
789
+ }
790
+ renderModels();
791
+ const authField = inputRow(t("providers.credentials"), auth, t("providers.anthropicCredentialsHelp"));
792
+ const keyField = el("div", { class: "form-field key-field" }, [el("span", { text: t("providers.apiKey") }), el("div", { class: "key-control" }, [keyInput, showKey]), el("small", { text: t("providers.keyHelp") })]);
793
+ const subscriptionActions = el("div", { class: "actions" }, [el("button", { class: "btn secondary", type: "button", text: t("providers.anthropicLogin") }), el("button", { class: "btn secondary", type: "button", text: t("providers.anthropicLogout") })]);
794
+ function syncAuthFields() {
795
+ const reused = auth.value === "claude-code";
796
+ keyField.hidden = reused;
797
+ subscriptionActions.hidden = !reused;
798
+ sourceLine.hidden = !reused;
799
+ signedInLine.hidden = !reused || !signedInLine.textContent;
800
+ }
801
+ auth.addEventListener("change", syncAuthFields);
802
+ syncAuthFields();
803
+ async function runProbe() {
804
+ probeButton.disabled = true;
805
+ result.textContent = t("providers.checking");
806
+ try {
807
+ const body = auth.value === "claude-code" ? { type: "anthropic", auth: "claude-code" } : { type: "anthropic", auth: "api-key", apiKey: keyInput.value || (existing && existing.apiKey) };
808
+ const response = await api("/api/providers/probe", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
809
+ sourceLine.textContent = anthropicSourceText(response.source);
810
+ showSignedIn(response);
811
+ const noCredits = response.ok && /^no-credits:/.test(response.error || "");
812
+ result.replaceChildren(...[
813
+ el("span", { class: response.ok && !noCredits ? "ok-text" : noCredits ? "warn-text" : "bad-text", text: noCredits ? t("providers.probeNoCredits") : response.ok ? t("providers.probeOk") : response.auth === "bad-key" ? t("providers.probeBadKey") : auth.value === "claude-code" ? anthropicSourceText(response.source) : t("providers.probeFailed") }),
814
+ response.error ? el("div", { class: "small", text: response.error.replace(/^no-credits:\s*/, "") }) : null,
815
+ ].filter(Boolean));
816
+ if (Array.isArray(response.models) && response.models.length) {
817
+ foundModels = response.models;
818
+ selected = new Set(modelsOf(existing).length ? modelsOf(existing).map((model) => model.id) : foundModels.map((model) => model.id));
819
+ renderModels();
820
+ }
821
+ } catch (error) { result.replaceChildren(el("span", { class: "bad-text", text: t("providers.probeFailed") }), el("div", { class: "small", text: error.message })); }
822
+ finally { probeButton.disabled = false; }
823
+ }
824
+ probeButton.addEventListener("click", () => void runProbe());
825
+ // Our own browser sign-in (PKCE): start it, show the link in case no window opened, poll until the
826
+ // router has the credential. When the loopback port is taken the code is pasted here instead.
827
+ let signInPoll = null;
828
+ function renderSignIn(state) {
829
+ const parts = [el("div", { text: state.manual ? t("providers.anthropicSignInPaste") : t("providers.anthropicSignInBrowser") })];
830
+ if (state.url) parts.push(el("a", { href: state.url, target: "_blank", rel: "noreferrer", text: t("providers.anthropicSignInLink") }));
831
+ if (state.manual) {
832
+ const codeInput = el("input", { type: "text", autocomplete: "off", placeholder: "code#state" });
833
+ const submit = el("button", { class: "btn secondary", type: "button", text: t("providers.anthropicSignInSubmit") });
834
+ submit.addEventListener("click", async () => {
835
+ submit.disabled = true;
836
+ try { const done = await api("/api/claude-oauth/code", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ code: codeInput.value }) }); finishSignIn(done); }
837
+ catch (error) { finishSignIn({ ok: false, error: (error.body && error.body.error) || error.message }); }
838
+ });
839
+ parts.push(el("div", { class: "key-control" }, [codeInput, submit]));
840
+ } else parts.push(el("div", { class: "small", text: t("providers.anthropicSignInWaiting") }));
841
+ const cancel = el("button", { class: "btn secondary", type: "button", text: t("common.cancel") });
842
+ cancel.addEventListener("click", async () => { clearInterval(signInPoll); await api("/api/claude-oauth/cancel", { method: "POST" }).catch(() => {}); result.replaceChildren(); });
843
+ parts.push(cancel);
844
+ result.replaceChildren(el("div", { class: "sign-in" }, parts));
845
+ }
846
+ function finishSignIn(state) {
847
+ clearInterval(signInPoll);
848
+ // The probe refreshes the source and the stored-sign-in line, then the outcome is put back:
849
+ // the probe's own wording would otherwise erase the answer to the button that was just pressed.
850
+ if (state.ok) { void runProbe().then(() => result.replaceChildren(el("span", { class: "ok-text", text: t("providers.anthropicLoginDone") }))); return; }
851
+ const retry = el("button", { class: "btn secondary", type: "button", text: t("providers.anthropicSignInManual") });
852
+ retry.addEventListener("click", () => void startSignIn(true));
853
+ result.replaceChildren(el("span", { class: "bad-text", text: t("providers.anthropicSignInFailed") }), el("div", { class: "small", text: state.error || "" }), retry);
854
+ }
855
+ async function startSignIn(manual) {
856
+ clearInterval(signInPoll);
857
+ try {
858
+ const state = await api("/api/claude-oauth", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ manual: Boolean(manual) }) });
859
+ renderSignIn(state);
860
+ signInPoll = setInterval(async () => {
861
+ try { const now = await api("/api/claude-oauth"); if (!now.running) finishSignIn(now); }
862
+ catch { /* the router may be busy; keep polling */ }
863
+ }, 2000);
864
+ } catch (error) { result.replaceChildren(el("span", { class: "bad-text", text: t("common.actionFailed") }), el("div", { class: "small", text: error.message })); }
865
+ }
866
+ subscriptionActions.children[0].addEventListener("click", () => void startSignIn(false));
867
+ subscriptionActions.children[1].addEventListener("click", async () => {
868
+ try { const response = await api("/api/claude-logout", { method: "POST" }); result.replaceChildren(el("span", { class: "ok-text", text: response.output })); await runProbe(); }
869
+ catch (error) { toast(t("common.actionFailed"), true, error.message); }
870
+ });
871
+ const form = el("div", { class: "provider-form" }, [
872
+ el("h1", { id: "modal-title", text: existing ? t("providers.edit") : t("providers.addTitle") }),
873
+ inputRow(t("providers.name"), nameInput, t("providers.nameHelp")), authField, keyField, probeButton, sourceLine, signedInLine, result, subscriptionActions, modelArea,
874
+ hint(t("providers.anthropicIngressOnly")),
875
+ ]);
876
+ const saveButton = el("button", { class: "btn", type: "button", "data-default-action": "", text: existing ? t("common.save") : t("providers.add") });
877
+ saveButton.addEventListener("click", async () => {
878
+ const typedName = nameInput.value.trim();
879
+ if (!typedName) { toast(t("providers.nameRequired"), true); return; }
880
+ const next = clone(currentConfig);
881
+ const providerName = existing ? options.name : uniqueName(typedName, next.providers);
882
+ const checkedModels = form.querySelector(".model-picker").selected();
883
+ const provider = { type: "anthropic", auth: auth.value, ...(auth.value === "api-key" && (keyInput.value || (existing && existing.apiKey)) ? { apiKey: keyInput.value || existing.apiKey } : {}), models: checkedModels };
884
+ next.providers[providerName] = provider;
885
+ // No picker/direct entries: these models are served to Codex through the OpenAI ingress, not
886
+ // routed from the Claude app, so a rule naming this provider would never fire.
887
+ saveButton.disabled = true;
888
+ try { await configRequest(next); currentConfig = next; slotsLoaded = false; clientsLoaded = false; providersLoaded = false; closeModal(); await loadProviders(); toast(t("common.saved")); }
889
+ catch (error) { toast(t("common.saveFailed"), true, error.message); }
890
+ finally { saveButton.disabled = false; }
891
+ });
892
+ form.appendChild(el("div", { class: "actions end" }, [el("button", { class: "btn secondary", type: "button", text: t("common.cancel"), onclick: closeModal }), saveButton]));
893
+ showModal(form);
894
+ if (auth.value === "claude-code") void runProbe();
895
+ }
896
+ function openProviderForm(options) {
897
+ const existing = options.provider;
898
+ const preset = options.preset || (existing && existing.preset && presetById(existing.preset));
899
+ const isChatgpt = options.kind === "chatgpt" || (existing && existing.type === "chatgpt");
900
+ const isAnthropic = options.kind === "anthropic" || (existing && existing.type === "anthropic");
901
+ const isOpenAi = !isChatgpt && !isAnthropic && ((existing && existing.type === "openai-compatible") || (preset && preset.kind === "openai-compatible"));
902
+ const isCustom = options.kind === "custom";
903
+ const displayName = options.name || (preset && preset.name) || (isChatgpt ? t("providers.chatgpt") : isAnthropic ? t("providers.anthropic") : t("providers.customName"));
904
+ if (isAnthropic) { openAnthropicProviderForm(options); return; }
905
+ const nameInput = el("input", { value: displayName, maxlength: "60" });
906
+ const keyInput = el("input", { type: "password", autocomplete: "off", placeholder: t("providers.keyPlaceholder") });
907
+ const showKey = el("button", { class: "eye-button", type: "button", text: t("common.show") });
908
+ showKey.addEventListener("click", () => { const show = keyInput.type === "password"; keyInput.type = show ? "text" : "password"; showKey.textContent = show ? t("common.hide") : t("common.show"); });
909
+ const currentHeaders = (existing && existing.headers) || {};
910
+ const headerKind = preset ? preset.authHeader : (isOpenAi || Object.keys(currentHeaders).some((key) => key.toLowerCase() === "authorization") ? "authorization-bearer" : "x-api-key");
911
+ const existingKey = headerKind === "authorization-bearer" ? String(currentHeaders.authorization || "").replace(/^Bearer\s+/i, "") : currentHeaders["x-api-key"] || "";
912
+ if (existingKey) keyInput.placeholder = t("providers.keySaved");
913
+ const urlInput = el("input", { value: (existing && existing.url) || (preset && preset.anthropicBaseUrl) || "", placeholder: "https://" });
914
+ const wireSelect = el("select", {}, [selectOption("chat", "Chat Completions"), selectOption("responses", "Responses")]);
915
+ wireSelect.value = (existing && existing.wire) || (preset && preset.wire) || "chat";
916
+ const pickerInput = el("input", { type: "checkbox", checked: Boolean(existing && (existing.models || []).some((model) => {
917
+ const id = typeof model === "string" ? model : model.id;
918
+ return ((currentConfig.cli && currentConfig.cli.extraModels) || []).some((extra) => extra.model === id);
919
+ })) });
920
+ const initialModels = modelsOf(existing).length ? modelsOf(existing) : (preset ? (preset.fallbackModels || []) : (isChatgpt ? CHATGPT_MODELS : []));
921
+ let foundModels = initialModels;
922
+ const currentChecked = new Set(modelsOf(existing).map((model) => model.id));
923
+ const modelsBox = modelChecklist(foundModels, currentChecked.size ? currentChecked : new Set(foundModels.map((model) => model.id)));
924
+ const providerEffortLevels = isChatgpt
925
+ ? effortLevelsFor(options.name || "chatgpt", "gpt-5.6-terra")
926
+ : (existing ? effortLevelsFor(options.name, initialModels[0] && initialModels[0].id) : fallbackEffortLevels({ ...(preset ? { preset: preset.id } : {}), ...(isCustom ? { caps: {} } : {}) }, initialModels[0] && initialModels[0].id));
927
+ const modelArea = el("div", { class: "form-field" }, [
928
+ el("span", { text: t("providers.models") }),
929
+ hint(t("providers.modelsHelp")),
930
+ hasModelEffortData({ models: initialModels }) ? null : el("small", { text: t("providers.effortLevels", { levels: providerEffortLevels.length ? providerEffortLevels.join(" · ") : t("providers.effortNone") }) }),
931
+ modelsBox,
932
+ ]);
933
+ const result = el("div", { class: "probe-result" });
934
+ const probeButton = el("button", { class: "btn secondary", type: "button", text: t("providers.check") });
935
+ const advanced = el("details", { class: "details" });
936
+ advanced.append(el("summary", { text: t("common.advanced") }));
937
+ const advancedContent = el("div", { class: "advanced-content" });
938
+ let chatgptFields = [];
939
+ if (!isChatgpt) {
940
+ advancedContent.appendChild(inputRow(t("providers.url"), urlInput, isOpenAi ? t("providers.openaiUrlHelp") : t("providers.urlHelp")));
941
+ if (isOpenAi) advancedContent.appendChild(inputRow(t("providers.wire"), wireSelect, t("providers.wireHelp")));
942
+ if (isCustom) {
943
+ const authSelect = el("select", {}, [selectOption("x-api-key", "x-api-key"), selectOption("authorization-bearer", "Authorization: Bearer")]);
944
+ authSelect.value = headerKind;
945
+ advancedContent.appendChild(inputRow(t("providers.keyType"), authSelect, t("providers.keyTypeHelp")));
946
+ authSelect.addEventListener("change", () => { keyInput.dataset.headerKind = authSelect.value; });
947
+ }
948
+ const extraHeaders = el("textarea", { rows: "2", placeholder: "header-name: value" });
949
+ const additional = Object.entries(currentHeaders).filter(([key]) => key.toLowerCase() !== "x-api-key" && key.toLowerCase() !== "authorization");
950
+ extraHeaders.value = additional.map(([key, value]) => `${key}: ${value}`).join("\n");
951
+ advancedContent.appendChild(inputRow(t("providers.extraHeaders"), extraHeaders, t("providers.extraHeadersHelp")));
952
+ advancedContent._extraHeaders = extraHeaders;
953
+ // Without a line of its own the model reads Claude Code's system prompt and answers that it is
954
+ // Claude, which is what DeepSeek did before this was offered here too.
955
+ const identity = el("input", { type: "checkbox", checked: !(existing && existing.identity === false) });
956
+ const append = el("textarea", { rows: "2", value: (existing && existing.instructionsAppend) || "" });
957
+ advancedContent.append(
958
+ el("div", { class: "form-field" }, [el("label", { class: "check" }, [identity, el("span", { text: t("providers.identity") })]), el("small", { text: t("providers.identityHelp") })]),
959
+ inputRow(t("providers.append"), append, t("providers.appendHelp")),
960
+ );
961
+ advancedContent._prompt = { identity, append };
962
+ } else {
963
+ const auth = el("select", {}, [selectOption("auto", t("providers.authAuto")), selectOption("own", t("providers.authOwn")), selectOption("borrow-codex", t("providers.authBorrow"))]);
964
+ auth.value = (existing && existing.auth) || "auto";
965
+ const login = el("button", { class: "btn secondary", type: "button", text: t("providers.chatgptLogin") });
966
+ login.addEventListener("click", () => void startChatgptLogin(() => {
967
+ login.disabled = chatgptLoginBusy;
968
+ loginHelp.textContent = chatgptLoginMessage || t("providers.loginHelp");
969
+ }));
970
+ const loginHelp = el("small", { text: t("providers.loginHelp") });
971
+ const effort = effortSelect((existing && existing.defaultEffort) || "high", ["low", "medium", "high", "xhigh", "max"]);
972
+ const identity = el("input", { type: "checkbox", checked: !(existing && existing.identity === false) });
973
+ const append = el("textarea", { rows: "2", value: (existing && existing.instructionsAppend) || "" });
974
+ chatgptFields = [
975
+ inputRow(t("providers.credentials"), auth, t("providers.credentialsHelp")),
976
+ el("div", { class: "form-field" }, [el("span", { text: t("providers.login") }), login, loginHelp]),
977
+ inputRow(t("providers.defaultEffort"), effort, t("providers.defaultEffortHelp")),
978
+ ];
979
+ advancedContent.append(
980
+ el("div", { class: "form-field" }, [el("label", { class: "check" }, [identity, el("span", { text: t("providers.identity") })]), el("small", { text: t("providers.identityHelp") })]),
981
+ inputRow(t("providers.append"), append, t("providers.appendHelp")),
982
+ );
983
+ advancedContent._chatgpt = { auth, effort, identity, append };
984
+ }
985
+ advanced.appendChild(advancedContent);
986
+ const form = el("div", { class: "provider-form" }, [
987
+ el("h1", { id: "modal-title", text: existing ? t("providers.edit") : t("providers.addTitle") }),
988
+ inputRow(t("providers.name"), nameInput, t("providers.nameHelp")),
989
+ !isChatgpt ? el("div", { class: "form-field key-field" }, [el("span", { text: t("providers.apiKey") }), el("div", { class: "key-control" }, [keyInput, showKey]), el("small", { text: t("providers.keyHelp") })]) : null,
990
+ isCustom ? inputRow(t("providers.url"), urlInput, t("providers.urlHelp")) : null,
991
+ ...chatgptFields,
992
+ !isChatgpt ? probeButton : null,
993
+ result,
994
+ modelArea,
995
+ el("label", { class: "check picker-check" }, [pickerInput, el("span", { text: t("providers.showInPicker") })]),
996
+ pickerModeOn() ? null : hint(t("providers.pickerOffHint")),
997
+ advanced,
998
+ ]);
999
+ function readHeaders() {
1000
+ const headers = {};
1001
+ const kind = keyInput.dataset.headerKind || headerKind;
1002
+ const key = keyInput.value.replace(/^\s*bearer\s+/i, "").replace(/\s+/g, "");
1003
+ if (key) {
1004
+ if (kind === "authorization-bearer") headers.authorization = `Bearer ${key}`;
1005
+ else headers["x-api-key"] = key;
1006
+ } else if (existingKey) {
1007
+ if (kind === "authorization-bearer") headers.authorization = currentHeaders.authorization;
1008
+ else headers["x-api-key"] = currentHeaders["x-api-key"];
1009
+ }
1010
+ const rawHeaders = advancedContent._extraHeaders && advancedContent._extraHeaders.value;
1011
+ for (const line of String(rawHeaders || "").split("\n")) {
1012
+ const at = line.indexOf(":");
1013
+ if (at > 0 && line.slice(0, at).trim()) headers[line.slice(0, at).trim()] = line.slice(at + 1).trim();
1014
+ }
1015
+ return headers;
1016
+ }
1017
+ function draftProvider() {
1018
+ if (isChatgpt) {
1019
+ const chat = advancedContent._chatgpt;
1020
+ return { type: "chatgpt", auth: chat.auth.value, defaultEffort: chat.effort.value, identity: chat.identity.checked, ...(chat.append.value.trim() ? { instructionsAppend: chat.append.value.trim() } : {}) };
1021
+ }
1022
+ const prompt = advancedContent._prompt;
1023
+ return {
1024
+ type: isOpenAi ? "openai-compatible" : "anthropic-compatible",
1025
+ url: urlInput.value.trim(),
1026
+ ...(prompt ? { identity: prompt.identity.checked } : {}),
1027
+ ...(prompt && prompt.append.value.trim() ? { instructionsAppend: prompt.append.value.trim() } : {}),
1028
+ ...(preset ? { preset: preset.id } : {}),
1029
+ ...(isOpenAi ? { wire: wireSelect.value, caps: { effortLevels: (preset && preset.effortLevels) || [], reasoning: preset && preset.effortLevels && preset.effortLevels.length ? "effort" : "none" } } : {}),
1030
+ ...(Object.keys(readHeaders()).length ? { headers: readHeaders() } : {}),
1031
+ };
1032
+ }
1033
+ function probeDraft() {
1034
+ const d = draftProvider();
1035
+ if (preset) { d.modelsUrl = preset.modelsUrl; d.modelsAuthHeader = preset.modelsAuthHeader; d.probeModel = (preset.fallbackModels || [])[0] && preset.fallbackModels[0].id; }
1036
+ return d;
1037
+ }
1038
+ async function runProbe() {
1039
+ const draft = probeDraft();
1040
+ if (!draft.url || !/^https?:\/\//.test(draft.url)) { toast(t("providers.urlRequired"), true); return; }
1041
+ probeButton.disabled = true;
1042
+ result.textContent = t("providers.checking");
1043
+ const temporary = `__new_${Date.now()}`;
1044
+ const response = await probeProvider(temporary, draft);
1045
+ probeButton.disabled = false;
1046
+ const noCredits = response.ok && /^no-credits:/.test(response.error || "");
1047
+ const headline = noCredits ? t("providers.probeNoCredits") : response.ok ? t("providers.probeOk") : response.auth === "bad-key" ? t("providers.probeBadKey") : response.unavailable ? t("providers.apiSoon") : t("providers.probeFailed");
1048
+ result.replaceChildren(...[
1049
+ el("span", { class: response.ok && !noCredits ? "ok-text" : noCredits ? "warn-text" : "bad-text", text: headline }),
1050
+ response.error ? el("div", { class: "small", text: response.error.replace(/^no-credits:\s*/, "") }) : null,
1051
+ ].filter(Boolean));
1052
+ foundModels = response.models && response.models.length ? response.models.map((model) => typeof model === "string" ? { id: model, name: model } : model) : (preset ? (preset.fallbackModels || []) : foundModels);
1053
+ const keep = foundModels.length > 12 ? new Set([...currentChecked, ...modelArea.querySelector(".model-picker").selected().map((m) => m.id)]) : new Set(foundModels.map((model) => model.id));
1054
+ // Saved picks that the provider no longer lists stay visible and ticked so nothing is dropped silently.
1055
+ for (const saved of modelsOf(existing)) if (keep.has(saved.id) && !foundModels.some((m) => m.id === saved.id)) foundModels = [saved, ...foundModels];
1056
+ modelArea.querySelector(".model-picker").replaceWith(modelChecklist(foundModels, keep));
1057
+ modelArea.replaceChildren(el("span", { text: t("providers.models") }), hint(response.ok ? (foundModels.length > 12 ? t("providers.modelsFoundMany") : t("providers.modelsFound")) : t("providers.modelsFallback")), modelArea.querySelector(".model-picker") || document.createTextNode(""));
1058
+ }
1059
+ probeButton && probeButton.addEventListener("click", runProbe);
1060
+ // Editing a provider that can list models: fetch the full list right away so the saved picks are
1061
+ // shown among everything available, not as a two-entry list.
1062
+ if (existing && !isChatgpt && preset && preset.modelsUrl && existingKey) setTimeout(() => void runProbe(), 0);
1063
+ const saveButton = el("button", { class: "btn", type: "button", "data-default-action": "", text: existing ? t("common.save") : t("providers.add") });
1064
+ saveButton.addEventListener("click", async () => {
1065
+ const typedName = nameInput.value.trim();
1066
+ if (!typedName) { toast(t("providers.nameRequired"), true); return; }
1067
+ const provider = draftProvider();
1068
+ if (!isChatgpt && (!provider.url || !/^https?:\/\//.test(provider.url))) { toast(t("providers.urlRequired"), true); return; }
1069
+ const next = clone(currentConfig);
1070
+ const providerName = existing ? options.name : uniqueName(typedName, next.providers);
1071
+ if (existing && providerName !== options.name) delete next.providers[options.name];
1072
+ const checkedModels = form.querySelector(".model-picker").selected();
1073
+ provider.models = checkedModels;
1074
+ next.providers[providerName] = provider;
1075
+ const existingSelections = ((next.cli && next.cli.extraModels) || []).filter((entry) => entry.model !== null && entry.model !== undefined).map((entry) => {
1076
+ const direct = (next.direct || []).filter((rule) => entry.model.startsWith(rule.prefix)).sort((a, b) => b.prefix.length - a.prefix.length)[0];
1077
+ return { id: entry.model, name: entry.name, provider: direct && direct.provider };
1078
+ }).filter((entry) => entry.provider && entry.provider !== options.name);
1079
+ if (pickerInput.checked) {
1080
+ applyPickerSelections(next, [...existingSelections, ...checkedModels.map((model) => ({ ...model, provider: providerName }))]);
1081
+ } else {
1082
+ applyPickerSelections(next, existingSelections);
1083
+ }
1084
+ saveButton.disabled = true;
1085
+ try {
1086
+ await configRequest(next);
1087
+ currentConfig = next;
1088
+ slotsLoaded = false;
1089
+ providersLoaded = false;
1090
+ closeModal();
1091
+ await loadProviders();
1092
+ toast(t("common.saved"));
1093
+ await offerPickerOn(pickerInput.checked && checkedModels.length > 0);
1094
+ } catch (error) {
1095
+ toast(t("common.saveFailed"), true, error.message);
1096
+ } finally { saveButton.disabled = false; }
1097
+ });
1098
+ form.appendChild(el("div", { class: "actions end" }, [el("button", { class: "btn secondary", type: "button", text: t("common.cancel"), onclick: closeModal }), saveButton]));
1099
+ showModal(form);
1100
+ }
1101
+
1102
+ // ---- Logs ---------------------------------------------------------------------------
1103
+
1104
+ let logsPanel = "requests";
1105
+ let requestRows = [];
1106
+ let knownRequestProviders = new Set();
1107
+ let requestProvider = "";
1108
+ let showCountTokens = false;
1109
+ let expandedRequestId = null;
1110
+
1111
+ function colorizeLogLine(line) {
1112
+ const cls = /\bERROR\b|\berror\b/.test(line) ? "tag-ERR" : /\bWARN\b/.test(line) ? "tag-WARN" : "tag-PASS";
1113
+ return el("div", { class: cls, text: line });
1114
+ }
1115
+ function formatNumber(value) { return typeof value === "number" ? value.toLocaleString() : t("common.notAvailable"); }
1116
+ function formatSeconds(ms) { return t("logs.seconds", { value: (Math.max(0, ms || 0) / 1000).toFixed(ms >= 10_000 ? 1 : 2) }); }
1117
+ function timeOf(iso) {
1118
+ const at = new Date(iso);
1119
+ if (Number.isNaN(at.valueOf())) return t("common.notAvailable");
1120
+ const two = (n) => String(n).padStart(2, "0");
1121
+ return `${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}:${two(at.getSeconds())}`;
1122
+ }
1123
+ function providerClass(name) { return name === "anthropic" ? "provider-anthropic" : name === "chatgpt" ? "provider-chatgpt" : "provider-default"; }
1124
+ function summaryChip(label, value) { return el("div", { class: "logs-chip" }, [el("span", { class: "label", text: label }), el("span", { class: "value", text: value })]); }
1125
+
1126
+ function showLogsPanel(name) {
1127
+ logsPanel = name;
1128
+ const requests = name === "requests";
1129
+ $("#logs-requests-tab").classList.toggle("active", requests);
1130
+ $("#logs-raw-tab").classList.toggle("active", !requests);
1131
+ $("#logs-requests-tab").setAttribute("aria-selected", String(requests));
1132
+ $("#logs-raw-tab").setAttribute("aria-selected", String(!requests));
1133
+ $("#logs-requests-panel").hidden = !requests;
1134
+ $("#logs-raw-panel").hidden = requests;
1135
+ if (requests) void refreshRequests();
1136
+ else void refreshLogs();
1137
+ }
1138
+ $("#logs-requests-tab").addEventListener("click", () => showLogsPanel("requests"));
1139
+ $("#logs-raw-tab").addEventListener("click", () => showLogsPanel("raw"));
1140
+ $("#logs-provider").addEventListener("change", (event) => { requestProvider = event.target.value; void refreshRequests(); });
1141
+
1142
+ function renderSummary(summary) {
1143
+ const total = summary && summary.total || { count: 0, ok: 0, failed: 0, input: 0, cached: 0, output: 0, avgMs: 0, cacheHitPercent: 0 };
1144
+ $("#logs-summary").replaceChildren(
1145
+ summaryChip(t("logs.summary.requests"), formatNumber(total.count)),
1146
+ summaryChip(t("logs.summary.success"), `${formatNumber(total.ok)} / ${formatNumber(total.failed)}`),
1147
+ summaryChip(t("logs.summary.input"), `${formatNumber(total.input)} · ${t("logs.cacheHit", { percent: total.cacheHitPercent || 0 })}`),
1148
+ summaryChip(t("logs.summary.output"), formatNumber(total.output)),
1149
+ summaryChip(t("logs.summary.duration"), formatSeconds(total.avgMs)),
1150
+ );
1151
+ }
1152
+ function renderProviderFilter(records) {
1153
+ const select = $("#logs-provider");
1154
+ for (const record of records) if (record.provider) knownRequestProviders.add(record.provider);
1155
+ const names = [...knownRequestProviders].sort();
1156
+ const before = select.value;
1157
+ select.replaceChildren(el("option", { value: "", text: t("logs.all") }));
1158
+ for (const name of names) select.appendChild(el("option", { value: name, text: name }));
1159
+ select.value = names.includes(requestProvider) ? requestProvider : "";
1160
+ if (!names.includes(requestProvider)) requestProvider = "";
1161
+ if (before && before !== select.value) select.value = requestProvider;
1162
+ }
1163
+ function requestDetail(record) {
1164
+ const details = [
1165
+ [t("logs.detail.id"), record.id],
1166
+ [t("logs.detail.kind"), record.kind],
1167
+ [t("logs.detail.stop"), record.stopReason || t("logs.none")],
1168
+ [t("logs.detail.cacheWrite"), record.usage && record.usage.cacheWrite ? formatNumber(record.usage.cacheWrite) : t("logs.none")],
1169
+ ].map(([label, value]) => el("div", {}, [el("span", { class: "detail-label", text: label }), el("span", { class: "detail-value", text: value })]));
1170
+ if (record.note) details.push(el("div", { class: "request-note" }, [el("span", { class: "detail-label", text: t("logs.detail.note") }), el("span", { class: "detail-value", text: record.note })]));
1171
+ return el("tr", { class: "request-detail" }, [el("td", { colspan: "7" }, [el("div", { class: "request-detail-grid" }, details)])]);
1172
+ }
1173
+ function requestRow(record) {
1174
+ const row = el("tr", { class: "request-row", title: record.note || "", onclick: () => { expandedRequestId = expandedRequestId === record.id ? null : record.id; renderRequests(requestRows); } });
1175
+ const model = el("div", { class: "request-models" }, [
1176
+ el("span", { class: "model", text: record.target }),
1177
+ record.source && record.source !== record.target ? el("span", { class: "small", text: `(${record.source})` }) : null,
1178
+ el("span", { class: `provider-badge ${providerClass(record.provider)}`, text: record.provider }),
1179
+ ].filter(Boolean));
1180
+ const input = record.usage
1181
+ ? el("span", { class: "token-cell", text: formatNumber(record.usage.input) }, [el("span", { class: "cache-pill", text: t("logs.cacheHit", { percent: Math.round(record.usage.cached / Math.max(1, record.usage.input + record.usage.cached) * 100) }) })])
1182
+ : el("span", { class: "no-usage", text: t("common.notAvailable") });
1183
+ const status = el("span", { class: `status-text ${record.ok ? "ok" : "bad"}`, text: `${record.ok ? t("logs.status.ok") : t("logs.status.error")} ${record.status}` });
1184
+ row.append(
1185
+ el("td", { text: timeOf(record.at) }),
1186
+ el("td", {}, [model]),
1187
+ el("td", { text: record.effort || t("common.notAvailable") }),
1188
+ el("td", {}, [input]),
1189
+ el("td", { class: record.usage ? "" : "no-usage", text: record.usage ? formatNumber(record.usage.output) : t("common.notAvailable") }),
1190
+ el("td", { text: formatSeconds(record.ms) }),
1191
+ el("td", {}, [status]),
1192
+ );
1193
+ return row;
1194
+ }
1195
+ function renderRequests(records) {
1196
+ const scroll = $(".logs-table-card");
1197
+ const left = scroll.scrollLeft;
1198
+ const tbody = $("#requests-table tbody");
1199
+ tbody.replaceChildren(...records.flatMap((record) => expandedRequestId === record.id ? [requestRow(record), requestDetail(record)] : [requestRow(record)]));
1200
+ $("#requests-table").closest(".logs-table-card").hidden = records.length === 0;
1201
+ $("#requests-empty").hidden = records.length !== 0;
1202
+ scroll.scrollLeft = left;
1203
+ }
1204
+ async function refreshRequests() {
1205
+ if (logsPanel !== "requests") return;
1206
+ try {
1207
+ const suffix = requestProvider ? `&provider=${encodeURIComponent(requestProvider)}` : "";
1208
+ const kind = "&kind=messages";
1209
+ const [records, summary, allRecords] = await Promise.all([api(`/api/requests?n=200${suffix}${kind}`), api("/api/requests/summary?since=3600"), api("/api/requests?n=200")]);
1210
+ requestRows = Array.isArray(records.requests) ? records.requests : [];
1211
+ renderProviderFilter(Array.isArray(allRecords.requests) ? allRecords.requests : requestRows);
1212
+ renderSummary(summary);
1213
+ renderRequests(requestRows);
1214
+ } catch { /* preserve the last successful request table */ }
1215
+ }
1216
+ async function refreshLogs() {
1217
+ if (logsPanel !== "raw") return;
1218
+ const box = $("#logbox");
1219
+ try {
1220
+ const response = await fetch("/api/logs?n=200");
1221
+ const text = await response.text();
1222
+ const nearBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 30;
1223
+ box.replaceChildren(...text.split("\n").filter(Boolean).map(colorizeLogLine));
1224
+ if ($("#logs-autoscroll").checked || nearBottom) box.scrollTop = box.scrollHeight;
1225
+ } catch { /* preserve the last contents */ }
1226
+ }
1227
+ void refreshRequests();
1228
+ setInterval(() => { void refreshRequests(); void refreshLogs(); }, 3000);