@schlessera/brain-ui-server 0.33.1 → 0.34.1

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.
@@ -1,3 +1,4 @@
1
+ import { BackendProfileConfigError } from "@schlessera/brain-ui-sdk/server";
1
2
  import { createRequire } from "module";
2
3
  const LEVEL_SEVERITY = {
3
4
  debug: "DEBUG",
@@ -12,18 +13,8 @@ function toBackendLog(log) {
12
13
  ...(attrs ? { attributes: attrs } : {}),
13
14
  });
14
15
  }
15
- /** Reasoning levels pi accepts (mirror of pi-agent-core's `ThinkingLevel`). */
16
- export const PI_THINKING_LEVELS = [
17
- "off",
18
- "minimal",
19
- "low",
20
- "medium",
21
- "high",
22
- "xhigh",
23
- "max",
24
- ];
25
16
  const PROFILE_MEMO_MS = 5_000;
26
- function buildSnapshot(backends, defaultBackendId) {
17
+ function buildSnapshot(backends, defaultBackendId, resolved = new Map(), modelSource = null) {
27
18
  if (backends.length === 0) {
28
19
  throw new Error("Backend registry requires at least one backend.");
29
20
  }
@@ -35,12 +26,31 @@ function buildSnapshot(backends, defaultBackendId) {
35
26
  byId.get(resolvedDefaultId),
36
27
  ...backends.filter((backend) => backend.id !== resolvedDefaultId),
37
28
  ];
38
- return { backends: ordered, byId, defaultBackendId: resolvedDefaultId };
29
+ return { backends: ordered, byId, defaultBackendId: resolvedDefaultId, resolved, modelSource };
30
+ }
31
+ // The only runtime-discovery surface: two fixed first-party packages. The
32
+ // environment never supplies a specifier or introduces another identity.
33
+ const FIRST_PARTY_BACKENDS = [
34
+ {
35
+ id: "claude",
36
+ specifier: "@schlessera/brain-backend-claude",
37
+ profiles: (agent) => agent.profilesJson,
38
+ joinsPrimary: false,
39
+ },
40
+ {
41
+ id: "pi",
42
+ specifier: "@schlessera/brain-backend-pi",
43
+ profiles: (agent) => agent.piProfilesJson,
44
+ joinsPrimary: true,
45
+ },
46
+ ];
47
+ function firstParty(id) {
48
+ return FIRST_PARTY_BACKENDS.find((entry) => entry.id === id);
49
+ }
50
+ function activeFirstPartyBackends(agent) {
51
+ const primary = agent.backend || "claude";
52
+ return FIRST_PARTY_BACKENDS.filter((entry) => entry.id === primary || (entry.joinsPrimary && Boolean(entry.profiles(agent))));
39
53
  }
40
- const BACKEND_SPECIFIERS = {
41
- claude: "@schlessera/brain-backend-claude",
42
- pi: "@schlessera/brain-backend-pi",
43
- };
44
54
  function unknownBackendError(primary) {
45
55
  return new Error(`AGENT_BACKEND="${primary}" does not match any configured backend ` +
46
56
  `(expected "claude" or "pi").`);
@@ -50,31 +60,23 @@ function missingBackendError(primary) {
50
60
  return new Error('AGENT_BACKEND=pi but "@schlessera/brain-backend-pi" is not installed. ' +
51
61
  "Add it (with its pi SDK dependencies) or set AGENT_BACKEND=claude.");
52
62
  }
53
- return new Error(`AGENT_BACKEND=${primary} but "@schlessera/brain-backend-claude" is not installed. ` +
63
+ return new Error('AGENT_BACKEND=claude but "@schlessera/brain-backend-claude" is not installed. ' +
54
64
  "Add it (it carries the Claude Agent SDK) or set AGENT_BACKEND=pi.");
55
65
  }
56
- /**
57
- * Boot-time guard: the SELECTED backend's package must at least RESOLVE, so a
58
- * broken install refuses to start instead of reporting healthy and failing
59
- * every agent turn (the guarantee the old static import gave, restored without
60
- * giving up the lazy load — `require.resolve` never executes the module, so
61
- * the Agent SDK still loads on first use only). Called by `createApp()` next
62
- * to the auth assertions; skipped when an explicit registry is injected.
63
- *
64
- * An unrecognized AGENT_BACKEND fails here too, for the same reason.
65
- *
66
- * The `resolve` parameter exists for tests (simulating an absent package);
67
- * production callers pass nothing.
68
- */
69
- /** The specifier an ERR_MODULE_NOT_FOUND failed on, or null for any other error. */
66
+ function missingConfiguredBackendError(entry) {
67
+ if (entry.id === "pi") {
68
+ return new Error('BRAIN_UI_PI_PROFILES is set but "@schlessera/brain-backend-pi" is not ' +
69
+ "installed. Add it (with its pi SDK dependencies) or unset the variable.");
70
+ }
71
+ return new Error('BRAIN_UI_CLAUDE_PROFILES is set but "@schlessera/brain-backend-claude" is not ' +
72
+ "installed. Add it (it carries the Claude Agent SDK) or unset the variable.");
73
+ }
70
74
  function moduleNotFoundSpecifier(error) {
71
75
  if (typeof error !== "object" || error === null)
72
76
  return null;
73
77
  const { code, specifier, message } = error;
74
78
  if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND")
75
79
  return null;
76
- // Bun's ResolveMessage (not an Error instance) carries the failing
77
- // specifier as a property; Node quotes it in the message instead.
78
80
  if (typeof specifier === "string")
79
81
  return specifier;
80
82
  if (typeof message === "string") {
@@ -84,441 +86,192 @@ function moduleNotFoundSpecifier(error) {
84
86
  }
85
87
  return null;
86
88
  }
87
- /**
88
- * Load an optional backend package, mapping ONLY "the backend package itself
89
- * is not installed" to the actionable install hint. Everything else — the
90
- * backend missing one of its OWN transitive deps, a syntax error, a throwing
91
- * top-level — is a different failure whose original error IS the diagnostic,
92
- * so it is rethrown untouched. The distinction rides on ERR_MODULE_NOT_FOUND
93
- * naming the specifier it failed on: a transitive miss names the transitive
94
- * dep, not the backend, and must not read as "backend not installed".
95
- *
96
- * `await import()` rather than `createRequire()(...)`: the backend packages
97
- * are ESM, and a CJS require of them under plain Node dies with
98
- * ERR_REQUIRE_ESM — which the old blanket catch then reported as "not
99
- * installed" on a machine where the package was sitting right there.
100
- *
101
- * The `importer` parameter exists for tests (simulating absent or broken
102
- * packages); production callers pass nothing.
103
- */
104
- export async function loadBackendModule(
105
- // "claude" | "pi" spelled out, NOT keyof typeof BACKEND_SPECIFIERS: the
106
- // keyof form drags the table's literal string types — the backend
107
- // specifiers — into the emitted .d.ts, which the declaration-surface gate
108
- // rightly refuses.
109
- key, importer = (specifier) => import(specifier)) {
110
- const specifier = BACKEND_SPECIFIERS[key];
89
+ export async function loadBackendModule(key, importer = (specifier) => import(specifier)) {
90
+ const entry = firstParty(key);
111
91
  try {
112
- return await importer(specifier);
92
+ return await importer(entry.specifier);
113
93
  }
114
94
  catch (error) {
115
- if (moduleNotFoundSpecifier(error) === specifier)
95
+ if (moduleNotFoundSpecifier(error) === entry.specifier) {
116
96
  throw missingBackendError(key);
97
+ }
117
98
  throw error;
118
99
  }
119
100
  }
120
- /**
121
- * Parse + validate the pi roster from BRAIN_UI_PI_PROFILES (a JSON array of
122
- * {id,label,vendor,model,thinkingLevel?}). Malformed input THROWS rather than
123
- * silently dropping the roster; `assertBackendResolvable` runs this at boot so
124
- * a bad config refuses to start instead of reporting healthy and 500ing later.
125
- *
126
- * Ids Claude's side of the picker uses or can mint later ("default",
127
- * "claude", "claude-*" — discovery canonicalizes every Anthropic model to a
128
- * claude-* alias) are rejected, as is any collision with an id declared in
129
- * BRAIN_UI_CLAUDE_PROFILES a cross-backend collision otherwise surfaces as
130
- * a 500 on first request. Claude's own JSON is parsed leniently here: when it
131
- * is malformed, the Claude loader raises its own (more precise) boot error.
132
- */
133
- export function parsePiProfiles(raw, claudeProfilesRaw) {
134
- if (!raw)
135
- return [];
136
- let inputs;
137
- try {
138
- inputs = JSON.parse(raw);
139
- }
140
- catch (err) {
141
- throw new Error(`BRAIN_UI_PI_PROFILES is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
142
- }
143
- if (!Array.isArray(inputs)) {
144
- throw new Error("BRAIN_UI_PI_PROFILES must be a JSON array.");
145
- }
146
- const claudeIds = new Set();
147
- if (claudeProfilesRaw) {
148
- try {
149
- const claudeInputs = JSON.parse(claudeProfilesRaw);
150
- if (Array.isArray(claudeInputs)) {
151
- for (const entry of claudeInputs) {
152
- if (entry && typeof entry.id === "string")
153
- claudeIds.add(entry.id);
154
- }
155
- }
156
- }
157
- catch {
158
- // Malformed Claude JSON is the Claude loader's error to raise.
159
- }
101
+ /** Load and structurally validate the one descriptor exported by a backend package. */
102
+ export async function loadBackendDescriptor(key, importer) {
103
+ return backendDescriptorFromModule(key, await loadBackendModule(key, importer));
104
+ }
105
+ function backendDescriptorFromModule(key, loaded) {
106
+ const backendPackage = loaded;
107
+ const descriptor = backendPackage.backendModule;
108
+ if (!descriptor ||
109
+ descriptor.id !== key ||
110
+ typeof descriptor.resolveFromEnv !== "function" ||
111
+ typeof descriptor.profileSchema?.parse !== "function" ||
112
+ !descriptor.settingsHooks) {
113
+ throw new Error(`The ${key} backend package does not export a valid backendModule descriptor.`);
160
114
  }
161
- const seen = new Set();
162
- for (const input of inputs) {
163
- if (!input || typeof input !== "object") {
164
- throw new Error("Each BRAIN_UI_PI_PROFILES entry must be an object.");
165
- }
166
- for (const field of ["id", "label", "vendor", "model"]) {
167
- if (typeof input[field] !== "string" || input[field].length === 0) {
168
- throw new Error(`Each BRAIN_UI_PI_PROFILES entry needs a non-empty string ${field}.`);
169
- }
170
- }
171
- if (input.id === "default" || /^claude(-|$)/.test(input.id)) {
172
- throw new Error(`BRAIN_UI_PI_PROFILES id "${input.id}" is reserved for the Claude ` +
173
- "roster (built-in default and discovered claude-* aliases).");
174
- }
175
- if (claudeIds.has(input.id)) {
176
- throw new Error(`BRAIN_UI_PI_PROFILES id "${input.id}" collides with a ` +
177
- "BRAIN_UI_CLAUDE_PROFILES entry.");
178
- }
179
- if (seen.has(input.id)) {
180
- throw new Error(`Duplicate profile id in BRAIN_UI_PI_PROFILES: "${input.id}".`);
181
- }
182
- seen.add(input.id);
183
- if (input.thinkingLevel !== undefined &&
184
- !PI_THINKING_LEVELS.includes(input.thinkingLevel)) {
185
- throw new Error(`BRAIN_UI_PI_PROFILES entry "${input.id}" has invalid thinkingLevel ` +
186
- `"${input.thinkingLevel}" (expected one of ${PI_THINKING_LEVELS.join(", ")}).`);
115
+ return descriptor;
116
+ }
117
+ function parseBackendDescriptors(agent, entries, descriptors) {
118
+ const occupiedProfiles = [];
119
+ const activeIds = new Set(entries.map((entry) => entry.id));
120
+ const inactiveRosters = FIRST_PARTY_BACKENDS.filter((entry) => !activeIds.has(entry.id)).map((entry) => ({ backendId: entry.id, raw: entry.profiles(agent) }));
121
+ const parsed = new Map();
122
+ for (const entry of entries) {
123
+ const descriptor = descriptors.get(entry.id);
124
+ if (!descriptor) {
125
+ throw new Error(`Backend descriptor "${entry.id}" was not loaded.`);
126
+ }
127
+ const result = descriptor.profileSchema.parse(entry.profiles(agent), {
128
+ occupiedProfiles,
129
+ inactiveRosters,
130
+ });
131
+ if (!result.ok)
132
+ throw new BackendProfileConfigError(result.errors);
133
+ parsed.set(entry.id, { descriptor, profiles: result.profiles });
134
+ for (const profile of result.profiles) {
135
+ occupiedProfiles.push({ id: profile.id, source: descriptor.profileSchema.source });
187
136
  }
188
137
  }
189
- return inputs;
138
+ return parsed;
190
139
  }
140
+ /**
141
+ * Boot guard. It resolves active optional backend packages and synchronously
142
+ * loads their descriptors so backend-owned profile validation finishes before
143
+ * the application can report healthy. Inactive rosters are supplied as raw
144
+ * collision data without loading or strictly parsing their packages. Backend
145
+ * construction and model discovery remain lazy in the registry.
146
+ */
191
147
  export function assertBackendResolvable(agent, resolve = (specifier) => {
192
148
  createRequire(import.meta.url).resolve(specifier);
149
+ }, load = (specifier) => {
150
+ return createRequire(import.meta.url)(specifier);
193
151
  }) {
194
152
  const primary = agent.backend || "claude";
195
- if (!(primary in BACKEND_SPECIFIERS))
153
+ const primaryEntry = firstParty(primary);
154
+ if (!primaryEntry)
196
155
  throw unknownBackendError(primary);
197
- const key = primary;
198
- try {
199
- resolve(BACKEND_SPECIFIERS[key]);
200
- }
201
- catch {
202
- throw missingBackendError(key);
203
- }
204
- // BRAIN_UI_PI_PROFILES opts the pi backend in ALONGSIDE the primary — its
205
- // package must resolve at boot too, or the roster silently loses those
206
- // profiles on first request instead of refusing to start.
207
- if (agent.piProfilesJson && key !== "pi") {
156
+ const entries = activeFirstPartyBackends(agent);
157
+ const descriptors = new Map();
158
+ for (const entry of entries) {
208
159
  try {
209
- resolve(BACKEND_SPECIFIERS.pi);
160
+ resolve(entry.specifier);
210
161
  }
211
162
  catch {
212
- throw new Error('BRAIN_UI_PI_PROFILES is set but "@schlessera/brain-backend-pi" is not ' +
213
- "installed. Add it (with its pi SDK dependencies) or unset the variable.");
163
+ if (entry.id === primary)
164
+ throw missingBackendError(entry.id);
165
+ throw missingConfiguredBackendError(entry);
214
166
  }
215
- }
216
- // Validate the pi roster itself at boot too — the registry is built lazily,
217
- // so without this a malformed BRAIN_UI_PI_PROFILES would still report a
218
- // healthy startup and only fail on first request.
219
- parsePiProfiles(agent.piProfilesJson, agent.profilesJson);
220
- }
221
- export function createBackendRegistry(options) {
222
- const { brainPath, agent } = options;
223
- const getHidden = options.getHiddenModelIds ?? (() => []);
224
- const backendLog = options.log ? toBackendLog(options.log) : undefined;
225
- let cachedRegistry = null;
226
- let modelSource = null;
227
- /**
228
- * Declared profiles carrying their OWN credential env vars
229
- * (authTokenEnv/apiKeyEnv) — api-billed regardless of the ambient
230
- * credential. Populated when the Claude roster resolves, which happens
231
- * before any profile can be listed or run.
232
- */
233
- const declaredApiProfileIds = new Set();
234
- /** The Claude backend's id, once built — Claude's subscription path only applies to its own profiles. */
235
- let claudeBackendId = null;
236
- /**
237
- * Base billing classification for one roster entry, BEFORE the settings
238
- * override (applied last by the shared accessor surface):
239
- * - a non-Claude backend profile → by VENDOR: pi's "openai-codex" runs
240
- * only against a ChatGPT-subscription OAuth credential (the provider
241
- * has no API-key path at all) → "subscription"; every other vendor
242
- * resolves ambient API keys → "api";
243
- * - a declared Claude profile with explicit credentials → "api";
244
- * - everything ambient (built-in default, discovered models, declared
245
- * entries without their own credentials) → the env-resolved ambient
246
- * mode (subscription iff the OAuth token is present and no
247
- * ANTHROPIC_API_KEY — the Agent SDK's own precedence).
248
- */
249
- function classifyBilling(profile) {
250
- if (claudeBackendId === null || profile.backendId !== claudeBackendId) {
251
- return profile.vendor === "openai-codex" ? "subscription" : "api";
252
- }
253
- if (declaredApiProfileIds.has(profile.id))
254
- return "api";
255
- return agent.ambientBilling;
256
- }
257
- /**
258
- * Declared profiles (built-in default + BRAIN_UI_CLAUDE_PROFILES) plus every
259
- * discovered model whose id isn't already declared. Declared wins: the env
260
- * stays an override mechanism, and a hand-pinned entry keeps its label and
261
- * endpoint.
262
- *
263
- * Memoized on the discovered array's identity — `createModelSource` swaps the
264
- * array only on a successful refresh, so steady state is one comparison.
265
- */
266
- let mergeCache = null;
267
- /**
268
- * User-managed OpenRouter models (Settings → Models) as declared Claude
269
- * profiles — same shape a BRAIN_UI_CLAUDE_PROFILES OpenRouter entry has,
270
- * but editable at runtime without an env change or redeploy.
271
- */
272
- function customOpenRouterInputs() {
273
- const models = options.getCustomOpenRouterModels?.() ?? [];
274
- return models.map((model) => ({
275
- id: `openrouter:${model}`,
276
- label: `${model} (OpenRouter)`,
277
- vendor: "openrouter",
278
- model,
279
- baseUrl: "https://openrouter.ai/api",
280
- authTokenEnv: "OPENROUTER_API_KEY",
281
- modelAliases: true,
282
- source: "declared",
283
- }));
284
- }
285
- function mergeDiscovered(claude, declared, discovered) {
286
- const custom = customOpenRouterInputs();
287
- const customKey = custom.map((profile) => profile.id).join("\n");
288
- if (mergeCache &&
289
- mergeCache.declared === declared &&
290
- mergeCache.discovered === discovered &&
291
- mergeCache.customKey === customKey) {
292
- return mergeCache.result;
293
- }
294
- const declaredIds = new Set(declared.map((profile) => profile.id));
295
- const customExtra = custom.filter((input) => !declaredIds.has(input.id));
296
- // OpenRouter runs on its own key: always api-billed, like an env-declared
297
- // profile with explicit credentials.
298
- for (const input of customExtra)
299
- declaredApiProfileIds.add(input.id);
300
- const knownIds = new Set([...declaredIds, ...customExtra.map((input) => input.id)]);
301
- const extra = discovered.filter((input) => !knownIds.has(input.id));
302
- const result = [...declared, ...claude.defineProfiles([...customExtra, ...extra])];
303
- mergeCache = { declared, discovered, customKey, result };
304
- return result;
305
- }
306
- // The built-in default profile's model. The Claude backend historically
307
- // pinned the default to a specific model; after the SDK extraction the pin
308
- // was lost and resumed default-profile sessions drifted to "whatever the CLI
309
- // defaults to", silently changing model/behaviour/billing on every CLI bump.
310
- // Re-pin it (a committed default, overridable per deploy via
311
- // BRAIN_UI_CLAUDE_DEFAULT_MODEL) so the default stays on a known model.
312
- function builtinDefaultProfiles(claude) {
313
- return claude.defineProfiles([
314
- {
315
- id: "claude",
316
- label: "Claude",
317
- vendor: "anthropic",
318
- model: agent.defaultModel,
319
- modelAliases: true,
320
- source: "builtin",
321
- },
322
- ]);
323
- }
324
- /**
325
- * The Claude roster: the pinned built-in "claude" default first, plus any
326
- * extra profiles from BRAIN_UI_CLAUDE_PROFILES (a JSON array of profile
327
- * inputs: {id,label,model?,baseUrl?,authTokenEnv?,apiKeyEnv?,modelAliases?}).
328
- *
329
- * A malformed or duplicate-id roster THROWS — caught at boot by the registry
330
- * fail-fast — rather than silently degrading to default-only and rebilling
331
- * every pinned session to the subscription with nothing in the logs.
332
- */
333
- function loadClaudeProfiles(claude) {
334
- const base = builtinDefaultProfiles(claude);
335
- const raw = agent.profilesJson;
336
- if (!raw)
337
- return base;
338
- let inputs;
339
167
  try {
340
- inputs = JSON.parse(raw);
341
- }
342
- catch (err) {
343
- throw new Error(`BRAIN_UI_CLAUDE_PROFILES is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
344
- }
345
- if (!Array.isArray(inputs)) {
346
- throw new Error("BRAIN_UI_CLAUDE_PROFILES must be a JSON array.");
168
+ descriptors.set(entry.id, backendDescriptorFromModule(entry.id, load(entry.specifier)));
347
169
  }
348
- // Reject duplicate ids (including collisions with the built-in "claude"): a
349
- // duplicate silently shadows and can resolve to the wrong credentials.
350
- const seen = new Set(base.map((profile) => profile.id));
351
- for (const input of inputs) {
352
- if (!input || typeof input.id !== "string" || input.id.length === 0) {
353
- throw new Error("Each BRAIN_UI_CLAUDE_PROFILES entry needs a non-empty string id.");
170
+ catch (error) {
171
+ if (entry.id === primary && moduleNotFoundSpecifier(error) === entry.specifier) {
172
+ throw missingBackendError(entry.id);
354
173
  }
355
- if (seen.has(input.id)) {
356
- throw new Error(`Duplicate profile id in BRAIN_UI_CLAUDE_PROFILES: "${input.id}".`);
174
+ if (entry.id !== primary && moduleNotFoundSpecifier(error) === entry.specifier) {
175
+ throw missingConfiguredBackendError(entry);
357
176
  }
358
- seen.add(input.id);
359
- // The same non-empty test defineProfiles applies to requiredEnvKeys: a
360
- // profile bringing its own credential env var is api-billed.
361
- if (input.authTokenEnv || input.apiKeyEnv)
362
- declaredApiProfileIds.add(input.id);
177
+ throw error;
363
178
  }
364
- const declared = inputs.map((input) => ({
365
- source: "declared",
366
- ...input,
367
- }));
368
- return [...base, ...claude.defineProfiles(declared)];
369
179
  }
370
- /**
371
- * NEITHER backend package is a hard dependency — a deployment installs the
372
- * one its AGENT_BACKEND names (both, if it switches). Loaded lazily through
373
- * {@link loadBackendModule} so the server still boots without the unused
374
- * one; only "the package is absent" maps to the actionable install hint,
375
- * any other load failure surfaces as itself.
376
- */
377
- async function buildClaudeBackend() {
378
- const claude = (await loadBackendModule("claude"));
379
- if (typeof claude.createClaudeBackend !== "function") {
380
- throw new Error('"@schlessera/brain-backend-claude" does not export createClaudeBackend.');
381
- }
382
- // Declared profiles are resolved EAGERLY so a malformed
383
- // BRAIN_UI_CLAUDE_PROFILES still fails at boot rather than on first request.
384
- const declared = loadClaudeProfiles(claude);
385
- modelSource = claude.createModelSource({
386
- brainPath,
387
- enabled: agent.modelDiscovery,
388
- ttlMs: agent.modelTtlMs,
389
- });
390
- const backend = claude.createClaudeBackend({
391
- brainPath,
392
- claudeCodePath: agent.claudeCodePath,
393
- ...(backendLog ? { log: backendLog } : {}),
394
- // Omitted entirely when unconfigured, so the backend's own defaults
395
- // apply; an explicit [] passes through and disables confirmation.
396
- ...(agent.confirmBashPatterns !== null
397
- ? { confirmBashPatterns: agent.confirmBashPatterns }
398
- : {}),
399
- // A function, not an array: discovery refreshes in the background and the
400
- // new roster has to be visible without restarting the process.
401
- profiles: () => mergeDiscovered(claude, declared, modelSource?.list() ?? []),
402
- });
403
- claudeBackendId = backend.id;
404
- return backend;
405
- }
406
- async function buildPiBackend() {
407
- const mod = (await loadBackendModule("pi"));
408
- if (typeof mod.createPiBackend !== "function") {
409
- throw new Error('"@schlessera/brain-backend-pi" does not export createPiBackend.');
410
- }
411
- // Re-parsed here (assertBackendResolvable already validated at boot) so an
412
- // injected-registry path without the boot assert still fails loudly.
413
- const profiles = parsePiProfiles(agent.piProfilesJson, agent.profilesJson);
414
- // A FUNCTION, so per-profile thinking overrides from settings are read on
415
- // every use (roster listing AND new-session model resolution) — a change
416
- // in Settings applies to the next turn without a rebuild.
417
- const withOverrides = () => {
418
- const overrides = options.getThinkingOverrides?.() ?? {};
419
- return profiles.map((profile) => overrides[profile.id]
420
- ? { ...profile, thinkingLevel: overrides[profile.id] }
421
- : profile);
422
- };
423
- return mod.createPiBackend({
424
- brainPath,
425
- ...(profiles.length > 0 ? { profiles: withOverrides } : {}),
426
- ...(backendLog ? { log: backendLog } : {}),
427
- // Same shared confirm-pattern config as the Claude backend, so both
428
- // backends stop on the same destructive bash shapes.
429
- ...(agent.confirmBashPatterns !== null
430
- ? { confirmBashPatterns: agent.confirmBashPatterns }
431
- : {}),
432
- });
180
+ parseBackendDescriptors(agent, entries, descriptors);
181
+ }
182
+ function settingsFor(hooks, readers) {
183
+ return {
184
+ ...(hooks.hiddenModelIds ? { getHiddenModelIds: readers.getHiddenModelIds } : {}),
185
+ ...(hooks.defaultModelId ? { getDefaultModelId: readers.getDefaultModelId } : {}),
186
+ ...(hooks.customOpenRouterModels
187
+ ? { getCustomOpenRouterModels: readers.getCustomOpenRouterModels }
188
+ : {}),
189
+ ...(hooks.thinkingOverrides
190
+ ? { getThinkingOverrides: readers.getThinkingOverrides }
191
+ : {}),
192
+ ...(hooks.billingOverrides
193
+ ? { getBillingOverrides: readers.getBillingOverrides }
194
+ : {}),
195
+ };
196
+ }
197
+ export function createBackendRegistry(options) {
198
+ const { brainPath, agent } = options;
199
+ const primary = agent.backend || "claude";
200
+ if (!firstParty(primary)) {
201
+ return makeRegistry(async () => {
202
+ throw unknownBackendError(primary);
203
+ }, options);
433
204
  }
205
+ const readers = {
206
+ getHiddenModelIds: options.getHiddenModelIds ?? (() => []),
207
+ getDefaultModelId: options.getDefaultModelId ?? (() => null),
208
+ getCustomOpenRouterModels: options.getCustomOpenRouterModels ?? (() => []),
209
+ getThinkingOverrides: options.getThinkingOverrides ?? (() => ({})),
210
+ getBillingOverrides: options.getBillingOverrides ?? (() => ({})),
211
+ };
212
+ const backendLog = options.log ? toBackendLog(options.log) : undefined;
213
+ let cachedRegistry = null;
434
214
  async function buildRegistry() {
435
- const primary = agent.backend || "claude";
436
- if (primary === "pi") {
437
- const pi = await buildPiBackend();
438
- return buildSnapshot([pi], pi.id);
439
- }
440
- const backends = [await buildClaudeBackend()];
441
- // BRAIN_UI_PI_PROFILES opts the pi backend in ALONGSIDE claude: its
442
- // profiles join the picker (e.g. OpenAI models under a ChatGPT
443
- // subscription via pi's "openai-codex" vendor) while claude stays the
444
- // default backend. Without the variable, behavior is unchanged.
445
- if (agent.piProfilesJson) {
446
- backends.push(await buildPiBackend());
447
- }
448
- // AGENT_BACKEND must name a configured backend. The in-process options are
449
- // "claude" (default) and "pi" (handled above). An unrecognized value is a
450
- // misconfiguration — fail loudly instead of silently coercing to claude and
451
- // running on the wrong backend with nothing in the logs.
452
- if (agent.backend && !backends.some((b) => b.id === primary)) {
453
- throw unknownBackendError(primary);
215
+ const entries = activeFirstPartyBackends(agent);
216
+ const descriptors = new Map();
217
+ for (const entry of entries) {
218
+ descriptors.set(entry.id, await loadBackendDescriptor(entry.id));
219
+ }
220
+ const parsedDescriptors = parseBackendDescriptors(agent, entries, descriptors);
221
+ const resolved = new Map();
222
+ const backends = [];
223
+ let modelSource = null;
224
+ for (const entry of entries) {
225
+ const parsed = parsedDescriptors.get(entry.id);
226
+ const { descriptor } = parsed;
227
+ const baseContext = {
228
+ brainPath,
229
+ config: { ...agent },
230
+ profiles: parsed.profiles,
231
+ confirmBashPatterns: agent.confirmBashPatterns,
232
+ settings: settingsFor(descriptor.settingsHooks, readers),
233
+ ...(backendLog ? { log: backendLog } : {}),
234
+ };
235
+ const source = descriptor.modelSource?.(baseContext) ?? null;
236
+ const resolution = await descriptor.resolveFromEnv({
237
+ ...baseContext,
238
+ ...(source ? { modelSource: source } : {}),
239
+ });
240
+ if (!resolution.ok)
241
+ throw resolution.error;
242
+ if (resolution.value.backend.id !== descriptor.id) {
243
+ throw new Error(`Backend descriptor "${descriptor.id}" built backend "${resolution.value.backend.id}".`);
244
+ }
245
+ resolved.set(descriptor.id, resolution.value);
246
+ backends.push(resolution.value.backend);
247
+ modelSource ??= source;
454
248
  }
455
- return buildSnapshot(backends, primary);
249
+ return buildSnapshot(backends, primary, resolved, modelSource);
456
250
  }
457
- async function getRegistry() {
251
+ const getRegistry = () => {
458
252
  if (!cachedRegistry)
459
253
  cachedRegistry = buildRegistry();
460
254
  return cachedRegistry;
461
- }
462
- /**
463
- * Whether the openai-codex (ChatGPT subscription) account is connected —
464
- * a cheap file probe through the pi package, memoized briefly so provider
465
- * listings and turn routing don't re-read the auth store on every call.
466
- */
467
- let codexCredentialCache = null;
468
- async function hasCodexCredential() {
469
- const piInPlay = Boolean(agent.piProfilesJson) || (agent.backend || "claude") === "pi";
470
- if (!piInPlay)
471
- return false;
472
- if (codexCredentialCache && Date.now() - codexCredentialCache.at < PROFILE_MEMO_MS) {
473
- return codexCredentialCache.value;
474
- }
475
- let value = false;
476
- try {
477
- const mod = (await loadBackendModule("pi"));
478
- value =
479
- typeof mod.hasStoredCredential === "function" &&
480
- mod.hasStoredCredential("openai-codex");
481
- }
482
- catch {
483
- value = false;
484
- }
485
- codexCredentialCache = { at: Date.now(), value };
486
- return value;
487
- }
488
- return makeRegistry(getRegistry, getHidden, async () => {
489
- await getRegistry();
490
- return modelSource;
491
- }, options.log, {
492
- classify: classifyBilling,
493
- ...(options.getBillingOverrides
494
- ? { getOverrides: options.getBillingOverrides }
495
- : {}),
496
- }, {
497
- ...(options.getDefaultModelId ? { getOverride: options.getDefaultModelId } : {}),
498
- auto: { vendor: "openai-codex", hasCredential: hasCodexCredential },
499
- });
255
+ };
256
+ return makeRegistry(getRegistry, options);
500
257
  }
501
- /**
502
- * A registry over an explicit backend list — the seam tests (and embedders
503
- * with their own backend wiring) use instead of mutating module state. Takes
504
- * the same optional hidden-ids reader so visibility behavior matches
505
- * production.
506
- */
507
- export function createStaticBackendRegistry(backends, defaultBackendId = backends[0]?.id ?? "", options = {}) {
508
- const snapshot = buildSnapshot(backends, defaultBackendId);
509
- return makeRegistry(async () => snapshot, options.getHiddenModelIds ?? (() => []), async () => null, options.log,
510
- // No classifier: an embedder's backends carry no credential topology this
511
- // registry could reason about, so only an explicit override sets a mode.
512
- options.getBillingOverrides
513
- ? { getOverrides: options.getBillingOverrides }
514
- : {},
515
- // No auto rule either — only the stored default applies here.
516
- options.getDefaultModelId ? { getOverride: options.getDefaultModelId } : {});
258
+ export function createStaticBackendRegistry(entries, defaultBackendId = entries[0]
259
+ ? "backend" in entries[0]
260
+ ? entries[0].backend.id
261
+ : entries[0].id
262
+ : "", options = {}) {
263
+ const resolved = new Map();
264
+ const backends = entries.map((entry) => {
265
+ if (!("backend" in entry))
266
+ return entry;
267
+ resolved.set(entry.backend.id, entry);
268
+ return entry.backend;
269
+ });
270
+ const snapshot = buildSnapshot(backends, defaultBackendId, resolved, options.modelSource ?? null);
271
+ return makeRegistry(async () => snapshot, options);
517
272
  }
518
- /** The accessor surface, shared by the config-driven and static registries. */
519
- function makeRegistry(getRegistry, getHidden, getModelSource, log, billing = {}, defaults = {}) {
273
+ function makeRegistry(getRegistry, options) {
520
274
  let profileSnapshot = null;
521
- /** Recompute the profile roster (memoized), asserting cross-backend id uniqueness. */
522
275
  async function getProfileSnapshot() {
523
276
  if (profileSnapshot && Date.now() - profileSnapshot.at < PROFILE_MEMO_MS) {
524
277
  return profileSnapshot;
@@ -540,57 +293,47 @@ function makeRegistry(getRegistry, getHidden, getModelSource, log, billing = {},
540
293
  profileSnapshot = { at: Date.now(), byBackend, owners };
541
294
  return profileSnapshot;
542
295
  }
543
- /** A settings read failure must not take the picker down — degrade to "nothing hidden". */
544
296
  function hiddenIds() {
545
297
  try {
546
- return new Set(getHidden());
298
+ return new Set(options.getHiddenModelIds?.() ?? []);
547
299
  }
548
- catch (err) {
549
- log?.emit({
300
+ catch (error) {
301
+ options.log?.emit({
550
302
  severityText: "WARN",
551
303
  body: "could not read hidden models; treating none as hidden",
552
- attributes: { error: err instanceof Error ? err.message : String(err) },
304
+ attributes: { error: error instanceof Error ? error.message : String(error) },
553
305
  });
554
306
  return new Set();
555
307
  }
556
308
  }
557
- /** Same degradation discipline: a failed override read means derived modes apply. */
558
309
  function billingOverrides() {
559
310
  try {
560
- return billing.getOverrides?.() ?? {};
311
+ return options.getBillingOverrides?.() ?? {};
561
312
  }
562
- catch (err) {
563
- log?.emit({
313
+ catch (error) {
314
+ options.log?.emit({
564
315
  severityText: "WARN",
565
316
  body: "could not read billing overrides; using derived billing modes",
566
- attributes: { error: err instanceof Error ? err.message : String(err) },
317
+ attributes: { error: error instanceof Error ? error.message : String(error) },
567
318
  });
568
319
  return {};
569
320
  }
570
321
  }
571
- /**
572
- * The preferred-default profile. The stored Settings override wins while it
573
- * still names a roster profile (a vanished profile falls through to auto
574
- * rather than pinning turns to nothing); auto is the first non-hidden
575
- * roster entry of the auto vendor, but only while its credential exists.
576
- * Any failure degrades to "no preference" — this must never take routing
577
- * down.
578
- */
579
322
  async function getPreferredProfileId() {
580
323
  try {
581
324
  const snapshot = await getProfileSnapshot();
582
- const override = defaults.getOverride?.() ?? null;
325
+ const override = options.getDefaultModelId?.() ?? null;
583
326
  if (override && snapshot.owners.has(override))
584
327
  return override;
585
- const auto = defaults.auto;
586
- if (!auto)
587
- return null;
588
328
  const registry = await getRegistry();
589
329
  const hidden = hiddenIds();
590
330
  for (const backend of registry.backends) {
591
- const match = (snapshot.byBackend.get(backend.id) ?? []).find((profile) => profile.vendor === auto.vendor && !hidden.has(profile.id));
331
+ const preference = registry.resolved.get(backend.id)?.preferredProfile;
332
+ if (!preference)
333
+ continue;
334
+ const match = (snapshot.byBackend.get(backend.id) ?? []).find((profile) => preference.matches(profile) && !hidden.has(profile.id));
592
335
  if (match)
593
- return (await auto.hasCredential()) ? match.id : null;
336
+ return (await preference.hasCredential()) ? match.id : null;
594
337
  }
595
338
  return null;
596
339
  }
@@ -614,37 +357,33 @@ function makeRegistry(getRegistry, getHidden, getModelSource, log, billing = {},
614
357
  },
615
358
  async getBackendForProfile(profileId) {
616
359
  const registry = await getRegistry();
617
- // Deliberately resolved against the UNFILTERED roster: a session pinned
618
- // to a profile the user later hid must keep running.
619
360
  const snapshot = await getProfileSnapshot();
620
361
  const backendId = snapshot.owners.get(profileId);
621
362
  return backendId ? registry.byId.get(backendId) : undefined;
622
363
  },
623
364
  async getBackendForSession(backendId) {
624
365
  const registry = await getRegistry();
625
- if (backendId) {
626
- const backend = registry.byId.get(backendId);
627
- if (backend)
628
- return backend;
366
+ if (!backendId)
367
+ return registry.byId.get(registry.defaultBackendId);
368
+ const backend = registry.byId.get(backendId);
369
+ if (!backend) {
370
+ throw new Error(`Stored backend id "${backendId}" is not configured.`);
629
371
  }
630
- return registry.byId.get(registry.defaultBackendId);
372
+ return backend;
631
373
  },
632
- async listAllProviders(options = {}) {
374
+ async listAllProviders(listOptions = {}) {
633
375
  const registry = await getRegistry();
634
376
  const snapshot = await getProfileSnapshot();
635
- const hidden = options.includeHidden ? new Set() : hiddenIds();
636
- // Read fresh on every listing (not memoized with the snapshot), so a
637
- // saved override is live for the very next run without invalidation.
377
+ const hidden = listOptions.includeHidden ? new Set() : hiddenIds();
638
378
  const overrides = billingOverrides();
639
379
  const providers = registry.backends.flatMap((backend) => (snapshot.byBackend.get(backend.id) ?? [])
640
380
  .filter((profile) => !hidden.has(profile.id))
641
381
  .map((profile) => {
642
382
  const entry = { ...profile, backendId: backend.id };
643
- const billingMode = overrides[profile.id] ?? billing.classify?.(entry);
383
+ const billingMode = overrides[profile.id] ??
384
+ registry.resolved.get(backend.id)?.classifyBilling?.(profile);
644
385
  return billingMode ? { ...entry, billingMode } : entry;
645
386
  }));
646
- // A connected subscription-auth profile leads the list: a fresh client
647
- // with no stored selection defaults to providers[0].
648
387
  const preferredId = await getPreferredProfileId();
649
388
  if (preferredId) {
650
389
  const index = providers.findIndex((provider) => provider.id === preferredId);
@@ -661,7 +400,9 @@ function makeRegistry(getRegistry, getHidden, getModelSource, log, billing = {},
661
400
  { id: backend.id, capabilities: backend.capabilities },
662
401
  ]));
663
402
  },
664
- getModelSource,
403
+ async getModelSource() {
404
+ return (await getRegistry()).modelSource;
405
+ },
665
406
  invalidateProfiles() {
666
407
  profileSnapshot = null;
667
408
  },