@schlessera/brain-ui-server 0.21.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +1 -0
  2. package/dist/agent/backend.d.ts +51 -0
  3. package/dist/agent/backend.d.ts.map +1 -1
  4. package/dist/agent/backend.js +222 -12
  5. package/dist/agent/backend.js.map +1 -1
  6. package/dist/app.d.ts.map +1 -1
  7. package/dist/app.js +5 -1
  8. package/dist/app.js.map +1 -1
  9. package/dist/config/env.d.ts +6 -0
  10. package/dist/config/env.d.ts.map +1 -1
  11. package/dist/config/env.js +10 -0
  12. package/dist/config/env.js.map +1 -1
  13. package/dist/db/settings.d.ts +12 -0
  14. package/dist/db/settings.d.ts.map +1 -1
  15. package/dist/db/settings.js +27 -0
  16. package/dist/db/settings.js.map +1 -1
  17. package/dist/routes/models.d.ts.map +1 -1
  18. package/dist/routes/models.js +64 -1
  19. package/dist/routes/models.js.map +1 -1
  20. package/dist/routes/pi-auth.d.ts +44 -0
  21. package/dist/routes/pi-auth.d.ts.map +1 -0
  22. package/dist/routes/pi-auth.js +100 -0
  23. package/dist/routes/pi-auth.js.map +1 -0
  24. package/dist/ws/bridge.d.ts.map +1 -1
  25. package/dist/ws/bridge.js +4 -0
  26. package/dist/ws/bridge.js.map +1 -1
  27. package/dist/ws/connection.d.ts.map +1 -1
  28. package/dist/ws/connection.js +1 -0
  29. package/dist/ws/connection.js.map +1 -1
  30. package/dist/ws/dispatch.d.ts.map +1 -1
  31. package/dist/ws/dispatch.js +1 -0
  32. package/dist/ws/dispatch.js.map +1 -1
  33. package/dist/ws/routing.d.ts.map +1 -1
  34. package/dist/ws/routing.js +9 -0
  35. package/dist/ws/routing.js.map +1 -1
  36. package/package.json +6 -4
  37. package/src/agent/backend.ts +293 -11
  38. package/src/app.ts +10 -1
  39. package/src/config/env.ts +17 -0
  40. package/src/db/settings.ts +30 -0
  41. package/src/routes/models.ts +80 -0
  42. package/src/routes/pi-auth.ts +153 -0
  43. package/src/ws/bridge.ts +4 -0
  44. package/src/ws/connection.ts +1 -0
  45. package/src/ws/dispatch.ts +1 -0
  46. package/src/ws/routing.ts +9 -0
@@ -18,8 +18,12 @@ import type { BackendRegistry } from "../agent/backend.js";
18
18
  import type { ModelPricingState } from "../pricing/model-pricing.js";
19
19
  import {
20
20
  getBillingOverrides,
21
+ getCustomOpenRouterModels,
22
+ getDefaultModelId,
21
23
  getHiddenModelIds,
22
24
  setBillingOverrides,
25
+ setCustomOpenRouterModels,
26
+ setDefaultModelId,
23
27
  setHiddenModelIds,
24
28
  } from "../db/settings.js";
25
29
 
@@ -49,8 +53,12 @@ export function createModelRoutes(deps: {
49
53
  }));
50
54
 
51
55
  const state = source?.state();
