@schlessera/brain-ui-server 0.33.0 → 0.34.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 (44) hide show
  1. package/README.md +12 -0
  2. package/dist/agent/backend.d.ts +17 -219
  3. package/dist/agent/backend.d.ts.map +1 -1
  4. package/dist/agent/backend.js +212 -471
  5. package/dist/agent/backend.js.map +1 -1
  6. package/dist/app.d.ts.map +1 -1
  7. package/dist/app.js +2 -1
  8. package/dist/app.js.map +1 -1
  9. package/dist/bin/brain-ui-cron.d.ts +1 -1
  10. package/dist/bin/brain-ui-cron.d.ts.map +1 -1
  11. package/dist/bin/brain-ui-cron.js +24 -7
  12. package/dist/bin/brain-ui-cron.js.map +1 -1
  13. package/dist/brain/client.js +2 -2
  14. package/dist/brain/client.js.map +1 -1
  15. package/dist/config/env.d.ts +18 -15
  16. package/dist/config/env.d.ts.map +1 -1
  17. package/dist/config/env.js +33 -13
  18. package/dist/config/env.js.map +1 -1
  19. package/dist/cron/emit.d.ts +5 -3
  20. package/dist/cron/emit.d.ts.map +1 -1
  21. package/dist/cron/emit.js +31 -12
  22. package/dist/cron/emit.js.map +1 -1
  23. package/dist/middleware/origin.d.ts.map +1 -1
  24. package/dist/middleware/origin.js +22 -1
  25. package/dist/middleware/origin.js.map +1 -1
  26. package/dist/routes/brain.js +2 -2
  27. package/dist/routes/brain.js.map +1 -1
  28. package/dist/routes/pi-auth.d.ts.map +1 -1
  29. package/dist/routes/pi-auth.js +14 -6
  30. package/dist/routes/pi-auth.js.map +1 -1
  31. package/dist/routes/web-search.d.ts.map +1 -1
  32. package/dist/routes/web-search.js +12 -8
  33. package/dist/routes/web-search.js.map +1 -1
  34. package/package.json +3 -3
  35. package/src/agent/backend.ts +281 -793
  36. package/src/app.ts +2 -1
  37. package/src/bin/brain-ui-cron.ts +32 -6
  38. package/src/brain/client.ts +2 -2
  39. package/src/config/env.ts +69 -17
  40. package/src/cron/emit.ts +42 -10
  41. package/src/middleware/origin.ts +21 -1
  42. package/src/routes/brain.ts +2 -2
  43. package/src/routes/pi-auth.ts +13 -8
  44. package/src/routes/web-search.ts +12 -10
@@ -1,63 +1,25 @@
1
1
  import type { Logger } from "@opentelemetry/api-logs";
2
- import { createRequire } from "module";
3
- import type { AgentConfig } from "../config/env.js";
4
2
  import type { BillingMode, ProviderInfo } from "@schlessera/brain-ui-sdk";
5
3
  import type {
6
4
  AgentBackend,
7
5
  BackendCapabilities,
6
+ BackendLogFn,
7
+ BackendModelSource,
8
+ BackendModelSourceState,
9
+ BackendModule,
10
+ BackendModuleContext,
11
+ BackendSettingsHooks,
12
+ BackendSettingsReaders,
13
+ ResolvedBackendModule,
8
14
  } from "@schlessera/brain-ui-sdk/server";
15
+ import { BackendProfileConfigError } from "@schlessera/brain-ui-sdk/server";
16
+ import { createRequire } from "module";
9
17
 
10
- /**
11
- * Backend registry for one app instance. Built by `createApp()` from the
12
- * resolved configuration — no ambient environment and no module-level state,
13
- * so two apps with different backend configuration coexist in one process and
14
- * tests install fakes by constructing a registry, not by mutating a module.
15
- *
16
- * BOTH backend packages are optional peers loaded lazily (the same
17
- * dynamic-import path): a deployment installs the one its AGENT_BACKEND
18
- * names, and the other never has to be present — at runtime AND at
19
- * type-check time (see the structural mirrors below).
20
- *
21
- * The old five hardcoded personal inference profiles are gone: the Claude
22
- * backend ships the single "claude" default, and additional Anthropic-compatible
23
- * endpoints are declared via the BRAIN_UI_CLAUDE_PROFILES env var (documented in
24
- * .env.example) rather than in code.
25
- */
26
-
27
- /*
28
- * Structural mirrors of @schlessera/brain-backend-claude.
29
- *
30
- * Both backend packages are optional peers, and NOTHING here may reference
31
- * their specifiers — not even in a type position. A type import (or a
32
- * `typeof import(...)`) is resolved by TypeScript, so it would either survive
33
- * into the emitted `.d.ts` (breaking the `types`-condition consumer) or, once
34
- * scrubbed there, still break the `bun`-condition consumer whose tsconfig
35
- * follows `src/index.ts` (this repo's own root tsconfig does exactly that).
36
- * Either way a pi-only TypeScript deployment would need the Claude package —
37
- * and its Anthropic Agent SDK — installed just to typecheck. Half of F1
38
- * undone.
39
- *
40
- * So the slice of the Claude module this registry drives is mirrored here by
41
- * hand, in exchange for two mechanical guards in
42
- * tests/declaration-surface.test.ts: type-level assertions that the REAL
43
- * module (tests may import it; only src must stay clean) is assignable to
44
- * these mirrors — so drift fails the build — and a scan proving no backend
45
- * specifier appears anywhere under src/ or in any emitted declaration.
46
- * Function members are property-style on purpose: strictFunctionTypes checks
47
- * them contravariantly, where method syntax would be bivariant and let an
48
- * incompatible drift slide.
49
- */
18
+ import type { AgentConfig } from "../config/env.js";
50
19
 
51
- /**
52
- * Mirror of the backends' `BackendLogFn` — the minimal log seam a backend
53
- * accepts. A callback rather than a Logger so the backend packages carry no
54
- * telemetry dependency; {@link toBackendLog} adapts the registry's Logger.
55
- */
56
- export type BackendLogFn = (
57
- level: "debug" | "info" | "warn" | "error",
58
- message: string,
59
- attrs?: Record<string, string | number | boolean>
60
- ) => void;
20
+ export type { BackendLogFn } from "@schlessera/brain-ui-sdk/server";
21
+ export type ModelDiscoverySource = BackendModelSource;
22
+ export type ModelDiscoveryState = BackendModelSourceState;
61
23
 
62
24
  const LEVEL_SEVERITY = {
63
25
  debug: "DEBUG",
@@ -75,203 +37,31 @@ function toBackendLog(log: Logger): BackendLogFn {
75
37
  });
76
38
  }
77
39
 
