@schlessera/brain-ui-server 0.24.0 → 0.26.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.
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Web-search configuration for the pi backend's `pi-web-access` extension —
3
+ * the Settings UI's way to pick a search provider and store its API key
4
+ * without a shell on the host.
5
+ *
6
+ * The extension reads `web-search.json` from the pi config dir; these routes
7
+ * read-modify-write that same file, touching ONLY the managed fields (the
8
+ * `provider` selector and the per-provider `<name>ApiKey` entries) so any
9
+ * hand-edited config beside them survives.
10
+ *
11
+ * Defaults are deliberate: no file (or `provider: "auto"`) means the
12
+ * extension's automatic chain, which starts with Exa's keyless MCP endpoint —
13
+ * free, throttled. Adding an `exaApiKey` lifts the throttle; picking another
14
+ * provider routes searches there instead.
15
+ *
16
+ * Key VALUES never leave the server: GET reports only which providers have a
17
+ * key configured. Mount BEHIND the /api auth guard.
18
+ */
19
+ import { Hono } from "hono";
20
+ import type { AgentConfig } from "../config/env.js";
21
+ /** One selectable provider, as the Settings UI renders it. */
22
+ export interface WebSearchProviderView {
23
+ id: string;
24
+ label: string;
25
+ /** Whether this provider accepts an API key ("auto" does not). */
26
+ hasKeyField: boolean;
27
+ /** True when a key for it is present in the config file. */
28
+ keyConfigured: boolean;
29
+ /** True when the provider works without any key (Exa's free MCP tier). */
30
+ keyless: boolean;
31
+ }
32
+ export interface WebSearchConfigView {
33
+ /** False when the pi backend is not configured — the client hides the card. */
34
+ configured: boolean;
35
+ /** The active provider selection ("auto" when unset). */
36
+ provider: string;
37
+ providers: WebSearchProviderView[];
38
+ }
39
+ export interface WebSearchRoutesDeps {
40
+ agent: AgentConfig;
41
+ /** Test seam — overrides the config file location. */
42
+ configPath?: string;
43
+ /** Test seam — forwarded to loadBackendModule. */
44
+ importer?: (specifier: string) => Promise<unknown>;
45
+ }
46
+ export declare function createWebSearchRoutes(deps: WebSearchRoutesDeps): Hono;
47
+ //# sourceMappingURL=web-search.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"web-search.d.ts","sourceRoot":"","sources":["../../src/routes/web-search.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAIH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC5B,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAIpD,8DAA8D;AAC9D,MAAM,WAAW,qBAAqB;IACpC,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,kEAAkE;IAClE,WAAW,EAAE,OAAO,CAAC;IACrB,4DAA4D;IAC5D,aAAa,EAAE,OAAO,CAAC;IACvB,0EAA0E;IAC1E,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,mBAAmB;IAClC,+EAA+E;IAC/E,UAAU,EAAE,OAAO,CAAC;IACpB,yDAAyD;IACzD,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,qBAAqB,EAAE,CAAC;CACpC;AA4BD,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,WAAW,CAAC;IACnB,sDAAsD;IACtD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,kDAAkD;IAClD,QAAQ,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CACpD;AA0BD,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,mBAAmB,GAAG,IAAI,CAmHrE"}
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Web-search configuration for the pi backend's `pi-web-access` extension —
3
+ * the Settings UI's way to pick a search provider and store its API key
4
+ * without a shell on the host.
5
+ *
6
+ * The extension reads `web-search.json` from the pi config dir; these routes
7
+ * read-modify-write that same file, touching ONLY the managed fields (the
8
+ * `provider` selector and the per-provider `<name>ApiKey` entries) so any
9
+ * hand-edited config beside them survives.
10
+ *
11
+ * Defaults are deliberate: no file (or `provider: "auto"`) means the
12
+ * extension's automatic chain, which starts with Exa's keyless MCP endpoint —
13
+ * free, throttled. Adding an `exaApiKey` lifts the throttle; picking another
14
+ * provider routes searches there instead.
15
+ *
16
+ * Key VALUES never leave the server: GET reports only which providers have a
17
+ * key configured. Mount BEHIND the /api auth guard.
18
+ */
19
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
20
+ import { dirname, join } from "path";
21
+ import { Hono } from "hono";
22
+ import { resolvePiConfigDir } from "../config/env.js";
23
+ import { loadBackendModule } from "../agent/backend.js";
24
+ /**
25
+ * The providers this surface manages. A curated subset of pi-web-access's
26
+ * catalog: the ones configurable by a single `<id>ApiKey` field. The
27
+ * extension itself accepts more (SearXNG endpoints, Ollama, …) — those stay
28
+ * hand-edited config, deliberately outside this UI.
29
+ */
30
+ const MANAGED_PROVIDERS = [
31
+ { id: "auto", label: "Auto — Exa (free) with fallbacks", keyField: null, keyless: true },
32
+ { id: "exa", label: "Exa", keyField: "exaApiKey", keyless: true },
33
+ { id: "openai", label: "OpenAI", keyField: "openaiApiKey", keyless: false },
34
+ { id: "brave", label: "Brave", keyField: "braveApiKey", keyless: false },
35
+ { id: "tavily", label: "Tavily", keyField: "tavilyApiKey", keyless: false },
36
+ { id: "perplexity", label: "Perplexity", keyField: "perplexityApiKey", keyless: false },
37
+ { id: "firecrawl", label: "Firecrawl", keyField: "firecrawlApiKey", keyless: false },
38
+ { id: "jina", label: "Jina", keyField: "jinaApiKey", keyless: false },
39
+ { id: "kagi", label: "Kagi", keyField: "kagiApiKey", keyless: false },
40
+ { id: "gemini", label: "Gemini", keyField: "geminiApiKey", keyless: false },
41
+ ];
42
+ const MANAGED_IDS = new Set(MANAGED_PROVIDERS.map((p) => p.id));
43
+ function readConfig(path) {
44
+ if (!existsSync(path))
45
+ return {};
46
+ try {
47
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
48
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
49
+ ? parsed
50
+ : {};
51
+ }
52
+ catch {
53
+ // Unreadable config: treat as empty for GET; PUT refuses instead of
54
+ // silently clobbering whatever the user had there.
55
+ return {};
56
+ }
57
+ }
58
+ function writeConfig(path, config) {
59
+ mkdirSync(dirname(path), { recursive: true });
60
+ // Atomic-enough: temp file + rename, so the extension never reads a torn write.
61
+ const tmp = `${path}.${process.pid}.tmp`;
62
+ writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
63
+ renameSync(tmp, path);
64
+ }
65
+ export function createWebSearchRoutes(deps) {
66
+ const { agent } = deps;
67
+ const configPath = () => deps.configPath ?? join(resolvePiConfigDir(), "web-search.json");
68
+ const piConfigured = () => Boolean(agent.piProfilesJson) || (agent.backend || "claude") === "pi";
69
+ function view() {
70
+ const config = readConfig(configPath());
71
+ const provider = typeof config.provider === "string" && MANAGED_IDS.has(config.provider)
72
+ ? config.provider
73
+ : "auto";
74
+ return {
75
+ configured: true,
76
+ provider,
77
+ providers: MANAGED_PROVIDERS.map((p) => ({
78
+ id: p.id,
79
+ label: p.label,
80
+ hasKeyField: p.keyField !== null,
81
+ keyConfigured: p.keyField !== null &&
82
+ typeof config[p.keyField] === "string" &&
83
+ config[p.keyField].trim() !== "",
84
+ keyless: p.keyless,
85
+ })),
86
+ };
87
+ }
88
+ return new Hono()
89
+ .get("/web-search", (c) => {
90
+ // Not-configured is a normal state, not an error: the client hides the
91
+ // whole card (same convention as /pi-auth/providers).
92
+ if (!piConfigured()) {
93
+ return c.json({ configured: false, provider: "auto", providers: [] });
94
+ }
95
+ return c.json(view());
96
+ })
97
+ .put("/web-search", async (c) => {
98
+ if (!piConfigured()) {
99
+ return c.json({ error: "The pi backend is not configured." }, 409);
100
+ }
101
+ const body = (await c.req.json().catch(() => null));
102
+ if (!body || typeof body !== "object") {
103
+ return c.json({ error: "Invalid body." }, 400);
104
+ }
105
+ const path = configPath();
106
+ if (existsSync(path)) {
107
+ // A config that exists but does not parse must not be clobbered by a
108
+ // settings save — that would eat hand-written provider config.
109
+ try {
110
+ JSON.parse(readFileSync(path, "utf-8"));
111
+ }
112
+ catch {
113
+ return c.json({ error: `${path} exists but is not valid JSON — fix or remove it first.` }, 409);
114
+ }
115
+ }
116
+ const config = readConfig(path);
117
+ if (body.provider !== undefined) {
118
+ if (typeof body.provider !== "string" || !MANAGED_IDS.has(body.provider)) {
119
+ return c.json({ error: "Unknown provider." }, 400);
120
+ }
121
+ // "auto" is the extension's default — store it as absence, so an
122
+ // untouched deployment and a reset-to-default one look identical.
123
+ if (body.provider === "auto")
124
+ delete config.provider;
125
+ else
126
+ config.provider = body.provider;
127
+ }
128
+ if (body.apiKeys !== undefined) {
129
+ if (!body.apiKeys || typeof body.apiKeys !== "object" || Array.isArray(body.apiKeys)) {
130
+ return c.json({ error: "apiKeys must be an object." }, 400);
131
+ }
132
+ for (const [id, value] of Object.entries(body.apiKeys)) {
133
+ const spec = MANAGED_PROVIDERS.find((p) => p.id === id);
134
+ if (!spec || spec.keyField === null) {
135
+ return c.json({ error: `Provider "${id}" does not take an API key here.` }, 400);
136
+ }
137
+ if (value === null || value === "") {
138
+ delete config[spec.keyField];
139
+ }
140
+ else if (typeof value === "string") {
141
+ const trimmed = value.trim();
142
+ if (!trimmed || trimmed.length > 512 || /[\r\n]/.test(trimmed)) {
143
+ return c.json({ error: `Invalid API key for "${id}".` }, 400);
144
+ }
145
+ config[spec.keyField] = trimmed;
146
+ }
147
+ else {
148
+ return c.json({ error: `Invalid API key for "${id}".` }, 400);
149
+ }
150
+ }
151
+ }
152
+ writeConfig(path, config);
153
+ // Extension modules cache their config per process; clearing pi's
154
+ // extension cache makes NEW sessions re-read web-search.json. Best
155
+ // effort: on failure the change still lands, it just needs a restart
156
+ // (or a naturally recycled session) to apply.
157
+ try {
158
+ const mod = (await loadBackendModule("pi", deps.importer));
159
+ await mod.invalidateExtensionCache?.();
160
+ }
161
+ catch {
162
+ /* config written; cache clearing is an optimization */
163
+ }
164
+ return c.json(view());
165
+ });
166
+ }
167
+ //# sourceMappingURL=web-search.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"web-search.js","sourceRoot":"","sources":["../../src/routes/web-search.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@schlessera/brain-ui-server",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "description": "brain-kit chat-UI server: Hono app factory, WebSocket turn coordinator, auth (password/passkeys/tailscale/proxy), session catalog, and brain/files/voice routes",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -51,8 +51,8 @@
51
51
  "dependencies": {
52
52
  "@opentelemetry/api": "^1.9.1",
53
53
  "@opentelemetry/api-logs": "0.221.0",
54
- "@schlessera/brain-render-template": "0.24.0",
55
- "@schlessera/brain-ui-sdk": "0.24.0",
54
+ "@schlessera/brain-render-template": "0.26.0",
55
+ "@schlessera/brain-ui-sdk": "0.26.0",
56
56
  "@simplewebauthn/server": "^13.3.2",
57
57
  "hono": "^4",
58
58
  "ignore": "^7.0.5",
@@ -210,6 +210,12 @@ export interface BackendRegistryOptions {
210
210
  * to the Claude roster as declared OpenRouter profiles at runtime.
211
211
  */
212
212
  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>;
213
219
  /**
214
220
  * Per-profile billing-mode overrides (from the app's settings table).
215
221
  * Consulted LAST: an override wins over both the declared-credential rule
@@ -747,8 +753,9 @@ export function createBackendRegistry(
747
753
  const mod = (await loadBackendModule("pi")) as {
748
754
  createPiBackend?: (opts: {
749
755
  brainPath: string;
750
- profiles?: PiProfileInput[];
756
+ profiles?: PiProfileInput[] | (() => PiProfileInput[]);
751
757
  log?: BackendLogFn;
758
+ confirmBashPatterns?: readonly string[];
752
759
  }) => AgentBackend;
753
760
  };
754
761
  if (typeof mod.createPiBackend !== "function") {
@@ -759,10 +766,26 @@ export function createBackendRegistry(
759
766
  // Re-parsed here (assertBackendResolvable already validated at boot) so an
760
767
  // injected-registry path without the boot assert still fails loudly.
761
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
+ };
762
780
  return mod.createPiBackend({
763
781
  brainPath,
764
- ...(profiles.length > 0 ? { profiles } : {}),
782
+ ...(profiles.length > 0 ? { profiles: withOverrides } : {}),
765
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
+ : {}),
766
789
  });
767
790
  }
768
791
 
package/src/app.ts CHANGED
@@ -16,6 +16,7 @@ import { createRenderRoutes, type AppRenderer } from "./routes/render.js";
16
16
  import { createProviderRoutes } from "./routes/providers.js";
17
17
  import { createModelRoutes } from "./routes/models.js";
18
18
  import { createPiAuthRoutes } from "./routes/pi-auth.js";
19
+ import { createWebSearchRoutes } from "./routes/web-search.js";
19
20
  import { createGraphRoutes } from "./routes/graph.js";
20
21
  import {
21
22
  resolveAuthMode,
@@ -39,6 +40,7 @@ import {
39
40
  getCustomOpenRouterModels,
40
41
  getDefaultModelId,
41
42
  getHiddenModelIds,
43
+ getThinkingOverrides,
42
44
  } from "./db/settings.js";
43
45
  import { createActivityRuntime } from "./activity/runtime.js";
44
46
  import { createModelPricing } from "./pricing/model-pricing.js";
@@ -207,6 +209,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
207
209
  getHiddenModelIds: () => getHiddenModelIds(db, dbLog),
208
210
  getDefaultModelId: () => getDefaultModelId(db, dbLog),
209
211
  getCustomOpenRouterModels: () => getCustomOpenRouterModels(db, dbLog),
212
+ getThinkingOverrides: () => getThinkingOverrides(db, dbLog),
210
213
  getBillingOverrides: () => getBillingOverrides(db, dbLog),
211
214
  log: observability.logger("agent"),
212
215
  });
@@ -353,6 +356,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
353
356
  createModelRoutes({ registry, db, pricing, log: observability.logger("models") })
354
357
  );
355
358
  app.route("/api", createPiAuthRoutes({ agent: config.agent }));
359
+ app.route("/api", createWebSearchRoutes({ agent: config.agent }));
356
360
  app.route(
357
361
  "/api",
358
362
  createGraphRoutes({ brainRoot: config.brainPath, log: observability.logger("graph") })
package/src/config/env.ts CHANGED
@@ -60,10 +60,26 @@ export const ENV_VARS: readonly EnvVarDescriptor[] = [
60
60
  },
61
61
  {
62
62
  name: "HOME",
63
- description: "Fallback anchor for the BRAIN_PATH default only.",
63
+ description:
64
+ "Fallback anchor for the BRAIN_PATH default and the pi config dir (~/.pi).",
64
65
  default: "/root",
65
66
  required: false,
66
67
  },
68
+ {
69
+ name: "PI_CODING_AGENT_DIR",
70
+ description:
71
+ "pi config dir override — where the web-search settings write the " +
72
+ "pi-web-access extension's web-search.json (same precedence the " +
73
+ "extension itself uses).",
74
+ default: "$XDG_CONFIG_HOME/pi, else $HOME/.pi",
75
+ required: false,
76
+ },
77
+ {
78
+ name: "XDG_CONFIG_HOME",
79
+ description: "Second-precedence anchor for the pi config dir ($XDG_CONFIG_HOME/pi).",
80
+ default: null,
81
+ required: false,
82
+ },
67
83
  {
68
84
  name: "DB_PATH",
69
85
  description: "SQLite file for the UI's own database (sessions, passkeys, settings).",
@@ -624,3 +640,15 @@ export function resolveServerConfig(env: EnvRecord = process.env): ServerConfig
624
640
  export function subprocessEnv(extra: Record<string, string> = {}): EnvRecord {
625
641
  return { ...process.env, ...extra };
626
642
  }
643
+
644
+ /**
645
+ * The pi config directory, resolved with EXACTLY the precedence pi-web-access
646
+ * uses for its `web-search.json` (PI_CODING_AGENT_DIR, then XDG_CONFIG_HOME/pi,
647
+ * then ~/.pi) — the settings routes write the file the extension reads, so
648
+ * the two resolutions must never diverge.
649
+ */
650
+ export function resolvePiConfigDir(env: EnvRecord = process.env): string {
651
+ if (env.PI_CODING_AGENT_DIR) return env.PI_CODING_AGENT_DIR;
652
+ if (env.XDG_CONFIG_HOME) return join(env.XDG_CONFIG_HOME, "pi");
653
+ return join(env.HOME || "/root", ".pi");
654
+ }
@@ -8,12 +8,18 @@
8
8
 
9
9
  import type { Logger } from "@opentelemetry/api-logs";
10
10
  import type { Database } from "bun:sqlite";
11
- import { isBillingMode, type BillingMode } from "@schlessera/brain-ui-sdk/protocol";
11
+ import {
12
+ isBillingMode,
13
+ isThinkingLevel,
14
+ type BillingMode,
15
+ type ThinkingLevel,
16
+ } from "@schlessera/brain-ui-sdk/protocol";
12
17
 
13
18
  const HIDDEN_MODELS_KEY = "models.hidden";
14
19
  const BILLING_OVERRIDES_KEY = "models.billing";
15
20
  const DEFAULT_MODEL_KEY = "models.default";
16
21
  const CUSTOM_OPENROUTER_KEY = "models.customOpenRouter";
22
+ const THINKING_OVERRIDES_KEY = "models.thinking";
17
23
  const DETAIL_RETENTION_KEY = "activity.retention.detailDays";
18
24
  const DETAIL_RETENTION_DEFAULT_DAYS = 7;
19
25
 
@@ -119,6 +125,39 @@ export function setCustomOpenRouterModels(db: Database, models: string[]): void
119
125
  setSetting(db, CUSTOM_OPENROUTER_KEY, unique);
120
126
  }
121
127
 
128
+ /**
129
+ * Per-profile reasoning-effort overrides: profile id → forced level. A
130
+ * profile absent from the record keeps its configured default. Invalid
131
+ * levels are dropped on read (same degradation discipline as billing).
132
+ */
133
+ export function getThinkingOverrides(
134
+ db: Database,
135
+ log?: Logger
136
+ ): Record<string, ThinkingLevel> {
137
+ const value = getSetting<unknown>(db, THINKING_OVERRIDES_KEY, {}, log);
138
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
139
+ return Object.create(null);
140
+ }
141
+ const overrides: Record<string, ThinkingLevel> = Object.create(null);
142
+ for (const [profileId, level] of Object.entries(value)) {
143
+ if (isThinkingLevel(level)) overrides[profileId] = level;
144
+ }
145
+ return overrides;
146
+ }
147
+
148
+ /** Replace the override record (the client always sends the full record, not a delta). */
149
+ export function setThinkingOverrides(
150
+ db: Database,
151
+ overrides: Record<string, ThinkingLevel>
152
+ ): void {
153
+ const clean = Object.fromEntries(
154
+ Object.entries(overrides).filter(
155
+ ([profileId, level]) => profileId && isThinkingLevel(level)
156
+ )
157
+ );
158
+ setSetting(db, THINKING_OVERRIDES_KEY, clean);
159
+ }
160
+
122
161
  /** Minimum days a finished run keeps its detail (spans/events) before the
123
162
  * digest-covered prune may take it. 0 is valid (prune as soon as covered);
124
163
  * anything non-numeric or negative degrades to the default. */
