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