@schlessera/brain-ui-server 0.21.0 → 0.24.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/README.md +1 -0
- package/dist/agent/backend.d.ts +51 -0
- package/dist/agent/backend.d.ts.map +1 -1
- package/dist/agent/backend.js +222 -12
- package/dist/agent/backend.js.map +1 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +5 -1
- package/dist/app.js.map +1 -1
- package/dist/config/env.d.ts +6 -0
- package/dist/config/env.d.ts.map +1 -1
- package/dist/config/env.js +10 -0
- package/dist/config/env.js.map +1 -1
- package/dist/db/settings.d.ts +12 -0
- package/dist/db/settings.d.ts.map +1 -1
- package/dist/db/settings.js +27 -0
- package/dist/db/settings.js.map +1 -1
- package/dist/routes/models.d.ts.map +1 -1
- package/dist/routes/models.js +64 -1
- package/dist/routes/models.js.map +1 -1
- package/dist/routes/pi-auth.d.ts +44 -0
- package/dist/routes/pi-auth.d.ts.map +1 -0
- package/dist/routes/pi-auth.js +100 -0
- package/dist/routes/pi-auth.js.map +1 -0
- package/dist/ws/bridge.d.ts.map +1 -1
- package/dist/ws/bridge.js +4 -0
- package/dist/ws/bridge.js.map +1 -1
- package/dist/ws/connection.d.ts.map +1 -1
- package/dist/ws/connection.js +1 -0
- package/dist/ws/connection.js.map +1 -1
- package/dist/ws/dispatch.d.ts.map +1 -1
- package/dist/ws/dispatch.js +1 -0
- package/dist/ws/dispatch.js.map +1 -1
- package/dist/ws/routing.d.ts.map +1 -1
- package/dist/ws/routing.js +9 -0
- package/dist/ws/routing.js.map +1 -1
- package/package.json +6 -4
- package/src/agent/backend.ts +293 -11
- package/src/app.ts +10 -1
- package/src/config/env.ts +17 -0
- package/src/db/settings.ts +30 -0
- package/src/routes/models.ts +80 -0
- package/src/routes/pi-auth.ts +153 -0
- package/src/ws/bridge.ts +4 -0
- package/src/ws/connection.ts +1 -0
- package/src/ws/dispatch.ts +1 -0
- package/src/ws/routing.ts +9 -0
package/dist/ws/routing.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"routing.d.ts","sourceRoot":"","sources":["../../src/ws/routing.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iCAAiC,CAAC;AACpE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAE3D;;;GAGG;AACH,wBAAsB,iBAAiB,CACrC,QAAQ,EAAE,eAAe,EACzB,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,SAAS,EAAE,MAAM,GAAG,SAAS,GAC5B,OAAO,CAAC;IAAE,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,YAAY,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,
|
|
1
|
+
{"version":3,"file":"routing.d.ts","sourceRoot":"","sources":["../../src/ws/routing.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iCAAiC,CAAC;AACpE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAE3D;;;GAGG;AACH,wBAAsB,iBAAiB,CACrC,QAAQ,EAAE,eAAe,EACzB,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,SAAS,EAAE,MAAM,GAAG,SAAS,GAC5B,OAAO,CAAC;IAAE,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,YAAY,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAkC7E"}
|
package/dist/ws/routing.js
CHANGED
|
@@ -23,6 +23,15 @@ export async function resolveTurnTarget(registry, catalog, sessionId, requested)
|
|
|
23
23
|
return { backend, profileId: requested };
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
|
+
// No explicit choice (fresh client, share, host-initiated action): the
|
|
27
|
+
// preferred default — the Settings-stored default model, else a connected
|
|
28
|
+
// subscription-auth profile — before the default backend's own first profile.
|
|
29
|
+
const preferred = await registry.getPreferredProfileId();
|
|
30
|
+
if (preferred) {
|
|
31
|
+
const backend = await registry.getBackendForProfile(preferred);
|
|
32
|
+
if (backend)
|
|
33
|
+
return { backend, profileId: preferred };
|
|
34
|
+
}
|
|
26
35
|
const backend = await registry.getDefaultBackend();
|
|
27
36
|
const profileId = (await backend.listProfiles())[0]?.id;
|
|
28
37
|
return { backend, ...(profileId ? { profileId } : {}) };
|
package/dist/ws/routing.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"routing.js","sourceRoot":"","sources":["../../src/ws/routing.ts"],"names":[],"mappings":"AAIA;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CACrC,QAAyB,EACzB,OAAuB,EACvB,SAA6B,EAC7B,SAA6B;IAE7B,IAAI,SAAS,EAAE,CAAC;QACd,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC,oBAAoB,CAAC,OAAO,CAAC,kBAAkB,CAAC,SAAS,CAAC,CAAC,CAAC;QAC3F,MAAM,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC,SAAS,CAAC,CAAC;QACtD,MAAM,SAAS,GAAG,MAAM,OAAO,CAAC,YAAY,EAAE,CAAC;QAC/C,IAAI,MAAM,IAAI,SAAS,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,EAAE,KAAK,MAAM,CAAC,EAAE,CAAC;YACjE,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,CAAC;QACxC,CAAC;QACD,yEAAyE;QACzE,0EAA0E;QAC1E,wEAAwE;QACxE,OAAO,EAAE,OAAO,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;IAChE,CAAC;IAED,IAAI,SAAS,EAAE,CAAC;QACd,MAAM,SAAS,GAAG,MAAM,QAAQ,CAAC,gBAAgB,EAAE,CAAC;QACpD,IAAI,SAAS,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,EAAE,KAAK,SAAS,CAAC,EAAE,CAAC;YAC1D,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC,oBAAoB,CAAC,SAAS,CAAC,CAAC;YAC/D,IAAI,OAAO;gBAAE,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,CAAC;QACxD,CAAC;IACH,CAAC;IAED,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC,iBAAiB,EAAE,CAAC;IACnD,MAAM,SAAS,GAAG,CAAC,MAAM,OAAO,CAAC,YAAY,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC;IACxD,OAAO,EAAE,OAAO,EAAE,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;AAC1D,CAAC"}
|
|
1
|
+
{"version":3,"file":"routing.js","sourceRoot":"","sources":["../../src/ws/routing.ts"],"names":[],"mappings":"AAIA;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CACrC,QAAyB,EACzB,OAAuB,EACvB,SAA6B,EAC7B,SAA6B;IAE7B,IAAI,SAAS,EAAE,CAAC;QACd,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC,oBAAoB,CAAC,OAAO,CAAC,kBAAkB,CAAC,SAAS,CAAC,CAAC,CAAC;QAC3F,MAAM,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC,SAAS,CAAC,CAAC;QACtD,MAAM,SAAS,GAAG,MAAM,OAAO,CAAC,YAAY,EAAE,CAAC;QAC/C,IAAI,MAAM,IAAI,SAAS,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,EAAE,KAAK,MAAM,CAAC,EAAE,CAAC;YACjE,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,CAAC;QACxC,CAAC;QACD,yEAAyE;QACzE,0EAA0E;QAC1E,wEAAwE;QACxE,OAAO,EAAE,OAAO,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;IAChE,CAAC;IAED,IAAI,SAAS,EAAE,CAAC;QACd,MAAM,SAAS,GAAG,MAAM,QAAQ,CAAC,gBAAgB,EAAE,CAAC;QACpD,IAAI,SAAS,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,EAAE,KAAK,SAAS,CAAC,EAAE,CAAC;YAC1D,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC,oBAAoB,CAAC,SAAS,CAAC,CAAC;YAC/D,IAAI,OAAO;gBAAE,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,CAAC;QACxD,CAAC;IACH,CAAC;IAED,uEAAuE;IACvE,0EAA0E;IAC1E,8EAA8E;IAC9E,MAAM,SAAS,GAAG,MAAM,QAAQ,CAAC,qBAAqB,EAAE,CAAC;IACzD,IAAI,SAAS,EAAE,CAAC;QACd,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC,oBAAoB,CAAC,SAAS,CAAC,CAAC;QAC/D,IAAI,OAAO;YAAE,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,CAAC;IACxD,CAAC;IAED,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC,iBAAiB,EAAE,CAAC;IACnD,MAAM,SAAS,GAAG,CAAC,MAAM,OAAO,CAAC,YAAY,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC;IACxD,OAAO,EAAE,OAAO,EAAE,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;AAC1D,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@schlessera/brain-ui-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "brain-kit chat-UI server: Hono app factory, WebSocket turn coordinator, auth (password/passkeys/tailscale/proxy), session catalog, and brain/files/voice routes",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -44,13 +44,15 @@
|
|
|
44
44
|
],
|
|
45
45
|
"scripts": {
|
|
46
46
|
"test": "bun test tests --timeout 30000",
|
|
47
|
-
"prepublishOnly": "bun ../../scripts/check-dist.ts"
|
|
47
|
+
"prepublishOnly": "bun ../../scripts/check-dist.ts",
|
|
48
|
+
"eval:triage": "bun run evals/triage/run.ts",
|
|
49
|
+
"eval:triage:validate": "bun run evals/triage/validate.ts"
|
|
48
50
|
},
|
|
49
51
|
"dependencies": {
|
|
50
52
|
"@opentelemetry/api": "^1.9.1",
|
|
51
53
|
"@opentelemetry/api-logs": "0.221.0",
|
|
52
|
-
"@schlessera/brain-render-template": "0.
|
|
53
|
-
"@schlessera/brain-ui-sdk": "0.
|
|
54
|
+
"@schlessera/brain-render-template": "0.24.0",
|
|
55
|
+
"@schlessera/brain-ui-sdk": "0.24.0",
|
|
54
56
|
"@simplewebauthn/server": "^13.3.2",
|
|
55
57
|
"hono": "^4",
|
|
56
58
|
"ignore": "^7.0.5",
|
package/src/agent/backend.ts
CHANGED
|
@@ -97,6 +97,30 @@ export interface ClaudeProfileInput {
|
|
|
97
97
|
source?: "builtin" | "declared" | "discovered";
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/** Reasoning levels pi accepts (mirror of pi-agent-core's `ThinkingLevel`). */
|
|
101
|
+
export const PI_THINKING_LEVELS = [
|
|
102
|
+
"off",
|
|
103
|
+
"minimal",
|
|
104
|
+
"low",
|
|
105
|
+
"medium",
|
|
106
|
+
"high",
|
|
107
|
+
"xhigh",
|
|
108
|
+
"max",
|
|
109
|
+
] as const;
|
|
110
|
+
export type PiThinkingLevel = (typeof PI_THINKING_LEVELS)[number];
|
|
111
|
+
|
|
112
|
+
/** Mirror of the pi package's `PiProfile` (declarative shape). */
|
|
113
|
+
export interface PiProfileInput {
|
|
114
|
+
id: string;
|
|
115
|
+
label: string;
|
|
116
|
+
/** pi provider id, e.g. "openai-codex", "anthropic", "google". */
|
|
117
|
+
vendor: string;
|
|
118
|
+
/** pi model id within the vendor, e.g. "gpt-5.6-sol". */
|
|
119
|
+
model: string;
|
|
120
|
+
/** Reasoning level for new sessions; pi clamps to the model's capability. */
|
|
121
|
+
thinkingLevel?: PiThinkingLevel;
|
|
122
|
+
}
|
|
123
|
+
|
|
100
124
|
/** Mirror of the Claude package's resolved `InferenceProfile`. */
|
|
101
125
|
export interface ClaudeProfile {
|
|
102
126
|
id: string;
|
|
@@ -175,6 +199,17 @@ export interface BackendRegistryOptions {
|
|
|
175
199
|
* sessions pinned to it. Defaults to "nothing hidden".
|
|
176
200
|
*/
|
|
177
201
|
getHiddenModelIds?: () => string[];
|
|
202
|
+
/**
|
|
203
|
+
* Settings-stored default profile id (Settings → Models). Null/absent =
|
|
204
|
+
* auto (a connected subscription-auth profile, else the default backend's
|
|
205
|
+
* own default).
|
|
206
|
+
*/
|
|
207
|
+
getDefaultModelId?: () => string | null;
|
|
208
|
+
/**
|
|
209
|
+
* User-managed OpenRouter model ids (from the app's settings table), added
|
|
210
|
+
* to the Claude roster as declared OpenRouter profiles at runtime.
|
|
211
|
+
*/
|
|
212
|
+
getCustomOpenRouterModels?: () => string[];
|
|
178
213
|
/**
|
|
179
214
|
* Per-profile billing-mode overrides (from the app's settings table).
|
|
180
215
|
* Consulted LAST: an override wins over both the declared-credential rule
|
|
@@ -210,6 +245,17 @@ export interface BackendRegistry {
|
|
|
210
245
|
* catalog.
|
|
211
246
|
*/
|
|
212
247
|
listAllProviders(options?: { includeHidden?: boolean }): Promise<ProviderInfo[]>;
|
|
248
|
+
/**
|
|
249
|
+
* The profile new sessions and host-initiated turns (shares, actions)
|
|
250
|
+
* default to when the client named none, or null for "the default
|
|
251
|
+
* backend's own default". Resolution: the Settings-stored default model
|
|
252
|
+
* (when it still exists on the roster) wins; otherwise AUTO — a
|
|
253
|
+
* subscription-auth profile (pi vendor "openai-codex") that is on the
|
|
254
|
+
* roster, not hidden, and whose account is actually connected. The
|
|
255
|
+
* resolved profile is also listed FIRST by `listAllProviders`, so a fresh
|
|
256
|
+
* client's picker lands on it.
|
|
257
|
+
*/
|
|
258
|
+
getPreferredProfileId(): Promise<string | null>;
|
|
213
259
|
/** Per-backend capability metadata exposed by the providers route. */
|
|
214
260
|
getBackendsInfo(): Promise<
|
|
215
261
|
Record<string, { id: string; capabilities: BackendCapabilities }>
|
|
@@ -354,6 +400,94 @@ export async function loadBackendModule(
|
|
|
354
400
|
}
|
|
355
401
|
}
|
|
356
402
|
|
|
403
|
+
/**
|
|
404
|
+
* Parse + validate the pi roster from BRAIN_UI_PI_PROFILES (a JSON array of
|
|
405
|
+
* {id,label,vendor,model,thinkingLevel?}). Malformed input THROWS rather than
|
|
406
|
+
* silently dropping the roster; `assertBackendResolvable` runs this at boot so
|
|
407
|
+
* a bad config refuses to start instead of reporting healthy and 500ing later.
|
|
408
|
+
*
|
|
409
|
+
* Ids Claude's side of the picker uses or can mint later ("default",
|
|
410
|
+
* "claude", "claude-*" — discovery canonicalizes every Anthropic model to a
|
|
411
|
+
* claude-* alias) are rejected, as is any collision with an id declared in
|
|
412
|
+
* BRAIN_UI_CLAUDE_PROFILES — a cross-backend collision otherwise surfaces as
|
|
413
|
+
* a 500 on first request. Claude's own JSON is parsed leniently here: when it
|
|
414
|
+
* is malformed, the Claude loader raises its own (more precise) boot error.
|
|
415
|
+
*/
|
|
416
|
+
export function parsePiProfiles(
|
|
417
|
+
raw: string | null,
|
|
418
|
+
claudeProfilesRaw?: string | null
|
|
419
|
+
): PiProfileInput[] {
|
|
420
|
+
if (!raw) return [];
|
|
421
|
+
|
|
422
|
+
let inputs: unknown;
|
|
423
|
+
try {
|
|
424
|
+
inputs = JSON.parse(raw);
|
|
425
|
+
} catch (err) {
|
|
426
|
+
throw new Error(
|
|
427
|
+
`BRAIN_UI_PI_PROFILES is not valid JSON: ${
|
|
428
|
+
err instanceof Error ? err.message : String(err)
|
|
429
|
+
}`
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
if (!Array.isArray(inputs)) {
|
|
433
|
+
throw new Error("BRAIN_UI_PI_PROFILES must be a JSON array.");
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const claudeIds = new Set<string>();
|
|
437
|
+
if (claudeProfilesRaw) {
|
|
438
|
+
try {
|
|
439
|
+
const claudeInputs = JSON.parse(claudeProfilesRaw);
|
|
440
|
+
if (Array.isArray(claudeInputs)) {
|
|
441
|
+
for (const entry of claudeInputs) {
|
|
442
|
+
if (entry && typeof entry.id === "string") claudeIds.add(entry.id);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
} catch {
|
|
446
|
+
// Malformed Claude JSON is the Claude loader's error to raise.
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const seen = new Set<string>();
|
|
451
|
+
for (const input of inputs as PiProfileInput[]) {
|
|
452
|
+
if (!input || typeof input !== "object") {
|
|
453
|
+
throw new Error("Each BRAIN_UI_PI_PROFILES entry must be an object.");
|
|
454
|
+
}
|
|
455
|
+
for (const field of ["id", "label", "vendor", "model"] as const) {
|
|
456
|
+
if (typeof input[field] !== "string" || input[field].length === 0) {
|
|
457
|
+
throw new Error(
|
|
458
|
+
`Each BRAIN_UI_PI_PROFILES entry needs a non-empty string ${field}.`
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (input.id === "default" || /^claude(-|$)/.test(input.id)) {
|
|
463
|
+
throw new Error(
|
|
464
|
+
`BRAIN_UI_PI_PROFILES id "${input.id}" is reserved for the Claude ` +
|
|
465
|
+
"roster (built-in default and discovered claude-* aliases)."
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
if (claudeIds.has(input.id)) {
|
|
469
|
+
throw new Error(
|
|
470
|
+
`BRAIN_UI_PI_PROFILES id "${input.id}" collides with a ` +
|
|
471
|
+
"BRAIN_UI_CLAUDE_PROFILES entry."
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
if (seen.has(input.id)) {
|
|
475
|
+
throw new Error(`Duplicate profile id in BRAIN_UI_PI_PROFILES: "${input.id}".`);
|
|
476
|
+
}
|
|
477
|
+
seen.add(input.id);
|
|
478
|
+
if (
|
|
479
|
+
input.thinkingLevel !== undefined &&
|
|
480
|
+
!PI_THINKING_LEVELS.includes(input.thinkingLevel)
|
|
481
|
+
) {
|
|
482
|
+
throw new Error(
|
|
483
|
+
`BRAIN_UI_PI_PROFILES entry "${input.id}" has invalid thinkingLevel ` +
|
|
484
|
+
`"${input.thinkingLevel}" (expected one of ${PI_THINKING_LEVELS.join(", ")}).`
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return inputs as PiProfileInput[];
|
|
489
|
+
}
|
|
490
|
+
|
|
357
491
|
export function assertBackendResolvable(
|
|
358
492
|
agent: AgentConfig,
|
|
359
493
|
resolve: (specifier: string) => void = (specifier) => {
|
|
@@ -368,6 +502,23 @@ export function assertBackendResolvable(
|
|
|
368
502
|
} catch {
|
|
369
503
|
throw missingBackendError(key);
|
|
370
504
|
}
|
|
505
|
+
// BRAIN_UI_PI_PROFILES opts the pi backend in ALONGSIDE the primary — its
|
|
506
|
+
// package must resolve at boot too, or the roster silently loses those
|
|
507
|
+
// profiles on first request instead of refusing to start.
|
|
508
|
+
if (agent.piProfilesJson && key !== "pi") {
|
|
509
|
+
try {
|
|
510
|
+
resolve(BACKEND_SPECIFIERS.pi);
|
|
511
|
+
} catch {
|
|
512
|
+
throw new Error(
|
|
513
|
+
'BRAIN_UI_PI_PROFILES is set but "@schlessera/brain-backend-pi" is not ' +
|
|
514
|
+
"installed. Add it (with its pi SDK dependencies) or unset the variable."
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
// Validate the pi roster itself at boot too — the registry is built lazily,
|
|
519
|
+
// so without this a malformed BRAIN_UI_PI_PROFILES would still report a
|
|
520
|
+
// healthy startup and only fail on first request.
|
|
521
|
+
parsePiProfiles(agent.piProfilesJson, agent.profilesJson);
|
|
371
522
|
}
|
|
372
523
|
|
|
373
524
|
export function createBackendRegistry(
|
|
@@ -387,21 +538,26 @@ export function createBackendRegistry(
|
|
|
387
538
|
* before any profile can be listed or run.
|
|
388
539
|
*/
|
|
389
540
|
const declaredApiProfileIds = new Set<string>();
|
|
390
|
-
/** The Claude backend's id, once built —
|
|
541
|
+
/** The Claude backend's id, once built — Claude's subscription path only applies to its own profiles. */
|
|
391
542
|
let claudeBackendId: string | null = null;
|
|
392
543
|
|
|
393
544
|
/**
|
|
394
545
|
* Base billing classification for one roster entry, BEFORE the settings
|
|
395
546
|
* override (applied last by the shared accessor surface):
|
|
396
|
-
* - a non-Claude backend
|
|
397
|
-
*
|
|
547
|
+
* - a non-Claude backend profile → by VENDOR: pi's "openai-codex" runs
|
|
548
|
+
* only against a ChatGPT-subscription OAuth credential (the provider
|
|
549
|
+
* has no API-key path at all) → "subscription"; every other vendor
|
|
550
|
+
* resolves ambient API keys → "api";
|
|
551
|
+
* - a declared Claude profile with explicit credentials → "api";
|
|
398
552
|
* - everything ambient (built-in default, discovered models, declared
|
|
399
553
|
* entries without their own credentials) → the env-resolved ambient
|
|
400
554
|
* mode (subscription iff the OAuth token is present and no
|
|
401
555
|
* ANTHROPIC_API_KEY — the Agent SDK's own precedence).
|
|
402
556
|
*/
|
|
403
557
|
function classifyBilling(profile: ProviderInfo & { backendId: string }): BillingMode {
|
|
404
|
-
if (claudeBackendId === null || profile.backendId !== claudeBackendId)
|
|
558
|
+
if (claudeBackendId === null || profile.backendId !== claudeBackendId) {
|
|
559
|
+
return profile.vendor === "openai-codex" ? "subscription" : "api";
|
|
560
|
+
}
|
|
405
561
|
if (declaredApiProfileIds.has(profile.id)) return "api";
|
|
406
562
|
return agent.ambientBilling;
|
|
407
563
|
}
|
|
@@ -418,26 +574,54 @@ export function createBackendRegistry(
|
|
|
418
574
|
let mergeCache: {
|
|
419
575
|
declared: ClaudeProfile[];
|
|
420
576
|
discovered: ClaudeProfileInput[];
|
|
577
|
+
customKey: string;
|
|
421
578
|
result: ClaudeProfile[];
|
|
422
579
|
} | null = null;
|
|
423
580
|
|
|
581
|
+
/**
|
|
582
|
+
* User-managed OpenRouter models (Settings → Models) as declared Claude
|
|
583
|
+
* profiles — same shape a BRAIN_UI_CLAUDE_PROFILES OpenRouter entry has,
|
|
584
|
+
* but editable at runtime without an env change or redeploy.
|
|
585
|
+
*/
|
|
586
|
+
function customOpenRouterInputs(): ClaudeProfileInput[] {
|
|
587
|
+
const models = options.getCustomOpenRouterModels?.() ?? [];
|
|
588
|
+
return models.map((model) => ({
|
|
589
|
+
id: `openrouter:${model}`,
|
|
590
|
+
label: `${model} (OpenRouter)`,
|
|
591
|
+
vendor: "openrouter",
|
|
592
|
+
model,
|
|
593
|
+
baseUrl: "https://openrouter.ai/api",
|
|
594
|
+
authTokenEnv: "OPENROUTER_API_KEY",
|
|
595
|
+
modelAliases: true,
|
|
596
|
+
source: "declared" as const,
|
|
597
|
+
}));
|
|
598
|
+
}
|
|
599
|
+
|
|
424
600
|
function mergeDiscovered(
|
|
425
601
|
claude: ClaudeBackendModule,
|
|
426
602
|
declared: ClaudeProfile[],
|
|
427
603
|
discovered: ClaudeProfileInput[]
|
|
428
604
|
): ClaudeProfile[] {
|
|
605
|
+
const custom = customOpenRouterInputs();
|
|
606
|
+
const customKey = custom.map((profile) => profile.id).join("\n");
|
|
429
607
|
if (
|
|
430
608
|
mergeCache &&
|
|
431
609
|
mergeCache.declared === declared &&
|
|
432
|
-
mergeCache.discovered === discovered
|
|
610
|
+
mergeCache.discovered === discovered &&
|
|
611
|
+
mergeCache.customKey === customKey
|
|
433
612
|
) {
|
|
434
613
|
return mergeCache.result;
|
|
435
614
|
}
|
|
436
615
|
|
|
437
616
|
const declaredIds = new Set(declared.map((profile) => profile.id));
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
|
|
617
|
+
const customExtra = custom.filter((input) => !declaredIds.has(input.id));
|
|
618
|
+
// OpenRouter runs on its own key: always api-billed, like an env-declared
|
|
619
|
+
// profile with explicit credentials.
|
|
620
|
+
for (const input of customExtra) declaredApiProfileIds.add(input.id);
|
|
621
|
+
const knownIds = new Set([...declaredIds, ...customExtra.map((input) => input.id)]);
|
|
622
|
+
const extra = discovered.filter((input) => !knownIds.has(input.id));
|
|
623
|
+
const result = [...declared, ...claude.defineProfiles([...customExtra, ...extra])];
|
|
624
|
+
mergeCache = { declared, discovered, customKey, result };
|
|
441
625
|
return result;
|
|
442
626
|
}
|
|
443
627
|
|
|
@@ -515,6 +699,7 @@ export function createBackendRegistry(
|
|
|
515
699
|
return [...base, ...claude.defineProfiles(declared)];
|
|
516
700
|
}
|
|
517
701
|
|
|
702
|
+
|
|
518
703
|
/**
|
|
519
704
|
* NEITHER backend package is a hard dependency — a deployment installs the
|
|
520
705
|
* one its AGENT_BACKEND names (both, if it switches). Loaded lazily through
|
|
@@ -560,15 +745,23 @@ export function createBackendRegistry(
|
|
|
560
745
|
|
|
561
746
|
async function buildPiBackend(): Promise<AgentBackend> {
|
|
562
747
|
const mod = (await loadBackendModule("pi")) as {
|
|
563
|
-
createPiBackend?: (opts: {
|
|
748
|
+
createPiBackend?: (opts: {
|
|
749
|
+
brainPath: string;
|
|
750
|
+
profiles?: PiProfileInput[];
|
|
751
|
+
log?: BackendLogFn;
|
|
752
|
+
}) => AgentBackend;
|
|
564
753
|
};
|
|
565
754
|
if (typeof mod.createPiBackend !== "function") {
|
|
566
755
|
throw new Error(
|
|
567
756
|
'"@schlessera/brain-backend-pi" does not export createPiBackend.'
|
|
568
757
|
);
|
|
569
758
|
}
|
|
759
|
+
// Re-parsed here (assertBackendResolvable already validated at boot) so an
|
|
760
|
+
// injected-registry path without the boot assert still fails loudly.
|
|
761
|
+
const profiles = parsePiProfiles(agent.piProfilesJson, agent.profilesJson);
|
|
570
762
|
return mod.createPiBackend({
|
|
571
763
|
brainPath,
|
|
764
|
+
...(profiles.length > 0 ? { profiles } : {}),
|
|
572
765
|
...(backendLog ? { log: backendLog } : {}),
|
|
573
766
|
});
|
|
574
767
|
}
|
|
@@ -583,6 +776,14 @@ export function createBackendRegistry(
|
|
|
583
776
|
|
|
584
777
|
const backends = [await buildClaudeBackend()];
|
|
585
778
|
|
|
779
|
+
// BRAIN_UI_PI_PROFILES opts the pi backend in ALONGSIDE claude: its
|
|
780
|
+
// profiles join the picker (e.g. OpenAI models under a ChatGPT
|
|
781
|
+
// subscription via pi's "openai-codex" vendor) while claude stays the
|
|
782
|
+
// default backend. Without the variable, behavior is unchanged.
|
|
783
|
+
if (agent.piProfilesJson) {
|
|
784
|
+
backends.push(await buildPiBackend());
|
|
785
|
+
}
|
|
786
|
+
|
|
586
787
|
// AGENT_BACKEND must name a configured backend. The in-process options are
|
|
587
788
|
// "claude" (default) and "pi" (handled above). An unrecognized value is a
|
|
588
789
|
// misconfiguration — fail loudly instead of silently coercing to claude and
|
|
@@ -598,6 +799,33 @@ export function createBackendRegistry(
|
|
|
598
799
|
return cachedRegistry;
|
|
599
800
|
}
|
|
600
801
|
|
|
802
|
+
/**
|
|
803
|
+
* Whether the openai-codex (ChatGPT subscription) account is connected —
|
|
804
|
+
* a cheap file probe through the pi package, memoized briefly so provider
|
|
805
|
+
* listings and turn routing don't re-read the auth store on every call.
|
|
806
|
+
*/
|
|
807
|
+
let codexCredentialCache: { at: number; value: boolean } | null = null;
|
|
808
|
+
async function hasCodexCredential(): Promise<boolean> {
|
|
809
|
+
const piInPlay = Boolean(agent.piProfilesJson) || (agent.backend || "claude") === "pi";
|
|
810
|
+
if (!piInPlay) return false;
|
|
811
|
+
if (codexCredentialCache && Date.now() - codexCredentialCache.at < PROFILE_MEMO_MS) {
|
|
812
|
+
return codexCredentialCache.value;
|
|
813
|
+
}
|
|
814
|
+
let value = false;
|
|
815
|
+
try {
|
|
816
|
+
const mod = (await loadBackendModule("pi")) as {
|
|
817
|
+
hasStoredCredential?: (providerId: string) => boolean;
|
|
818
|
+
};
|
|
819
|
+
value =
|
|
820
|
+
typeof mod.hasStoredCredential === "function" &&
|
|
821
|
+
mod.hasStoredCredential("openai-codex");
|
|
822
|
+
} catch {
|
|
823
|
+
value = false;
|
|
824
|
+
}
|
|
825
|
+
codexCredentialCache = { at: Date.now(), value };
|
|
826
|
+
return value;
|
|
827
|
+
}
|
|
828
|
+
|
|
601
829
|
return makeRegistry(
|
|
602
830
|
getRegistry,
|
|
603
831
|
getHidden,
|
|
@@ -611,6 +839,10 @@ export function createBackendRegistry(
|
|
|
611
839
|
...(options.getBillingOverrides
|
|
612
840
|
? { getOverrides: options.getBillingOverrides }
|
|
613
841
|
: {}),
|
|
842
|
+
},
|
|
843
|
+
{
|
|
844
|
+
...(options.getDefaultModelId ? { getOverride: options.getDefaultModelId } : {}),
|
|
845
|
+
auto: { vendor: "openai-codex", hasCredential: hasCodexCredential },
|
|
614
846
|
}
|
|
615
847
|
);
|
|
616
848
|
}
|
|
@@ -626,6 +858,7 @@ export function createStaticBackendRegistry(
|
|
|
626
858
|
defaultBackendId = backends[0]?.id ?? "",
|
|
627
859
|
options: {
|
|
628
860
|
getHiddenModelIds?: () => string[];
|
|
861
|
+
getDefaultModelId?: () => string | null;
|
|
629
862
|
getBillingOverrides?: () => Record<string, BillingMode>;
|
|
630
863
|
log?: Logger;
|
|
631
864
|
} = {}
|
|
@@ -640,7 +873,9 @@ export function createStaticBackendRegistry(
|
|
|
640
873
|
// registry could reason about, so only an explicit override sets a mode.
|
|
641
874
|
options.getBillingOverrides
|
|
642
875
|
? { getOverrides: options.getBillingOverrides }
|
|
643
|
-
: {}
|
|
876
|
+
: {},
|
|
877
|
+
// No auto rule either — only the stored default applies here.
|
|
878
|
+
options.getDefaultModelId ? { getOverride: options.getDefaultModelId } : {}
|
|
644
879
|
);
|
|
645
880
|
}
|
|
646
881
|
|
|
@@ -655,6 +890,12 @@ function makeRegistry(
|
|
|
655
890
|
classify?: (profile: ProviderInfo & { backendId: string }) => BillingMode;
|
|
656
891
|
/** Settings overrides, consulted LAST — an override wins over `classify`. */
|
|
657
892
|
getOverrides?: () => Record<string, BillingMode>;
|
|
893
|
+
} = {},
|
|
894
|
+
defaults: {
|
|
895
|
+
/** Settings-stored default profile id; null/absent = auto. */
|
|
896
|
+
getOverride?: () => string | null;
|
|
897
|
+
/** Auto rule: prefer this vendor's profile while its account is connected. */
|
|
898
|
+
auto?: { vendor: string; hasCredential: () => Promise<boolean> };
|
|
658
899
|
} = {}
|
|
659
900
|
): BackendRegistry {
|
|
660
901
|
let profileSnapshot: ProfileSnapshot | null = null;
|
|
@@ -715,6 +956,36 @@ function makeRegistry(
|
|
|
715
956
|
}
|
|
716
957
|
}
|
|
717
958
|
|
|
959
|
+
/**
|
|
960
|
+
* The preferred-default profile. The stored Settings override wins while it
|
|
961
|
+
* still names a roster profile (a vanished profile falls through to auto
|
|
962
|
+
* rather than pinning turns to nothing); auto is the first non-hidden
|
|
963
|
+
* roster entry of the auto vendor, but only while its credential exists.
|
|
964
|
+
* Any failure degrades to "no preference" — this must never take routing
|
|
965
|
+
* down.
|
|
966
|
+
*/
|
|
967
|
+
async function getPreferredProfileId(): Promise<string | null> {
|
|
968
|
+
try {
|
|
969
|
+
const snapshot = await getProfileSnapshot();
|
|
970
|
+
const override = defaults.getOverride?.() ?? null;
|
|
971
|
+
if (override && snapshot.owners.has(override)) return override;
|
|
972
|
+
|
|
973
|
+
const auto = defaults.auto;
|
|
974
|
+
if (!auto) return null;
|
|
975
|
+
const registry = await getRegistry();
|
|
976
|
+
const hidden = hiddenIds();
|
|
977
|
+
for (const backend of registry.backends) {
|
|
978
|
+
const match = (snapshot.byBackend.get(backend.id) ?? []).find(
|
|
979
|
+
(profile) => profile.vendor === auto.vendor && !hidden.has(profile.id)
|
|
980
|
+
);
|
|
981
|
+
if (match) return (await auto.hasCredential()) ? match.id : null;
|
|
982
|
+
}
|
|
983
|
+
return null;
|
|
984
|
+
} catch {
|
|
985
|
+
return null;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
718
989
|
return {
|
|
719
990
|
async getBackends() {
|
|
720
991
|
return (await getRegistry()).backends;
|
|
@@ -759,7 +1030,7 @@ function makeRegistry(
|
|
|
759
1030
|
// saved override is live for the very next run without invalidation.
|
|
760
1031
|
const overrides = billingOverrides();
|
|
761
1032
|
|
|
762
|
-
|
|
1033
|
+
const providers = registry.backends.flatMap((backend) =>
|
|
763
1034
|
(snapshot.byBackend.get(backend.id) ?? [])
|
|
764
1035
|
.filter((profile) => !hidden.has(profile.id))
|
|
765
1036
|
.map((profile) => {
|
|
@@ -768,8 +1039,19 @@ function makeRegistry(
|
|
|
768
1039
|
return billingMode ? { ...entry, billingMode } : entry;
|
|
769
1040
|
})
|
|
770
1041
|
);
|
|
1042
|
+
|
|
1043
|
+
// A connected subscription-auth profile leads the list: a fresh client
|
|
1044
|
+
// with no stored selection defaults to providers[0].
|
|
1045
|
+
const preferredId = await getPreferredProfileId();
|
|
1046
|
+
if (preferredId) {
|
|
1047
|
+
const index = providers.findIndex((provider) => provider.id === preferredId);
|
|
1048
|
+
if (index > 0) providers.unshift(...providers.splice(index, 1));
|
|
1049
|
+
}
|
|
1050
|
+
return providers;
|
|
771
1051
|
},
|
|
772
1052
|
|
|
1053
|
+
getPreferredProfileId,
|
|
1054
|
+
|
|
773
1055
|
async getBackendsInfo() {
|
|
774
1056
|
const backends = (await getRegistry()).backends;
|
|
775
1057
|
return Object.fromEntries(
|
package/src/app.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { createShareRoutes, shareTargetFallbackRoutes } from "./routes/share.js"
|
|
|
15
15
|
import { createRenderRoutes, type AppRenderer } from "./routes/render.js";
|
|
16
16
|
import { createProviderRoutes } from "./routes/providers.js";
|
|
17
17
|
import { createModelRoutes } from "./routes/models.js";
|
|
18
|
+
import { createPiAuthRoutes } from "./routes/pi-auth.js";
|
|
18
19
|
import { createGraphRoutes } from "./routes/graph.js";
|
|
19
20
|
import {
|
|
20
21
|
resolveAuthMode,
|
|
@@ -33,7 +34,12 @@ import {
|
|
|
33
34
|
type PasskeyContext,
|
|
34
35
|
} from "./middleware/passkeys.js";
|
|
35
36
|
import { createUiDb } from "./db/client.js";
|
|
36
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
getBillingOverrides,
|
|
39
|
+
getCustomOpenRouterModels,
|
|
40
|
+
getDefaultModelId,
|
|
41
|
+
getHiddenModelIds,
|
|
42
|
+
} from "./db/settings.js";
|
|
37
43
|
import { createActivityRuntime } from "./activity/runtime.js";
|
|
38
44
|
import { createModelPricing } from "./pricing/model-pricing.js";
|
|
39
45
|
import { createPushRoutes } from "./routes/push.js";
|
|
@@ -199,6 +205,8 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
199
205
|
brainPath: config.brainPath,
|
|
200
206
|
agent: config.agent,
|
|
201
207
|
getHiddenModelIds: () => getHiddenModelIds(db, dbLog),
|
|
208
|
+
getDefaultModelId: () => getDefaultModelId(db, dbLog),
|
|
209
|
+
getCustomOpenRouterModels: () => getCustomOpenRouterModels(db, dbLog),
|
|
202
210
|
getBillingOverrides: () => getBillingOverrides(db, dbLog),
|
|
203
211
|
log: observability.logger("agent"),
|
|
204
212
|
});
|
|
@@ -344,6 +352,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
344
352
|
"/api",
|
|
345
353
|
createModelRoutes({ registry, db, pricing, log: observability.logger("models") })
|
|
346
354
|
);
|
|
355
|
+
app.route("/api", createPiAuthRoutes({ agent: config.agent }));
|
|
347
356
|
app.route(
|
|
348
357
|
"/api",
|
|
349
358
|
createGraphRoutes({ brainRoot: config.brainPath, log: observability.logger("graph") })
|
package/src/config/env.ts
CHANGED
|
@@ -259,6 +259,16 @@ export const ENV_VARS: readonly EnvVarDescriptor[] = [
|
|
|
259
259
|
default: "(none)",
|
|
260
260
|
required: false,
|
|
261
261
|
},
|
|
262
|
+
{
|
|
263
|
+
name: "BRAIN_UI_PI_PROFILES",
|
|
264
|
+
description:
|
|
265
|
+
"JSON array of pi-backend model profiles ({id,label,vendor,model,thinkingLevel?}). " +
|
|
266
|
+
"When set (and AGENT_BACKEND is claude), the pi backend runs ALONGSIDE " +
|
|
267
|
+
"the Claude backend and these profiles join the picker — e.g. OpenAI " +
|
|
268
|
+
'models under a ChatGPT subscription via vendor "openai-codex".',
|
|
269
|
+
default: "(none)",
|
|
270
|
+
required: false,
|
|
271
|
+
},
|
|
262
272
|
{
|
|
263
273
|
name: "CLAUDE_CODE_OAUTH_TOKEN",
|
|
264
274
|
description:
|
|
@@ -379,6 +389,12 @@ export interface AgentConfig {
|
|
|
379
389
|
defaultModel: string;
|
|
380
390
|
/** Raw BRAIN_UI_CLAUDE_PROFILES JSON, parsed lazily by the registry. */
|
|
381
391
|
profilesJson: string | null;
|
|
392
|
+
/**
|
|
393
|
+
* Raw BRAIN_UI_PI_PROFILES JSON, parsed by the registry. When set, the pi
|
|
394
|
+
* backend runs alongside the Claude backend and these profiles join the
|
|
395
|
+
* picker.
|
|
396
|
+
*/
|
|
397
|
+
piProfilesJson: string | null;
|
|
382
398
|
modelDiscovery: boolean;
|
|
383
399
|
modelTtlMs: number;
|
|
384
400
|
/**
|
|
@@ -575,6 +591,7 @@ export function resolveServerConfig(env: EnvRecord = process.env): ServerConfig
|
|
|
575
591
|
claudeCodePath: env.CLAUDE_CODE_PATH || "/usr/local/bin/claude",
|
|
576
592
|
defaultModel: env.BRAIN_UI_CLAUDE_DEFAULT_MODEL?.trim() || "claude-sonnet-4-6",
|
|
577
593
|
profilesJson: env.BRAIN_UI_CLAUDE_PROFILES?.trim() || null,
|
|
594
|
+
piProfilesJson: env.BRAIN_UI_PI_PROFILES?.trim() || null,
|
|
578
595
|
modelDiscovery,
|
|
579
596
|
modelTtlMs: ttlHours * 60 * 60 * 1000,
|
|
580
597
|
ambientBilling: resolveAmbientBillingMode(env),
|
package/src/db/settings.ts
CHANGED
|
@@ -12,6 +12,8 @@ import { isBillingMode, type BillingMode } from "@schlessera/brain-ui-sdk/protoc
|
|
|
12
12
|
|
|
13
13
|
const HIDDEN_MODELS_KEY = "models.hidden";
|
|
14
14
|
const BILLING_OVERRIDES_KEY = "models.billing";
|
|
15
|
+
const DEFAULT_MODEL_KEY = "models.default";
|
|
16
|
+
const CUSTOM_OPENROUTER_KEY = "models.customOpenRouter";
|
|
15
17
|
const DETAIL_RETENTION_KEY = "activity.retention.detailDays";
|
|
16
18
|
const DETAIL_RETENTION_DEFAULT_DAYS = 7;
|
|
17
19
|
|
|
@@ -89,6 +91,34 @@ export function setBillingOverrides(
|
|
|
89
91
|
setSetting(db, BILLING_OVERRIDES_KEY, clean);
|
|
90
92
|
}
|
|
91
93
|
|
|
94
|
+
/**
|
|
95
|
+
* The user's chosen default model profile, used when a turn names none (new
|
|
96
|
+
* conversations from a fresh client, shares, host-initiated actions). Null =
|
|
97
|
+
* auto (a connected subscription-auth profile, else the default backend's
|
|
98
|
+
* own default).
|
|
99
|
+
*/
|
|
100
|
+
export function getDefaultModelId(db: Database, log?: Logger): string | null {
|
|
101
|
+
const value = getSetting<unknown>(db, DEFAULT_MODEL_KEY, null, log);
|
|
102
|
+
return typeof value === "string" && value ? value : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function setDefaultModelId(db: Database, id: string | null): void {
|
|
106
|
+
setSetting(db, DEFAULT_MODEL_KEY, id);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** User-managed OpenRouter model ids (e.g. "z.ai/glm-5.3-flash"). */
|
|
110
|
+
export function getCustomOpenRouterModels(db: Database, log?: Logger): string[] {
|
|
111
|
+
const value = getSetting<unknown>(db, CUSTOM_OPENROUTER_KEY, [], log);
|
|
112
|
+
if (!Array.isArray(value)) return [];
|
|
113
|
+
return value.filter((id): id is string => typeof id === "string" && id.length > 0);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Replace the custom OpenRouter list (full list, not a delta). */
|
|
117
|
+
export function setCustomOpenRouterModels(db: Database, models: string[]): void {
|
|
118
|
+
const unique = [...new Set(models.filter((id) => typeof id === "string" && id))];
|
|
119
|
+
setSetting(db, CUSTOM_OPENROUTER_KEY, unique);
|
|
120
|
+
}
|
|
121
|
+
|
|
92
122
|
/** Minimum days a finished run keeps its detail (spans/events) before the
|
|
93
123
|
* digest-covered prune may take it. 0 is valid (prune as soon as covered);
|
|
94
124
|
* anything non-numeric or negative degrades to the default. */
|