@@ -10,9 +10,11 @@ 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";
@@ -21,10 +23,12 @@ import {
21
23
  getCustomOpenRouterModels,
22
24
  getDefaultModelId,
23
25
  getHiddenModelIds,
26
+ getThinkingOverrides,
24
27
  setBillingOverrides,
25
28
  setCustomOpenRouterModels,
26
29
  setDefaultModelId,
27
30
  setHiddenModelIds,
31
+ setThinkingOverrides,
28
32
  } from "../db/settings.js";
29
33
 
30
34
  export function createModelRoutes(deps: {
@@ -41,6 +45,7 @@ export function createModelRoutes(deps: {
41
45
  const source = await registry.getModelSource();
42
46
  const hidden = new Set(getHiddenModelIds(db));
43
47
  const overrides = getBillingOverrides(db);
48
+ const thinking = getThinkingOverrides(db);
44
49
  // `billingMode` already rides each provider entry (the registry applies the
45
50
  // override last); the catalog additionally tags WHICH rows carry an explicit
46
51
  // override, so the settings screen can render auto vs forced.
@@ -50,6 +55,10 @@ export function createModelRoutes(deps: {
50
55
  ...profile,
51
56
  hidden: hidden.has(profile.id),
52
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] } : {}),
53
62
  }));
54
63
 
55
64
  const state = source?.state();
@@ -176,6 +185,39 @@ export function createModelRoutes(deps: {
176
185
  return c.json(await buildCatalog());
177
186
  })
178
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
+
179
221
  .put("/models/billing", async (c) => {
180
222
  const body = (await c.req.json().catch(() => null)) as unknown;
181
223
  const billing = (body as { billing?: unknown } | null)?.billing;