78
- /** Mirror of the Claude package's `InferenceProfileInput` (declarative shape). */
79
- export interface ClaudeProfileInput {
80
- id: string;
81
- label: string;
82
- vendor?: string;
83
- /** Model id. Undefined = the SDK/CLI default model. */
84
- model?: string;
85
- /** Anthropic-compatible endpoint. */
86
- baseUrl?: string;
87
- /** Name of the env var holding a bearer token. */
88
- authTokenEnv?: string;
89
- /** Name of the env var holding an x-api-key. */
90
- apiKeyEnv?: string;
91
- /** Remap the CLI's model-alias envs to `model`. Requires `model`. */
92
- modelAliases?: boolean;
93
- allowedTools?: string[];
94
- /** Context window in tokens, when known. Presentation only. */
95
- contextWindow?: number;
96
- /** Where this profile came from. Presentation only. */
97
- source?: "builtin" | "declared" | "discovered";
98
- }
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
-
124
- /** Mirror of the Claude package's resolved `InferenceProfile`. */
125
- export interface ClaudeProfile {
126
- id: string;
127
- label: string;
128
- vendor?: string;
129
- model?: string;
130
- allowedTools?: string[];
131
- contextWindow?: number;
132
- source?: "builtin" | "declared" | "discovered";
133
- /** Env vars that must be present (non-empty) for this profile to be usable. */
134
- requiredEnvKeys: string[];
135
- /** Environment overrides merged over the host environment. */
136
- buildEnv: () => Record<string, string>;
137
- }
138
-
139
- /**
140
- * The slice of the lazily-required Claude module this registry drives. The
141
- * real module carries more (and looser-optional) options; assignability is
142
- * asserted in tests/declaration-surface.test.ts.
143
- */
144
- export interface ClaudeBackendModule {
145
- createClaudeBackend: (options: {
146
- brainPath: string;
147
- claudeCodePath?: string;
148
- profiles?: ClaudeProfile[] | (() => ClaudeProfile[]);
149
- confirmBashPatterns?: readonly string[];
150
- log?: BackendLogFn;
151
- }) => AgentBackend;
152
- createModelSource: (options: {
153
- brainPath: string;
154
- ttlMs?: number;
155
- enabled?: boolean;
156
- }) => ClaudeModelSource;
157
- defineProfiles: (inputs: ClaudeProfileInput[]) => ClaudeProfile[];
158
- }
159
-
160
- /** Discovery source as the registry internals see it: the public slice + list(). */
161
- export interface ClaudeModelSource extends ModelDiscoverySource {
162
- list: () => ClaudeProfileInput[];
163
- }
164
-
165
- /**
166
- * Discovery freshness, as exposed on the public registry surface.
167
- *
168
- * `list()` is omitted on purpose — it returns Claude profile inputs, only the
169
- * registry internals consume it (see {@link ClaudeModelSource}), and exposing
170
- * it would put those types on every consumer's plate for no reader.
171
- */
172
- export interface ModelDiscoveryState {
173
- enabled: boolean;
174
- /** When discovery last succeeded; null when it never has. */
175
- refreshedAt: number | null;
176
- /** The cached result is older than the TTL (or absent). */
177
- stale: boolean;
178
- /** Last discovery failure, if the current list is served despite one. */
179
- error?: string;
180
- }
181
-
182
- /** The slice of the Claude backend's discovery source the routes consume. */
183
- export interface ModelDiscoverySource {
184
- state(): ModelDiscoveryState;
185
- /** Refresh if stale. Awaits only when there is nothing cached to serve. */
186
- ensureFresh(): Promise<void>;
187
- /** Force a refresh regardless of TTL. Rejects on failure. */
188
- refresh(): Promise<void>;
189
- }
190
-
191
40
  export interface BackendRegistryOptions {
192
- /** Where the brain repo lives (handed to every backend). */
193
41
  brainPath: string;
194
- /** Resolved agent configuration (AGENT_BACKEND, profiles, discovery...). */
195
42
  agent: AgentConfig;
196
- /**
197
- * Profile ids the user keeps out of the model picker (from the app's
198
- * settings table). Presentation only — a hidden profile still resolves for
199
- * sessions pinned to it. Defaults to "nothing hidden".
200
- */
201
43
  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
44
  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
45
  getCustomOpenRouterModels?: () => string[];
213
- /**
214
- * Per-profile reasoning-effort overrides (from the app's settings table),
215
- * applied over the pi roster's configured levels at read time — no rebuild
216
- * or redeploy needed.
217
- */
218
- getThinkingOverrides?: () => Record<string, PiThinkingLevel>;
219
- /**
220
- * Per-profile billing-mode overrides (from the app's settings table).
221
- * Consulted LAST: an override wins over both the declared-credential rule
222
- * and the ambient predicate. Defaults to "no overrides".
223
- */
46
+ getThinkingOverrides?: () => Record<string, string>;
224
47
  getBillingOverrides?: () => Record<string, BillingMode>;
225
- /**
226
- * Where the registry and its backends report. Adapted to the backends'
227
- * minimal callback ({@link BackendLogFn}) before crossing the package
228
- * boundary. Absent means silence.
229
- */
230
48
  log?: Logger;
231
49
  }
232
50
 
233
51
  export interface BackendRegistry {
234
- /** All configured backends, with the default backend first. */
235
52
  getBackends(): Promise<AgentBackend[]>;
236
- /** Find a configured backend by its stable id. */
237
53
  getBackendById(id: string): Promise<AgentBackend | undefined>;
238
- /** The default backend used for new and legacy sessions. */
239
54
  getDefaultBackend(): Promise<AgentBackend>;
240
- /** The stable id of the default backend. */
241
55
  getDefaultBackendId(): Promise<string>;
242
- /** Resolve the backend that owns a globally unique profile id. */
243
56
  getBackendForProfile(profileId: string): Promise<AgentBackend | undefined>;
244
- /** Resolve a persisted backend id, falling back for legacy/unknown sessions. */
57
+ /** Null/empty is a legacy session; an unknown non-empty id is an error. */
245
58
  getBackendForSession(backendId: string | null | undefined): Promise<AgentBackend>;
246
- /**
247
- * Every available profile, tagged with its owning backend id and (when the
248
- * registry can classify it) its resolved `billingMode` — settings override
249
- * applied last. Hidden profiles are omitted by default (this feeds the
250
- * picker); the settings screen passes `includeHidden` to render the full
251
- * catalog.
252
- */
253
59
  listAllProviders(options?: { includeHidden?: boolean }): Promise<ProviderInfo[]>;
254
- /**
255
- * The profile new sessions and host-initiated turns (shares, actions)
256
- * default to when the client named none, or null for "the default
257
- * backend's own default". Resolution: the Settings-stored default model
258
- * (when it still exists on the roster) wins; otherwise AUTO — a
259
- * subscription-auth profile (pi vendor "openai-codex") that is on the
260
- * roster, not hidden, and whose account is actually connected. The
261
- * resolved profile is also listed FIRST by `listAllProviders`, so a fresh
262
- * client's picker lands on it.
263
- */
264
60
  getPreferredProfileId(): Promise<string | null>;
265
- /** Per-backend capability metadata exposed by the providers route. */
266
61
  getBackendsInfo(): Promise<
267
62
  Record<string, { id: string; capabilities: BackendCapabilities }>
268
63
  >;
269
- /**
270
- * The Claude backend's discovery source, once the registry is built. Null
271
- * when discovery is disabled or the deployment runs a different backend.
272
- */
273
64
  getModelSource(): Promise<ModelDiscoverySource | null>;
274
- /** Drop the profile memo so the next read reflects a refresh or settings change. */
275
65
  invalidateProfiles(): void;
276
66
  }
277
67
 
@@ -279,13 +69,10 @@ interface RegistrySnapshot {
279
69
  backends: AgentBackend[];
280
70
  byId: Map<string, AgentBackend>;
281
71
  defaultBackendId: string;
72
+ resolved: Map<string, ResolvedBackendModule>;
73
+ modelSource: BackendModelSource | null;
282
74
  }
283
75
 
