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/router/src/admin.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// POST /api/chatgpt-login begin ChatGPT browser login without holding the request open
|
|
8
8
|
// GET /api/chatgpt-login ChatGPT browser login state and credential status
|
|
9
9
|
// GET /* static files from packages/ui (the GUI itself)
|
|
10
|
+
import crypto from "node:crypto";
|
|
10
11
|
import fs from "node:fs";
|
|
11
12
|
import net from "node:net";
|
|
12
13
|
import os from "node:os";
|
|
@@ -17,12 +18,17 @@ import { fileURLToPath } from "node:url";
|
|
|
17
18
|
import { homeDir, validate } from "./config.js";
|
|
18
19
|
import { PRESETS } from "./presets.js";
|
|
19
20
|
import { resolveCompatibleCaps } from "./compat.js";
|
|
21
|
+
import { CHATGPT_FALLBACK_MODELS } from "./providers/chatgpt/catalog.js";
|
|
22
|
+
import { measureModel, refusedByPlan } from "./capabilities.js";
|
|
20
23
|
import { ClaudeCodeAuthStore, nativeAnthropicHeaders } from "./providers/anthropic.js";
|
|
21
24
|
import { codexEnabled, codexHome } from "../../cli/src/codex.js";
|
|
22
25
|
import { openBrowser } from "../../cli/src/browser.js";
|
|
23
26
|
import { caTrusted, currentAppProxy } from "../../cli/src/picker.js";
|
|
27
|
+
import { certPaths } from "../../cli/src/certs.js";
|
|
28
|
+
import { syncModelSlots } from "../../cli/src/settings.js";
|
|
24
29
|
import { ClaudeOAuthSession } from "./providers/claude-oauth.js";
|
|
25
30
|
import { readClaudeAuthFile } from "./providers/anthropic-token-file.js";
|
|
31
|
+
import { listClaudeAccounts, removeClaudeAccount, renameClaudeAccount } from "./providers/anthropic-accounts.js";
|
|
26
32
|
const MAX_BODY = 1024 * 1024;
|
|
27
33
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
28
34
|
const STARTED_AT = new Date().toISOString();
|
|
@@ -40,7 +46,6 @@ const MIME = {
|
|
|
40
46
|
};
|
|
41
47
|
const ANTHROPIC_EFFORT_LEVELS = ["low", "medium", "high", "max"];
|
|
42
48
|
const CHATGPT_DEFAULT_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
|
|
43
|
-
const CHATGPT_LUNA_EFFORT_LEVELS = [...CHATGPT_DEFAULT_EFFORT_LEVELS, "ultra"];
|
|
44
49
|
let chatgptLogin = { running: false };
|
|
45
50
|
/** The Claude subscription sign-in in progress (or the last one), for GET /api/claude-oauth. */
|
|
46
51
|
let claudeOAuth = null;
|
|
@@ -51,15 +56,15 @@ export function effortLevels(cfg) {
|
|
|
51
56
|
};
|
|
52
57
|
for (const [name, provider] of Object.entries(cfg.providers)) {
|
|
53
58
|
if (provider.type === "chatgpt") {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
};
|
|
59
|
+
// The backend's catalogue is the truth about which models exist and what each supports, but
|
|
60
|
+
// it lives behind a login and a network call, so the measured fallback list carries the
|
|
61
|
+
// per-model ladders (ultra on astra/sol/5.6 terra+sol, not on either Luna) and any config
|
|
62
|
+
// entries the user has saved override them.
|
|
63
|
+
const models = Object.fromEntries(CHATGPT_FALLBACK_MODELS.filter((model) => model.effortLevels !== undefined).map((model) => [model.id, [...model.effortLevels]]));
|
|
64
|
+
for (const model of provider.models ?? [])
|
|
65
|
+
if (model.effortLevels !== undefined)
|
|
66
|
+
models[model.id] = [...model.effortLevels];
|
|
67
|
+
providers[name] = { default: CHATGPT_DEFAULT_EFFORT_LEVELS, ...(Object.keys(models).length ? { models } : {}) };
|
|
63
68
|
continue;
|
|
64
69
|
}
|
|
65
70
|
const modelLevels = Object.fromEntries((provider.models ?? [])
|
|
@@ -78,7 +83,7 @@ export function effortLevels(cfg) {
|
|
|
78
83
|
}
|
|
79
84
|
const preset = provider.preset ? PRESETS.find((entry) => entry.id === provider.preset) : undefined;
|
|
80
85
|
providers[name] = {
|
|
81
|
-
default: resolveCompatibleCaps(preset ? { effortLevels: preset.effortLevels, thinking: preset.thinking } : undefined, provider.caps).effortLevels,
|
|
86
|
+
default: resolveCompatibleCaps(preset ? { effortLevels: preset.effortLevels, thinking: preset.thinking, ...(preset.serverTools ? { serverTools: true } : {}) } : undefined, provider.caps).effortLevels,
|
|
82
87
|
...(Object.keys(modelLevels).length ? { models: modelLevels } : {}),
|
|
83
88
|
};
|
|
84
89
|
}
|
|
@@ -86,6 +91,7 @@ export function effortLevels(cfg) {
|
|
|
86
91
|
}
|
|
87
92
|
const CLAUDE_MODEL_FALLBACK = [
|
|
88
93
|
{ id: "claude-fable-5-1", name: "Fable 5.1" },
|
|
94
|
+
{ id: "claude-opus-5-5", name: "Opus 5.5" },
|
|
89
95
|
{ id: "claude-opus-5", name: "Opus 5" },
|
|
90
96
|
{ id: "claude-sonnet-5", name: "Sonnet 5" },
|
|
91
97
|
{ id: "claude-haiku-4-5", name: "Haiku 4.5" },
|
|
@@ -184,10 +190,12 @@ export function chatgptSignedIn(mode) {
|
|
|
184
190
|
const borrowed = fs.existsSync(path.join(os.homedir(), ".codex", "auth.json"));
|
|
185
191
|
return mode === "own" ? own : mode === "borrow-codex" ? borrowed : own || borrowed;
|
|
186
192
|
}
|
|
187
|
-
async function buildStatus(deps) {
|
|
193
|
+
async function buildStatus(deps, opts = {}) {
|
|
188
194
|
const cfg = deps.config();
|
|
189
195
|
const providers = {};
|
|
190
|
-
|
|
196
|
+
// Keyed in the order the config lists them. Assigning inside the Promise.all callbacks ordered
|
|
197
|
+
// them by whichever TCP check answered first, so the Health list reshuffled on every poll.
|
|
198
|
+
const checked = await Promise.all(Object.entries(cfg.providers).map(async ([name, p]) => {
|
|
191
199
|
const url = p.type === "anthropic" ? "https://api.anthropic.com" : p.type === "chatgpt" ? (p.url ?? "https://chatgpt.com/backend-api") : p.url;
|
|
192
200
|
let reachable = false;
|
|
193
201
|
try {
|
|
@@ -197,22 +205,59 @@ async function buildStatus(deps) {
|
|
|
197
205
|
catch {
|
|
198
206
|
reachable = false;
|
|
199
207
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
208
|
+
return [name, {
|
|
209
|
+
url,
|
|
210
|
+
type: p.type,
|
|
211
|
+
reachable,
|
|
212
|
+
// Whether the router could run a `WebSearch` side request through this provider, so the
|
|
213
|
+
// Clients screen can say so beside a model name. Nothing on that screen said which ones
|
|
214
|
+
// those are, and a `smallFast` slot pointed at one turns every search into a visible failure
|
|
215
|
+
// (2026-09-21: it was set to OpenCode Go's DeepSeek, which accepts the search request and
|
|
216
|
+
// answers with a fabricated one).
|
|
217
|
+
//
|
|
218
|
+
// It answers "can the router search here", not "can this model search". Those differ:
|
|
219
|
+
// measured 2026-09-21, OpenCode Go's Responses endpoint runs OpenAI's hosted `web_search`
|
|
220
|
+
// and cites real pages, but the wire is OpenAI-shaped while its hits come back as Responses
|
|
221
|
+
// events — a backend the router does not have, so a search still cannot go through it.
|
|
222
|
+
// Naming the provider's own capability instead would be a promise the router cannot keep.
|
|
223
|
+
...(providerCanSearch(name, cfg) ? { webSearch: true } : {}),
|
|
224
|
+
// Reaching the host says nothing about being able to use it: a chatgpt provider with no
|
|
225
|
+
// credentials is not "connected", and calling it that sends the user off believing it works.
|
|
226
|
+
...(p.type === "chatgpt" ? { needsLogin: !chatgptSignedIn(p.auth) } : {}),
|
|
227
|
+
...(p.type === "anthropic"
|
|
228
|
+
? {
|
|
229
|
+
authSource: p.auth === "claude-code" ? claudeAuthStore(deps).describeSource() : null,
|
|
230
|
+
signedIn: p.auth === "claude-code" ? (listClaudeAccounts(homeDir()).length > 0 ? "oauth" : readClaudeAuthFile(homeDir())?.source ?? null) : null,
|
|
231
|
+
accountCount: p.auth === "claude-code" ? listClaudeAccounts(homeDir()).length : 0,
|
|
232
|
+
}
|
|
233
|
+
: {}),
|
|
234
|
+
}];
|
|
214
235
|
}));
|
|
236
|
+
for (const [name, entry] of checked)
|
|
237
|
+
providers[name] = entry;
|
|
215
238
|
const chatgpt = deps.chatgpt?.() ?? { quota: {}, auth: {} };
|
|
239
|
+
// A snapshot from response headers ages the moment GPT traffic stops: measured 2026-09-20, the
|
|
240
|
+
// status route showed 1% from 11 hours earlier while the account was at 40%. Past ten minutes,
|
|
241
|
+
// ask the backend directly. The lookup shares one in-flight promise per provider, so concurrent
|
|
242
|
+
// status polls (the GUI does one every 5s) collapse into a single request. A failed lookup keeps
|
|
243
|
+
// the last good numbers and says why, rather than blanking the read the budget decision uses.
|
|
244
|
+
const STALE_MS = 10 * 60 * 1000;
|
|
245
|
+
const staleReasons = {};
|
|
246
|
+
const refresh = deps.chatgpt?.().refresh;
|
|
247
|
+
if (refresh) {
|
|
248
|
+
const now = Date.now();
|
|
249
|
+
for (const [name, q] of Object.entries(chatgpt.quota)) {
|
|
250
|
+
const at = q && typeof q.at === "number" ? q.at : 0;
|
|
251
|
+
if (!opts.refresh && at && now - at < STALE_MS)
|
|
252
|
+
continue;
|
|
253
|
+
const fresh = await refresh(name);
|
|
254
|
+
if (fresh)
|
|
255
|
+
chatgpt.quota[name] = fresh;
|
|
256
|
+
else
|
|
257
|
+
staleReasons[name] = opts.refresh ? "refresh requested but lookup failed" : "lookup failed; showing last known value";
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const stale = Object.keys(staleReasons).length > 0;
|
|
216
261
|
const signedIn = {};
|
|
217
262
|
for (const [name, p] of Object.entries(cfg.providers)) {
|
|
218
263
|
if (p.type !== "chatgpt")
|
|
@@ -260,7 +305,10 @@ async function buildStatus(deps) {
|
|
|
260
305
|
pointsAtRouter: env.HTTPS_PROXY === wantProxy,
|
|
261
306
|
},
|
|
262
307
|
cliVersion: cliVersion(),
|
|
263
|
-
chatgpt: { ...chatgpt, signedIn },
|
|
308
|
+
chatgpt: { ...chatgpt, signedIn, ...(stale ? { stale: true, staleReason: staleReasons } : {}) },
|
|
309
|
+
// Only present for providers that declare a pool; a provider with one credential has nothing
|
|
310
|
+
// to report and would only add a row that never changes.
|
|
311
|
+
credentials: deps.credentials?.() ?? {},
|
|
264
312
|
picker,
|
|
265
313
|
agentTitle: agentTitleHookEnabled(),
|
|
266
314
|
pickerModels: cfg.cli.extraModels.map((m) => m.name || m.model),
|
|
@@ -307,6 +355,57 @@ function runCli(args, timeout = 180_000) {
|
|
|
307
355
|
});
|
|
308
356
|
});
|
|
309
357
|
}
|
|
358
|
+
/**
|
|
359
|
+
* Whether this provider can actually run a web search — measured capability, not an assumption from
|
|
360
|
+
* the vendor name. The rule is the router's own (`Proxy.canRunServerTools`, mirrored here because
|
|
361
|
+
* that one is private to a live request): Anthropic runs its own server tools; an
|
|
362
|
+
* `anthropic-compatible` provider only when its preset says it was measured doing so; a `chatgpt`
|
|
363
|
+
* provider through its hosted search; an `openai-compatible` provider never — its web plugin is a
|
|
364
|
+
* vendor extension, and OpenCode Go's silently ignores it, leaving the model to answer with invented
|
|
365
|
+
* results. A configured `webSearch` backend is deliberately not counted here: it serves the search
|
|
366
|
+
* for the session, but a model still cannot search by itself, which is what a slot points at.
|
|
367
|
+
*/
|
|
368
|
+
function providerCanSearch(name, cfg) {
|
|
369
|
+
const p = cfg.providers[name];
|
|
370
|
+
if (!p)
|
|
371
|
+
return false;
|
|
372
|
+
if (p.type === "chatgpt")
|
|
373
|
+
return true;
|
|
374
|
+
if (p.type === "anthropic")
|
|
375
|
+
return p.accountPool === true && p.auth === "claude-code";
|
|
376
|
+
if (p.type !== "anthropic-compatible")
|
|
377
|
+
return false;
|
|
378
|
+
const preset = p.preset ? PRESETS.find((entry) => entry.id === p.preset) : undefined;
|
|
379
|
+
return resolveCompatibleCaps(preset ? { ...(preset.serverTools ? { serverTools: true } : {}) } : undefined, p.caps).serverTools;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Write `cli.models` into `~/.claude/settings.json` right after a GUI save, so a slot chosen on the
|
|
383
|
+
* Clients screen takes effect in the next session instead of waiting for an `install`.
|
|
384
|
+
*
|
|
385
|
+
* Returns a human-readable warning when the write could not happen, or null. A slot that silently
|
|
386
|
+
* does nothing is the exact bug this closes, so a failure has to come back to the screen rather
|
|
387
|
+
* than being swallowed into the router log.
|
|
388
|
+
*/
|
|
389
|
+
function syncModelSlotsForGui(cfg) {
|
|
390
|
+
try {
|
|
391
|
+
const home = homeDir();
|
|
392
|
+
syncModelSlots({
|
|
393
|
+
proxyUrl: `http://127.0.0.1:${cfg.listen.port}`,
|
|
394
|
+
caPath: certPaths(home).caPem,
|
|
395
|
+
force: false,
|
|
396
|
+
// Always an object, never omitted: `cli.models` absent means every slot should be back on
|
|
397
|
+
// Claude, and `syncModelSlots` reads an absent `models` as "do not touch these".
|
|
398
|
+
models: cfg.cli.models ?? {},
|
|
399
|
+
});
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
catch (e) {
|
|
403
|
+
const message = e.message;
|
|
404
|
+
if (/not ours/.test(message))
|
|
405
|
+
return "settings.json has another proxy's HTTPS_PROXY; the model slots were not written. Run `clauderipple install --force` to take it over.";
|
|
406
|
+
return `model slots were not written to settings.json: ${message}`;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
310
409
|
function readBody(req) {
|
|
311
410
|
return new Promise((resolveP, reject) => {
|
|
312
411
|
const chunks = [];
|
|
@@ -329,6 +428,14 @@ function sendJson(res, status, body) {
|
|
|
329
428
|
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "content-length": String(Buffer.byteLength(out)) });
|
|
330
429
|
res.end(out);
|
|
331
430
|
}
|
|
431
|
+
/** Write the config file atomically. The one save path: the GUI's PUT and the capability
|
|
432
|
+
* measurement both go through it, so the router never picks up a half-written file either way. */
|
|
433
|
+
function writeConfigFile(configFile, config) {
|
|
434
|
+
const tmp = `${configFile}.tmp-${process.pid}-${Date.now()}`;
|
|
435
|
+
fs.mkdirSync(path.dirname(configFile), { recursive: true });
|
|
436
|
+
fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n");
|
|
437
|
+
fs.renameSync(tmp, configFile);
|
|
438
|
+
}
|
|
332
439
|
function headerValue(headers) {
|
|
333
440
|
if (!headers)
|
|
334
441
|
return null;
|
|
@@ -391,9 +498,12 @@ async function probeAnthropicApiKey(apiKey, probeFetch = fetchWithTimeout) {
|
|
|
391
498
|
}
|
|
392
499
|
function probeClaudeCodeAuth(deps) {
|
|
393
500
|
const source = claudeAuthStore(deps).describeSource();
|
|
394
|
-
// Our own
|
|
395
|
-
// the screen would answer a finished sign-in with the source it was already showing.
|
|
396
|
-
|
|
501
|
+
// Our own accounts are reported separately: a Claude Desktop session can outrank them, and without
|
|
502
|
+
// this the screen would answer a finished sign-in with the source it was already showing.
|
|
503
|
+
const accounts = listClaudeAccounts(homeDir());
|
|
504
|
+
const hasUsableAccount = accounts.some((account) => !account.needsReauth && account.expiresAt > Date.now());
|
|
505
|
+
const signedIn = accounts.length > 0 ? "oauth" : readClaudeAuthFile(homeDir())?.source ?? null;
|
|
506
|
+
return { ok: source !== null || hasUsableAccount, auth: source !== null || hasUsableAccount ? "ok" : "missing", source, signedIn, accountCount: accounts.length, models: CLAUDE_MODEL_FALLBACK };
|
|
397
507
|
}
|
|
398
508
|
function chatCompletionsUrl(base) {
|
|
399
509
|
return `${base.replace(/\/+$/, "")}/chat/completions`;
|
|
@@ -412,30 +522,82 @@ function parsedModels(value) {
|
|
|
412
522
|
const supported = Array.isArray(r.supported_parameters) && r.supported_parameters.every((value) => typeof value === "string")
|
|
413
523
|
? r.supported_parameters
|
|
414
524
|
: undefined;
|
|
525
|
+
// OpenRouter and several other OpenAI-compatible vendors report each model's real context
|
|
526
|
+
// window here. Taking it means a routed model gets its own window without anyone typing it.
|
|
527
|
+
const contextWindow = typeof r.context_length === "number" && Number.isFinite(r.context_length) && r.context_length > 0
|
|
528
|
+
? Math.floor(r.context_length)
|
|
529
|
+
: undefined;
|
|
415
530
|
return [{
|
|
416
531
|
id: r.id,
|
|
417
532
|
...(typeof r.name === "string" ? { name: r.name } : {}),
|
|
418
533
|
...(supported ? { effortLevels: supported.includes("reasoning_effort") ? ["low", "medium", "high"] : [] } : {}),
|
|
534
|
+
...(contextWindow ? { contextWindow } : {}),
|
|
419
535
|
}];
|
|
420
536
|
});
|
|
421
537
|
}
|
|
422
538
|
function snippet(text) {
|
|
423
539
|
return text.replace(/\s+/g, " ").trim().slice(0, 200);
|
|
424
540
|
}
|
|
541
|
+
/**
|
|
542
|
+
* Fill each discovered model with the wire, endpoint and auth convention its preset declares for it.
|
|
543
|
+
*
|
|
544
|
+
* `/models` reports ids alone, and one plan can serve several wires on one catalog (OpenCode Go's
|
|
545
|
+
* Responses, Chat and Anthropic groups). A discovered model left bare would be routed on the
|
|
546
|
+
* provider's default wire — the wrong shape on the wrong path for two of the three groups. The
|
|
547
|
+
* preset's fallback list is the only place that mapping is written down, so it is read here; an id
|
|
548
|
+
* not on it stays bare and gets the provider's own wire.
|
|
549
|
+
*/
|
|
550
|
+
function withPresetOverrides(models, presetId) {
|
|
551
|
+
if (!presetId)
|
|
552
|
+
return models;
|
|
553
|
+
const preset = PRESETS.find((entry) => entry.id === presetId);
|
|
554
|
+
if (!preset)
|
|
555
|
+
return models;
|
|
556
|
+
return models.map((model) => {
|
|
557
|
+
const known = preset.fallbackModels.find((fallback) => fallback.id === model.id);
|
|
558
|
+
if (!known)
|
|
559
|
+
return model;
|
|
560
|
+
return {
|
|
561
|
+
...model,
|
|
562
|
+
...(known.wire !== undefined ? { wire: known.wire } : {}),
|
|
563
|
+
...(known.url !== undefined ? { url: known.url } : {}),
|
|
564
|
+
...(known.authHeader !== undefined ? { authHeader: known.authHeader } : {}),
|
|
565
|
+
};
|
|
566
|
+
});
|
|
567
|
+
}
|
|
425
568
|
async function fetchWithTimeout(url, init) {
|
|
426
569
|
return fetch(url, { ...init, signal: AbortSignal.timeout(8_000) });
|
|
427
570
|
}
|
|
428
|
-
|
|
571
|
+
/**
|
|
572
|
+
* The model a connection test should use, when the preset names one that speaks the wire the test
|
|
573
|
+
* speaks (Chat Completions).
|
|
574
|
+
*
|
|
575
|
+
* The catalogue's first entry is whatever the vendor happened to list first, and OpenCode lists its
|
|
576
|
+
* free tier there. Measured 2026-09-22: `big-pickle`, the head of Zen's catalogue, answers 403
|
|
577
|
+
* FreeTierError — "OpenCode's free tier can only be used from within OpenCode" — while
|
|
578
|
+
* `claude-fable-5` on the same key answers 402. Testing with the head therefore reported a paying
|
|
579
|
+
* account as refused, and pointed the operator at a key that was never the problem.
|
|
580
|
+
*
|
|
581
|
+
* The preset's own list is picked from deliberately, and only its Chat entries: the test posts to
|
|
582
|
+
* Chat Completions, so a Responses-only model like Muse Spark would answer 503 and look broken.
|
|
583
|
+
*/
|
|
584
|
+
function presetProbeModel(presetId) {
|
|
585
|
+
const preset = presetId ? PRESETS.find((entry) => entry.id === presetId) : undefined;
|
|
586
|
+
if (!preset)
|
|
587
|
+
return undefined;
|
|
588
|
+
return preset.fallbackModels.find((model) => (model.wire ?? preset.wire ?? "chat") === "chat")?.id;
|
|
589
|
+
}
|
|
590
|
+
async function probeProvider(body, probeFetch = fetchWithTimeout) {
|
|
429
591
|
const source = headerValue(body.headers);
|
|
430
592
|
let models = [];
|
|
431
593
|
let modelsError;
|
|
432
594
|
if (body.modelsUrl) {
|
|
433
595
|
try {
|
|
434
|
-
const response = await
|
|
596
|
+
const response = await probeFetch(body.modelsUrl, { headers: authHeaders(source, body.modelsAuthHeader) });
|
|
435
597
|
if (response.status === 401 || response.status === 403)
|
|
436
598
|
return { ok: false, auth: "bad-key", models: [], error: `models endpoint returned ${response.status}` };
|
|
437
599
|
if (response.ok)
|
|
438
|
-
models = parsedModels(await response.json());
|
|
600
|
+
models = withPresetOverrides(parsedModels(await response.json()), body.preset);
|
|
439
601
|
else
|
|
440
602
|
modelsError = `models endpoint returned ${response.status}`;
|
|
441
603
|
}
|
|
@@ -445,18 +607,26 @@ async function probeProvider(body) {
|
|
|
445
607
|
}
|
|
446
608
|
const openai = body.type === "openai-compatible";
|
|
447
609
|
const checkUrl = openai ? chatCompletionsUrl(body.url) : messagesUrl(body.url);
|
|
610
|
+
const checkModel = presetProbeModel(body.preset) ?? models[0]?.id ?? body.probeModel ?? "test";
|
|
448
611
|
const checkBody = openai
|
|
449
|
-
? { model:
|
|
450
|
-
: { model:
|
|
612
|
+
? { model: checkModel, max_tokens: 1, stream: false, messages: [{ role: "user", content: "hi" }] }
|
|
613
|
+
: { model: checkModel, max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
|
|
451
614
|
const label = openai ? "chat completions endpoint" : "messages endpoint";
|
|
452
615
|
try {
|
|
453
|
-
const response = await
|
|
616
|
+
const response = await probeFetch(checkUrl, {
|
|
454
617
|
method: "POST",
|
|
455
|
-
headers: {
|
|
618
|
+
headers: {
|
|
619
|
+
"content-type": "application/json",
|
|
620
|
+
...(body.sessionHeader ? { [body.sessionHeader]: `probe-${crypto.randomBytes(8).toString("hex")}` } : {}),
|
|
621
|
+
...authHeaders(source),
|
|
622
|
+
},
|
|
456
623
|
body: JSON.stringify(checkBody),
|
|
457
624
|
});
|
|
458
|
-
if (response.status === 401 || response.status === 403)
|
|
459
|
-
|
|
625
|
+
if (response.status === 401 || response.status === 403) {
|
|
626
|
+
const refusal = snippet(await response.text());
|
|
627
|
+
const auth = response.status === 403 && refusedByPlan(refusal) ? "not-entitled" : "bad-key";
|
|
628
|
+
return { ok: false, auth, models, error: `${label} returned ${response.status}: ${refusal}` };
|
|
629
|
+
}
|
|
460
630
|
if (response.ok)
|
|
461
631
|
return { ok: true, auth: "ok", models, ...(modelsError ? { error: modelsError } : {}) };
|
|
462
632
|
const detail = snippet(await response.text());
|
|
@@ -475,6 +645,162 @@ async function probeProvider(body) {
|
|
|
475
645
|
return { ok: false, auth: "unreachable", models, error: modelsError ? `${modelsError}; ${message}` : message };
|
|
476
646
|
}
|
|
477
647
|
}
|
|
648
|
+
const MEASURE_JOBS = new Map();
|
|
649
|
+
/** Measured capability does not change minute to minute, so a finished job is served for a while
|
|
650
|
+
* and then dropped: a test or a GUI that polls late should re-measure rather than hold the result
|
|
651
|
+
* (and its error strings) in memory for the life of the process. */
|
|
652
|
+
const MEASURE_JOB_TTL_MS = 10 * 60 * 1000;
|
|
653
|
+
const MEASURE_TIMEOUT_MS = 20_000;
|
|
654
|
+
async function measureFetchWithTimeout(url, init) {
|
|
655
|
+
return fetch(url, { ...init, signal: AbortSignal.timeout(MEASURE_TIMEOUT_MS) });
|
|
656
|
+
}
|
|
657
|
+
/** Drop finished jobs past their TTL. Run on each measure request, so no timer keeps the process up. */
|
|
658
|
+
function sweepMeasureJobs() {
|
|
659
|
+
const cutoff = Date.now() - MEASURE_JOB_TTL_MS;
|
|
660
|
+
for (const [id, job] of MEASURE_JOBS)
|
|
661
|
+
if (job.state !== "running" && job.finishedAt !== undefined && job.finishedAt < cutoff)
|
|
662
|
+
MEASURE_JOBS.delete(id);
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* The wires a model of this provider might speak, in the order they are worth trying.
|
|
666
|
+
*
|
|
667
|
+
* The preset's own entry for this exact model goes first when there is one. Order decides the
|
|
668
|
+
* answer, because the first wire to reply wins — and a plan can serve one model on two of them:
|
|
669
|
+
* measured 2026-09-22, `minimax-m3` answers on both Chat Completions and Anthropic Messages, so
|
|
670
|
+
* trying the list in catalogue order made it measure as `chat` and quietly overrule the `anthropic`
|
|
671
|
+
* the preset recorded for it. A recorded wire is still only a starting point: it has to answer like
|
|
672
|
+
* any other candidate, and the rest of the list is tried when it does not.
|
|
673
|
+
*/
|
|
674
|
+
function measureCandidates(p, modelId) {
|
|
675
|
+
const defaultWire = p.type === "anthropic-compatible" ? "anthropic"
|
|
676
|
+
: p.type === "openai-compatible" ? (p.wire ?? "chat")
|
|
677
|
+
: "chat";
|
|
678
|
+
const ownUrl = "url" in p ? p.url : "";
|
|
679
|
+
const ownHeader = "authHeader" in p ? p.authHeader : undefined;
|
|
680
|
+
const own = { wire: defaultWire, url: ownUrl, ...(ownHeader ? { authHeader: ownHeader } : {}) };
|
|
681
|
+
const candidates = [];
|
|
682
|
+
const seen = new Set();
|
|
683
|
+
const add = (c) => {
|
|
684
|
+
const key = `${c.wire}|${c.url}|${c.authHeader ?? ""}`;
|
|
685
|
+
if (seen.has(key))
|
|
686
|
+
return;
|
|
687
|
+
seen.add(key);
|
|
688
|
+
candidates.push(c);
|
|
689
|
+
};
|
|
690
|
+
const known = "preset" in p && p.preset
|
|
691
|
+
? PRESETS.find((entry) => entry.id === p.preset)?.fallbackModels?.find((m) => m.id === modelId)
|
|
692
|
+
: undefined;
|
|
693
|
+
if (known?.wire)
|
|
694
|
+
add({ wire: known.wire, url: known.url ?? ownUrl, ...(known.authHeader ? { authHeader: known.authHeader } : {}) });
|
|
695
|
+
add(own);
|
|
696
|
+
if (p.type === "openai-compatible" && p.preset) {
|
|
697
|
+
// One plan can serve several wires on one catalog. The preset's fallback list is the only place
|
|
698
|
+
// any of that mapping is written down, so it is what the remaining candidates are read from.
|
|
699
|
+
const preset = PRESETS.find((entry) => entry.id === p.preset);
|
|
700
|
+
for (const model of preset?.fallbackModels ?? [])
|
|
701
|
+
add({ wire: model.wire ?? defaultWire, url: model.url ?? p.url, ...(model.authHeader ? { authHeader: model.authHeader } : {}) });
|
|
702
|
+
}
|
|
703
|
+
else if (p.type === "openai-compatible") {
|
|
704
|
+
// No table to read: the two OpenAI wires this kind of provider could be serving.
|
|
705
|
+
add({ wire: "responses", url: p.url });
|
|
706
|
+
add({ wire: "chat", url: p.url });
|
|
707
|
+
}
|
|
708
|
+
else if (p.type === "anthropic-compatible" && p.preset) {
|
|
709
|
+
const preset = PRESETS.find((entry) => entry.id === p.preset);
|
|
710
|
+
for (const model of preset?.fallbackModels ?? [])
|
|
711
|
+
if (model.wire)
|
|
712
|
+
add({ wire: model.wire, url: model.url ?? p.url, ...(model.authHeader ? { authHeader: model.authHeader } : {}) });
|
|
713
|
+
}
|
|
714
|
+
return candidates;
|
|
715
|
+
}
|
|
716
|
+
/** The effort ladder this provider offers, before any per-model override narrows it. */
|
|
717
|
+
function providerLadder(p) {
|
|
718
|
+
if (p.type === "openai-compatible")
|
|
719
|
+
return p.caps?.reasoning === "effort" ? [...(p.caps.effortLevels ?? [])] : [];
|
|
720
|
+
if (p.type === "anthropic-compatible")
|
|
721
|
+
return [...(p.caps?.effortLevels ?? [])];
|
|
722
|
+
return [];
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Fold measurements into the config on disk.
|
|
726
|
+
*
|
|
727
|
+
* Read again here rather than reusing what the job started with: the user may have saved from the
|
|
728
|
+
* GUI while the measurements ran, and writing back a stale copy would silently revert that save.
|
|
729
|
+
* Only a model that is still present is touched, and only into a blank field — a `wire` or
|
|
730
|
+
* `effortLevels` already on the model is the user's, and an explicitly empty array is a decision
|
|
731
|
+
* (it disables the provider fallback), not an empty slot. A model whose measurement errored is
|
|
732
|
+
* left entirely alone. The result is validated with the same `validate` the GUI's save uses, so a
|
|
733
|
+
* bad edit is refused rather than written.
|
|
734
|
+
*/
|
|
735
|
+
function applyMeasurements(configFile, results) {
|
|
736
|
+
let current;
|
|
737
|
+
try {
|
|
738
|
+
current = JSON.parse(fs.readFileSync(configFile, "utf8"));
|
|
739
|
+
}
|
|
740
|
+
catch (e) {
|
|
741
|
+
return { error: `could not re-read config: ${errorText(e)}` };
|
|
742
|
+
}
|
|
743
|
+
let applied = 0;
|
|
744
|
+
for (const result of results) {
|
|
745
|
+
if (result.error || !result.wire)
|
|
746
|
+
continue;
|
|
747
|
+
for (const provider of Object.values(current.providers)) {
|
|
748
|
+
if (!("models" in provider) || !provider.models)
|
|
749
|
+
continue;
|
|
750
|
+
const model = provider.models.find((entry) => entry.id === result.id);
|
|
751
|
+
if (!model)
|
|
752
|
+
continue;
|
|
753
|
+
const before = `${model.wire ?? ""}|${model.effortLevels ? model.effortLevels.join(",") : "∅"}`;
|
|
754
|
+
if (model.wire === undefined)
|
|
755
|
+
model.wire = result.wire;
|
|
756
|
+
if (model.effortLevels === undefined && result.effortLevels !== undefined)
|
|
757
|
+
model.effortLevels = result.effortLevels;
|
|
758
|
+
if (`${model.wire ?? ""}|${model.effortLevels ? model.effortLevels.join(",") : "∅"}` !== before)
|
|
759
|
+
applied++;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
const errors = validate(current);
|
|
763
|
+
if (errors.length > 0)
|
|
764
|
+
return { error: `measured config did not validate: ${errors[0]}` };
|
|
765
|
+
writeConfigFile(configFile, current);
|
|
766
|
+
return { applied };
|
|
767
|
+
}
|
|
768
|
+
/** Run one provider measurement job to completion. Detached: the request that started it has already answered. */
|
|
769
|
+
async function runMeasureJob(jobId, providerName, models, deps) {
|
|
770
|
+
const job = MEASURE_JOBS.get(jobId);
|
|
771
|
+
if (!job)
|
|
772
|
+
return;
|
|
773
|
+
const provider = deps.config().providers[providerName];
|
|
774
|
+
if (!provider || (provider.type !== "openai-compatible" && provider.type !== "anthropic-compatible")) {
|
|
775
|
+
MEASURE_JOBS.set(jobId, { ...job, state: "failed", error: `provider "${providerName}" is not openai-compatible or anthropic-compatible` });
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
const ladder = providerLadder(provider);
|
|
779
|
+
const fetchImpl = deps.measureFetch ?? measureFetchWithTimeout;
|
|
780
|
+
const headers = "headers" in provider ? provider.headers : undefined;
|
|
781
|
+
const sessionHeader = "sessionHeader" in provider ? provider.sessionHeader : undefined;
|
|
782
|
+
for (const id of models) {
|
|
783
|
+
// Per model, not once per job: the order depends on what the preset recorded for this id.
|
|
784
|
+
const candidates = measureCandidates(provider, id);
|
|
785
|
+
const measured = await measureModel(id, candidates, ladder, { fetch: fetchImpl, ...(sessionHeader ? { sessionHeader } : {}), ...(headers ? { headers } : {}) });
|
|
786
|
+
job.results.push(measured);
|
|
787
|
+
job.done++;
|
|
788
|
+
deps.log.info(`admin: measured ${providerName}/${id} -> ${measured.error ? `error ${measured.error}` : `wire ${measured.wire}${measured.effortLevels ? ` effort ${measured.effortLevels.join(",")}` : ""}`}`);
|
|
789
|
+
}
|
|
790
|
+
try {
|
|
791
|
+
const outcome = applyMeasurements(deps.configFile, job.results);
|
|
792
|
+
if ("error" in outcome) {
|
|
793
|
+
MEASURE_JOBS.set(jobId, { ...job, state: "failed", error: outcome.error, finishedAt: Date.now() });
|
|
794
|
+
deps.log.warn(`admin: measurement for ${providerName} was not applied: ${outcome.error}`);
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
deps.log.info(`admin: measurement for ${providerName} applied to ${outcome.applied} model(s)`);
|
|
798
|
+
MEASURE_JOBS.set(jobId, { ...job, state: "done", finishedAt: Date.now() });
|
|
799
|
+
}
|
|
800
|
+
catch (e) {
|
|
801
|
+
MEASURE_JOBS.set(jobId, { ...job, state: "failed", error: errorText(e), finishedAt: Date.now() });
|
|
802
|
+
}
|
|
803
|
+
}
|
|
478
804
|
function pickerModels(deps) {
|
|
479
805
|
const last = deps.picker?.().last;
|
|
480
806
|
if (last && typeof last === "object" && Array.isArray(last.surfaces)) {
|
|
@@ -582,10 +908,14 @@ export function startAdmin(deps) {
|
|
|
582
908
|
}
|
|
583
909
|
async function handle(req, res) {
|
|
584
910
|
const url = req.url ?? "/";
|
|
585
|
-
|
|
911
|
+
// A HEAD is a GET whose body is discarded, and Node discards it for us: measured 2026-09-22,
|
|
912
|
+
// the response goes out with its `content-length` and no bytes after the headers. Normalising
|
|
913
|
+
// it here lets every read-only route answer one, where matching on "GET" alone fell through to
|
|
914
|
+
// 404 — which reads as a dead router to anything that checks with a HEAD.
|
|
915
|
+
const method = req.method === "HEAD" ? "GET" : (req.method ?? "GET");
|
|
586
916
|
const pathname = url.split("?")[0] ?? "/";
|
|
587
917
|
try {
|
|
588
|
-
if (method !== "GET" &&
|
|
918
|
+
if (method !== "GET" && crossSitePost(req)) {
|
|
589
919
|
deps.log.warn(`admin: refused ${method} ${pathname} from origin ${String(req.headers.origin)}`);
|
|
590
920
|
sendJson(res, 403, { error: "cross-site request refused" });
|
|
591
921
|
return;
|
|
@@ -610,7 +940,8 @@ export function startAdmin(deps) {
|
|
|
610
940
|
return;
|
|
611
941
|
}
|
|
612
942
|
if (pathname === "/api/status" && method === "GET") {
|
|
613
|
-
|
|
943
|
+
const refresh = new URL(url, "http://127.0.0.1").searchParams.get("refresh") === "1";
|
|
944
|
+
sendJson(res, 200, await buildStatus(deps, { refresh }));
|
|
614
945
|
return;
|
|
615
946
|
}
|
|
616
947
|
if (pathname === "/api/presets" && method === "GET") {
|
|
@@ -665,16 +996,20 @@ export function startAdmin(deps) {
|
|
|
665
996
|
if (probe.type === "chatgpt") {
|
|
666
997
|
const statuses = Object.values(deps.chatgpt?.().auth ?? {});
|
|
667
998
|
const signed = chatgptSignedIn(typeof probe.auth === "string" ? probe.auth : undefined);
|
|
999
|
+
// Which provider to ask: the form knows the one it is editing, and a provider merely
|
|
1000
|
+
// configured by hand is the only other candidate. Unknown name → ask nobody, show the
|
|
1001
|
+
// measured fallback, which is also what an unreachable catalogue yields.
|
|
1002
|
+
const providers = deps.config().providers;
|
|
1003
|
+
const named = typeof probe.name === "string" && providers[probe.name]?.type === "chatgpt" ? probe.name : undefined;
|
|
1004
|
+
const providerName = named ?? Object.entries(providers).find(([, p]) => p.type === "chatgpt")?.[0];
|
|
1005
|
+
const live = providerName ? await deps.chatgpt?.().models?.(providerName) : undefined;
|
|
1006
|
+
const models = live?.length ? live : CHATGPT_FALLBACK_MODELS;
|
|
668
1007
|
sendJson(res, 200, {
|
|
669
1008
|
ok: signed,
|
|
670
1009
|
auth: signed ? (statuses[0] ?? "ok") : "missing",
|
|
671
1010
|
...(signed ? {} : { error: "no ChatGPT credentials: sign in from the tray menu, or install and sign in to the Codex CLI" }),
|
|
672
|
-
models
|
|
673
|
-
|
|
674
|
-
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol" },
|
|
675
|
-
{ id: "gpt-5.6-luna", name: "GPT-5.6 Luna" },
|
|
676
|
-
{ id: "gpt-6-astra", name: "GPT-6 Astra" },
|
|
677
|
-
],
|
|
1011
|
+
models,
|
|
1012
|
+
modelsSource: live?.length ? "catalog" : "fallback",
|
|
678
1013
|
});
|
|
679
1014
|
return;
|
|
680
1015
|
}
|
|
@@ -692,12 +1027,56 @@ export function startAdmin(deps) {
|
|
|
692
1027
|
url: probe.url,
|
|
693
1028
|
...(probe.headers ? { headers: probe.headers } : {}),
|
|
694
1029
|
...(probe.modelsUrl ? { modelsUrl: probe.modelsUrl } : {}),
|
|
1030
|
+
...(typeof probe.sessionHeader === "string" && probe.sessionHeader ? { sessionHeader: probe.sessionHeader } : {}),
|
|
695
1031
|
...(probe.modelsAuthHeader ? { modelsAuthHeader: probe.modelsAuthHeader } : {}),
|
|
1032
|
+
...(typeof probe.preset === "string" && probe.preset ? { preset: probe.preset } : {}),
|
|
696
1033
|
...(typeof probe.probeModel === "string" ? { probeModel: probe.probeModel } : {}),
|
|
697
|
-
});
|
|
1034
|
+
}, deps.probeModelFetch ?? fetchWithTimeout);
|
|
698
1035
|
sendJson(res, 200, result);
|
|
699
1036
|
return;
|
|
700
1037
|
}
|
|
1038
|
+
// Measure what each model's wire and effort ladder actually is, in the background: a job is
|
|
1039
|
+
// several small upstream requests, and the caller polls /api/providers/measure/<jobId> rather
|
|
1040
|
+
// than holding a request open for all of them.
|
|
1041
|
+
if (pathname === "/api/providers/measure" && method === "POST") {
|
|
1042
|
+
let body;
|
|
1043
|
+
try {
|
|
1044
|
+
body = JSON.parse((await readBody(req)).toString("utf8"));
|
|
1045
|
+
}
|
|
1046
|
+
catch {
|
|
1047
|
+
sendJson(res, 400, { error: "invalid JSON" });
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
if (typeof body.provider !== "string" || !Array.isArray(body.models) || body.models.some((m) => typeof m !== "string")) {
|
|
1051
|
+
sendJson(res, 400, { error: "expected {provider: string, models: string[]}" });
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
const provider = deps.config().providers[body.provider];
|
|
1055
|
+
if (!provider || (provider.type !== "openai-compatible" && provider.type !== "anthropic-compatible")) {
|
|
1056
|
+
sendJson(res, 400, { error: `provider "${body.provider}" is not openai-compatible or anthropic-compatible` });
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
sweepMeasureJobs();
|
|
1060
|
+
const jobId = crypto.randomBytes(12).toString("hex");
|
|
1061
|
+
const job = { state: "running", done: 0, total: body.models.length, results: [] };
|
|
1062
|
+
MEASURE_JOBS.set(jobId, job);
|
|
1063
|
+
deps.log.info(`admin: measuring ${body.provider} (${body.models.length} model(s)) -> ${jobId.slice(0, 8)}`);
|
|
1064
|
+
void runMeasureJob(jobId, body.provider, body.models, deps);
|
|
1065
|
+
sendJson(res, 202, { jobId, total: job.total });
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
if (pathname.startsWith("/api/providers/measure/") && method === "GET") {
|
|
1069
|
+
const jobId = pathname.slice("/api/providers/measure/".length).split("/")[0] ?? "";
|
|
1070
|
+
const job = MEASURE_JOBS.get(jobId);
|
|
1071
|
+
if (!job) {
|
|
1072
|
+
sendJson(res, 404, { error: "unknown or expired measure job" });
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
// The polled shape, without the sweep's own bookkeeping field.
|
|
1076
|
+
const { finishedAt: _finishedAt, ...view } = job;
|
|
1077
|
+
sendJson(res, 200, view);
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
701
1080
|
if (pathname === "/api/config" && method === "GET") {
|
|
702
1081
|
sendJson(res, 200, deps.config());
|
|
703
1082
|
return;
|
|
@@ -724,12 +1103,13 @@ export function startAdmin(deps) {
|
|
|
724
1103
|
sendJson(res, 400, { errors });
|
|
725
1104
|
return;
|
|
726
1105
|
}
|
|
727
|
-
|
|
728
|
-
fs.mkdirSync(path.dirname(deps.configFile), { recursive: true });
|
|
729
|
-
fs.writeFileSync(tmp, JSON.stringify(parsed, null, 2) + "\n");
|
|
730
|
-
fs.renameSync(tmp, deps.configFile);
|
|
1106
|
+
writeConfigFile(deps.configFile, parsed);
|
|
731
1107
|
deps.log.info(`admin: config saved via GUI (${Object.keys(parsed.routes).length} routes, ${Object.keys(parsed.providers).length} providers)`);
|
|
732
|
-
|
|
1108
|
+
// `cli.models` cannot take effect through the router at all: Claude Code reads the slots
|
|
1109
|
+
// before a request exists. So a GUI save has to write them to settings.json itself;
|
|
1110
|
+
// otherwise the Clients screen shows a choice that only a later `install` would honour.
|
|
1111
|
+
const slotWarning = syncModelSlotsForGui(parsed);
|
|
1112
|
+
sendJson(res, 200, { ok: true, ...(slotWarning ? { warning: slotWarning } : {}) });
|
|
733
1113
|
return;
|
|
734
1114
|
}
|
|
735
1115
|
if (pathname === "/api/codex" && method === "GET") {
|
|
@@ -776,10 +1156,63 @@ export function startAdmin(deps) {
|
|
|
776
1156
|
sendJson(res, 200, { started: true });
|
|
777
1157
|
return;
|
|
778
1158
|
}
|
|
1159
|
+
// Safe account metadata only. The current Claude Code/Desktop source remains externally owned;
|
|
1160
|
+
// ClaudeRipple accounts can be renamed or removed by their local opaque id.
|
|
1161
|
+
if (pathname === "/api/claude-accounts" && method === "GET") {
|
|
1162
|
+
const source = claudeAuthStore(deps).describeSource();
|
|
1163
|
+
sendJson(res, 200, {
|
|
1164
|
+
current: source ? { id: "current", label: "Current Claude login", source, external: true } : null,
|
|
1165
|
+
accounts: listClaudeAccounts(homeDir()),
|
|
1166
|
+
});
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
if (pathname.startsWith("/api/claude-accounts/") && (method === "PATCH" || method === "DELETE")) {
|
|
1170
|
+
let id;
|
|
1171
|
+
try {
|
|
1172
|
+
id = decodeURIComponent(pathname.slice("/api/claude-accounts/".length));
|
|
1173
|
+
}
|
|
1174
|
+
catch {
|
|
1175
|
+
sendJson(res, 400, { error: "invalid account id" });
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
const validId = id === "legacy" || /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id);
|
|
1179
|
+
if (!validId || id === "current") {
|
|
1180
|
+
sendJson(res, id === "current" ? 409 : 400, { error: id === "current" ? "the current Claude login is managed by Claude Code or Claude Desktop" : "invalid account id" });
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
if (method === "DELETE") {
|
|
1184
|
+
if (!removeClaudeAccount(homeDir(), id)) {
|
|
1185
|
+
sendJson(res, 404, { error: "Claude account not found" });
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
deps.log.info(`admin: removed Claude account ${id.slice(0, 8)}`);
|
|
1189
|
+
sendJson(res, 200, { ok: true });
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
let label;
|
|
1193
|
+
try {
|
|
1194
|
+
label = JSON.parse((await readBody(req)).toString("utf8")).label;
|
|
1195
|
+
}
|
|
1196
|
+
catch {
|
|
1197
|
+
sendJson(res, 400, { error: "invalid JSON" });
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
if (typeof label !== "string" || !label.trim()) {
|
|
1201
|
+
sendJson(res, 400, { error: "expected {label: non-empty string}" });
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
if (!renameClaudeAccount(homeDir(), id, label)) {
|
|
1205
|
+
sendJson(res, 404, { error: "Claude account not found" });
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
deps.log.info(`admin: renamed Claude account ${id.slice(0, 8)}`);
|
|
1209
|
+
sendJson(res, 200, { ok: true });
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
779
1212
|
// Claude subscription sign-in of our own (browser, PKCE). The GUI starts it, polls the state,
|
|
780
1213
|
// and pastes the code when the loopback port could not be used. Tokens never leave the router.
|
|
781
1214
|
if (pathname === "/api/claude-oauth" && method === "GET") {
|
|
782
|
-
const state = { ...(claudeOAuth?.snapshot ?? { running: false, url: null, manual: false, startedAt: null, finishedAt: null, ok: null, error: null }), source: claudeAuthStore(deps).describeSource() };
|
|
1215
|
+
const state = { ...(claudeOAuth?.snapshot ?? { running: false, url: null, manual: false, startedAt: null, finishedAt: null, ok: null, error: null, account: null }), source: claudeAuthStore(deps).describeSource() };
|
|
783
1216
|
sendJson(res, 200, state);
|
|
784
1217
|
return;
|
|
785
1218
|
}
|