@schlessera/brain-ui-server 0.23.0 → 0.25.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.
@@ -10,17 +10,25 @@ import { Hono } from "hono";
10
10
  import type { Database } from "bun:sqlite";
11
11
  import {
12
12
  isBillingMode,
13
+ isThinkingLevel,
13
14
  type BillingMode,
14
15
  type ModelCatalogEntry,
15
16
  type ModelCatalogResponse,
17
+ type ThinkingLevel,
16
18
  } from "@schlessera/brain-ui-sdk";
17
19
  import type { BackendRegistry } from "../agent/backend.js";
18
20
  import type { ModelPricingState } from "../pricing/model-pricing.js";
19
21
  import {
20
22
  getBillingOverrides,
23
+ getCustomOpenRouterModels,
24
+ getDefaultModelId,
21
25
  getHiddenModelIds,
26
+ getThinkingOverrides,
22
27
  setBillingOverrides,
28
+ setCustomOpenRouterModels,
29
+ setDefaultModelId,
23
30
  setHiddenModelIds,
31
+ setThinkingOverrides,
24
32
  } from "../db/settings.js";
25
33
 
26
34
  export function createModelRoutes(deps: {
@@ -37,6 +45,7 @@ export function createModelRoutes(deps: {
37
45
  const source = await registry.getModelSource();
38
46
  const hidden = new Set(getHiddenModelIds(db));
39
47
  const overrides = getBillingOverrides(db);
48
+ const thinking = getThinkingOverrides(db);
40
49
  // `billingMode` already rides each provider entry (the registry applies the
41
50
  // override last); the catalog additionally tags WHICH rows carry an explicit
42
51
  // override, so the settings screen can render auto vs forced.
@@ -46,11 +55,19 @@ export function createModelRoutes(deps: {
46
55
  ...profile,
47
56
  hidden: hidden.has(profile.id),
48
57
  ...(overrides[profile.id] ? { billingOverride: overrides[profile.id] } : {}),
58
+ // `thinkingLevel` on the profile is already the EFFECTIVE level (the
59
+ // registry applies overrides at read time); this tags which rows carry
60
+ // an explicit user choice, so the UI can render default vs forced.
61
+ ...(thinking[profile.id] ? { thinkingOverride: thinking[profile.id] } : {}),
49
62
  }));
50
63
 
51
64
  const state = source?.state();
65
+ const resolvedDefaultId = await registry.getPreferredProfileId();
52
66
  return {
53
67
  models,
68
+ defaultModelId: getDefaultModelId(db),
69
+ ...(resolvedDefaultId ? { resolvedDefaultId } : {}),
70
+ customModels: getCustomOpenRouterModels(db),
54
71
  refreshedAt: state?.refreshedAt ?? null,
55
72
  stale: state?.stale ?? false,
56
73
  discovery: {
@@ -96,6 +113,111 @@ export function createModelRoutes(deps: {
96
113
  return c.json(await buildCatalog());
97
114
  })
98
115
 
116
+ .put("/models/default", async (c) => {
117
+ const body = (await c.req.json().catch(() => null)) as unknown;
118
+ const defaultId = (body as { defaultId?: unknown } | null)?.defaultId;
119
+ if (defaultId !== null && typeof defaultId !== "string") {
120
+ return c.json({ error: "defaultId must be a profile id or null" }, 400);
121
+ }
122
+ if (typeof defaultId === "string") {
123
+ const known = await registry.listAllProviders({ includeHidden: true });
124
+ if (!known.some((profile) => profile.id === defaultId)) {
125
+ return c.json({ error: `Unknown profile id: ${defaultId}` }, 400);
126
+ }
127
+ }
128
+
129
+ setDefaultModelId(db, defaultId);
130
+ registry.invalidateProfiles();
131
+ return c.json(await buildCatalog());
132
+ })
133
+
134
+ .put("/models/custom", async (c) => {
135
+ const body = (await c.req.json().catch(() => null)) as unknown;
136
+ const models = (body as { models?: unknown } | null)?.models;
137
+ if (!Array.isArray(models) || models.some((id) => typeof id !== "string")) {
138
+ return c.json({ error: "models must be an array of OpenRouter model ids" }, 400);
139
+ }
140
+ // OpenRouter ids are "<org>/<model>", org and model from a small safe
141
+ // charset (e.g. "z.ai/glm-5.3-flash", "openai/gpt-oss-120b:nitro").
142
+ const ID_SHAPE = /^[A-Za-z0-9][\w.-]*\/[\w.:-]+$/;
143
+ const bad = (models as string[]).find((id) => !ID_SHAPE.test(id));
144
+ if (bad !== undefined) {
145
+ return c.json({ error: `Not an OpenRouter model id: "${bad}"` }, 400);
146
+ }
147
+
148
+ // A generated id ("openrouter:<model>") that collides with a profile
149
+ // another source owns (a pi profile, a discovered model) must be refused
150
+ // BEFORE persisting: stored, it would 500 every roster read until the
151
+ // setting is dug out of the database. An id the CLAUDE env already
152
+ // declares is fine — the merge dedupes it in the declared entry's favor.
153
+ const currentCustomIds = new Set(
154
+ getCustomOpenRouterModels(db).map((model) => `openrouter:${model}`)
155
+ );
156
+ const nonCustomIds = new Set(
157
+ (await registry.listAllProviders({ includeHidden: true }))
158
+ .filter((profile) => !currentCustomIds.has(profile.id))
159
+ .map((profile) => profile.id)
160
+ );
161
+ const collision = (models as string[])
162
+ .map((model) => `openrouter:${model}`)
163
+ .find((id) => nonCustomIds.has(id));
164
+ if (collision !== undefined) {
165
+ return c.json(
166
+ { error: `"${collision}" collides with an existing profile id.` },
167
+ 400
168
+ );
169
+ }
170
+
171
+ setCustomOpenRouterModels(db, models as string[]);
172
+ registry.invalidateProfiles();
173
+
174
+ // A removal that takes the stored default's profile off the roster would
175
+ // strand the default on nothing; reset it to auto — but only when the
176
+ // profile is actually gone (an id the env also declares survives the
177
+ // removal of its custom duplicate).
178
+ const currentDefault = getDefaultModelId(db);
179
+ if (currentDefault) {
180
+ const roster = await registry.listAllProviders({ includeHidden: true });
181
+ if (!roster.some((profile) => profile.id === currentDefault)) {
182
+ setDefaultModelId(db, null);
183
+ }
184
+ }
185
+ return c.json(await buildCatalog());
186
+ })
187
+
188
+ .put("/models/thinking", async (c) => {
189
+ const body = (await c.req.json().catch(() => null)) as unknown;
190
+ const thinking = (body as { thinking?: unknown } | null)?.thinking;
191
+ if (
192
+ typeof thinking !== "object" ||
193
+ thinking === null ||
194
+ Array.isArray(thinking) ||
195
+ Object.values(thinking).some((level) => !isThinkingLevel(level))
196
+ ) {
197
+ return c.json(
198
+ { error: "thinking must map profile ids to a reasoning-effort level" },
199
+ 400
200
+ );
201
+ }
202
+ // Only rows that actually take an effort level accept an override — a
203
+ // stray id would be stored dead weight and mislead the settings UI.
204
+ const known = await registry.listAllProviders({ includeHidden: true });
205
+ const supported = new Set(
206
+ known.filter((profile) => profile.thinkingLevel).map((profile) => profile.id)
207
+ );
208
+ const stray = Object.keys(thinking).find((id) => !supported.has(id));
209
+ if (stray !== undefined) {
210
+ return c.json(
211
+ { error: `Profile "${stray}" does not take a reasoning-effort level.` },
212
+ 400
213
+ );
214
+ }
215
+
216
+ setThinkingOverrides(db, thinking as Record<string, ThinkingLevel>);
217
+ registry.invalidateProfiles();
218
+ return c.json(await buildCatalog());
219
+ })
220
+
99
221
  .put("/models/billing", async (c) => {
100
222
  const body = (await c.req.json().catch(() => null)) as unknown;
101
223
  const billing = (body as { billing?: unknown } | null)?.billing;
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 } : {}) };