284
- /**
285
- * Profiles are NOT part of the registry snapshot: a backend's roster can grow
286
- * while the process runs (model discovery refreshing behind a request), so they
287
- * are recomputed behind a short memo and can be invalidated explicitly.
288
- */
289
76
  interface ProfileSnapshot {
290
77
  at: number;
291
78
  byBackend: Map<string, ProviderInfo[]>;
@@ -296,12 +83,13 @@ const PROFILE_MEMO_MS = 5_000;
296
83
 
297
84
  function buildSnapshot(
298
85
  backends: AgentBackend[],
299
- defaultBackendId: string
86
+ defaultBackendId: string,
87
+ resolved = new Map<string, ResolvedBackendModule>(),
88
+ modelSource: BackendModelSource | null = null
300
89
  ): RegistrySnapshot {
301
90
  if (backends.length === 0) {
302
91
  throw new Error("Backend registry requires at least one backend.");
303
92
  }
304
-
305
93
  const byId = new Map(backends.map((backend) => [backend.id, backend]));
306
94
  const resolvedDefaultId = byId.has(defaultBackendId)
307
95
  ? defaultBackendId
@@ -310,15 +98,50 @@ function buildSnapshot(
310
98
  byId.get(resolvedDefaultId)!,
311
99
  ...backends.filter((backend) => backend.id !== resolvedDefaultId),
312
100
  ];
101
+ return { backends: ordered, byId, defaultBackendId: resolvedDefaultId, resolved, modelSource };
102
+ }
313
103
 
314
- return { backends: ordered, byId, defaultBackendId: resolvedDefaultId };
104
+ interface FirstPartyBackend {
105
+ id: "claude" | "pi";
106
+ specifier: string;
107
+ profiles(agent: AgentConfig): string | null;
108
+ /** A non-primary module whose configured profile roster opts it in. */
109
+ joinsPrimary: boolean;
315
110
  }
316
111
 
112
+ interface ParsedBackendDescriptor {
113
+ descriptor: BackendModule;
114
+ profiles: BackendModuleContext["profiles"];
115
+ }
317
116
 
318
- const BACKEND_SPECIFIERS = {
319
- claude: "@schlessera/brain-backend-claude",
320
- pi: "@schlessera/brain-backend-pi",
321
- } as const;
117
+ // The only runtime-discovery surface: two fixed first-party packages. The
118
+ // environment never supplies a specifier or introduces another identity.
119
+ const FIRST_PARTY_BACKENDS: readonly FirstPartyBackend[] = [
120
+ {
121
+ id: "claude",
122
+ specifier: "@schlessera/brain-backend-claude",
123
+ profiles: (agent) => agent.profilesJson,
124
+ joinsPrimary: false,
125
+ },
126
+ {
127
+ id: "pi",
128
+ specifier: "@schlessera/brain-backend-pi",
129
+ profiles: (agent) => agent.piProfilesJson,
130
+ joinsPrimary: true,
131
+ },
132
+ ];
133
+
134
+ function firstParty(id: string): FirstPartyBackend | undefined {
135
+ return FIRST_PARTY_BACKENDS.find((entry) => entry.id === id);
136
+ }
137
+
138
+ function activeFirstPartyBackends(agent: AgentConfig): readonly FirstPartyBackend[] {
139
+ const primary = agent.backend || "claude";
140
+ return FIRST_PARTY_BACKENDS.filter(
141
+ (entry) =>
142
+ entry.id === primary || (entry.joinsPrimary && Boolean(entry.profiles(agent)))
143
+ );
144
+ }
322
145
 
323
146
  function unknownBackendError(primary: string): Error {
324
147
  return new Error(
@@ -335,25 +158,24 @@ function missingBackendError(primary: "claude" | "pi"): Error {
335
158
  );
336
159
  }
337
160
  return new Error(
338
- `AGENT_BACKEND=${primary} but "@schlessera/brain-backend-claude" is not installed. ` +
161
+ 'AGENT_BACKEND=claude but "@schlessera/brain-backend-claude" is not installed. ' +
339
162
  "Add it (it carries the Claude Agent SDK) or set AGENT_BACKEND=pi."
340
163
  );
341
164
  }
342
165
 
343
- /**
344
- * Boot-time guard: the SELECTED backend's package must at least RESOLVE, so a
345
- * broken install refuses to start instead of reporting healthy and failing
346
- * every agent turn (the guarantee the old static import gave, restored without
347
- * giving up the lazy load `require.resolve` never executes the module, so
348
- * the Agent SDK still loads on first use only). Called by `createApp()` next
349
- * to the auth assertions; skipped when an explicit registry is injected.
350
- *
351
- * An unrecognized AGENT_BACKEND fails here too, for the same reason.
352
- *
353
- * The `resolve` parameter exists for tests (simulating an absent package);
354
- * production callers pass nothing.
355
- */
356
- /** The specifier an ERR_MODULE_NOT_FOUND failed on, or null for any other error. */
166
+ function missingConfiguredBackendError(entry: FirstPartyBackend): Error {
167
+ if (entry.id === "pi") {
168
+ return new Error(
169
+ 'BRAIN_UI_PI_PROFILES is set but "@schlessera/brain-backend-pi" is not ' +
170
+ "installed. Add it (with its pi SDK dependencies) or unset the variable."
171
+ );
172
+ }
173
+ return new Error(
174
+ 'BRAIN_UI_CLAUDE_PROFILES is set but "@schlessera/brain-backend-claude" is not ' +
175
+ "installed. Add it (it carries the Claude Agent SDK) or unset the variable."
176
+ );
177
+ }
178
+
357
179
  function moduleNotFoundSpecifier(error: unknown): string | null {
358
180
  if (typeof error !== "object" || error === null) return null;
359
181
  const { code, specifier, message } = error as {
@@ -362,8 +184,6 @@ function moduleNotFoundSpecifier(error: unknown): string | null {
362
184
  message?: unknown;
363
185
  };
364
186
  if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND") return null;
365
- // Bun's ResolveMessage (not an Error instance) carries the failing
366
- // specifier as a property; Node quotes it in the message instead.
367
187
  if (typeof specifier === "string") return specifier;
368
188
  if (typeof message === "string") {
369
189
  const quoted = /Cannot find (?:package|module) '([^']+)'/.exec(message);
@@ -372,567 +192,259 @@ function moduleNotFoundSpecifier(error: unknown): string | null {
372
192
  return null;
373
193
  }
374
194
 
375
- /**
376
- * Load an optional backend package, mapping ONLY "the backend package itself
377
- * is not installed" to the actionable install hint. Everything else — the
378
- * backend missing one of its OWN transitive deps, a syntax error, a throwing
379
- * top-level — is a different failure whose original error IS the diagnostic,
380
- * so it is rethrown untouched. The distinction rides on ERR_MODULE_NOT_FOUND
381
- * naming the specifier it failed on: a transitive miss names the transitive
382
- * dep, not the backend, and must not read as "backend not installed".
383
- *
384
- * `await import()` rather than `createRequire()(...)`: the backend packages
385
- * are ESM, and a CJS require of them under plain Node dies with
386
- * ERR_REQUIRE_ESM — which the old blanket catch then reported as "not
387
- * installed" on a machine where the package was sitting right there.
388
- *
389
- * The `importer` parameter exists for tests (simulating absent or broken
390
- * packages); production callers pass nothing.
391
- */
392
195
  export async function loadBackendModule(
393
- // "claude" | "pi" spelled out, NOT keyof typeof BACKEND_SPECIFIERS: the
394
- // keyof form drags the table's literal string types — the backend
395
- // specifiers — into the emitted .d.ts, which the declaration-surface gate
396
- // rightly refuses.
397
196
  key: "claude" | "pi",
398
197
  importer: (specifier: string) => Promise<unknown> = (specifier) => import(specifier)
399
198
  ): Promise<unknown> {
400
- const specifier = BACKEND_SPECIFIERS[key];
199
+ const entry = firstParty(key)!;
401
200
  try {
402
- return await importer(specifier);
201
+ return await importer(entry.specifier);
403
202
  } catch (error) {
404
- if (moduleNotFoundSpecifier(error) === specifier) throw missingBackendError(key);
203
+ if (moduleNotFoundSpecifier(error) === entry.specifier) {
204
+ throw missingBackendError(key);
205
+ }
405
206
  throw error;
406
207
  }
407
208
  }
408
209
 
409
- /**
410
- * Parse + validate the pi roster from BRAIN_UI_PI_PROFILES (a JSON array of
411
- * {id,label,vendor,model,thinkingLevel?}). Malformed input THROWS rather than
412
- * silently dropping the roster; `assertBackendResolvable` runs this at boot so
413
- * a bad config refuses to start instead of reporting healthy and 500ing later.
414
- *
415
- * Ids Claude's side of the picker uses or can mint later ("default",
416
- * "claude", "claude-*" — discovery canonicalizes every Anthropic model to a
417
- * claude-* alias) are rejected, as is any collision with an id declared in
418
- * BRAIN_UI_CLAUDE_PROFILES — a cross-backend collision otherwise surfaces as
419
- * a 500 on first request. Claude's own JSON is parsed leniently here: when it
420
- * is malformed, the Claude loader raises its own (more precise) boot error.
421
- */
422
- export function parsePiProfiles(
423
- raw: string | null,
424
- claudeProfilesRaw?: string | null
425
- ): PiProfileInput[] {
426
- if (!raw) return [];
210
+ /** Load and structurally validate the one descriptor exported by a backend package. */
211
+ export async function loadBackendDescriptor(
212
+ key: "claude" | "pi",
213
+ importer?: (specifier: string) => Promise<unknown>
214
+ ): Promise<BackendModule> {
215
+ return backendDescriptorFromModule(key, await loadBackendModule(key, importer));
216
+ }
427
217
 
428
- let inputs: unknown;
429
- try {
430
- inputs = JSON.parse(raw);
431
- } catch (err) {
218
+ function backendDescriptorFromModule(
219
+ key: "claude" | "pi",
220
+ loaded: unknown
221
+ ): BackendModule {
222
+ const backendPackage = loaded as { backendModule?: unknown };
223
+ const descriptor = backendPackage.backendModule as Partial<BackendModule> | undefined;
224
+ if (
225
+ !descriptor ||
226
+ descriptor.id !== key ||
227
+ typeof descriptor.resolveFromEnv !== "function" ||
228
+ typeof descriptor.profileSchema?.parse !== "function" ||
229
+ !descriptor.settingsHooks
230
+ ) {
432
231
  throw new Error(
433
- `BRAIN_UI_PI_PROFILES is not valid JSON: ${
434
- err instanceof Error ? err.message : String(err)
435
- }`
232
+ `The ${key} backend package does not export a valid backendModule descriptor.`
436
233
  );
437
234
  }
438
- if (!Array.isArray(inputs)) {
439
- throw new Error("BRAIN_UI_PI_PROFILES must be a JSON array.");
440
- }
441
-
442
- const claudeIds = new Set<string>();
443
- if (claudeProfilesRaw) {
444
- try {
445
- const claudeInputs = JSON.parse(claudeProfilesRaw);
446
- if (Array.isArray(claudeInputs)) {
447
- for (const entry of claudeInputs) {
448
- if (entry && typeof entry.id === "string") claudeIds.add(entry.id);
449
- }
450
- }
451
- } catch {
452
- // Malformed Claude JSON is the Claude loader's error to raise.
453
- }
454
- }
235
+ return descriptor as BackendModule;
236
+ }
455
237
 
456
- const seen = new Set<string>();
457
- for (const input of inputs as PiProfileInput[]) {
458
- if (!input || typeof input !== "object") {
459
- throw new Error("Each BRAIN_UI_PI_PROFILES entry must be an object.");
460
- }
461
- for (const field of ["id", "label", "vendor", "model"] as const) {
462
- if (typeof input[field] !== "string" || input[field].length === 0) {
463
- throw new Error(
464
- `Each BRAIN_UI_PI_PROFILES entry needs a non-empty string ${field}.`
465
- );
466
- }
467
- }
468
- if (input.id === "default" || /^claude(-|$)/.test(input.id)) {
469
- throw new Error(
470
- `BRAIN_UI_PI_PROFILES id "${input.id}" is reserved for the Claude ` +
471
- "roster (built-in default and discovered claude-* aliases)."
472
- );
473
- }
474
- if (claudeIds.has(input.id)) {
475
- throw new Error(
476
- `BRAIN_UI_PI_PROFILES id "${input.id}" collides with a ` +
477
- "BRAIN_UI_CLAUDE_PROFILES entry."
478
- );
479
- }
480
- if (seen.has(input.id)) {
481
- throw new Error(`Duplicate profile id in BRAIN_UI_PI_PROFILES: "${input.id}".`);
238
+ function parseBackendDescriptors(
239
+ agent: AgentConfig,
240
+ entries: readonly FirstPartyBackend[],
241
+ descriptors: ReadonlyMap<string, BackendModule>
242
+ ): Map<string, ParsedBackendDescriptor> {
243
+ const occupiedProfiles: { id: string; source: string }[] = [];
244
+ const activeIds = new Set(entries.map((entry) => entry.id));
245
+ const inactiveRosters = FIRST_PARTY_BACKENDS.filter(
246
+ (entry) => !activeIds.has(entry.id)
247
+ ).map((entry) => ({ backendId: entry.id, raw: entry.profiles(agent) }));
248
+ const parsed = new Map<string, ParsedBackendDescriptor>();
249
+ for (const entry of entries) {
250
+ const descriptor = descriptors.get(entry.id);
251
+ if (!descriptor) {
252
+ throw new Error(`Backend descriptor "${entry.id}" was not loaded.`);
482
253
  }
483
- seen.add(input.id);
484
- if (
485
- input.thinkingLevel !== undefined &&
486
- !PI_THINKING_LEVELS.includes(input.thinkingLevel)
487
- ) {
488
- throw new Error(
489
- `BRAIN_UI_PI_PROFILES entry "${input.id}" has invalid thinkingLevel ` +
490
- `"${input.thinkingLevel}" (expected one of ${PI_THINKING_LEVELS.join(", ")}).`
491
- );
254
+ const result = descriptor.profileSchema.parse(entry.profiles(agent), {
255
+ occupiedProfiles,
256
+ inactiveRosters,
257
+ });
258
+ if (!result.ok) throw new BackendProfileConfigError(result.errors);
259
+ parsed.set(entry.id, { descriptor, profiles: result.profiles });
260
+ for (const profile of result.profiles) {
261
+ occupiedProfiles.push({ id: profile.id, source: descriptor.profileSchema.source });
492
262
  }
493
263
  }
494
- return inputs as PiProfileInput[];
264
+ return parsed;
495
265
  }
496
266
 
267
+ /**
268
+ * Boot guard. It resolves active optional backend packages and synchronously
269
+ * loads their descriptors so backend-owned profile validation finishes before
270
+ * the application can report healthy. Inactive rosters are supplied as raw
271
+ * collision data without loading or strictly parsing their packages. Backend
272
+ * construction and model discovery remain lazy in the registry.
273
+ */
497
274
  export function assertBackendResolvable(
498
275
  agent: AgentConfig,
499
276
  resolve: (specifier: string) => void = (specifier) => {
500
277
  createRequire(import.meta.url).resolve(specifier);
278
+ },
279
+ load: (specifier: string) => unknown = (specifier) => {
280
+ return createRequire(import.meta.url)(specifier);
501
281
  }
502
282
  ): void {
503
283
  const primary = agent.backend || "claude";
504
- if (!(primary in BACKEND_SPECIFIERS)) throw unknownBackendError(primary);
505
- const key = primary as keyof typeof BACKEND_SPECIFIERS;
506
- try {
507
- resolve(BACKEND_SPECIFIERS[key]);
508
- } catch {
509
- throw missingBackendError(key);
510
- }
511
- // BRAIN_UI_PI_PROFILES opts the pi backend in ALONGSIDE the primary — its
512
- // package must resolve at boot too, or the roster silently loses those
513
- // profiles on first request instead of refusing to start.
514
- if (agent.piProfilesJson && key !== "pi") {
284
+ const primaryEntry = firstParty(primary);
285
+ if (!primaryEntry) throw unknownBackendError(primary);
286
+
287
+ const entries = activeFirstPartyBackends(agent);
288
+ const descriptors = new Map<string, BackendModule>();
289
+ for (const entry of entries) {
515
290
  try {
516
- resolve(BACKEND_SPECIFIERS.pi);
291
+ resolve(entry.specifier);
517
292
  } catch {
518
- throw new Error(
519
- 'BRAIN_UI_PI_PROFILES is set but "@schlessera/brain-backend-pi" is not ' +
520
- "installed. Add it (with its pi SDK dependencies) or unset the variable."
521
- );
522
- }
523
- }
524
- // Validate the pi roster itself at boot too — the registry is built lazily,
525
- // so without this a malformed BRAIN_UI_PI_PROFILES would still report a
526
- // healthy startup and only fail on first request.
527
- parsePiProfiles(agent.piProfilesJson, agent.profilesJson);
528
- }
529
-
530
- export function createBackendRegistry(
531
- options: BackendRegistryOptions
532
- ): BackendRegistry {
533
- const { brainPath, agent } = options;
534
- const getHidden = options.getHiddenModelIds ?? (() => []);
535
- const backendLog = options.log ? toBackendLog(options.log) : undefined;
536
-
537
- let cachedRegistry: Promise<RegistrySnapshot> | null = null;
538
- let modelSource: ClaudeModelSource | null = null;
539
-
540
- /**
541
- * Declared profiles carrying their OWN credential env vars
542
- * (authTokenEnv/apiKeyEnv) — api-billed regardless of the ambient
543
- * credential. Populated when the Claude roster resolves, which happens
544
- * before any profile can be listed or run.
545
- */
546
- const declaredApiProfileIds = new Set<string>();
547
- /** The Claude backend's id, once built — Claude's subscription path only applies to its own profiles. */
548
- let claudeBackendId: string | null = null;
549
-
550
- /**
551
- * Base billing classification for one roster entry, BEFORE the settings
552
- * override (applied last by the shared accessor surface):
553
- * - a non-Claude backend profile → by VENDOR: pi's "openai-codex" runs
554
- * only against a ChatGPT-subscription OAuth credential (the provider
555
- * has no API-key path at all) → "subscription"; every other vendor
556
- * resolves ambient API keys → "api";
557
- * - a declared Claude profile with explicit credentials → "api";
558
- * - everything ambient (built-in default, discovered models, declared
559
- * entries without their own credentials) → the env-resolved ambient
560
- * mode (subscription iff the OAuth token is present and no
561
- * ANTHROPIC_API_KEY — the Agent SDK's own precedence).
562
- */
563
- function classifyBilling(profile: ProviderInfo & { backendId: string }): BillingMode {
564
- if (claudeBackendId === null || profile.backendId !== claudeBackendId) {
565
- return profile.vendor === "openai-codex" ? "subscription" : "api";
566
- }
567
- if (declaredApiProfileIds.has(profile.id)) return "api";
568
- return agent.ambientBilling;
569
- }
570
-
571
- /**
572
- * Declared profiles (built-in default + BRAIN_UI_CLAUDE_PROFILES) plus every
573
- * discovered model whose id isn't already declared. Declared wins: the env
574
- * stays an override mechanism, and a hand-pinned entry keeps its label and
575
- * endpoint.
576
- *
577
- * Memoized on the discovered array's identity — `createModelSource` swaps the
578
- * array only on a successful refresh, so steady state is one comparison.
579
- */
580
- let mergeCache: {
581
- declared: ClaudeProfile[];
582
- discovered: ClaudeProfileInput[];
583
- customKey: string;
584
- result: ClaudeProfile[];
585
- } | null = null;
586
-
587
- /**
588
- * User-managed OpenRouter models (Settings → Models) as declared Claude
589
- * profiles — same shape a BRAIN_UI_CLAUDE_PROFILES OpenRouter entry has,
590
- * but editable at runtime without an env change or redeploy.
591
- */
592
- function customOpenRouterInputs(): ClaudeProfileInput[] {
593
- const models = options.getCustomOpenRouterModels?.() ?? [];
594
- return models.map((model) => ({
595
- id: `openrouter:${model}`,
596
- label: `${model} (OpenRouter)`,
597
- vendor: "openrouter",
598
- model,
599
- baseUrl: "https://openrouter.ai/api",
600
- authTokenEnv: "OPENROUTER_API_KEY",
601
- modelAliases: true,
602
- source: "declared" as const,
603
- }));
604
- }
605
-
606
- function mergeDiscovered(
607
- claude: ClaudeBackendModule,
608
- declared: ClaudeProfile[],
609
- discovered: ClaudeProfileInput[]
610
- ): ClaudeProfile[] {
611
- const custom = customOpenRouterInputs();
612
- const customKey = custom.map((profile) => profile.id).join("\n");
613
- if (
614
- mergeCache &&
615
- mergeCache.declared === declared &&
616
- mergeCache.discovered === discovered &&
617
- mergeCache.customKey === customKey
618
- ) {
619
- return mergeCache.result;
293
+ if (entry.id === primary) throw missingBackendError(entry.id);
294
+ throw missingConfiguredBackendError(entry);
620
295
  }
621
-
622
- const declaredIds = new Set(declared.map((profile) => profile.id));
623
- const customExtra = custom.filter((input) => !declaredIds.has(input.id));
624
- // OpenRouter runs on its own key: always api-billed, like an env-declared
625
- // profile with explicit credentials.
626
- for (const input of customExtra) declaredApiProfileIds.add(input.id);
627
- const knownIds = new Set([...declaredIds, ...customExtra.map((input) => input.id)]);
628
- const extra = discovered.filter((input) => !knownIds.has(input.id));
629
- const result = [...declared, ...claude.defineProfiles([...customExtra, ...extra])];
630
- mergeCache = { declared, discovered, customKey, result };
631
- return result;
632
- }
633
-
634
- // The built-in default profile's model. The Claude backend historically
635
- // pinned the default to a specific model; after the SDK extraction the pin
636
- // was lost and resumed default-profile sessions drifted to "whatever the CLI
637
- // defaults to", silently changing model/behaviour/billing on every CLI bump.
638
- // Re-pin it (a committed default, overridable per deploy via
639
- // BRAIN_UI_CLAUDE_DEFAULT_MODEL) so the default stays on a known model.
640
- function builtinDefaultProfiles(claude: ClaudeBackendModule): ClaudeProfile[] {
641
- return claude.defineProfiles([
642
- {
643
- id: "claude",
644
- label: "Claude",
645
- vendor: "anthropic",
646
- model: agent.defaultModel,
647
- modelAliases: true,
648
- source: "builtin",
649
- },
650
- ]);
651
- }
652
-
653
- /**
654
- * The Claude roster: the pinned built-in "claude" default first, plus any
655
- * extra profiles from BRAIN_UI_CLAUDE_PROFILES (a JSON array of profile
656
- * inputs: {id,label,model?,baseUrl?,authTokenEnv?,apiKeyEnv?,modelAliases?}).
657
- *
658
- * A malformed or duplicate-id roster THROWS — caught at boot by the registry
659
- * fail-fast — rather than silently degrading to default-only and rebilling
660
- * every pinned session to the subscription with nothing in the logs.
661
- */
662
- function loadClaudeProfiles(claude: ClaudeBackendModule): ClaudeProfile[] {
663
- const base = builtinDefaultProfiles(claude);
664
- const raw = agent.profilesJson;
665
- if (!raw) return base;
666
-
667
- let inputs: unknown;
668
296
  try {
669
- inputs = JSON.parse(raw);
670
- } catch (err) {
671
- throw new Error(
672
- `BRAIN_UI_CLAUDE_PROFILES is not valid JSON: ${
673
- err instanceof Error ? err.message : String(err)
674
- }`
297
+ descriptors.set(
298
+ entry.id,
299
+ backendDescriptorFromModule(entry.id, load(entry.specifier))
675
300
  );
676
- }
677
- if (!Array.isArray(inputs)) {
678
- throw new Error("BRAIN_UI_CLAUDE_PROFILES must be a JSON array.");
679
- }
680
-
681
- // Reject duplicate ids (including collisions with the built-in "claude"): a
682
- // duplicate silently shadows and can resolve to the wrong credentials.
683
- const seen = new Set(base.map((profile) => profile.id));
684
- for (const input of inputs as ClaudeProfileInput[]) {
685
- if (!input || typeof input.id !== "string" || input.id.length === 0) {
686
- throw new Error(
687
- "Each BRAIN_UI_CLAUDE_PROFILES entry needs a non-empty string id."
688
- );
301
+ } catch (error) {
302
+ if (entry.id === primary && moduleNotFoundSpecifier(error) === entry.specifier) {
303
+ throw missingBackendError(entry.id);
689
304
  }
690
- if (seen.has(input.id)) {
691
- throw new Error(
692
- `Duplicate profile id in BRAIN_UI_CLAUDE_PROFILES: "${input.id}".`
693
- );
305
+ if (entry.id !== primary && moduleNotFoundSpecifier(error) === entry.specifier) {
306
+ throw missingConfiguredBackendError(entry);
694
307
  }
695
- seen.add(input.id);
696
- // The same non-empty test defineProfiles applies to requiredEnvKeys: a
697
- // profile bringing its own credential env var is api-billed.
698
- if (input.authTokenEnv || input.apiKeyEnv) declaredApiProfileIds.add(input.id);
308
+ throw error;
699
309
  }
700
-
701
- const declared = (inputs as ClaudeProfileInput[]).map((input) => ({
702
- source: "declared" as const,
703
- ...input,
704
- }));
705
- return [...base, ...claude.defineProfiles(declared)];
706
310
  }
311
+ parseBackendDescriptors(agent, entries, descriptors);
312
+ }
707
313
 
314
+ function settingsFor(
315
+ hooks: BackendSettingsHooks,
316
+ readers: BackendSettingsReaders
317
+ ): Partial<BackendSettingsReaders> {
318
+ return {
319
+ ...(hooks.hiddenModelIds ? { getHiddenModelIds: readers.getHiddenModelIds } : {}),
320
+ ...(hooks.defaultModelId ? { getDefaultModelId: readers.getDefaultModelId } : {}),
321
+ ...(hooks.customOpenRouterModels
322
+ ? { getCustomOpenRouterModels: readers.getCustomOpenRouterModels }
323
+ : {}),
324
+ ...(hooks.thinkingOverrides
325
+ ? { getThinkingOverrides: readers.getThinkingOverrides }
326
+ : {}),
327
+ ...(hooks.billingOverrides
328
+ ? { getBillingOverrides: readers.getBillingOverrides }
329
+ : {}),
330
+ };
331
+ }
708
332
 
709
- /**
710
- * NEITHER backend package is a hard dependency — a deployment installs the
711
- * one its AGENT_BACKEND names (both, if it switches). Loaded lazily through
712
- * {@link loadBackendModule} so the server still boots without the unused
713
- * one; only "the package is absent" maps to the actionable install hint,
714
- * any other load failure surfaces as itself.
715
- */
716
- async function buildClaudeBackend(): Promise<AgentBackend> {
717
- const claude = (await loadBackendModule("claude")) as ClaudeBackendModule;
718
- if (typeof claude.createClaudeBackend !== "function") {
719
- throw new Error(
720
- '"@schlessera/brain-backend-claude" does not export createClaudeBackend.'
721
- );
722
- }
723
-
724
- // Declared profiles are resolved EAGERLY so a malformed
725
- // BRAIN_UI_CLAUDE_PROFILES still fails at boot rather than on first request.
726
- const declared = loadClaudeProfiles(claude);
727
-
728
- modelSource = claude.createModelSource({
729
- brainPath,
730
- enabled: agent.modelDiscovery,
731
- ttlMs: agent.modelTtlMs,
732
- });
733
-
734
- const backend = claude.createClaudeBackend({
735
- brainPath,
736
- claudeCodePath: agent.claudeCodePath,
737
- ...(backendLog ? { log: backendLog } : {}),
738
- // Omitted entirely when unconfigured, so the backend's own defaults
739
- // apply; an explicit [] passes through and disables confirmation.
740
- ...(agent.confirmBashPatterns !== null
741
- ? { confirmBashPatterns: agent.confirmBashPatterns }
742
- : {}),
743
- // A function, not an array: discovery refreshes in the background and the
744
- // new roster has to be visible without restarting the process.
745
- profiles: () =>
746
- mergeDiscovered(claude, declared, modelSource?.list() ?? []),
747
- });
748
- claudeBackendId = backend.id;
749
- return backend;
333
+ export function createBackendRegistry(options: BackendRegistryOptions): BackendRegistry {
334
+ const { brainPath, agent } = options;
335
+ const primary = agent.backend || "claude";
336
+ if (!firstParty(primary)) {
337
+ return makeRegistry(async () => {
338
+ throw unknownBackendError(primary);
339
+ }, options);
750
340
  }
751
341
 
752
- async function buildPiBackend(): Promise<AgentBackend> {
753
- const mod = (await loadBackendModule("pi")) as {
754
- createPiBackend?: (opts: {
755
- brainPath: string;
756
- profiles?: PiProfileInput[] | (() => PiProfileInput[]);
757
- log?: BackendLogFn;
758
- confirmBashPatterns?: readonly string[];
759
- }) => AgentBackend;
760
- };
761
- if (typeof mod.createPiBackend !== "function") {
762
- throw new Error(
763
- '"@schlessera/brain-backend-pi" does not export createPiBackend.'
764
- );
765
- }
766
- // Re-parsed here (assertBackendResolvable already validated at boot) so an
767
- // injected-registry path without the boot assert still fails loudly.
768
- const profiles = parsePiProfiles(agent.piProfilesJson, agent.profilesJson);
769
- // A FUNCTION, so per-profile thinking overrides from settings are read on
770
- // every use (roster listing AND new-session model resolution) — a change
771
- // in Settings applies to the next turn without a rebuild.
772
- const withOverrides = (): PiProfileInput[] => {
773
- const overrides = options.getThinkingOverrides?.() ?? {};
774
- return profiles.map((profile) =>
775
- overrides[profile.id]
776
- ? { ...profile, thinkingLevel: overrides[profile.id] }
777
- : profile
778
- );
779
- };
780
- return mod.createPiBackend({
781
- brainPath,
782
- ...(profiles.length > 0 ? { profiles: withOverrides } : {}),
783
- ...(backendLog ? { log: backendLog } : {}),
784
- // Same shared confirm-pattern config as the Claude backend, so both
785
- // backends stop on the same destructive bash shapes.
786
- ...(agent.confirmBashPatterns !== null
787
- ? { confirmBashPatterns: agent.confirmBashPatterns }
788
- : {}),
789
- });
790
- }
342
+ const readers: BackendSettingsReaders = {
343
+ getHiddenModelIds: options.getHiddenModelIds ?? (() => []),
344
+ getDefaultModelId: options.getDefaultModelId ?? (() => null),
345
+ getCustomOpenRouterModels: options.getCustomOpenRouterModels ?? (() => []),
346
+ getThinkingOverrides: options.getThinkingOverrides ?? (() => ({})),
347
+ getBillingOverrides: options.getBillingOverrides ?? (() => ({})),
348
+ };
349
+ const backendLog = options.log ? toBackendLog(options.log) : undefined;
350
+ let cachedRegistry: Promise<RegistrySnapshot> | null = null;
791
351
 
792
352
  async function buildRegistry(): Promise<RegistrySnapshot> {
793
- const primary = agent.backend || "claude";
794
-
795
- if (primary === "pi") {
796
- const pi = await buildPiBackend();
797
- return buildSnapshot([pi], pi.id);
798
- }
799
-
800
- const backends = [await buildClaudeBackend()];
801
-
802
- // BRAIN_UI_PI_PROFILES opts the pi backend in ALONGSIDE claude: its
803
- // profiles join the picker (e.g. OpenAI models under a ChatGPT
804
- // subscription via pi's "openai-codex" vendor) while claude stays the
805
- // default backend. Without the variable, behavior is unchanged.
806
- if (agent.piProfilesJson) {
807
- backends.push(await buildPiBackend());
353
+ const entries = activeFirstPartyBackends(agent);
354
+ const descriptors = new Map<string, BackendModule>();
355
+ for (const entry of entries) {
356
+ descriptors.set(entry.id, await loadBackendDescriptor(entry.id));
808
357
  }
809
-
810
- // AGENT_BACKEND must name a configured backend. The in-process options are
811
- // "claude" (default) and "pi" (handled above). An unrecognized value is a
812
- // misconfiguration fail loudly instead of silently coercing to claude and
813
- // running on the wrong backend with nothing in the logs.
814
- if (agent.backend && !backends.some((b) => b.id === primary)) {
815
- throw unknownBackendError(primary);
358
+ const parsedDescriptors = parseBackendDescriptors(agent, entries, descriptors);
359
+ const resolved = new Map<string, ResolvedBackendModule>();
360
+ const backends: AgentBackend[] = [];
361
+ let modelSource: BackendModelSource | null = null;
362
+
363
+ for (const entry of entries) {
364
+ const parsed = parsedDescriptors.get(entry.id)!;
365
+ const { descriptor } = parsed;
366
+
367
+ const baseContext: BackendModuleContext = {
368
+ brainPath,
369
+ config: { ...agent },
370
+ profiles: parsed.profiles,
371
+ confirmBashPatterns: agent.confirmBashPatterns,
372
+ settings: settingsFor(descriptor.settingsHooks, readers),
373
+ ...(backendLog ? { log: backendLog } : {}),
374
+ };
375
+ const source = descriptor.modelSource?.(baseContext) ?? null;
376
+ const resolution = await descriptor.resolveFromEnv({
377
+ ...baseContext,
378
+ ...(source ? { modelSource: source } : {}),
379
+ });
380
+ if (!resolution.ok) throw resolution.error;
381
+ if (resolution.value.backend.id !== descriptor.id) {
382
+ throw new Error(
383
+ `Backend descriptor "${descriptor.id}" built backend "${resolution.value.backend.id}".`
384
+ );
385
+ }
386
+ resolved.set(descriptor.id, resolution.value);
387
+ backends.push(resolution.value.backend);
388
+ modelSource ??= source;
816
389
  }
817
- return buildSnapshot(backends, primary);
390
+ return buildSnapshot(backends, primary, resolved, modelSource);
818
391
  }
819
392
 
820
- async function getRegistry(): Promise<RegistrySnapshot> {
393
+ const getRegistry = (): Promise<RegistrySnapshot> => {
821
394
  if (!cachedRegistry) cachedRegistry = buildRegistry();
822
395
  return cachedRegistry;
823
- }
824
-
825
- /**
826
- * Whether the openai-codex (ChatGPT subscription) account is connected —
827
- * a cheap file probe through the pi package, memoized briefly so provider
828
- * listings and turn routing don't re-read the auth store on every call.
829
- */
830
- let codexCredentialCache: { at: number; value: boolean } | null = null;
831
- async function hasCodexCredential(): Promise<boolean> {
832
- const piInPlay = Boolean(agent.piProfilesJson) || (agent.backend || "claude") === "pi";
833
- if (!piInPlay) return false;
834
- if (codexCredentialCache && Date.now() - codexCredentialCache.at < PROFILE_MEMO_MS) {
835
- return codexCredentialCache.value;
836
- }
837
- let value = false;
838
- try {
839
- const mod = (await loadBackendModule("pi")) as {
840
- hasStoredCredential?: (providerId: string) => boolean;
841
- };
842
- value =
843
- typeof mod.hasStoredCredential === "function" &&
844
- mod.hasStoredCredential("openai-codex");
845
- } catch {
846
- value = false;
847
- }
848
- codexCredentialCache = { at: Date.now(), value };
849
- return value;
850
- }
851
-
852
- return makeRegistry(
853
- getRegistry,
854
- getHidden,
855
- async () => {
856
- await getRegistry();
857
- return modelSource;
858
- },
859
- options.log,
860
- {
861
- classify: classifyBilling,
862
- ...(options.getBillingOverrides
863
- ? { getOverrides: options.getBillingOverrides }
864
- : {}),
865
- },
866
- {
867
- ...(options.getDefaultModelId ? { getOverride: options.getDefaultModelId } : {}),
868
- auto: { vendor: "openai-codex", hasCredential: hasCodexCredential },
869
- }
870
- );
396
+ };
397
+ return makeRegistry(getRegistry, options);
871
398
  }
872
399
 
873
- /**
874
- * A registry over an explicit backend list — the seam tests (and embedders
875
- * with their own backend wiring) use instead of mutating module state. Takes
876
- * the same optional hidden-ids reader so visibility behavior matches
877
- * production.
878
- */
879
400
  export function createStaticBackendRegistry(
880
- backends: AgentBackend[],
881
- defaultBackendId = backends[0]?.id ?? "",
401
+ entries: Array<AgentBackend | ResolvedBackendModule>,
402
+ defaultBackendId = entries[0]
403
+ ? "backend" in entries[0]
404
+ ? entries[0].backend.id
405
+ : entries[0].id
406
+ : "",
882
407
  options: {
883
408
  getHiddenModelIds?: () => string[];
884
409
  getDefaultModelId?: () => string | null;
885
410
  getBillingOverrides?: () => Record<string, BillingMode>;
411
+ modelSource?: BackendModelSource | null;
886
412
  log?: Logger;
887
413
  } = {}
888
414
  ): BackendRegistry {
889
- const snapshot = buildSnapshot(backends, defaultBackendId);
890
- return makeRegistry(
891
- async () => snapshot,
892
- options.getHiddenModelIds ?? (() => []),
893
- async () => null,
894
- options.log,
895
- // No classifier: an embedder's backends carry no credential topology this
896
- // registry could reason about, so only an explicit override sets a mode.
897
- options.getBillingOverrides
898
- ? { getOverrides: options.getBillingOverrides }
899
- : {},
900
- // No auto rule either — only the stored default applies here.
901
- options.getDefaultModelId ? { getOverride: options.getDefaultModelId } : {}
415
+ const resolved = new Map<string, ResolvedBackendModule>();
416
+ const backends = entries.map((entry) => {
417
+ if (!("backend" in entry)) return entry;
418
+ resolved.set(entry.backend.id, entry);
419
+ return entry.backend;
420
+ });
421
+ const snapshot = buildSnapshot(
422
+ backends,
423
+ defaultBackendId,
424
+ resolved,
425
+ options.modelSource ?? null
902
426
  );
427
+ return makeRegistry(async () => snapshot, options);
903
428
  }
904
429
 
905
- /** The accessor surface, shared by the config-driven and static registries. */
906
430
  function makeRegistry(
907
431
  getRegistry: () => Promise<RegistrySnapshot>,
908
- getHidden: () => string[],
909
- getModelSource: () => Promise<ModelDiscoverySource | null>,
910
- log?: Logger,
911
- billing: {
912
- /** Base classification per roster entry; absent = the registry cannot classify. */
913
- classify?: (profile: ProviderInfo & { backendId: string }) => BillingMode;
914
- /** Settings overrides, consulted LAST — an override wins over `classify`. */
915
- getOverrides?: () => Record<string, BillingMode>;
916
- } = {},
917
- defaults: {
918
- /** Settings-stored default profile id; null/absent = auto. */
919
- getOverride?: () => string | null;
920
- /** Auto rule: prefer this vendor's profile while its account is connected. */
921
- auto?: { vendor: string; hasCredential: () => Promise<boolean> };
922
- } = {}
432
+ options: {
433
+ getHiddenModelIds?: () => string[];
434
+ getDefaultModelId?: () => string | null;
435
+ getBillingOverrides?: () => Record<string, BillingMode>;
436
+ log?: Logger;
437
+ }
923
438
  ): BackendRegistry {
924
439
  let profileSnapshot: ProfileSnapshot | null = null;
925
440
 
926
- /** Recompute the profile roster (memoized), asserting cross-backend id uniqueness. */
927
441
  async function getProfileSnapshot(): Promise<ProfileSnapshot> {
928
442
  if (profileSnapshot && Date.now() - profileSnapshot.at < PROFILE_MEMO_MS) {
929
443
  return profileSnapshot;
930
444
  }
931
-
932
445
  const registry = await getRegistry();
933
446
  const byBackend = new Map<string, ProviderInfo[]>();
934
447
  const owners = new Map<string, string>();
935
-
936
448
  for (const backend of registry.backends) {
937
449
  const profiles = await backend.listProfiles();
938
450
  byBackend.set(backend.id, profiles);
@@ -946,62 +458,51 @@ function makeRegistry(
946
458
  owners.set(profile.id, backend.id);
947
459
  }
948
460
  }
949
-
950
461
  profileSnapshot = { at: Date.now(), byBackend, owners };
951
462
  return profileSnapshot;
952
463
  }
953
464
 
954
- /** A settings read failure must not take the picker down — degrade to "nothing hidden". */
955
465
  function hiddenIds(): Set<string> {
956
466
  try {
957
- return new Set(getHidden());
958
- } catch (err) {
959
- log?.emit({
467
+ return new Set(options.getHiddenModelIds?.() ?? []);
468
+ } catch (error) {
469
+ options.log?.emit({
960
470
  severityText: "WARN",
961
471
  body: "could not read hidden models; treating none as hidden",
962
- attributes: { error: err instanceof Error ? err.message : String(err) },
472
+ attributes: { error: error instanceof Error ? error.message : String(error) },
963
473
  });
964
474
  return new Set();
965
475
  }
966
476
  }
967
477
 
968
- /** Same degradation discipline: a failed override read means derived modes apply. */
969
478
  function billingOverrides(): Record<string, BillingMode> {
970
479
  try {
971
- return billing.getOverrides?.() ?? {};
972
- } catch (err) {
973
- log?.emit({
480
+ return options.getBillingOverrides?.() ?? {};
481
+ } catch (error) {
482
+ options.log?.emit({
974
483
  severityText: "WARN",
975
484
  body: "could not read billing overrides; using derived billing modes",
976
- attributes: { error: err instanceof Error ? err.message : String(err) },
485
+ attributes: { error: error instanceof Error ? error.message : String(error) },
977
486
  });
978
487
  return {};
979
488
  }
980
489
  }
981
490
 
982
- /**
983
- * The preferred-default profile. The stored Settings override wins while it
984
- * still names a roster profile (a vanished profile falls through to auto
985
- * rather than pinning turns to nothing); auto is the first non-hidden
986
- * roster entry of the auto vendor, but only while its credential exists.
987
- * Any failure degrades to "no preference" — this must never take routing
988
- * down.
989
- */
990
491
  async function getPreferredProfileId(): Promise<string | null> {
991
492
  try {
992
493
  const snapshot = await getProfileSnapshot();
993
- const override = defaults.getOverride?.() ?? null;
494
+ const override = options.getDefaultModelId?.() ?? null;
994
495
  if (override && snapshot.owners.has(override)) return override;
995
496
 
996
- const auto = defaults.auto;
997
- if (!auto) return null;
998
497
  const registry = await getRegistry();
999
498
  const hidden = hiddenIds();
1000
499
  for (const backend of registry.backends) {
500
+ const preference = registry.resolved.get(backend.id)?.preferredProfile;
501
+ if (!preference) continue;
1001
502
  const match = (snapshot.byBackend.get(backend.id) ?? []).find(
1002
- (profile) => profile.vendor === auto.vendor && !hidden.has(profile.id)
503
+ (profile) => preference.matches(profile) && !hidden.has(profile.id)
1003
504
  );
1004
- if (match) return (await auto.hasCredential()) ? match.id : null;
505
+ if (match) return (await preference.hasCredential()) ? match.id : null;
1005
506
  }
1006
507
  return null;
1007
508
  } catch {
@@ -1013,58 +514,47 @@ function makeRegistry(
1013
514
  async getBackends() {
1014
515
  return (await getRegistry()).backends;
1015
516
  },
1016
-
1017
517
  async getBackendById(id) {
1018
518
  return (await getRegistry()).byId.get(id);
1019
519
  },
1020
-
1021
520
  async getDefaultBackend() {
1022
521
  const registry = await getRegistry();
1023
522
  return registry.byId.get(registry.defaultBackendId)!;
1024
523
  },
1025
-
1026
524
  async getDefaultBackendId() {
1027
525
  return (await getRegistry()).defaultBackendId;
1028
526
  },
1029
-
1030
527
  async getBackendForProfile(profileId) {
1031
528
  const registry = await getRegistry();
1032
- // Deliberately resolved against the UNFILTERED roster: a session pinned
1033
- // to a profile the user later hid must keep running.
1034
529
  const snapshot = await getProfileSnapshot();
1035
530
  const backendId = snapshot.owners.get(profileId);
1036
531
  return backendId ? registry.byId.get(backendId) : undefined;
1037
532
  },
1038
-
1039
533
  async getBackendForSession(backendId) {
1040
534
  const registry = await getRegistry();
1041
- if (backendId) {
1042
- const backend = registry.byId.get(backendId);
1043
- if (backend) return backend;
535
+ if (!backendId) return registry.byId.get(registry.defaultBackendId)!;
536
+ const backend = registry.byId.get(backendId);
537
+ if (!backend) {
538
+ throw new Error(`Stored backend id "${backendId}" is not configured.`);
1044
539
  }
1045
- return registry.byId.get(registry.defaultBackendId)!;
540
+ return backend;
1046
541
  },
1047
-
1048
- async listAllProviders(options = {}) {
542
+ async listAllProviders(listOptions = {}) {
1049
543
  const registry = await getRegistry();
1050
544
  const snapshot = await getProfileSnapshot();
1051
- const hidden = options.includeHidden ? new Set<string>() : hiddenIds();
1052
- // Read fresh on every listing (not memoized with the snapshot), so a
1053
- // saved override is live for the very next run without invalidation.
545
+ const hidden = listOptions.includeHidden ? new Set<string>() : hiddenIds();
1054
546
  const overrides = billingOverrides();
1055
-
1056
547
  const providers = registry.backends.flatMap((backend) =>
1057
548
  (snapshot.byBackend.get(backend.id) ?? [])
1058
549
  .filter((profile) => !hidden.has(profile.id))
1059
550
  .map((profile) => {
1060
551
  const entry = { ...profile, backendId: backend.id };
1061
- const billingMode = overrides[profile.id] ?? billing.classify?.(entry);
552
+ const billingMode =
553
+ overrides[profile.id] ??
554
+ registry.resolved.get(backend.id)?.classifyBilling?.(profile);
1062
555
  return billingMode ? { ...entry, billingMode } : entry;
1063
556
  })
1064
557
  );
1065
-
1066
- // A connected subscription-auth profile leads the list: a fresh client
1067
- // with no stored selection defaults to providers[0].
1068
558
  const preferredId = await getPreferredProfileId();
1069
559
  if (preferredId) {
1070
560
  const index = providers.findIndex((provider) => provider.id === preferredId);
@@ -1072,9 +562,7 @@ function makeRegistry(
1072
562
  }
1073
563
  return providers;
1074
564
  },
1075
-
1076
565
  getPreferredProfileId,
1077
-
1078
566
  async getBackendsInfo() {
1079
567
  const backends = (await getRegistry()).backends;
1080
568
  return Object.fromEntries(
@@ -1084,9 +572,9 @@ function makeRegistry(
1084
572
  ])
1085
573
  );
1086
574
  },
1087
-
1088
- getModelSource,
1089
-
575
+ async getModelSource() {
576
+ return (await getRegistry()).modelSource;
577
+ },
1090
578
  invalidateProfiles() {
1091
579
  profileSnapshot = null;
1092
580
  },