@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.
Files changed (46) hide show
  1. package/README.md +1 -0
  2. package/dist/agent/backend.d.ts +51 -0
  3. package/dist/agent/backend.d.ts.map +1 -1
  4. package/dist/agent/backend.js +222 -12
  5. package/dist/agent/backend.js.map +1 -1
  6. package/dist/app.d.ts.map +1 -1
  7. package/dist/app.js +5 -1
  8. package/dist/app.js.map +1 -1
  9. package/dist/config/env.d.ts +6 -0
  10. package/dist/config/env.d.ts.map +1 -1
  11. package/dist/config/env.js +10 -0
  12. package/dist/config/env.js.map +1 -1
  13. package/dist/db/settings.d.ts +12 -0
  14. package/dist/db/settings.d.ts.map +1 -1
  15. package/dist/db/settings.js +27 -0
  16. package/dist/db/settings.js.map +1 -1
  17. package/dist/routes/models.d.ts.map +1 -1
  18. package/dist/routes/models.js +64 -1
  19. package/dist/routes/models.js.map +1 -1
  20. package/dist/routes/pi-auth.d.ts +44 -0
  21. package/dist/routes/pi-auth.d.ts.map +1 -0
  22. package/dist/routes/pi-auth.js +100 -0
  23. package/dist/routes/pi-auth.js.map +1 -0
  24. package/dist/ws/bridge.d.ts.map +1 -1
  25. package/dist/ws/bridge.js +4 -0
  26. package/dist/ws/bridge.js.map +1 -1
  27. package/dist/ws/connection.d.ts.map +1 -1
  28. package/dist/ws/connection.js +1 -0
  29. package/dist/ws/connection.js.map +1 -1
  30. package/dist/ws/dispatch.d.ts.map +1 -1
  31. package/dist/ws/dispatch.js +1 -0
  32. package/dist/ws/dispatch.js.map +1 -1
  33. package/dist/ws/routing.d.ts.map +1 -1
  34. package/dist/ws/routing.js +9 -0
  35. package/dist/ws/routing.js.map +1 -1
  36. package/package.json +6 -4
  37. package/src/agent/backend.ts +293 -11
  38. package/src/app.ts +10 -1
  39. package/src/config/env.ts +17 -0
  40. package/src/db/settings.ts +30 -0
  41. package/src/routes/models.ts +80 -0
  42. package/src/routes/pi-auth.ts +153 -0
  43. package/src/ws/bridge.ts +4 -0
  44. package/src/ws/connection.ts +1 -0
  45. package/src/ws/dispatch.ts +1 -0
  46. package/src/ws/routing.ts +9 -0
@@ -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,CAyB7E"}
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"}
@@ -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 } : {}) };
@@ -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.21.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.21.0",
53
- "@schlessera/brain-ui-sdk": "0.21.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",
@@ -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 — profiles on any OTHER backend (pi) have no subscription path. */
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 (pi) → "api";
397
- * - a declared profile with explicit credentials → "api";
547
+ * - a non-Claude backend profileby 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) return "api";
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 extra = discovered.filter((input) => !declaredIds.has(input.id));
439
- const result = [...declared, ...claude.defineProfiles(extra)];
440
- mergeCache = { declared, discovered, result };
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: { brainPath: string; log?: BackendLogFn }) => AgentBackend;
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
- return registry.backends.flatMap((backend) =>
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 { getBillingOverrides, getHiddenModelIds } from "./db/settings.js";
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),
@@ -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. */