clauderipple 0.2.0 → 0.3.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 +76 -0
- package/README.ko.md +48 -4
- package/README.md +58 -4
- package/dist/cli/src/claude-auth.js +3 -2
- package/dist/cli/src/codex.js +20 -1
- package/dist/cli/src/hooks/agent-title.js +1 -1
- package/dist/cli/src/index.js +4 -4
- package/dist/cli/src/schtasks.js +43 -1
- package/dist/cli/src/settings.js +73 -6
- package/dist/router/src/admin.js +489 -56
- package/dist/router/src/agents.js +250 -0
- package/dist/router/src/bootstrap.js +24 -8
- package/dist/router/src/capabilities.js +214 -0
- package/dist/router/src/compat.js +5 -1
- package/dist/router/src/config.js +264 -11
- package/dist/router/src/index.js +14 -1
- package/dist/router/src/ingress/server.js +24 -14
- package/dist/router/src/picker.js +14 -6
- package/dist/router/src/pool.js +233 -0
- package/dist/router/src/presets.js +156 -1
- package/dist/router/src/providers/anthropic-account-pool.js +139 -0
- package/dist/router/src/providers/anthropic-accounts.js +281 -0
- package/dist/router/src/providers/chatgpt/catalog.js +97 -0
- package/dist/router/src/providers/chatgpt/index.js +343 -12
- package/dist/router/src/providers/chatgpt/sse.js +4 -0
- package/dist/router/src/providers/chatgpt/translate.js +156 -14
- package/dist/router/src/providers/claude-oauth.js +61 -19
- package/dist/router/src/providers/openai/index.js +55 -11
- package/dist/router/src/providers/openai/translate.js +82 -14
- package/dist/router/src/providers/retry.js +88 -0
- package/dist/router/src/proxy.js +697 -82
- package/dist/router/src/requestlog.js +5 -2
- package/dist/router/src/routing.js +151 -17
- package/dist/router/src/version.js +1 -1
- package/dist/router/src/websearch.js +307 -0
- package/dist/router/src/x509.js +7 -2
- package/dist/ui/app.js +740 -160
- package/dist/ui/i18n.js +14 -6
- package/dist/ui/index.html +18 -5
- package/dist/ui/presets-fallback.js +2 -0
- package/dist/ui/style.css +133 -9
- package/docs/ARCHITECTURE.md +381 -20
- package/package.json +5 -1
package/dist/ui/app.js
CHANGED
|
@@ -9,6 +9,9 @@ function el(tag, attrs, children) {
|
|
|
9
9
|
if (key === "class") node.className = value;
|
|
10
10
|
else if (key === "text") node.textContent = value;
|
|
11
11
|
else if (key === "checked") node.checked = Boolean(value);
|
|
12
|
+
// A textarea ignores the `value` attribute, so a form reopened on a saved provider showed an
|
|
13
|
+
// empty box and its save wiped what was there (instructionsAppend, 2026-09-23).
|
|
14
|
+
else if (key === "value" && tag === "textarea") node.value = value;
|
|
12
15
|
else if (key.startsWith("on") && typeof value === "function") node.addEventListener(key.slice(2), value);
|
|
13
16
|
else if (value !== undefined && value !== null) node.setAttribute(key, value);
|
|
14
17
|
}
|
|
@@ -44,8 +47,13 @@ async function api(path, opts) {
|
|
|
44
47
|
return body;
|
|
45
48
|
}
|
|
46
49
|
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
// The router writes `cli.models` to settings.json on this save (they only take effect there). If
|
|
51
|
+
// that write could not happen it says so here — a slot that silently does nothing is the bug this
|
|
52
|
+
// call used to be, so a failure must reach the screen rather than the router log.
|
|
53
|
+
async function configRequest(next) {
|
|
54
|
+
const out = await api("/api/config", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(next) });
|
|
55
|
+
if (out && out.warning) toast(out.warning, true);
|
|
56
|
+
return out;
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
function modelsOf(provider) {
|
|
@@ -65,9 +73,9 @@ function uniqueName(name, providers) {
|
|
|
65
73
|
function groupedModels(config) {
|
|
66
74
|
const groups = [];
|
|
67
75
|
for (const [name, provider] of Object.entries(config.providers || {})) {
|
|
68
|
-
// A native Claude provider
|
|
69
|
-
//
|
|
70
|
-
if (provider.type === "anthropic") continue;
|
|
76
|
+
// A native Claude provider becomes a mapping target only after the explicit account-pool opt-in.
|
|
77
|
+
// API-key and legacy login providers remain OpenAI-ingress-only.
|
|
78
|
+
if (provider.type === "anthropic" && !provider.accountPool) continue;
|
|
71
79
|
let models = modelsOf(provider);
|
|
72
80
|
if (!models.length && provider.type === "chatgpt") models = CHATGPT_MODELS;
|
|
73
81
|
if (models.length) groups.push({ name, provider, models });
|
|
@@ -79,11 +87,15 @@ function modelEffortLevels(provider, model) {
|
|
|
79
87
|
const entry = modelsOf(provider).find((item) => item.id === model);
|
|
80
88
|
return entry && Array.isArray(entry.effortLevels) ? entry.effortLevels : undefined;
|
|
81
89
|
}
|
|
90
|
+
// The ChatGPT ladders the Codex catalogue reports (measured 2026-09-23): every model takes
|
|
91
|
+
// low..max, and only these add ultra. The live probe replaces this table when it can be reached.
|
|
92
|
+
const CHATGPT_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
|
|
93
|
+
const CHATGPT_ULTRA_MODELS = new Set(["gpt-5.6-terra", "gpt-5.6-sol", "gpt-6-astra", "gpt-6-sol"]);
|
|
82
94
|
function fallbackEffortLevels(provider, model) {
|
|
83
95
|
if (!provider) return [];
|
|
84
96
|
const explicit = modelEffortLevels(provider, model);
|
|
85
97
|
if (explicit !== undefined) return explicit;
|
|
86
|
-
if (provider.type === "chatgpt") return model
|
|
98
|
+
if (provider.type === "chatgpt") return CHATGPT_ULTRA_MODELS.has(model) ? [...CHATGPT_EFFORT_LEVELS, "ultra"] : [...CHATGPT_EFFORT_LEVELS];
|
|
87
99
|
const preset = provider.preset && presetById(provider.preset);
|
|
88
100
|
if (provider.type === "openai-compatible") return provider.caps && provider.caps.reasoning === "effort" && Array.isArray(provider.caps.effortLevels) ? provider.caps.effortLevels : [];
|
|
89
101
|
return provider.caps && Array.isArray(provider.caps.effortLevels) ? provider.caps.effortLevels : (preset && preset.effortLevels) || [];
|
|
@@ -104,6 +116,18 @@ function modelEffortTag(model) {
|
|
|
104
116
|
if (!Array.isArray(model.effortLevels)) return null;
|
|
105
117
|
return model.effortLevels.length ? t("providers.modelEffort") : t("providers.modelNoEffort");
|
|
106
118
|
}
|
|
119
|
+
// Routed models do not share a context window, so each picker entry carries its own. What the user
|
|
120
|
+
// typed wins; otherwise whatever the vendor's /models reported (`context_length`); otherwise the
|
|
121
|
+
// global cli.autoCompactWindow fallback. This is the model's real window, not a compaction point.
|
|
122
|
+
function discoveredContextWindow(providerName, modelId) {
|
|
123
|
+
const provider = currentConfig && currentConfig.providers && currentConfig.providers[providerName];
|
|
124
|
+
const entry = provider && modelsOf(provider).find((item) => item.id === modelId);
|
|
125
|
+
return entry && Number.isFinite(entry.contextWindow) ? entry.contextWindow : undefined;
|
|
126
|
+
}
|
|
127
|
+
function savedContextWindow(modelId) {
|
|
128
|
+
const entry = ((currentConfig && currentConfig.cli && currentConfig.cli.extraModels) || []).find((item) => item.model === modelId);
|
|
129
|
+
return entry && Number.isFinite(entry.contextWindow) ? entry.contextWindow : undefined;
|
|
130
|
+
}
|
|
107
131
|
|
|
108
132
|
let currentConfig = null;
|
|
109
133
|
let status = null;
|
|
@@ -189,9 +213,35 @@ function quotaLine(name) {
|
|
|
189
213
|
const quota = status && status.chatgpt && status.chatgpt.quota && status.chatgpt.quota[name];
|
|
190
214
|
const primary = quota && quota.rate_limits && quota.rate_limits.primary;
|
|
191
215
|
if (!primary) return null;
|
|
192
|
-
|
|
193
|
-
|
|
216
|
+
// The usage figure is what anyone acts on. The reset countdown was a second number beside it
|
|
217
|
+
// that nobody does anything with, so it is left out.
|
|
218
|
+
return t("health.quota", { percent: primary.used_percent, reset: "" });
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* One line a person can read. Vendors answer with a JSON body and sometimes an escaped one inside
|
|
222
|
+
* it; the useful part is the status and the vendor's own message, not the envelope around them.
|
|
223
|
+
* The full text stays on the element's title.
|
|
224
|
+
*/
|
|
225
|
+
function shortError(text) {
|
|
226
|
+
const raw = String(text || "").trim();
|
|
227
|
+
if (!raw) return "";
|
|
228
|
+
const status = raw.match(/\b(\d{3})\b/);
|
|
229
|
+
let message = "";
|
|
230
|
+
try {
|
|
231
|
+
const json = raw.slice(raw.indexOf("{"));
|
|
232
|
+
const parsed = JSON.parse(json);
|
|
233
|
+
const err = parsed.error || parsed;
|
|
234
|
+
message = String(err.message || err.error || "").trim();
|
|
235
|
+
} catch { /* not JSON, or truncated: fall back to the raw head */ }
|
|
236
|
+
if (!message) message = raw.replace(/\s+/g, " ").slice(0, 120);
|
|
237
|
+
const head = status ? `HTTP ${status[1]}` : "";
|
|
238
|
+
const line = [head, message].filter(Boolean).join(" · ");
|
|
239
|
+
return line.length > 140 ? `${line.slice(0, 139)}…` : line;
|
|
194
240
|
}
|
|
241
|
+
|
|
242
|
+
/** How long a probe result speaks for the provider before the live reading takes over. */
|
|
243
|
+
const PROBE_TTL_MS = 90_000;
|
|
244
|
+
function probeIsStale(state) { return !state.pending && typeof state.at === "number" && Date.now() - state.at > PROBE_TTL_MS; }
|
|
195
245
|
function stateFor(name) { return probeStates.get(name); }
|
|
196
246
|
function chatgptLoginButton(onChange) {
|
|
197
247
|
const button = el("button", { class: "btn secondary", type: "button", text: t("providers.chatgptLogin") });
|
|
@@ -228,14 +278,40 @@ async function startChatgptLogin(onChange) {
|
|
|
228
278
|
onChange && onChange();
|
|
229
279
|
}
|
|
230
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* Which of a provider's credentials are resting, and until when. Absent for a provider with one
|
|
283
|
+
* credential: a row that can only ever say "ready" is noise. Ids and labels only — never a key.
|
|
284
|
+
*/
|
|
285
|
+
function credentialLine(name) {
|
|
286
|
+
const pool = (status && status.credentials && status.credentials[name]) || [];
|
|
287
|
+
if (!pool.length) return null;
|
|
288
|
+
const parts = pool.map((c) => {
|
|
289
|
+
const who = c.label || c.id;
|
|
290
|
+
if (c.state === "quarantined") return `${who}: ${t("pool.quarantined")}`;
|
|
291
|
+
if (c.state === "cooling") return `${who}: ${t("pool.cooling", { seconds: c.cooldownSeconds ?? 0 })}`;
|
|
292
|
+
return `${who}: ${t("pool.ready")}`;
|
|
293
|
+
});
|
|
294
|
+
const resting = pool.filter((c) => c.state !== "ready").length;
|
|
295
|
+
return el("div", { class: `small ${resting ? "bad-text" : ""}`.trim(), text: parts.join(" · ") });
|
|
296
|
+
}
|
|
297
|
+
|
|
231
298
|
function providerState(name, provider) {
|
|
232
299
|
const live = status && status.providers && status.providers[name];
|
|
233
300
|
// Reaching the host is not the same as being able to use it. A ChatGPT provider with no
|
|
234
301
|
// credentials would otherwise read "Connected" and send the user off believing it works.
|
|
235
302
|
if (live && live.needsLogin) return badge("warn", t("providerStatus.loginNeeded"));
|
|
236
303
|
const state = stateFor(name);
|
|
237
|
-
|
|
304
|
+
// A probe is the better evidence — it actually called the provider — but only while it is fresh.
|
|
305
|
+
// One caught mid-restart used to sit there in red for the rest of the session, while the poll
|
|
306
|
+
// every five seconds said the provider was fine and a fresh probe agreed. After PROBE_TTL_MS the
|
|
307
|
+
// live reading takes over, so a stale failure heals itself instead of needing a manual re-check.
|
|
308
|
+
if (state && !probeIsStale(state)) {
|
|
309
|
+
return state.ok ? badge("ok", t("providerStatus.connected"))
|
|
310
|
+
: state.auth === "bad-key" ? badge("bad", t("providerStatus.keyNeeded"))
|
|
311
|
+
: badge("bad", t("providerStatus.disconnected"));
|
|
312
|
+
}
|
|
238
313
|
if (live) return live.reachable ? badge("ok", t("providerStatus.connected")) : badge("bad", t("providerStatus.disconnected"));
|
|
314
|
+
if (state) return badge("bad", t("providerStatus.disconnected"));
|
|
239
315
|
return badge("warn", t("providerStatus.checking"));
|
|
240
316
|
}
|
|
241
317
|
function renderHealthProviders() {
|
|
@@ -255,6 +331,8 @@ function renderHealthProviders() {
|
|
|
255
331
|
].filter(Boolean));
|
|
256
332
|
const quota = quotaLine(name);
|
|
257
333
|
if (quota) line.appendChild(el("div", { class: "small", text: quota }));
|
|
334
|
+
const pool = credentialLine(name);
|
|
335
|
+
if (pool) line.appendChild(pool);
|
|
258
336
|
return line;
|
|
259
337
|
}));
|
|
260
338
|
}
|
|
@@ -277,6 +355,7 @@ function renderClients() {
|
|
|
277
355
|
pickerButton.disabled = pickerBusy;
|
|
278
356
|
pickerButton.onclick = () => togglePicker(!picker.enabled);
|
|
279
357
|
renderClientPickerModels(picker.enabled);
|
|
358
|
+
renderModelSlots();
|
|
280
359
|
const agentEnabled = Boolean(status.agentTitle);
|
|
281
360
|
$("#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
361
|
const agentButton = $("#client-agent-title-toggle");
|
|
@@ -300,13 +379,103 @@ function renderClientPickerModels(enabled) {
|
|
|
300
379
|
input.dataset.model = model.id;
|
|
301
380
|
input.dataset.provider = group.name;
|
|
302
381
|
input.dataset.name = labelOf(model);
|
|
303
|
-
|
|
304
|
-
|
|
382
|
+
const discovered = discoveredContextWindow(group.name, model.id);
|
|
383
|
+
const saved = savedContextWindow(model.id);
|
|
384
|
+
// Blank means "use the global fallback"; the placeholder says what that would be.
|
|
385
|
+
const windowField = el("input", {
|
|
386
|
+
type: "number", class: "model-window", min: "1", step: "1000",
|
|
387
|
+
value: saved !== undefined ? String(saved) : "",
|
|
388
|
+
placeholder: discovered !== undefined ? String(discovered) : t("slots.windowGlobal"),
|
|
389
|
+
title: t("slots.windowHelp"),
|
|
390
|
+
});
|
|
391
|
+
windowField.hidden = !input.checked;
|
|
392
|
+
windowField.dataset.model = model.id;
|
|
393
|
+
windowField.addEventListener("change", () => void saveClientPickerModels());
|
|
394
|
+
input.addEventListener("change", () => { windowField.hidden = !input.checked; void saveClientPickerModels(); });
|
|
395
|
+
// Two lines: the name owns the first — on one line it was the thing that collapsed, down to
|
|
396
|
+
// "GPT…" — and the provider and the window share the second. The field sits outside the
|
|
397
|
+
// <label>, because inside it every click toggles the checkbox instead of reaching the field.
|
|
398
|
+
box.appendChild(el("div", { class: "picker-model", title: model.id }, [
|
|
399
|
+
el("label", { class: "pm-main" }, [input, el("span", { text: labelOf(model) })]),
|
|
400
|
+
el("small", { text: group.name }),
|
|
401
|
+
windowField,
|
|
402
|
+
]));
|
|
305
403
|
}
|
|
306
404
|
if (!box.childElementCount) box.appendChild(hint(t("slots.noProviderModels")));
|
|
307
405
|
}
|
|
406
|
+
// Claude Code picks these before a request exists, so routing cannot reach them: a search, a title
|
|
407
|
+
// or a subagent goes wherever the CLI already decided. Left empty, Claude answers them — which is
|
|
408
|
+
// why a routed session still searches on Claude quota until `smallFast` is pointed somewhere.
|
|
409
|
+
const MODEL_SLOTS = ["smallFast", "subagent", "main"];
|
|
410
|
+
|
|
411
|
+
// A provider the router measured as unable to search. Only the router can know this — it comes from
|
|
412
|
+
// the status snapshot, never from the model's name — and the `smallFast` slot is exactly where it
|
|
413
|
+
// matters, because a search sent to such a model comes back as invented prose or a visible failure.
|
|
414
|
+
function providerCannotSearch(name) {
|
|
415
|
+
return Boolean(status && status.providers && status.providers[name] && status.providers[name].webSearch !== true);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function renderModelSlots() {
|
|
419
|
+
const rows = $("#client-model-slots");
|
|
420
|
+
if (!rows) return;
|
|
421
|
+
const chosen = (currentConfig.cli && currentConfig.cli.models) || {};
|
|
422
|
+
rows.replaceChildren(...MODEL_SLOTS.map((slot) => {
|
|
423
|
+
const select = el("select", {});
|
|
424
|
+
select.appendChild(el("option", { value: "", text: t("slots.slotDefault") }));
|
|
425
|
+
for (const group of groupedModels(currentConfig)) for (const model of group.models) {
|
|
426
|
+
const option = el("option", { value: model.id, text: `${labelOf(model)} · ${group.name}` });
|
|
427
|
+
if (chosen[slot] === model.id) option.selected = true;
|
|
428
|
+
// `smallFast` is what a WebSearch runs on, so an inability to search is fatal there and
|
|
429
|
+
// merely worth knowing everywhere else.
|
|
430
|
+
if (slot === "smallFast" && providerCannotSearch(group.name)) {
|
|
431
|
+
option.textContent += ` — ${t("slots.noWebSearch")}`;
|
|
432
|
+
}
|
|
433
|
+
select.appendChild(option);
|
|
434
|
+
}
|
|
435
|
+
// A model the config names but no provider offers any more would otherwise vanish silently.
|
|
436
|
+
if (chosen[slot] && !allKnownModelIds(currentConfig).has(chosen[slot])) {
|
|
437
|
+
const orphan = el("option", { value: chosen[slot], text: `${chosen[slot]} (?)` });
|
|
438
|
+
orphan.selected = true;
|
|
439
|
+
select.appendChild(orphan);
|
|
440
|
+
}
|
|
441
|
+
select.onchange = () => void saveModelSlots();
|
|
442
|
+
select.dataset.slot = slot;
|
|
443
|
+
return el("div", { class: "slot-row" }, [
|
|
444
|
+
el("span", { class: "k", text: t(`slots.slot.${slot}`) }),
|
|
445
|
+
select,
|
|
446
|
+
el("span", { class: "hint", text: t(`slots.slotHelp.${slot}`) }),
|
|
447
|
+
]);
|
|
448
|
+
}));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function saveModelSlots() {
|
|
452
|
+
if (!currentConfig) return;
|
|
453
|
+
const next = clone(currentConfig);
|
|
454
|
+
// Every slot is recorded, empty ones included. A select put back to "default (Claude)" has to
|
|
455
|
+
// remove the env key, and the router tells "clear this" from "leave this alone" by whether the
|
|
456
|
+
// slot is present at all — so dropping empty values here would make the choice a silent no-op.
|
|
457
|
+
const models = {};
|
|
458
|
+
for (const select of $all("#client-model-slots select")) models[select.dataset.slot] = select.value;
|
|
459
|
+
next.cli = { ...(next.cli || {}), models };
|
|
460
|
+
try {
|
|
461
|
+
await configRequest(next);
|
|
462
|
+
currentConfig = next;
|
|
463
|
+
// Saying "saved" over a choice that will make every search fail is the same silent no-op this
|
|
464
|
+
// screen was already guilty of once. The label on the option warns before the fact; this catches
|
|
465
|
+
// a session that already had it selected.
|
|
466
|
+
const owners = models.smallFast ? providersOffering(next, models.smallFast) : [];
|
|
467
|
+
if (owners.length && providerCannotSearch(owners[0])) toast(t("slots.noWebSearchWarn"), true);
|
|
468
|
+
else toast(t("slots.slotSaved"));
|
|
469
|
+
} catch (error) { toast(t("common.saveFailed"), true, error.message); }
|
|
470
|
+
}
|
|
471
|
+
|
|
308
472
|
function clientPickerSelections() {
|
|
309
|
-
return $all("#client-picker-models input:checked").map((input) =>
|
|
473
|
+
return $all("#client-picker-models input[type=checkbox]:checked").map((input) => {
|
|
474
|
+
const field = $(`#client-picker-models input.model-window[data-model="${CSS.escape(input.dataset.model)}"]`);
|
|
475
|
+
const typed = field && field.value.trim() ? Number(field.value) : NaN;
|
|
476
|
+
const contextWindow = Number.isFinite(typed) && typed > 0 ? Math.floor(typed) : discoveredContextWindow(input.dataset.provider, input.dataset.model);
|
|
477
|
+
return { id: input.dataset.model, name: input.dataset.name, provider: input.dataset.provider, contextWindow };
|
|
478
|
+
});
|
|
310
479
|
}
|
|
311
480
|
async function saveClientPickerModels() {
|
|
312
481
|
if (!currentConfig) return;
|
|
@@ -466,6 +635,33 @@ $("#slots-add").addEventListener("click", () => {
|
|
|
466
635
|
updateSlotSummary();
|
|
467
636
|
});
|
|
468
637
|
function allKnownModelIds(config) { return new Set(groupedModels(config).flatMap((group) => group.models.map((model) => model.id))); }
|
|
638
|
+
// Which providers carry this exact id. One is what lets the router route it with no rule at all;
|
|
639
|
+
// two is the ambiguity it refuses to guess through, and the only case a rule is still needed.
|
|
640
|
+
function providersOffering(config, id) { return groupedModels(config).filter((group) => group.models.some((model) => model.id === id)).map((group) => group.name); }
|
|
641
|
+
// Whose entry this is. A `direct` rule used to answer it, but since 2026-09-19 a model routes by
|
|
642
|
+
// the provider that declares it and most models have no rule at all — so the answer is the
|
|
643
|
+
// providers, with the rule kept only for the ambiguity they cannot settle: two offering one id.
|
|
644
|
+
function pickerOwner(config, id) {
|
|
645
|
+
const offering = providersOffering(config, id);
|
|
646
|
+
if (offering.length < 2) return offering[0];
|
|
647
|
+
const rule = (config.direct || []).filter((entry) => id.startsWith(entry.prefix)).sort((a, b) => b.prefix.length - a.prefix.length)[0];
|
|
648
|
+
return rule && offering.includes(rule.provider) ? rule.provider : offering[0];
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* The picker entries belonging to every provider but the one being saved — the ones no checkbox on
|
|
652
|
+
* this form can speak for, and which the rebuild must therefore be told to keep.
|
|
653
|
+
*
|
|
654
|
+
* Asking `direct` who owned them dropped all of them: the rules stopped being written in 2026-09-19
|
|
655
|
+
* and only the legacy `gpt-` prefix still matched anything, so one save of any provider emptied the
|
|
656
|
+
* picker of every non-GPT model (measured 2026-09-22 — deepseek and five more went that way).
|
|
657
|
+
*/
|
|
658
|
+
function pickerSelectionsExcept(config, providerName) {
|
|
659
|
+
return ((config.cli && config.cli.extraModels) || [])
|
|
660
|
+
.filter((entry) => entry.model !== null && entry.model !== undefined)
|
|
661
|
+
// The window is part of the entry; rebuilding without it reset every other provider's.
|
|
662
|
+
.map((entry) => ({ id: entry.model, name: entry.name, contextWindow: entry.contextWindow, provider: pickerOwner(config, entry.model) }))
|
|
663
|
+
.filter((entry) => entry.provider && entry.provider !== providerName);
|
|
664
|
+
}
|
|
469
665
|
function applyPickerSelections(next, selections) {
|
|
470
666
|
const known = allKnownModelIds(next);
|
|
471
667
|
// An entry no provider offers any more has no checkbox — the list is built from the providers —
|
|
@@ -479,12 +675,27 @@ function applyPickerSelections(next, selections) {
|
|
|
479
675
|
const extras = new Map();
|
|
480
676
|
const direct = new Map();
|
|
481
677
|
for (const entry of selected) {
|
|
482
|
-
|
|
483
|
-
|
|
678
|
+
// Without this the rebuild would drop the window on every checkbox click.
|
|
679
|
+
extras.set(entry.id, { model: entry.id, name: entry.name || entry.id, ...(Number.isFinite(entry.contextWindow) && entry.contextWindow > 0 ? { contextWindow: entry.contextWindow } : {}) });
|
|
680
|
+
// A rule per ticked model is no longer what makes it route: since 2026-09-19 the router sends a
|
|
681
|
+
// model to the one provider whose own `models` list carries it (routing.ts, "declared models").
|
|
682
|
+
// Writing one anyway restated the same fact in a second place and piled up — one config reached
|
|
683
|
+
// eighteen. It is still written for the case the router deliberately refuses to guess: an id
|
|
684
|
+
// that MORE THAN ONE provider offers, where only the operator knows which deal is meant. Keep
|
|
685
|
+
// this condition and the router's in step; the router is the one that decides.
|
|
686
|
+
if (providersOffering(next, entry.id).length > 1) direct.set(entry.id, { prefix: entry.id, provider: entry.provider });
|
|
484
687
|
}
|
|
485
688
|
next.cli = { ...(next.cli || {}), extraModels: [...extras.values()] };
|
|
486
|
-
|
|
487
|
-
|
|
689
|
+
// A rule survives when it is not a model id at all (the legacy `gpt-` prefix), or when it names an
|
|
690
|
+
// id two providers offer, which the router will not resolve on its own. Dropping that second kind
|
|
691
|
+
// because the model happens not to be ticked in the picker would stop it routing: the config here
|
|
692
|
+
// has `deepseek-v4-pro` on a direct mapping and on OpenCode Go, and it is in no picker list.
|
|
693
|
+
// An orphan goes either way — no provider offers it, and nothing in the interface can uncheck it.
|
|
694
|
+
const preservedDirect = (next.direct || []).filter((rule) =>
|
|
695
|
+
!orphans.has(rule.prefix) && (!known.has(rule.prefix) || providersOffering(next, rule.prefix).length > 1));
|
|
696
|
+
const byPrefix = new Map(preservedDirect.map((rule) => [rule.prefix, rule]));
|
|
697
|
+
for (const [prefix, rule] of direct) if (!byPrefix.has(prefix)) byPrefix.set(prefix, rule);
|
|
698
|
+
next.direct = [...byPrefix.values()];
|
|
488
699
|
// A legacy gpt- prefix rule is intentionally retained by the filter above.
|
|
489
700
|
return next;
|
|
490
701
|
}
|
|
@@ -541,6 +752,9 @@ async function saveSlots() {
|
|
|
541
752
|
function statusText(state) {
|
|
542
753
|
if (!state) return t("providerStatus.checking");
|
|
543
754
|
if (state.ok) return t("providerStatus.connected");
|
|
755
|
+
// The key was accepted and the plan refused: saying "check your key" sends the operator to the
|
|
756
|
+
// one thing that is not wrong. Measured 2026-09-22 against OpenCode's free-tier 403.
|
|
757
|
+
if (state.auth === "not-entitled") return t("providerStatus.notEntitled");
|
|
544
758
|
if (state.auth === "bad-key") return t("providerStatus.keyNeeded");
|
|
545
759
|
return t("providerStatus.disconnected");
|
|
546
760
|
}
|
|
@@ -557,79 +771,339 @@ async function probeProvider(name, provider, onComplete) {
|
|
|
557
771
|
headers,
|
|
558
772
|
modelsUrl: provider.modelsUrl || (provider.preset && presetById(provider.preset) && presetById(provider.preset).modelsUrl),
|
|
559
773
|
modelsAuthHeader: provider.modelsAuthHeader || (provider.preset && presetById(provider.preset) && presetById(provider.preset).modelsAuthHeader),
|
|
774
|
+
// The router reads the preset's fallback list to tag each discovered model with the wire it
|
|
775
|
+
// speaks, since /models reports ids alone and one plan can serve several wires.
|
|
776
|
+
preset: provider.preset,
|
|
560
777
|
probeModel: provider.probeModel || (provider.preset && presetById(provider.preset) && (presetById(provider.preset).fallbackModels || [])[0] && presetById(provider.preset).fallbackModels[0].id),
|
|
778
|
+
// Some vendors refuse a request without it rather than merely losing the cache, so a test
|
|
779
|
+
// that leaves it out reports a broken provider that works perfectly.
|
|
780
|
+
sessionHeader: provider.sessionHeader || (provider.preset && presetById(provider.preset) && presetById(provider.preset).sessionHeader),
|
|
561
781
|
};
|
|
562
782
|
const result = await api("/api/providers/probe", {
|
|
563
783
|
method: "POST",
|
|
564
784
|
headers: { "content-type": "application/json" },
|
|
565
785
|
body: JSON.stringify(body),
|
|
566
786
|
});
|
|
567
|
-
probeStates.set(name, result);
|
|
787
|
+
probeStates.set(name, { ...result, at: Date.now() });
|
|
568
788
|
onComplete && onComplete(result);
|
|
569
789
|
return result;
|
|
570
790
|
} catch (error) {
|
|
571
791
|
const unavailable = error.status === 404;
|
|
572
792
|
const result = { ok: false, auth: unavailable ? "unknown" : "unreachable", models: [], error: unavailable ? t("providers.apiSoon") : error.message, unavailable };
|
|
573
|
-
probeStates.set(name, result);
|
|
793
|
+
probeStates.set(name, { ...result, at: Date.now() });
|
|
574
794
|
onComplete && onComplete(result);
|
|
575
795
|
return result;
|
|
576
796
|
} finally {
|
|
577
797
|
renderHealthProviders();
|
|
578
798
|
}
|
|
579
799
|
}
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
800
|
+
// ---- Capability measurement ---------------------------------------------------------
|
|
801
|
+
//
|
|
802
|
+
// A model's wire and its effort ladder are measured by asking it, because a vendor's `/models`
|
|
803
|
+
// reports ids and nothing else. That takes several small requests per model, far too long to hold a
|
|
804
|
+
// save behind, so the save returns at once and this runs after it and settles the config when it
|
|
805
|
+
// finishes. Provider name → { state, done, total }, for the line the detail view shows meanwhile.
|
|
806
|
+
const measureStates = new Map();
|
|
807
|
+
|
|
808
|
+
/** What the operator wants to know from a finished job: what was settled, and what would not answer. */
|
|
809
|
+
function measureSummary(results) {
|
|
810
|
+
const measured = results.filter((entry) => entry.wire);
|
|
811
|
+
if (measured.length === 0) return "";
|
|
812
|
+
const failed = results.length - measured.length;
|
|
813
|
+
const parts = measured.map((entry) => `${entry.id} → ${entry.wire}${entry.effortLevels ? ` (${entry.effortLevels.length ? entry.effortLevels.join("/") : t("providers.effortNone")})` : ""}`);
|
|
814
|
+
return failed > 0 ? `${parts.join(", ")} · +${failed}` : parts.join(", ");
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Measure the models of one provider, in the background, and show the config the result settled.
|
|
819
|
+
*
|
|
820
|
+
* Only models missing a wire or a ladder are sent: one the operator set is their decision, and the
|
|
821
|
+
* router will not overwrite it either, so measuring it again would spend requests to change nothing.
|
|
822
|
+
*/
|
|
823
|
+
async function startMeasurement(providerName, provider) {
|
|
824
|
+
if (!provider || (provider.type !== "openai-compatible" && provider.type !== "anthropic-compatible")) return;
|
|
825
|
+
const models = modelsOf(provider).filter((model) => !model.wire || !model.effortLevels).map((model) => model.id);
|
|
826
|
+
if (models.length === 0) return;
|
|
827
|
+
let jobId;
|
|
828
|
+
try {
|
|
829
|
+
const started = await api("/api/providers/measure", {
|
|
830
|
+
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ provider: providerName, models }),
|
|
831
|
+
});
|
|
832
|
+
jobId = started.jobId;
|
|
833
|
+
measureStates.set(providerName, { state: "running", done: 0, total: started.total });
|
|
834
|
+
renderProviderWorkspace();
|
|
835
|
+
} catch { return; } // The router is older than this screen, or said no: the save itself stands.
|
|
836
|
+
try {
|
|
837
|
+
for (;;) {
|
|
838
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
839
|
+
const job = await api(`/api/providers/measure/${jobId}`);
|
|
840
|
+
measureStates.set(providerName, { state: job.state, done: job.done, total: job.total });
|
|
841
|
+
renderProviderWorkspace();
|
|
842
|
+
if (job.state === "running") continue;
|
|
843
|
+
measureStates.delete(providerName);
|
|
844
|
+
if (job.state === "failed") { toast(t("measure.failed"), true, job.error); renderProviderWorkspace(); return; }
|
|
845
|
+
const results = job.results || [];
|
|
846
|
+
// A model the plan refuses is not merely unmeasured: no wire will ever answer for it, so
|
|
847
|
+
// saying so once here is the only chance the operator gets before a session fails on it.
|
|
848
|
+
// Measured 2026-09-22: OpenCode answers every `-free` model with 403 FreeTierError.
|
|
849
|
+
const barred = results.filter((entry) => typeof entry.error === "string" && entry.error.startsWith("not-entitled"));
|
|
850
|
+
if (barred.length > 0) toast(t("measure.notEntitled", { models: barred.map((entry) => entry.id).join(", ") }), true);
|
|
851
|
+
const summary = measureSummary(results);
|
|
852
|
+
// An auth failure is reported as itself: it says nothing about any model, and leaving it as
|
|
853
|
+
// "nothing was measured" would send the operator looking at the wrong thing.
|
|
854
|
+
const refused = results.some((entry) => entry.error === "auth");
|
|
855
|
+
// The barred models already had their own line; do not also call the round a failure when
|
|
856
|
+
// every other model measured fine.
|
|
857
|
+
if (summary || barred.length === 0) toast(summary ? t("measure.done", { summary }) : refused ? t("measure.authFailed") : t("measure.nothing"), !summary);
|
|
858
|
+
await loadProviders();
|
|
859
|
+
return;
|
|
615
860
|
}
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
861
|
+
} catch (error) {
|
|
862
|
+
measureStates.delete(providerName);
|
|
863
|
+
toast(t("measure.failed"), true, error.message);
|
|
864
|
+
renderProviderWorkspace();
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
let selectedProviderName = null;
|
|
869
|
+
let providerDetailTab = "overview";
|
|
870
|
+
let providerSearch = "";
|
|
871
|
+
let providerDetailGeneration = 0;
|
|
872
|
+
|
|
873
|
+
function providerKind(name, provider) {
|
|
874
|
+
const preset = provider.preset && presetById(provider.preset);
|
|
875
|
+
if (provider.type === "chatgpt") return t("providers.chatgpt");
|
|
876
|
+
if (provider.type === "anthropic") return provider.auth === "claude-code"
|
|
877
|
+
? `${t("providers.anthropic")} · ${provider.accountPool ? t("providers.rotationOn") : t("providers.rotationOff")}`
|
|
878
|
+
: `${t("providers.anthropic")} · ${t("providers.apiKey")}`;
|
|
879
|
+
if (preset) return preset.name;
|
|
880
|
+
try { return new URL(provider.url).host; } catch { return provider.type || name; }
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function providerRailItem(name, provider) {
|
|
884
|
+
const selected = name === selectedProviderName;
|
|
885
|
+
const button = el("button", {
|
|
886
|
+
class: `provider-rail-item${selected ? " selected" : ""}`,
|
|
887
|
+
type: "button",
|
|
888
|
+
role: "option",
|
|
889
|
+
"aria-selected": String(selected),
|
|
890
|
+
}, [
|
|
891
|
+
el("span", { class: "provider-rail-copy" }, [
|
|
892
|
+
el("strong", { text: name }),
|
|
893
|
+
el("span", { text: t("providers.modelsCountShort", { count: modelsOf(provider).length }) }),
|
|
894
|
+
]),
|
|
895
|
+
providerState(name, provider),
|
|
896
|
+
]);
|
|
897
|
+
button.addEventListener("click", () => {
|
|
898
|
+
selectedProviderName = name;
|
|
899
|
+
providerDetailTab = provider.type === "anthropic" && provider.auth === "claude-code" ? "accounts" : "overview";
|
|
900
|
+
renderProviderWorkspace();
|
|
627
901
|
});
|
|
902
|
+
return button;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function renderProviderRail() {
|
|
906
|
+
const list = $("#providers-list");
|
|
907
|
+
const entries = Object.entries((currentConfig && currentConfig.providers) || {});
|
|
908
|
+
const query = providerSearch.trim().toLowerCase();
|
|
909
|
+
const visible = entries.filter(([name, provider]) => !query || name.toLowerCase().includes(query) || providerKind(name, provider).toLowerCase().includes(query));
|
|
910
|
+
list.replaceChildren(...visible.map(([name, provider]) => providerRailItem(name, provider)));
|
|
911
|
+
if (!entries.length) list.appendChild(el("div", { class: "provider-rail-empty", text: t("providers.empty") }));
|
|
912
|
+
else if (!visible.length) list.appendChild(el("div", { class: "provider-rail-empty", text: t("providers.noSelection") }));
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
async function removeProvider(name, provider) {
|
|
916
|
+
if (!confirm(t("providers.removeConfirm", { name }))) return;
|
|
917
|
+
const next = clone(currentConfig);
|
|
918
|
+
delete next.providers[name];
|
|
919
|
+
next.routes = Object.fromEntries(Object.entries(next.routes || {}).filter(([, route]) => route.provider !== name));
|
|
920
|
+
next.direct = (next.direct || []).filter((rule) => rule.provider !== name);
|
|
921
|
+
next.cli.extraModels = (next.cli.extraModels || []).filter((entry) => !modelsOf(provider).some((model) => model.id === entry.model));
|
|
922
|
+
try {
|
|
923
|
+
await configRequest(next);
|
|
924
|
+
currentConfig = next;
|
|
925
|
+
selectedProviderName = Object.keys(next.providers)[0] || null;
|
|
926
|
+
providerDetailTab = "overview";
|
|
927
|
+
slotsLoaded = false;
|
|
928
|
+
clientsLoaded = false;
|
|
929
|
+
renderProviderWorkspace();
|
|
930
|
+
toast(t("common.saved"));
|
|
931
|
+
} catch (error) { toast(t("common.saveFailed"), true, error.message); }
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function providerTabs(name, provider) {
|
|
935
|
+
const tabs = [{ id: "overview", label: t("providers.overview") }];
|
|
936
|
+
if (provider.type === "anthropic" && provider.auth === "claude-code") tabs.push({ id: "accounts", label: t("providers.accounts") });
|
|
937
|
+
tabs.push({ id: "models", label: t("providers.modelsTab") });
|
|
938
|
+
return el("div", { class: "provider-tabs", role: "tablist" }, tabs.map((tab) => {
|
|
939
|
+
const button = el("button", { class: providerDetailTab === tab.id ? "active" : "", type: "button", role: "tab", "aria-selected": String(providerDetailTab === tab.id), text: tab.label });
|
|
940
|
+
button.addEventListener("click", () => { providerDetailTab = tab.id; renderProviderDetail(); });
|
|
941
|
+
return button;
|
|
942
|
+
}));
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function providerOverview(name, provider) {
|
|
946
|
+
const state = stateFor(name);
|
|
947
|
+
const live = status && status.providers && status.providers[name];
|
|
948
|
+
const connection = el("section", { class: "detail-section" }, [
|
|
949
|
+
el("h3", { text: t("providers.connection") }),
|
|
950
|
+
el("div", { class: "detail-setting-row" }, [
|
|
951
|
+
el("div", { class: "setting-copy" }, [el("strong", { text: statusText(state) }), el("span", { text: providerKind(name, provider) })]),
|
|
952
|
+
providerState(name, provider),
|
|
953
|
+
]),
|
|
954
|
+
state && !state.ok && state.error ? el("p", { class: "bad-text small provider-detail-error", text: shortError(state.error), title: state.error }) : null,
|
|
955
|
+
live && live.needsLogin ? chatgptLoginButton(renderProviderDetail) : null,
|
|
956
|
+
].filter(Boolean));
|
|
957
|
+
const models = modelsOf(provider);
|
|
958
|
+
const modelSummary = el("section", { class: "detail-section" }, [
|
|
959
|
+
el("h3", { text: t("providers.selectedModels") }),
|
|
960
|
+
models.length ? el("div", { class: "model-chip-list" }, models.map((model) => el("span", { class: "model-chip", text: labelOf(model) }))) : hint(t("providers.noModels")),
|
|
961
|
+
]);
|
|
962
|
+
const measuring = measureStates.get(name);
|
|
963
|
+
if (measuring && measuring.state === "running") {
|
|
964
|
+
modelSummary.appendChild(el("p", { class: "small", text: t("measure.running", { done: measuring.done, total: measuring.total }) }));
|
|
965
|
+
modelSummary.appendChild(hint(t("measure.help")));
|
|
966
|
+
}
|
|
628
967
|
const quota = quotaLine(name);
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
968
|
+
return el("div", { class: "provider-panel" }, [connection, quota ? el("section", { class: "detail-section" }, [el("h3", { text: t("health.quota", { percent: "", reset: "" }).trim() }), el("p", { text: quota })]) : null, modelSummary].filter(Boolean));
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function providerModelsPanel(name, provider) {
|
|
972
|
+
const models = modelsOf(provider);
|
|
973
|
+
return el("div", { class: "provider-panel" }, [
|
|
974
|
+
el("section", { class: "detail-section" }, [
|
|
975
|
+
el("div", { class: "section-heading" }, [el("div", {}, [el("h3", { text: t("providers.selectedModels") }), hint(t("providers.modelsHelp"))]), el("button", { class: "btn secondary", type: "button", text: t("common.edit"), onclick: () => openProviderForm({ name, provider }) })]),
|
|
976
|
+
models.length ? el("div", { class: "provider-model-list" }, models.map((model) => el("div", { class: "provider-model-row" }, [el("strong", { text: labelOf(model) }), el("span", { class: "small", text: model.id }), modelEffortTag(model) ? el("span", { class: `model-effort-tag ${model.effortLevels.length ? "has-effort" : "no-effort"}`, text: modelEffortTag(model) }) : null].filter(Boolean)))) : hint(t("providers.noModels")),
|
|
977
|
+
]),
|
|
978
|
+
]);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
async function saveAnthropicRotation(name, provider, enabled, control, message) {
|
|
982
|
+
control.disabled = true;
|
|
983
|
+
message.textContent = t("providers.rotationSaving");
|
|
984
|
+
const next = clone(currentConfig);
|
|
985
|
+
if (enabled) next.providers[name].accountPool = true;
|
|
986
|
+
else delete next.providers[name].accountPool;
|
|
987
|
+
try {
|
|
988
|
+
await configRequest(next);
|
|
989
|
+
currentConfig = next;
|
|
990
|
+
slotsLoaded = false;
|
|
991
|
+
clientsLoaded = false;
|
|
992
|
+
renderProviderWorkspace();
|
|
993
|
+
toast(t("providers.rotationSaved"));
|
|
994
|
+
} catch (error) {
|
|
995
|
+
control.checked = !enabled;
|
|
996
|
+
message.textContent = "";
|
|
997
|
+
toast(t("common.saveFailed"), true, error.message);
|
|
998
|
+
} finally { control.disabled = false; }
|
|
632
999
|
}
|
|
1000
|
+
|
|
1001
|
+
function renderClaudeAccountRows(target, data, name, generation) {
|
|
1002
|
+
if (generation !== providerDetailGeneration) return;
|
|
1003
|
+
const rows = [];
|
|
1004
|
+
if (data.current) rows.push(el("article", { class: "account-card current" }, [
|
|
1005
|
+
el("div", { class: "account-card-copy" }, [el("strong", { text: data.current.label }), el("span", { class: "small", text: anthropicSourceText(data.current.source) }), hint(t("providers.currentAccountHelp"))]),
|
|
1006
|
+
el("span", { class: "badge ok", text: t("providers.anthropicCurrent") }),
|
|
1007
|
+
]));
|
|
1008
|
+
for (const account of Array.isArray(data.accounts) ? data.accounts : []) {
|
|
1009
|
+
const unavailable = account.needsReauth || account.expiresAt <= Date.now();
|
|
1010
|
+
const rename = el("button", { class: "btn secondary compact", type: "button", text: t("common.edit") });
|
|
1011
|
+
rename.addEventListener("click", async () => {
|
|
1012
|
+
const label = prompt(t("providers.anthropicRenamePrompt"), account.label);
|
|
1013
|
+
if (!label || !label.trim() || label.trim() === account.label) return;
|
|
1014
|
+
try { await api(`/api/claude-accounts/${encodeURIComponent(account.id)}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ label }) }); await refreshClaudeAccountPanel(name); }
|
|
1015
|
+
catch (error) { toast(t("common.actionFailed"), true, error.message); }
|
|
1016
|
+
});
|
|
1017
|
+
const remove = el("button", { class: "btn danger compact", type: "button", text: t("common.remove") });
|
|
1018
|
+
remove.addEventListener("click", async () => {
|
|
1019
|
+
if (!confirm(t("providers.anthropicRemoveConfirm", { name: account.label }))) return;
|
|
1020
|
+
try { await api(`/api/claude-accounts/${encodeURIComponent(account.id)}`, { method: "DELETE" }); await refreshClaudeAccountPanel(name); }
|
|
1021
|
+
catch (error) { toast(t("common.actionFailed"), true, error.message); }
|
|
1022
|
+
});
|
|
1023
|
+
const actions = [rename, remove];
|
|
1024
|
+
if (unavailable) {
|
|
1025
|
+
const reauth = el("button", { class: "btn secondary compact", type: "button", text: t("providers.reauthAction") });
|
|
1026
|
+
reauth.addEventListener("click", () => openClaudeAccountConsent(name));
|
|
1027
|
+
actions.unshift(reauth);
|
|
1028
|
+
}
|
|
1029
|
+
rows.push(el("article", { class: "account-card" }, [
|
|
1030
|
+
el("div", { class: "account-card-copy" }, [el("strong", { text: account.label }), account.email && account.email !== account.label ? el("span", { class: "small", text: account.email }) : null, hint(t("providers.addedAccountHelp"))]),
|
|
1031
|
+
el("span", { class: `badge ${unavailable ? "bad" : "ok"}`, text: unavailable ? t("providers.anthropicReauth") : t("pool.ready") }),
|
|
1032
|
+
el("div", { class: "account-card-actions" }, actions),
|
|
1033
|
+
]));
|
|
1034
|
+
}
|
|
1035
|
+
target.replaceChildren(...(rows.length ? rows : [el("div", { class: "empty-card", text: t("providers.anthropicNoAccounts") })]));
|
|
1036
|
+
const count = (data.current ? 1 : 0) + (Array.isArray(data.accounts) ? data.accounts.length : 0);
|
|
1037
|
+
const countNode = $("#claude-account-count");
|
|
1038
|
+
if (countNode) countNode.textContent = t("providers.accountCount", { count });
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
async function refreshClaudeAccountPanel(name) {
|
|
1042
|
+
const target = $("#claude-account-rows");
|
|
1043
|
+
if (!target || selectedProviderName !== name || providerDetailTab !== "accounts") return;
|
|
1044
|
+
const generation = providerDetailGeneration;
|
|
1045
|
+
try { renderClaudeAccountRows(target, await api("/api/claude-accounts"), name, generation); }
|
|
1046
|
+
catch (error) { if (generation === providerDetailGeneration) target.replaceChildren(el("div", { class: "bad-text small", text: error.message })); }
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function anthropicAccountsPanel(name, provider) {
|
|
1050
|
+
const rotation = el("input", { type: "checkbox", checked: Boolean(provider.accountPool) });
|
|
1051
|
+
const rotationMessage = el("span", { class: "small" });
|
|
1052
|
+
rotation.addEventListener("change", () => void saveAnthropicRotation(name, provider, rotation.checked, rotation, rotationMessage));
|
|
1053
|
+
const add = el("button", { class: "btn", type: "button", text: t("providers.addClaudeAccount") });
|
|
1054
|
+
add.addEventListener("click", () => openClaudeAccountConsent(name));
|
|
1055
|
+
const removeAll = el("button", { class: "btn danger", type: "button", text: t("providers.anthropicLogoutAll") });
|
|
1056
|
+
removeAll.addEventListener("click", async () => {
|
|
1057
|
+
if (!confirm(t("providers.anthropicLogoutAllConfirm"))) return;
|
|
1058
|
+
try { await api("/api/claude-logout", { method: "POST" }); await refreshClaudeAccountPanel(name); }
|
|
1059
|
+
catch (error) { toast(t("common.actionFailed"), true, error.message); }
|
|
1060
|
+
});
|
|
1061
|
+
const rows = el("div", { id: "claude-account-rows", class: "account-card-list" }, [el("div", { class: "small", text: t("providers.checking") })]);
|
|
1062
|
+
const panel = el("div", { class: "provider-panel" }, [
|
|
1063
|
+
el("section", { class: "detail-section account-summary" }, [
|
|
1064
|
+
el("div", { class: "section-heading" }, [el("div", {}, [el("h3", { text: t("providers.accountPoolTitle") }), el("p", { id: "claude-account-count", class: "account-count", text: t("providers.accountCount", { count: 0 }) }), hint(t("providers.accountCountHelp"))]), add]),
|
|
1065
|
+
el("div", { class: "detail-setting-row rotation-row" }, [el("div", { class: "setting-copy" }, [el("strong", { text: t("providers.anthropicPool") }), el("span", { text: t("providers.accountPoolSubtitle") })]), el("label", { class: "switch" }, [rotation, el("span")])]),
|
|
1066
|
+
!provider.accountPool ? el("div", { class: "notice warn" }, [el("strong", { text: t("providers.rotationOff") }), el("span", { text: t("providers.rotationRequired") })]) : null,
|
|
1067
|
+
rotationMessage,
|
|
1068
|
+
]),
|
|
1069
|
+
el("section", { class: "detail-section" }, [rows]),
|
|
1070
|
+
el("section", { class: "detail-section danger-section" }, [el("h3", { text: t("providers.dangerZone") }), hint(t("providers.anthropicLogoutAllConfirm")), removeAll]),
|
|
1071
|
+
]);
|
|
1072
|
+
queueMicrotask(() => void refreshClaudeAccountPanel(name));
|
|
1073
|
+
return panel;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function renderProviderDetail() {
|
|
1077
|
+
const detail = $("#provider-detail");
|
|
1078
|
+
providerDetailGeneration += 1;
|
|
1079
|
+
const provider = currentConfig && currentConfig.providers && currentConfig.providers[selectedProviderName];
|
|
1080
|
+
if (!provider) {
|
|
1081
|
+
detail.replaceChildren(el("div", { class: "provider-detail-empty" }, [el("strong", { text: t("providers.noSelection") })]));
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
const name = selectedProviderName;
|
|
1085
|
+
const check = el("button", { class: "btn secondary", type: "button", text: t("providers.check") });
|
|
1086
|
+
check.addEventListener("click", async () => { check.disabled = true; await probeProvider(name, provider); check.disabled = false; renderProviderWorkspace(); });
|
|
1087
|
+
const edit = el("button", { class: "btn secondary", type: "button", text: t("common.edit"), onclick: () => openProviderForm({ name, provider }) });
|
|
1088
|
+
const remove = el("button", { class: "btn danger", type: "button", text: t("common.remove"), onclick: () => void removeProvider(name, provider) });
|
|
1089
|
+
const header = el("header", { class: "provider-detail-header" }, [
|
|
1090
|
+
el("div", {}, [el("div", { class: "provider-title-line" }, [el("h2", { text: name }), providerState(name, provider)]), el("p", { class: "small", text: providerKind(name, provider) })]),
|
|
1091
|
+
el("div", { class: "provider-detail-actions" }, [check, edit, remove]),
|
|
1092
|
+
]);
|
|
1093
|
+
let content;
|
|
1094
|
+
if (providerDetailTab === "accounts" && provider.type === "anthropic" && provider.auth === "claude-code") content = anthropicAccountsPanel(name, provider);
|
|
1095
|
+
else if (providerDetailTab === "models") content = providerModelsPanel(name, provider);
|
|
1096
|
+
else { providerDetailTab = "overview"; content = providerOverview(name, provider); }
|
|
1097
|
+
detail.replaceChildren(header, providerTabs(name, provider), content);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function renderProviderWorkspace() {
|
|
1101
|
+
const entries = Object.entries((currentConfig && currentConfig.providers) || {});
|
|
1102
|
+
if (!selectedProviderName || !currentConfig.providers[selectedProviderName]) selectedProviderName = entries[0] ? entries[0][0] : null;
|
|
1103
|
+
renderProviderRail();
|
|
1104
|
+
renderProviderDetail();
|
|
1105
|
+
}
|
|
1106
|
+
|
|
633
1107
|
async function loadClients() {
|
|
634
1108
|
clientsLoaded = true;
|
|
635
1109
|
try {
|
|
@@ -645,32 +1119,41 @@ async function loadProviders() {
|
|
|
645
1119
|
try {
|
|
646
1120
|
await loadCatalogs();
|
|
647
1121
|
currentConfig = currentConfig || await api("/api/config");
|
|
648
|
-
|
|
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") }));
|
|
1122
|
+
renderProviderWorkspace();
|
|
651
1123
|
void Promise.all(Object.entries(currentConfig.providers || {}).map(([name, provider]) => probeProvider(name, provider, () => {
|
|
652
|
-
|
|
653
|
-
if (existing) { existing.remove(); list.appendChild(providerCard(name, provider)); }
|
|
1124
|
+
if ($("#view-providers").classList.contains("active")) renderProviderWorkspace();
|
|
654
1125
|
})));
|
|
655
1126
|
} catch (error) { toast(t("common.loadFailed"), true, error.message); }
|
|
656
1127
|
}
|
|
1128
|
+
$("#providers-search").addEventListener("input", (event) => { providerSearch = event.target.value; renderProviderRail(); });
|
|
657
1129
|
$("#providers-add").addEventListener("click", openProviderChooser);
|
|
658
1130
|
$("#providers-refresh").addEventListener("click", async () => {
|
|
659
1131
|
if (!currentConfig) return;
|
|
1132
|
+
const button = $("#providers-refresh");
|
|
1133
|
+
button.disabled = true;
|
|
660
1134
|
await Promise.all(Object.entries(currentConfig.providers).map(([name, provider]) => probeProvider(name, provider)));
|
|
661
|
-
|
|
662
|
-
|
|
1135
|
+
button.disabled = false;
|
|
1136
|
+
renderProviderWorkspace();
|
|
663
1137
|
});
|
|
664
1138
|
|
|
665
1139
|
// ---- Provider modal -----------------------------------------------------------------
|
|
666
1140
|
|
|
667
|
-
|
|
1141
|
+
let modalCleanup = null;
|
|
1142
|
+
function showModal(content, cleanup) {
|
|
1143
|
+
if (modalCleanup) modalCleanup();
|
|
1144
|
+
modalCleanup = cleanup || null;
|
|
668
1145
|
$("#modal-content").replaceChildren(content);
|
|
669
1146
|
$("#modal-backdrop").hidden = false;
|
|
670
1147
|
const first = $("#modal-content input, #modal-content button, #modal-content select");
|
|
671
1148
|
if (first) setTimeout(() => first.focus(), 0);
|
|
672
1149
|
}
|
|
673
|
-
function closeModal() {
|
|
1150
|
+
function closeModal() {
|
|
1151
|
+
const cleanup = modalCleanup;
|
|
1152
|
+
modalCleanup = null;
|
|
1153
|
+
if (cleanup) cleanup();
|
|
1154
|
+
$("#modal-backdrop").hidden = true;
|
|
1155
|
+
$("#modal-content").replaceChildren();
|
|
1156
|
+
}
|
|
674
1157
|
$("#modal-close").addEventListener("click", closeModal);
|
|
675
1158
|
$("#modal-backdrop").addEventListener("click", (event) => { if (event.target === $("#modal-backdrop")) closeModal(); });
|
|
676
1159
|
window.addEventListener("keydown", (event) => {
|
|
@@ -680,6 +1163,83 @@ window.addEventListener("keydown", (event) => {
|
|
|
680
1163
|
if (action && !action.disabled) { event.preventDefault(); action.click(); }
|
|
681
1164
|
}
|
|
682
1165
|
});
|
|
1166
|
+
|
|
1167
|
+
function openClaudeAccountConsent(providerName) {
|
|
1168
|
+
const accepted = el("input", { type: "checkbox" });
|
|
1169
|
+
const proceed = el("button", { class: "btn", type: "button", "data-default-action": "", text: t("providers.anthropicOAuthContinue") });
|
|
1170
|
+
proceed.disabled = true;
|
|
1171
|
+
accepted.addEventListener("change", () => { proceed.disabled = !accepted.checked; });
|
|
1172
|
+
proceed.addEventListener("click", () => void openClaudeAccountSignIn(providerName, false));
|
|
1173
|
+
showModal(el("div", { class: "oauth-consent" }, [
|
|
1174
|
+
el("h1", { id: "modal-title", text: t("providers.anthropicOAuthTitle") }),
|
|
1175
|
+
el("div", { class: "notice warn" }, [el("strong", { text: t("providers.anthropicOAuthTitle") }), el("span", { text: t("providers.anthropicOAuthWarning") })]),
|
|
1176
|
+
el("label", { class: "check oauth-accept" }, [accepted, el("span", { text: t("providers.anthropicOAuthAccept") })]),
|
|
1177
|
+
el("div", { class: "actions end" }, [el("button", { class: "btn secondary", type: "button", text: t("common.cancel"), onclick: closeModal }), proceed]),
|
|
1178
|
+
]));
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
async function openClaudeAccountSignIn(providerName, manual) {
|
|
1182
|
+
let active = true;
|
|
1183
|
+
let poll = null;
|
|
1184
|
+
const body = el("div", { class: "oauth-progress" });
|
|
1185
|
+
function stopPolling() { if (poll) clearInterval(poll); poll = null; }
|
|
1186
|
+
function showError(state) {
|
|
1187
|
+
stopPolling();
|
|
1188
|
+
if (!active) return;
|
|
1189
|
+
const retry = el("button", { class: "btn secondary", type: "button", text: t("providers.anthropicSignInManual") });
|
|
1190
|
+
retry.addEventListener("click", () => void openClaudeAccountSignIn(providerName, true));
|
|
1191
|
+
body.replaceChildren(el("span", { class: "bad-text", text: t("providers.anthropicSignInFailed") }), state.error ? el("p", { class: "small", text: state.error }) : null, retry);
|
|
1192
|
+
}
|
|
1193
|
+
async function finish(state) {
|
|
1194
|
+
stopPolling();
|
|
1195
|
+
if (!active) return;
|
|
1196
|
+
if (!state.ok) { showError(state); return; }
|
|
1197
|
+
active = false;
|
|
1198
|
+
closeModal();
|
|
1199
|
+
if (selectedProviderName === providerName) {
|
|
1200
|
+
providerDetailTab = "accounts";
|
|
1201
|
+
renderProviderDetail();
|
|
1202
|
+
}
|
|
1203
|
+
toast(t("providers.anthropicLoginDone"));
|
|
1204
|
+
}
|
|
1205
|
+
function renderState(state) {
|
|
1206
|
+
const controls = [
|
|
1207
|
+
el("p", { text: state.manual ? t("providers.anthropicSignInPaste") : t("providers.anthropicSignInBrowser") }),
|
|
1208
|
+
state.url ? el("a", { class: "login-link", href: state.url, target: "_blank", rel: "noreferrer", text: t("providers.anthropicSignInLink") }) : null,
|
|
1209
|
+
];
|
|
1210
|
+
if (state.manual) {
|
|
1211
|
+
const code = el("input", { type: "text", autocomplete: "off", placeholder: "code#state" });
|
|
1212
|
+
const submit = el("button", { class: "btn", type: "button", text: t("providers.anthropicSignInSubmit") });
|
|
1213
|
+
submit.addEventListener("click", async () => {
|
|
1214
|
+
submit.disabled = true;
|
|
1215
|
+
try { await finish(await api("/api/claude-oauth/code", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ code: code.value }) })); }
|
|
1216
|
+
catch (error) { showError((error.body && { ok: false, error: error.body.error }) || { ok: false, error: error.message }); }
|
|
1217
|
+
});
|
|
1218
|
+
controls.push(el("div", { class: "key-control" }, [code, submit]));
|
|
1219
|
+
} else controls.push(el("p", { class: "small", text: t("providers.anthropicSignInWaiting") }));
|
|
1220
|
+
controls.push(el("button", { class: "btn secondary", type: "button", text: t("common.cancel"), onclick: closeModal }));
|
|
1221
|
+
body.replaceChildren(...controls.filter(Boolean));
|
|
1222
|
+
}
|
|
1223
|
+
showModal(el("div", {}, [el("h1", { id: "modal-title", text: t("providers.anthropicOAuthTitle") }), body]), () => {
|
|
1224
|
+
const wasActive = active;
|
|
1225
|
+
active = false;
|
|
1226
|
+
stopPolling();
|
|
1227
|
+
if (wasActive) void api("/api/claude-oauth/cancel", { method: "POST" }).catch(() => {});
|
|
1228
|
+
});
|
|
1229
|
+
body.replaceChildren(el("p", { class: "small", text: t("providers.checking") }));
|
|
1230
|
+
try {
|
|
1231
|
+
const state = await api("/api/claude-oauth", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ manual: Boolean(manual) }) });
|
|
1232
|
+
if (!active) return;
|
|
1233
|
+
renderState(state);
|
|
1234
|
+
poll = setInterval(async () => {
|
|
1235
|
+
try {
|
|
1236
|
+
const next = await api("/api/claude-oauth");
|
|
1237
|
+
if (!next.running) await finish(next);
|
|
1238
|
+
} catch { /* keep the last useful state while the router is busy */ }
|
|
1239
|
+
}, 2000);
|
|
1240
|
+
} catch (error) { showError({ ok: false, error: error.message }); }
|
|
1241
|
+
}
|
|
1242
|
+
|
|
683
1243
|
function openProviderChooser() {
|
|
684
1244
|
const grid = el("div", { class: "chooser-grid" });
|
|
685
1245
|
const anthropic = el("button", { class: "chooser-tile", type: "button" }, [el("strong", { text: t("providers.anthropic") }), el("span", { text: t("providers.anthropicHelp") })]);
|
|
@@ -713,10 +1273,21 @@ function inputRow(label, control, helpText) {
|
|
|
713
1273
|
}
|
|
714
1274
|
// Checklist that stays usable with hundreds of models (OpenRouter lists 400+): a search box, checked
|
|
715
1275
|
// 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
|
|
1276
|
+
// loses ticks. `box.selected()` returns the chosen entries, each carrying whatever per-model override
|
|
1277
|
+
// it arrived with (effortLevels, contextWindow, and the wire/url/authHeader a preset gave it) so a
|
|
1278
|
+
// save does not strip the endpoint the model speaks.
|
|
1279
|
+
function modelOverrideFields(model) {
|
|
1280
|
+
return {
|
|
1281
|
+
...(Array.isArray(model.effortLevels) ? { effortLevels: [...model.effortLevels] } : {}),
|
|
1282
|
+
...(typeof model.contextWindow === "number" ? { contextWindow: model.contextWindow } : {}),
|
|
1283
|
+
...(model.wire ? { wire: model.wire } : {}),
|
|
1284
|
+
...(model.url ? { url: model.url } : {}),
|
|
1285
|
+
...(model.authHeader ? { authHeader: model.authHeader } : {}),
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
717
1288
|
function modelChecklist(models, checked) {
|
|
718
1289
|
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), ...(
|
|
1290
|
+
for (const model of models) if (checked.has(model.id)) selected.set(model.id, { id: model.id, name: labelOf(model), ...modelOverrideFields(model) });
|
|
720
1291
|
const wrap = el("div", { class: "model-picker" });
|
|
721
1292
|
const grid = el("div", { class: "model-checklist modal-checklist" });
|
|
722
1293
|
const note = el("div", { class: "small", text: "" });
|
|
@@ -733,7 +1304,7 @@ function modelChecklist(models, checked) {
|
|
|
733
1304
|
input.dataset.model = model.id;
|
|
734
1305
|
input.dataset.name = labelOf(model);
|
|
735
1306
|
input.addEventListener("change", () => {
|
|
736
|
-
if (input.checked) selected.set(model.id, { id: model.id, name: labelOf(model), ...(
|
|
1307
|
+
if (input.checked) selected.set(model.id, { id: model.id, name: labelOf(model), ...modelOverrideFields(model) });
|
|
737
1308
|
else selected.delete(model.id);
|
|
738
1309
|
note.textContent = summary(matches.length);
|
|
739
1310
|
});
|
|
@@ -759,6 +1330,7 @@ function anthropicSourceText(source) {
|
|
|
759
1330
|
return t("providers.anthropicSourceMissing");
|
|
760
1331
|
}
|
|
761
1332
|
function openAnthropicProviderForm(options) {
|
|
1333
|
+
let formActive = true;
|
|
762
1334
|
const existing = options.provider;
|
|
763
1335
|
const displayName = options.name || t("providers.anthropic");
|
|
764
1336
|
const nameInput = el("input", { value: displayName, maxlength: "60" });
|
|
@@ -770,16 +1342,6 @@ function openAnthropicProviderForm(options) {
|
|
|
770
1342
|
const result = el("div", { class: "probe-result" });
|
|
771
1343
|
const probeButton = el("button", { class: "btn secondary", type: "button", text: t("providers.check") });
|
|
772
1344
|
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
1345
|
let foundModels = modelsOf(existing).length ? modelsOf(existing) : claudeModels.map((model) => ({ id: model.id, name: labelOf(model) }));
|
|
784
1346
|
let selected = new Set(modelsOf(existing).length ? modelsOf(existing).map((model) => model.id) : foundModels.map((model) => model.id));
|
|
785
1347
|
const modelArea = el("div", { class: "form-field" });
|
|
@@ -790,13 +1352,11 @@ function openAnthropicProviderForm(options) {
|
|
|
790
1352
|
renderModels();
|
|
791
1353
|
const authField = inputRow(t("providers.credentials"), auth, t("providers.anthropicCredentialsHelp"));
|
|
792
1354
|
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
|
|
1355
|
+
const accountPool = existing ? Boolean(existing.accountPool) : true;
|
|
794
1356
|
function syncAuthFields() {
|
|
795
1357
|
const reused = auth.value === "claude-code";
|
|
796
1358
|
keyField.hidden = reused;
|
|
797
|
-
subscriptionActions.hidden = !reused;
|
|
798
1359
|
sourceLine.hidden = !reused;
|
|
799
|
-
signedInLine.hidden = !reused || !signedInLine.textContent;
|
|
800
1360
|
}
|
|
801
1361
|
auth.addEventListener("change", syncAuthFields);
|
|
802
1362
|
syncAuthFields();
|
|
@@ -806,11 +1366,11 @@ function openAnthropicProviderForm(options) {
|
|
|
806
1366
|
try {
|
|
807
1367
|
const body = auth.value === "claude-code" ? { type: "anthropic", auth: "claude-code" } : { type: "anthropic", auth: "api-key", apiKey: keyInput.value || (existing && existing.apiKey) };
|
|
808
1368
|
const response = await api("/api/providers/probe", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
1369
|
+
if (!formActive) return;
|
|
809
1370
|
sourceLine.textContent = anthropicSourceText(response.source);
|
|
810
|
-
showSignedIn(response);
|
|
811
1371
|
const noCredits = response.ok && /^no-credits:/.test(response.error || "");
|
|
812
1372
|
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") }),
|
|
1373
|
+
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 === "not-entitled" ? t("providers.probeNotEntitled") : response.auth === "bad-key" ? t("providers.probeBadKey") : auth.value === "claude-code" ? anthropicSourceText(response.source) : t("providers.probeFailed") }),
|
|
814
1374
|
response.error ? el("div", { class: "small", text: response.error.replace(/^no-credits:\s*/, "") }) : null,
|
|
815
1375
|
].filter(Boolean));
|
|
816
1376
|
if (Array.isArray(response.models) && response.models.length) {
|
|
@@ -818,60 +1378,18 @@ function openAnthropicProviderForm(options) {
|
|
|
818
1378
|
selected = new Set(modelsOf(existing).length ? modelsOf(existing).map((model) => model.id) : foundModels.map((model) => model.id));
|
|
819
1379
|
renderModels();
|
|
820
1380
|
}
|
|
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; }
|
|
1381
|
+
} catch (error) { if (formActive) result.replaceChildren(el("span", { class: "bad-text", text: t("providers.probeFailed") }), el("div", { class: "small", text: error.message })); }
|
|
1382
|
+
finally { if (formActive) probeButton.disabled = false; }
|
|
823
1383
|
}
|
|
824
1384
|
probeButton.addEventListener("click", () => void runProbe());
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
-
});
|
|
1385
|
+
const modeHint = hint("");
|
|
1386
|
+
function syncModeHint() { modeHint.textContent = auth.value === "claude-code" && accountPool ? t("providers.anthropicPoolRouting") : t("providers.anthropicIngressOnly"); }
|
|
1387
|
+
auth.addEventListener("change", syncModeHint);
|
|
1388
|
+
syncModeHint();
|
|
871
1389
|
const form = el("div", { class: "provider-form" }, [
|
|
872
1390
|
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,
|
|
874
|
-
|
|
1391
|
+
inputRow(t("providers.name"), nameInput, t("providers.nameHelp")), authField, keyField, probeButton, sourceLine, result, modelArea,
|
|
1392
|
+
modeHint,
|
|
875
1393
|
]);
|
|
876
1394
|
const saveButton = el("button", { class: "btn", type: "button", "data-default-action": "", text: existing ? t("common.save") : t("providers.add") });
|
|
877
1395
|
saveButton.addEventListener("click", async () => {
|
|
@@ -880,17 +1398,17 @@ function openAnthropicProviderForm(options) {
|
|
|
880
1398
|
const next = clone(currentConfig);
|
|
881
1399
|
const providerName = existing ? options.name : uniqueName(typedName, next.providers);
|
|
882
1400
|
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 };
|
|
1401
|
+
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 };
|
|
884
1402
|
next.providers[providerName] = provider;
|
|
885
|
-
//
|
|
886
|
-
//
|
|
1403
|
+
// accountPool makes these models native Claude routing targets. Without it this remains the
|
|
1404
|
+
// historical OpenAI ingress provider, and routing deliberately ignores it.
|
|
887
1405
|
saveButton.disabled = true;
|
|
888
|
-
try { await configRequest(next); currentConfig = next; slotsLoaded = false; clientsLoaded = false; providersLoaded = false; closeModal(); await loadProviders(); toast(t("common.saved")); }
|
|
1406
|
+
try { await configRequest(next); currentConfig = next; selectedProviderName = providerName; providerDetailTab = auth.value === "claude-code" ? "accounts" : "overview"; slotsLoaded = false; clientsLoaded = false; providersLoaded = false; closeModal(); await loadProviders(); toast(t("common.saved")); }
|
|
889
1407
|
catch (error) { toast(t("common.saveFailed"), true, error.message); }
|
|
890
1408
|
finally { saveButton.disabled = false; }
|
|
891
1409
|
});
|
|
892
1410
|
form.appendChild(el("div", { class: "actions end" }, [el("button", { class: "btn secondary", type: "button", text: t("common.cancel"), onclick: closeModal }), saveButton]));
|
|
893
|
-
showModal(form);
|
|
1411
|
+
showModal(form, () => { formActive = false; });
|
|
894
1412
|
if (auth.value === "claude-code") void runProbe();
|
|
895
1413
|
}
|
|
896
1414
|
function openProviderForm(options) {
|
|
@@ -1026,6 +1544,9 @@ function openProviderForm(options) {
|
|
|
1026
1544
|
...(prompt ? { identity: prompt.identity.checked } : {}),
|
|
1027
1545
|
...(prompt && prompt.append.value.trim() ? { instructionsAppend: prompt.append.value.trim() } : {}),
|
|
1028
1546
|
...(preset ? { preset: preset.id } : {}),
|
|
1547
|
+
// A vendor that asks for a session header keys its prompt cache on it, so carry it from the
|
|
1548
|
+
// preset rather than leaving the user to discover the bill.
|
|
1549
|
+
...(preset && preset.sessionHeader ? { sessionHeader: preset.sessionHeader } : {}),
|
|
1029
1550
|
...(isOpenAi ? { wire: wireSelect.value, caps: { effortLevels: (preset && preset.effortLevels) || [], reasoning: preset && preset.effortLevels && preset.effortLevels.length ? "effort" : "none" } } : {}),
|
|
1030
1551
|
...(Object.keys(readHeaders()).length ? { headers: readHeaders() } : {}),
|
|
1031
1552
|
};
|
|
@@ -1044,7 +1565,7 @@ function openProviderForm(options) {
|
|
|
1044
1565
|
const response = await probeProvider(temporary, draft);
|
|
1045
1566
|
probeButton.disabled = false;
|
|
1046
1567
|
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");
|
|
1568
|
+
const headline = noCredits ? t("providers.probeNoCredits") : response.ok ? t("providers.probeOk") : response.auth === "not-entitled" ? t("providers.probeNotEntitled") : response.auth === "bad-key" ? t("providers.probeBadKey") : response.unavailable ? t("providers.apiSoon") : t("providers.probeFailed");
|
|
1048
1569
|
result.replaceChildren(...[
|
|
1049
1570
|
el("span", { class: response.ok && !noCredits ? "ok-text" : noCredits ? "warn-text" : "bad-text", text: headline }),
|
|
1050
1571
|
response.error ? el("div", { class: "small", text: response.error.replace(/^no-credits:\s*/, "") }) : null,
|
|
@@ -1060,6 +1581,38 @@ function openProviderForm(options) {
|
|
|
1060
1581
|
// Editing a provider that can list models: fetch the full list right away so the saved picks are
|
|
1061
1582
|
// shown among everything available, not as a two-entry list.
|
|
1062
1583
|
if (existing && !isChatgpt && preset && preset.modelsUrl && existingKey) setTimeout(() => void runProbe(), 0);
|
|
1584
|
+
// ChatGPT has no key field and no probe button, so without this the list stayed the built-in
|
|
1585
|
+
// fallback and a model OpenAI shipped after this release never appeared. Ask the backend for its
|
|
1586
|
+
// catalogue in the background: ticks survive, new models arrive unticked, and a failure just
|
|
1587
|
+
// leaves the fallback list standing.
|
|
1588
|
+
let formActive = true;
|
|
1589
|
+
if (isChatgpt) {
|
|
1590
|
+
const chat = advancedContent._chatgpt;
|
|
1591
|
+
void (async () => {
|
|
1592
|
+
let response;
|
|
1593
|
+
try {
|
|
1594
|
+
response = await api("/api/providers/probe", {
|
|
1595
|
+
method: "POST",
|
|
1596
|
+
headers: { "content-type": "application/json" },
|
|
1597
|
+
body: JSON.stringify({ type: "chatgpt", auth: chat.auth.value, name: options.name }),
|
|
1598
|
+
});
|
|
1599
|
+
} catch { return; }
|
|
1600
|
+
if (!formActive || !Array.isArray(response.models) || !response.models.length) return;
|
|
1601
|
+
// Captured while the fetched list is checked, not against the fallback it is about to replace.
|
|
1602
|
+
const keep = new Set([...currentChecked, ...modelArea.querySelector(".model-picker").selected().map((model) => model.id)]);
|
|
1603
|
+
// A model the catalogue no longer lists but the config still names stays visible and ticked,
|
|
1604
|
+
// so a save cannot drop it silently.
|
|
1605
|
+
const named = new Set(response.models.map((model) => model.id));
|
|
1606
|
+
foundModels = [...response.models, ...foundModels.filter((saved) => !named.has(saved.id))];
|
|
1607
|
+
modelArea.querySelector(".model-picker").replaceWith(modelChecklist(foundModels, keep));
|
|
1608
|
+
modelArea.replaceChildren(
|
|
1609
|
+
el("span", { text: t("providers.models") }),
|
|
1610
|
+
hint(t("providers.modelsHelp")),
|
|
1611
|
+
hint(t("providers.modelsFoundLive", { n: response.models.length })),
|
|
1612
|
+
modelArea.querySelector(".model-picker"),
|
|
1613
|
+
);
|
|
1614
|
+
})();
|
|
1615
|
+
}
|
|
1063
1616
|
const saveButton = el("button", { class: "btn", type: "button", "data-default-action": "", text: existing ? t("common.save") : t("providers.add") });
|
|
1064
1617
|
saveButton.addEventListener("click", async () => {
|
|
1065
1618
|
const typedName = nameInput.value.trim();
|
|
@@ -1071,11 +1624,17 @@ function openProviderForm(options) {
|
|
|
1071
1624
|
if (existing && providerName !== options.name) delete next.providers[options.name];
|
|
1072
1625
|
const checkedModels = form.querySelector(".model-picker").selected();
|
|
1073
1626
|
provider.models = checkedModels;
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1627
|
+
// The form rebuilds the provider from its own fields, so anything it has no field for
|
|
1628
|
+
// (debugDump, a hand-written setting) is carried over from the saved one rather than dropped.
|
|
1629
|
+
// A field the form owns is left to the draft, so clearing it in the form still clears it.
|
|
1630
|
+
const formKeys = new Set(isChatgpt
|
|
1631
|
+
? ["type", "auth", "defaultEffort", "identity", "instructionsAppend", "models"]
|
|
1632
|
+
: ["type", "url", "identity", "instructionsAppend", "preset", "sessionHeader", "wire", "caps", "headers", "models"]);
|
|
1633
|
+
const kept = existing ? Object.fromEntries(Object.entries(existing).filter(([key]) => !formKeys.has(key))) : {};
|
|
1634
|
+
next.providers[providerName] = { ...kept, ...provider };
|
|
1635
|
+
// Read after `next.providers` has been updated, so an unticked model is already undeclared here
|
|
1636
|
+
// and falls out on its own, and a renamed provider answers to its new name.
|
|
1637
|
+
const existingSelections = pickerSelectionsExcept(next, providerName);
|
|
1079
1638
|
if (pickerInput.checked) {
|
|
1080
1639
|
applyPickerSelections(next, [...existingSelections, ...checkedModels.map((model) => ({ ...model, provider: providerName }))]);
|
|
1081
1640
|
} else {
|
|
@@ -1085,18 +1644,24 @@ function openProviderForm(options) {
|
|
|
1085
1644
|
try {
|
|
1086
1645
|
await configRequest(next);
|
|
1087
1646
|
currentConfig = next;
|
|
1647
|
+
selectedProviderName = providerName;
|
|
1648
|
+
providerDetailTab = "overview";
|
|
1088
1649
|
slotsLoaded = false;
|
|
1089
1650
|
providersLoaded = false;
|
|
1090
1651
|
closeModal();
|
|
1091
1652
|
await loadProviders();
|
|
1092
1653
|
toast(t("common.saved"));
|
|
1093
1654
|
await offerPickerOn(pickerInput.checked && checkedModels.length > 0);
|
|
1655
|
+
// Detached on purpose. Measuring asks every ticked model several questions, which is far
|
|
1656
|
+
// longer than a save should take, and a model whose wire is still unknown works exactly as it
|
|
1657
|
+
// did before — it is only unmeasured. The result lands in the config when it arrives.
|
|
1658
|
+
void startMeasurement(providerName, next.providers[providerName]);
|
|
1094
1659
|
} catch (error) {
|
|
1095
1660
|
toast(t("common.saveFailed"), true, error.message);
|
|
1096
1661
|
} finally { saveButton.disabled = false; }
|
|
1097
1662
|
});
|
|
1098
1663
|
form.appendChild(el("div", { class: "actions end" }, [el("button", { class: "btn secondary", type: "button", text: t("common.cancel"), onclick: closeModal }), saveButton]));
|
|
1099
|
-
showModal(form);
|
|
1664
|
+
showModal(form, () => { if (isChatgpt) formActive = false; });
|
|
1100
1665
|
}
|
|
1101
1666
|
|
|
1102
1667
|
// ---- Logs ---------------------------------------------------------------------------
|
|
@@ -1121,7 +1686,7 @@ function timeOf(iso) {
|
|
|
1121
1686
|
return `${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}:${two(at.getSeconds())}`;
|
|
1122
1687
|
}
|
|
1123
1688
|
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 })]); }
|
|
1689
|
+
function summaryChip(label, value, sub) { return el("div", { class: "logs-chip" }, [el("span", { class: "label", text: label }), el("span", { class: "value", text: value }), sub ? el("span", { class: "sub", text: sub }) : null]); }
|
|
1125
1690
|
|
|
1126
1691
|
function showLogsPanel(name) {
|
|
1127
1692
|
logsPanel = name;
|
|
@@ -1144,7 +1709,9 @@ function renderSummary(summary) {
|
|
|
1144
1709
|
$("#logs-summary").replaceChildren(
|
|
1145
1710
|
summaryChip(t("logs.summary.requests"), formatNumber(total.count)),
|
|
1146
1711
|
summaryChip(t("logs.summary.success"), `${formatNumber(total.ok)} / ${formatNumber(total.failed)}`),
|
|
1147
|
-
|
|
1712
|
+
// On its own line: with the whole input counted the number runs to eight digits, and the
|
|
1713
|
+
// ellipsis on one line cut the cache rate off entirely.
|
|
1714
|
+
summaryChip(t("logs.summary.input"), formatNumber(totalInputOf(total)), t("logs.cacheHit", { percent: total.cacheHitPercent || 0 })),
|
|
1148
1715
|
summaryChip(t("logs.summary.output"), formatNumber(total.output)),
|
|
1149
1716
|
summaryChip(t("logs.summary.duration"), formatSeconds(total.avgMs)),
|
|
1150
1717
|
);
|
|
@@ -1160,11 +1727,17 @@ function renderProviderFilter(records) {
|
|
|
1160
1727
|
if (!names.includes(requestProvider)) requestProvider = "";
|
|
1161
1728
|
if (before && before !== select.value) select.value = requestProvider;
|
|
1162
1729
|
}
|
|
1730
|
+
/** Everything the model read: uncached, read from the cache, and written to it. */
|
|
1731
|
+
function totalInputOf(usage) {
|
|
1732
|
+
return (usage.input || 0) + (usage.cached || 0) + (usage.cacheWrite || 0);
|
|
1733
|
+
}
|
|
1163
1734
|
function requestDetail(record) {
|
|
1164
1735
|
const details = [
|
|
1165
1736
|
[t("logs.detail.id"), record.id],
|
|
1166
1737
|
[t("logs.detail.kind"), record.kind],
|
|
1167
1738
|
[t("logs.detail.stop"), record.stopReason || t("logs.none")],
|
|
1739
|
+
[t("logs.detail.uncached"), record.usage ? formatNumber(record.usage.input) : t("logs.none")],
|
|
1740
|
+
[t("logs.detail.cacheRead"), record.usage && record.usage.cached ? formatNumber(record.usage.cached) : t("logs.none")],
|
|
1168
1741
|
[t("logs.detail.cacheWrite"), record.usage && record.usage.cacheWrite ? formatNumber(record.usage.cacheWrite) : t("logs.none")],
|
|
1169
1742
|
].map(([label, value]) => el("div", {}, [el("span", { class: "detail-label", text: label }), el("span", { class: "detail-value", text: value })]));
|
|
1170
1743
|
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 })]));
|
|
@@ -1172,13 +1745,20 @@ function requestDetail(record) {
|
|
|
1172
1745
|
}
|
|
1173
1746
|
function requestRow(record) {
|
|
1174
1747
|
const row = el("tr", { class: "request-row", title: record.note || "", onclick: () => { expandedRequestId = expandedRequestId === record.id ? null : record.id; renderRequests(requestRows); } });
|
|
1748
|
+
// The requested name's `@effort` is the agent file's default, not what was sent: a marker can
|
|
1749
|
+
// override it, and `gpt-6-sol@medium` beside an effort of high read as a contradiction
|
|
1750
|
+
// (2026-09-23). The effort column is the sent value, so the source is shown without it.
|
|
1751
|
+
const source = record.source ? record.source.replace(/@[^@]*$/, "") : "";
|
|
1175
1752
|
const model = el("div", { class: "request-models" }, [
|
|
1176
1753
|
el("span", { class: "model", text: record.target }),
|
|
1177
|
-
|
|
1754
|
+
source && source !== record.target ? el("span", { class: "small", text: `(${source})` }) : null,
|
|
1178
1755
|
el("span", { class: `provider-badge ${providerClass(record.provider)}`, text: record.provider }),
|
|
1179
1756
|
].filter(Boolean));
|
|
1757
|
+
// The whole input, not the uncached remainder: a fully cached Anthropic turn reports `input: 2`,
|
|
1758
|
+
// which read as a two-token request (2026-09-23). The split is in the detail row.
|
|
1759
|
+
const totalInput = record.usage ? totalInputOf(record.usage) : 0;
|
|
1180
1760
|
const input = record.usage
|
|
1181
|
-
? el("span", { class: "token-cell", text: formatNumber(
|
|
1761
|
+
? el("span", { class: "token-cell", text: formatNumber(totalInput) }, [el("span", { class: "cache-pill", text: t("logs.cacheHit", { percent: Math.round(record.usage.cached / Math.max(1, totalInput) * 100) }) })])
|
|
1182
1762
|
: el("span", { class: "no-usage", text: t("common.notAvailable") });
|
|
1183
1763
|
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
1764
|
row.append(
|