@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.
- package/README.md +1 -0
- package/dist/agent/backend.d.ts +51 -0
- package/dist/agent/backend.d.ts.map +1 -1
- package/dist/agent/backend.js +222 -12
- package/dist/agent/backend.js.map +1 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +5 -1
- package/dist/app.js.map +1 -1
- package/dist/config/env.d.ts +6 -0
- package/dist/config/env.d.ts.map +1 -1
- package/dist/config/env.js +10 -0
- package/dist/config/env.js.map +1 -1
- package/dist/db/settings.d.ts +12 -0
- package/dist/db/settings.d.ts.map +1 -1
- package/dist/db/settings.js +27 -0
- package/dist/db/settings.js.map +1 -1
- package/dist/routes/models.d.ts.map +1 -1
- package/dist/routes/models.js +64 -1
- package/dist/routes/models.js.map +1 -1
- package/dist/routes/pi-auth.d.ts +44 -0
- package/dist/routes/pi-auth.d.ts.map +1 -0
- package/dist/routes/pi-auth.js +100 -0
- package/dist/routes/pi-auth.js.map +1 -0
- package/dist/ws/bridge.d.ts.map +1 -1
- package/dist/ws/bridge.js +4 -0
- package/dist/ws/bridge.js.map +1 -1
- package/dist/ws/connection.d.ts.map +1 -1
- package/dist/ws/connection.js +1 -0
- package/dist/ws/connection.js.map +1 -1
- package/dist/ws/dispatch.d.ts.map +1 -1
- package/dist/ws/dispatch.js +1 -0
- package/dist/ws/dispatch.js.map +1 -1
- package/dist/ws/routing.d.ts.map +1 -1
- package/dist/ws/routing.js +9 -0
- package/dist/ws/routing.js.map +1 -1
- package/package.json +6 -4
- package/src/agent/backend.ts +293 -11
- package/src/app.ts +10 -1
- package/src/config/env.ts +17 -0
- package/src/db/settings.ts +30 -0
- package/src/routes/models.ts +80 -0
- package/src/routes/pi-auth.ts +153 -0
- package/src/ws/bridge.ts +4 -0
- package/src/ws/connection.ts +1 -0
- package/src/ws/dispatch.ts +1 -0
- package/src/ws/routing.ts +9 -0
package/src/routes/models.ts
CHANGED
|
@@ -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.
|
package/src/ws/connection.ts
CHANGED
|
@@ -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
|
)
|
package/src/ws/dispatch.ts
CHANGED
|
@@ -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 } : {}) };
|