56
+ const resolvedDefaultId = await registry.getPreferredProfileId();
52
57
  return {
53
58
  models,
59
+ defaultModelId: getDefaultModelId(db),
60
+ ...(resolvedDefaultId ? { resolvedDefaultId } : {}),
61
+ customModels: getCustomOpenRouterModels(db),
54
62
  refreshedAt: state?.refreshedAt ?? null,
55
63
  stale: state?.stale ?? false,
56
64
  discovery: {
@@ -96,6 +104,78 @@ export function createModelRoutes(deps: {
96
104
  return c.json(await buildCatalog());
97
105
  })
98
106
 
107
+ .put("/models/default", async (c) => {
108
+ const body = (await c.req.json().catch(() => null)) as unknown;
109
+ const defaultId = (body as { defaultId?: unknown } | null)?.defaultId;
110
+ if (defaultId !== null && typeof defaultId !== "string") {
111
+ return c.json({ error: "defaultId must be a profile id or null" }, 400);
112
+ }
113
+ if (typeof defaultId === "string") {
114
+ const known = await registry.listAllProviders({ includeHidden: true });
115
+ if (!known.some((profile) => profile.id === defaultId)) {
116
+ return c.json({ error: `Unknown profile id: ${defaultId}` }, 400);
117
+ }
118
+ }
119
+
120
+ setDefaultModelId(db, defaultId);
121
+ registry.invalidateProfiles();
122
+ return c.json(await buildCatalog());
123
+ })
124
+
125
+ .put("/models/custom", async (c) => {
126
+ const body = (await c.req.json().catch(() => null)) as unknown;
127
+ const models = (body as { models?: unknown } | null)?.models;
128
+ if (!Array.isArray(models) || models.some((id) => typeof id !== "string")) {
129
+ return c.json({ error: "models must be an array of OpenRouter model ids" }, 400);
130
+ }
131
+ // OpenRouter ids are "<org>/<model>", org and model from a small safe
132
+ // charset (e.g. "z.ai/glm-5.3-flash", "openai/gpt-oss-120b:nitro").
133
+ const ID_SHAPE = /^[A-Za-z0-9][\w.-]*\/[\w.:-]+$/;
134
+ const bad = (models as string[]).find((id) => !ID_SHAPE.test(id));
135
+ if (bad !== undefined) {
136
+ return c.json({ error: `Not an OpenRouter model id: "${bad}"` }, 400);
137
+ }
138
+
139
+ // A generated id ("openrouter:<model>") that collides with a profile
140
+ // another source owns (a pi profile, a discovered model) must be refused
141
+ // BEFORE persisting: stored, it would 500 every roster read until the
142
+ // setting is dug out of the database. An id the CLAUDE env already
143
+ // declares is fine — the merge dedupes it in the declared entry's favor.
144
+ const currentCustomIds = new Set(
145
+ getCustomOpenRouterModels(db).map((model) => `openrouter:${model}`)
146
+ );
147
+ const nonCustomIds = new Set(
148
+ (await registry.listAllProviders({ includeHidden: true }))
149
+ .filter((profile) => !currentCustomIds.has(profile.id))
150
+ .map((profile) => profile.id)
151
+ );
152
+ const collision = (models as string[])
153
+ .map((model) => `openrouter:${model}`)
154
+ .find((id) => nonCustomIds.has(id));
155
+ if (collision !== undefined) {
156
+ return c.json(
157
+ { error: `"${collision}" collides with an existing profile id.` },
158
+ 400
159
+ );
160
+ }
161
+
162
+ setCustomOpenRouterModels(db, models as string[]);
163
+ registry.invalidateProfiles();
164
+
165
+ // A removal that takes the stored default's profile off the roster would
166
+ // strand the default on nothing; reset it to auto — but only when the
167
+ // profile is actually gone (an id the env also declares survives the
168
+ // removal of its custom duplicate).
169
+ const currentDefault = getDefaultModelId(db);
170
+ if (currentDefault) {
171
+ const roster = await registry.listAllProviders({ includeHidden: true });
172
+ if (!roster.some((profile) => profile.id === currentDefault)) {
173
+ setDefaultModelId(db, null);
174
+ }
175
+ }
176
+ return c.json(await buildCatalog());
177
+ })
178
+
99
179
  .put("/models/billing", async (c) => {
100
180
  const body = (await c.req.json().catch(() => null)) as unknown;
101
181
  const billing = (body as { billing?: unknown } | null)?.billing;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Provider sign-in over the pi backend's OAuth service — the Settings UI's
3
+ * path to logging in to OpenAI (ChatGPT Plus/Pro, device-code flow) without a
4
+ * shell on the host.
5
+ *
6
+ * The pi package is an OPTIONAL peer: it is reached only through
7
+ * `loadBackendModule("pi")` and typed as a local structural mirror, per the
8
+ * declaration-surface rules. The routes are live only when pi profiles are
9
+ * configured (BRAIN_UI_PI_PROFILES, or AGENT_BACKEND=pi) — provider ids are
10
+ * validated against the CONFIGURED roster's vendors, so an authenticated
11
+ * client still cannot start login flows for arbitrary providers.
12
+ *
13
+ * Mount BEHIND the /api auth guard: an open login-start endpoint would let
14
+ * anyone mint OpenAI device codes against this server.
15
+ */
16
+
17
+ import { Hono } from "hono";
18
+ import type { AgentConfig } from "../config/env.js";
19
+ import { loadBackendModule, parsePiProfiles } from "../agent/backend.js";
20
+
21
+ /** Structural mirror of the pi package's PiLoginFlow (primitives only). */
22
+ export interface PiLoginFlowView {
23
+ id: string;
24
+ providerId: string;
25
+ status: "pending" | "success" | "error" | "cancelled";
26
+ userCode?: string;
27
+ verificationUri?: string;
28
+ intervalSeconds?: number;
29
+ expiresInSeconds?: number;
30
+ error?: string;
31
+ startedAt: number;
32
+ }
33
+
34
+ /** Structural mirror of the pi package's PiAuthProviderStatus. */
35
+ export interface PiAuthProviderStatusView {
36
+ providerId: string;
37
+ name: string;
38
+ configured: boolean;
39
+ source?: string;
40
+ oauth: boolean;
41
+ }
42
+
43
+ /** Structural mirror of the pi package's PiAuth service. */
44
+ interface PiAuthMirror {
45
+ status(providerIds: string[]): Promise<PiAuthProviderStatusView[]>;
46
+ startLogin(providerId: string): Promise<PiLoginFlowView>;
47
+ getFlow(id: string): PiLoginFlowView | null;
48
+ cancelFlow(id: string): void;
49
+ logout(providerId: string): Promise<void>;
50
+ }
51
+
52
+ export interface PiAuthRoutesDeps {
53
+ agent: AgentConfig;
54
+ /** Test seam — forwarded to loadBackendModule. */
55
+ importer?: (specifier: string) => Promise<unknown>;
56
+ }
57
+
58
+ export function createPiAuthRoutes(deps: PiAuthRoutesDeps): Hono {
59
+ const { agent } = deps;
60
+
61
+ /**
62
+ * Vendors the deployment actually configured — the only providers this
63
+ * surface may touch. Empty when pi is not in play at all.
64
+ */
65
+ function allowedProviders(): string[] {
66
+ const fromProfiles = parsePiProfiles(agent.piProfilesJson, agent.profilesJson).map(
67
+ (profile) => profile.vendor
68
+ );
69
+ return [...new Set(fromProfiles)];
70
+ }
71
+
72
+ const piConfigured = () =>
73
+ Boolean(agent.piProfilesJson) || (agent.backend || "claude") === "pi";
74
+
75
+ // One service for the app's lifetime: it owns the pending-flow state.
76
+ let authPromise: Promise<PiAuthMirror> | null = null;
77
+ function getAuth(): Promise<PiAuthMirror> {
78
+ if (!authPromise) {
79
+ authPromise = (async () => {
80
+ const mod = (await loadBackendModule("pi", deps.importer)) as {
81
+ createPiAuth?: () => PiAuthMirror;
82
+ };
83
+ if (typeof mod.createPiAuth !== "function") {
84
+ throw new Error(
85
+ '"@schlessera/brain-backend-pi" does not export createPiAuth — update the package.'
86
+ );
87
+ }
88
+ return mod.createPiAuth();
89
+ })();
90
+ authPromise.catch(() => {
91
+ authPromise = null;
92
+ });
93
+ }
94
+ return authPromise;
95
+ }
96
+
97
+ return new Hono()
98
+ .get("/pi-auth/providers", async (c) => {
99
+ // Not-configured is a normal state, not an error: the client hides the
100
+ // whole card on an empty list.
101
+ if (!piConfigured()) return c.json({ providers: [] });
102
+ const providers = allowedProviders();
103
+ if (providers.length === 0) return c.json({ providers: [] });
104
+ const auth = await getAuth();
105
+ return c.json({ providers: await auth.status(providers) });
106
+ })
107
+ .post("/pi-auth/login", async (c) => {
108
+ const body = (await c.req.json().catch(() => null)) as {
109
+ providerId?: unknown;
110
+ } | null;
111
+ const providerId = typeof body?.providerId === "string" ? body.providerId : "";
112
+ if (!piConfigured()) {
113
+ return c.json({ error: "The pi backend is not configured." }, 409);
114
+ }
115
+ if (!providerId || !allowedProviders().includes(providerId)) {
116
+ return c.json({ error: "Unknown provider." }, 400);
117
+ }
118
+ const auth = await getAuth();
119
+ try {
120
+ const flow = await auth.startLogin(providerId);
121
+ return c.json({ flow });
122
+ } catch (err) {
123
+ // e.g. a provider whose flow the web surface cannot drive.
124
+ return c.json(
125
+ { error: err instanceof Error ? err.message : "Could not start login." },
126
+ 400
127
+ );
128
+ }
129
+ })
130
+ .get("/pi-auth/login/:id", async (c) => {
131
+ const auth = await getAuth();
132
+ const flow = auth.getFlow(c.req.param("id"));
133
+ if (!flow) return c.json({ error: "Unknown login flow." }, 404);
134
+ return c.json({ flow });
135
+ })
136
+ .delete("/pi-auth/login/:id", async (c) => {
137
+ const auth = await getAuth();
138
+ auth.cancelFlow(c.req.param("id"));
139
+ return c.json({ ok: true });
140
+ })
141
+ .post("/pi-auth/logout", async (c) => {
142
+ const body = (await c.req.json().catch(() => null)) as {
143
+ providerId?: unknown;
144
+ } | null;
145
+ const providerId = typeof body?.providerId === "string" ? body.providerId : "";
146
+ if (!providerId || !allowedProviders().includes(providerId)) {
147
+ return c.json({ error: "Unknown provider." }, 400);
148
+ }
149
+ const auth = await getAuth();
150
+ await auth.logout(providerId);
151
+ return c.json({ ok: true });
152
+ });
153
+ }
package/src/ws/bridge.ts CHANGED
@@ -35,6 +35,10 @@ export function makeBridge(
35
35
  // Echo the client's correlation id, so it can recognise which
36
36
  // announcement is its own rather than adopting the first to arrive.
37
37
  if (turn.draftId) msg = { ...msg, draftId: turn.draftId };
38
+ // The host owns backend identity: stamp it authoritatively so an
39
+ // injected backend that omits (or mislabels) it still scopes the
40
+ // client's tool rendering correctly.
41
+ msg = { ...msg, backendId };
38
42
  coordinator.bySession.set(msg.sessionId, turn);
39
43
  // Persist ownership the moment the identity exists — a turn that
40
44
  // later fails or is cancelled must not leave an unowned transcript.
@@ -104,6 +104,7 @@ export function createWsHandlers(host: WsHost) {
104
104
  sessionId: sid,
105
105
  isNew: false,
106
106
  providerId: turn.providerId ?? catalog.getStoredProviderId(sid) ?? undefined,
107
+ backendId: catalog.getStoredBackendId(sid) ?? turn.backend.id,
107
108
  },
108
109
  turn
109
110
  )
@@ -196,6 +196,7 @@ export async function handleClientMessage(
196
196
  sessionId: msg.sessionId,
197
197
  isNew: false,
198
198
  providerId: catalog.getStoredProviderId(msg.sessionId) ?? undefined,
199
+ backendId: catalog.getStoredBackendId(msg.sessionId) ?? undefined,
199
200
  });
200
201
 
201
202
  try {
package/src/ws/routing.ts CHANGED
@@ -33,6 +33,15 @@ export async function resolveTurnTarget(
33
33
  }
34
34
  }
35
35
 
36
+ // No explicit choice (fresh client, share, host-initiated action): the
37
+ // preferred default — the Settings-stored default model, else a connected
38
+ // subscription-auth profile — before the default backend's own first profile.
39
+ const preferred = await registry.getPreferredProfileId();
40
+ if (preferred) {
41
+ const backend = await registry.getBackendForProfile(preferred);
42
+ if (backend) return { backend, profileId: preferred };
43
+ }
44
+
36
45
  const backend = await registry.getDefaultBackend();
37
46
  const profileId = (await backend.listProfiles())[0]?.id;
38
47
  return { backend, ...(profileId ? { profileId } : {}) };