@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.
- package/README.md +12 -0
- package/dist/agent/backend.d.ts +17 -219
- package/dist/agent/backend.d.ts.map +1 -1
- package/dist/agent/backend.js +212 -471
- package/dist/agent/backend.js.map +1 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +2 -1
- package/dist/app.js.map +1 -1
- package/dist/bin/brain-ui-cron.d.ts +1 -1
- package/dist/bin/brain-ui-cron.d.ts.map +1 -1
- package/dist/bin/brain-ui-cron.js +24 -7
- package/dist/bin/brain-ui-cron.js.map +1 -1
- package/dist/brain/client.js +2 -2
- package/dist/brain/client.js.map +1 -1
- package/dist/config/env.d.ts +18 -15
- package/dist/config/env.d.ts.map +1 -1
- package/dist/config/env.js +33 -13
- package/dist/config/env.js.map +1 -1
- package/dist/cron/emit.d.ts +5 -3
- package/dist/cron/emit.d.ts.map +1 -1
- package/dist/cron/emit.js +31 -12
- package/dist/cron/emit.js.map +1 -1
- package/dist/middleware/origin.d.ts.map +1 -1
- package/dist/middleware/origin.js +22 -1
- package/dist/middleware/origin.js.map +1 -1
- package/dist/routes/brain.js +2 -2
- package/dist/routes/brain.js.map +1 -1
- package/dist/routes/pi-auth.d.ts.map +1 -1
- package/dist/routes/pi-auth.js +14 -6
- package/dist/routes/pi-auth.js.map +1 -1
- package/dist/routes/web-search.d.ts.map +1 -1
- package/dist/routes/web-search.js +12 -8
- package/dist/routes/web-search.js.map +1 -1
- package/package.json +3 -3
- package/src/agent/backend.ts +281 -793
- package/src/app.ts +2 -1
- package/src/bin/brain-ui-cron.ts +32 -6
- package/src/brain/client.ts +2 -2
- package/src/config/env.ts +69 -17
- package/src/cron/emit.ts +42 -10
- package/src/middleware/origin.ts +21 -1
- package/src/routes/brain.ts +2 -2
- package/src/routes/pi-auth.ts +13 -8
- package/src/routes/web-search.ts +12 -10
package/src/agent/backend.ts
CHANGED
|
@@ -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
|
-
|
|
53
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
|
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)
|
|
203
|
+
if (moduleNotFoundSpecifier(error) === entry.specifier) {
|
|
204
|
+
throw missingBackendError(key);
|
|
205
|
+
}
|
|
405
206
|
throw error;
|
|
406
207
|
}
|
|
407
208
|
}
|
|
408
209
|
|
|
409
|
-
/**
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
439
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
)
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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(
|
|
291
|
+
resolve(entry.specifier);
|
|
517
292
|
} catch {
|
|
518
|
-
throw
|
|
519
|
-
|
|
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
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
678
|
-
|
|
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 (
|
|
691
|
-
throw
|
|
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
|
-
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
-
|
|
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
|
-
|
|
881
|
-
defaultBackendId =
|
|
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
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
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
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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(
|
|
958
|
-
} catch (
|
|
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:
|
|
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
|
|
972
|
-
} catch (
|
|
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:
|
|
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 =
|
|
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
|
|
503
|
+
(profile) => preference.matches(profile) && !hidden.has(profile.id)
|
|
1003
504
|
);
|
|
1004
|
-
if (match) return (await
|
|
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
|
-
|
|
1043
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
1089
|
-
|
|
575
|
+
async getModelSource() {
|
|
576
|
+
return (await getRegistry()).modelSource;
|
|
577
|
+
},
|
|
1090
578
|
invalidateProfiles() {
|
|
1091
579
|
profileSnapshot = null;
|
|
1092
580
|
},
|