diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 50a2926cb..5f3c4a51f 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -25857,6 +25857,28 @@ components:
minimum: 10
maximum: 600
- type: "null"
+ localReasoningEffort:
+ description: Reasoning setting of the Local provider entry, stored on that entry of the provider chain. `none` = Off,
+ `null` = Default (nothing sent). Omitted leaves it untouched.
+ anyOf:
+ - type: string
+ enum:
+ - none
+ - low
+ - medium
+ - high
+ - type: "null"
+ compatReasoningEffort:
+ description: Reasoning setting of the OpenAI-compatible gateway entry, stored on that entry of the provider chain.
+ `none` = Off, `null` = Default (nothing sent). Omitted leaves it untouched.
+ anyOf:
+ - type: string
+ enum:
+ - none
+ - low
+ - medium
+ - high
+ - type: "null"
description: "Partial update of the calling user's AI-provider configuration. An omitted key is left untouched; `null`
(or an empty string, on the string fields) clears the column. At least one recognised key must be present, or
the write is refused. Key material is write-only: it is encrypted on the way in and never returned. Unknown keys
@@ -27073,6 +27095,7 @@ components:
properties:
chain:
minItems: 1
+ maxItems: 7
type: array
items:
type: object
@@ -27098,15 +27121,30 @@ components:
maximum: 9007199254740991
enabled:
type: boolean
+ reasoningEffort:
+ description: Only on a `local` or `openai-compatible` entry; refused on any other. Omitted keeps the value already
+ stored for that provider, `null` restores Default, `none` / `low` / `medium` / `high` are sent as
+ `reasoning_effort`.
+ anyOf:
+ - type: string
+ enum:
+ - none
+ - low
+ - medium
+ - high
+ - type: "null"
required:
- providerType
- enabled
+ additionalProperties: false
description: The whole chain, in the order it should be walked. At least one entry and at most one per known provider
type; a repeated type is refused.
required:
- chain
+ additionalProperties: false
description: Replaces the account's provider chain wholesale. No credential is written or read here — only which
- provider types exist, in what order, and which are enabled.
+ provider types exist, in what order, which are enabled, and the reasoning setting of the Local and
+ OpenAI-compatible entries.
InsightsFeedbackRequest:
type: object
properties:
@@ -48545,6 +48583,27 @@ components:
- type: "null"
description: v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the built-in
comprehensive-briefing default (~120 s). Mainly for slow local/self-hosted backends.
+ localReasoningEffort:
+ anyOf:
+ - type: string
+ enum:
+ - none
+ - low
+ - medium
+ - high
+ - type: "null"
+ description: "#1126 — reasoning setting of the Local provider entry, sent as `reasoning_effort` (`none` = Off). Null =
+ Default: the key is not sent."
+ compatReasoningEffort:
+ anyOf:
+ - type: string
+ enum:
+ - none
+ - low
+ - medium
+ - high
+ - type: "null"
+ description: "#1126 — the same for the OpenAI-compatible gateway entry."
serverProviderHealth:
type: string
enum:
@@ -48589,6 +48648,8 @@ components:
- compatModel
- hasCompatKey
- responseTimeoutSeconds
+ - localReasoningEffort
+ - compatReasoningEffort
- serverProviderHealth
- serverProviderOffer
- serverProviderConsent
diff --git a/e2e/ai-provider-dropdown.spec.ts b/e2e/ai-provider-dropdown.spec.ts
index ea2c00bb2..adacbdd2c 100644
--- a/e2e/ai-provider-dropdown.spec.ts
+++ b/e2e/ai-provider-dropdown.spec.ts
@@ -339,4 +339,116 @@ test.describe("Settings → AI provider dropdown UX (B2)", () => {
.locator('button[type="submit"]'),
).toBeEnabled();
});
+
+ // #1126 — the Reasoning select on the Local and OpenAI-compatible forms
+ // saves with the form, and at phone width it sits in the same column as
+ // the model field without pushing the page sideways.
+ test("reasoning setting saves from the Local and gateway forms at 390 px", async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ const patchBodies: Array> = [];
+ let providerState: Record = {
+ provider: "LOCAL",
+ model: null,
+ baseUrl: "http://localhost:11434/v1",
+ responseTimeoutSeconds: null,
+ hasAnthropicKey: false,
+ anthropicKeyPreview: null,
+ hasLocalKey: false,
+ hasOpenaiKey: false,
+ openaiKeyPreview: null,
+ compatBaseUrl: "https://gateway.example.org/v1",
+ compatModel: "qwen3",
+ hasCompatKey: false,
+ localReasoningEffort: null,
+ compatReasoningEffort: null,
+ };
+ await page.route("**/api/insights/settings", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ data: {
+ codexStatus: "disconnected",
+ codexConnectedAt: null,
+ hasAdminKey: false,
+ codexOauthConfigured: true,
+ privacyMode: "aggregated",
+ lastInsightAt: null,
+ },
+ error: null,
+ }),
+ }),
+ );
+ await page.route("**/api/user/ai-provider", async (route) => {
+ if (route.request().method() === "PATCH") {
+ const body = route.request().postDataJSON() as Record;
+ patchBodies.push(body);
+ providerState = { ...providerState, ...body };
+ }
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ data: providerState, error: null }),
+ });
+ });
+ await page.route("**/api/insights/provider-chain", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ data: {
+ activeProvider: "local",
+ cachedActiveProvider: null,
+ configuredChain: [
+ { providerType: "local", enabled: true, available: true },
+ ],
+ },
+ error: null,
+ }),
+ }),
+ );
+
+ const providerHydrated = page.waitForResponse("**/api/user/ai-provider");
+ await page.goto("/settings/ai?provider=local", {
+ waitUntil: "domcontentloaded",
+ });
+ await providerHydrated;
+
+ const localForm = page.getByTestId("ai-provider-config-local");
+ const localReasoning = page.locator("#ai-local-reasoning");
+ await expect(localReasoning).toHaveValue("");
+ // Same column, same width as the model select above it.
+ const [modelBox, reasoningBox] = await Promise.all([
+ page.locator("#ai-local-model").boundingBox(),
+ localReasoning.boundingBox(),
+ ]);
+ expect(reasoningBox?.x).toBe(modelBox?.x);
+ expect(reasoningBox?.width).toBe(modelBox?.width);
+ expect(
+ await page.evaluate(
+ () =>
+ document.documentElement.scrollWidth <=
+ document.documentElement.clientWidth,
+ ),
+ ).toBe(true);
+
+ await localReasoning.selectOption("none");
+ await localForm.locator('button[type="submit"]').click();
+ await expect.poll(() => patchBodies.length).toBe(1);
+ expect(patchBodies[0].localReasoningEffort).toBe("none");
+
+ await page
+ .getByTestId("ai-active-provider-select")
+ .selectOption("openai-compatible");
+ const compatForm = page.getByTestId("ai-provider-config-openai-compatible");
+ const compatReasoning = page.locator("#ai-compat-reasoning");
+ await expect(compatReasoning).toHaveValue("");
+ await compatReasoning.selectOption("high");
+ await compatForm.locator('button[type="submit"]').click();
+ await expect.poll(() => patchBodies.length).toBe(2);
+ expect(patchBodies[1].compatReasoningEffort).toBe("high");
+ expect(patchBodies[1]).not.toHaveProperty("localReasoningEffort");
+ });
});
diff --git a/messages/de.json b/messages/de.json
index 0c7173452..5186bb8b6 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -6080,6 +6080,17 @@
"title": "Coach-Pläne",
"description": "Die Pläne, die du mit dem Coach vereinbart hast, zum Lesen oder Entfernen.",
"open": "Pläne öffnen"
+ },
+ "reasoning": {
+ "label": "Nachdenken",
+ "hint": "Reasoning-Modelle wie Gemma oder Qwen denken nach, bevor sie antworten. Schaltest du es aus, bleibt das ganze Antwortbudget für die Antwort selbst.",
+ "options": {
+ "default": "Standard",
+ "none": "Aus",
+ "low": "Niedrig",
+ "medium": "Mittel",
+ "high": "Hoch"
+ }
}
},
"dashboard": {
diff --git a/messages/en.json b/messages/en.json
index 3cd9aa9a4..fc41483a9 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -6080,6 +6080,17 @@
"title": "Coach plans",
"description": "The plans you agreed with the Coach, to read or remove.",
"open": "Open plans"
+ },
+ "reasoning": {
+ "label": "Reasoning",
+ "hint": "Reasoning models such as Gemma or Qwen think before they answer. Off leaves the whole answer budget for the answer itself.",
+ "options": {
+ "default": "Default",
+ "none": "Off",
+ "low": "Low",
+ "medium": "Medium",
+ "high": "High"
+ }
}
},
"dashboard": {
diff --git a/messages/es.json b/messages/es.json
index d40131208..90919f624 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -6080,6 +6080,17 @@
"title": "Planes del Coach",
"description": "Los planes que acordaste con el Coach, para leerlos o eliminarlos.",
"open": "Abrir planes"
+ },
+ "reasoning": {
+ "label": "Razonamiento",
+ "hint": "Los modelos de razonamiento como Gemma o Qwen piensan antes de responder. Si lo desactivas, todo el presupuesto de respuesta se queda para la respuesta.",
+ "options": {
+ "default": "Predeterminado",
+ "none": "Desactivado",
+ "low": "Bajo",
+ "medium": "Medio",
+ "high": "Alto"
+ }
}
},
"dashboard": {
diff --git a/messages/fr.json b/messages/fr.json
index a5d0afa08..5d0c5570d 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -6080,6 +6080,17 @@
"title": "Plans du Coach",
"description": "Les plans convenus avec le Coach, à lire ou à supprimer.",
"open": "Ouvrir les plans"
+ },
+ "reasoning": {
+ "label": "Raisonnement",
+ "hint": "Les modèles de raisonnement comme Gemma ou Qwen réfléchissent avant de répondre. Désactivé, tout le budget de réponse sert à la réponse elle-même.",
+ "options": {
+ "default": "Par défaut",
+ "none": "Désactivé",
+ "low": "Faible",
+ "medium": "Moyen",
+ "high": "Élevé"
+ }
}
},
"dashboard": {
diff --git a/messages/it.json b/messages/it.json
index e0e489863..8654d8fea 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -6080,6 +6080,17 @@
"title": "Piani del Coach",
"description": "I piani concordati con il Coach, da leggere o rimuovere.",
"open": "Apri i piani"
+ },
+ "reasoning": {
+ "label": "Ragionamento",
+ "hint": "I modelli di ragionamento come Gemma o Qwen pensano prima di rispondere. Se lo disattivi, tutto il budget della risposta resta per la risposta stessa.",
+ "options": {
+ "default": "Predefinito",
+ "none": "Disattivato",
+ "low": "Basso",
+ "medium": "Medio",
+ "high": "Alto"
+ }
}
},
"dashboard": {
diff --git a/messages/ko.json b/messages/ko.json
index e51af7017..ac44f12e5 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -6080,6 +6080,17 @@
"title": "코치 계획",
"description": "코치와 합의한 계획을 읽거나 삭제할 수 있습니다.",
"open": "계획 열기"
+ },
+ "reasoning": {
+ "label": "추론",
+ "hint": "Gemma나 Qwen 같은 추론 모델은 답하기 전에 먼저 생각합니다. 끄면 답변 예산을 모두 답변 자체에 씁니다.",
+ "options": {
+ "default": "기본값",
+ "none": "끄기",
+ "low": "낮음",
+ "medium": "보통",
+ "high": "높음"
+ }
}
},
"dashboard": {
diff --git a/messages/pl.json b/messages/pl.json
index caa7f9245..17a2e68fe 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -6080,6 +6080,17 @@
"title": "Plany z Coachem",
"description": "Plany uzgodnione z Coachem, do przeczytania lub usunięcia.",
"open": "Otwórz plany"
+ },
+ "reasoning": {
+ "label": "Rozumowanie",
+ "hint": "Modele rozumujące, takie jak Gemma czy Qwen, myślą przed odpowiedzią. Po wyłączeniu cały budżet odpowiedzi idzie na samą odpowiedź.",
+ "options": {
+ "default": "Domyślnie",
+ "none": "Wyłączone",
+ "low": "Niskie",
+ "medium": "Średnie",
+ "high": "Wysokie"
+ }
}
},
"dashboard": {
diff --git a/src/__tests__/ai-reasoning-effort-guard.test.ts b/src/__tests__/ai-reasoning-effort-guard.test.ts
new file mode 100644
index 000000000..e555ab141
--- /dev/null
+++ b/src/__tests__/ai-reasoning-effort-guard.test.ts
@@ -0,0 +1,124 @@
+/**
+ * Structural guard (#1126): `reasoning_effort` reaches a model only through
+ * the setting the resolver bound to the provider instance.
+ *
+ * Default must send nothing, so the key may not appear on any wire except as
+ * the bound value, and only the two clients whose endpoint a person chose may
+ * carry it. Pinned here:
+ *
+ * 1. the key appears in exactly the Local client and the OpenAI client,
+ * once each, and only as `reasoning_effort: this.reasoningEffort`;
+ * 2. nothing but the binder assigns `.reasoningEffort`;
+ * 3. every exported resolver in `provider.ts` calls `bindReasoningEffort`.
+ *
+ * That clients are constructed only in `provider.ts` (so a resolver always
+ * binds them) is frozen by `ai-call-timeout-guard.test.ts`.
+ *
+ * Its limit: it reads source text. A body built under a computed key, or a
+ * resolver that binds through an alias, would slip it.
+ * `reasoning-effort.test.ts` and `provider-reasoning-effort.test.ts` prove
+ * the behaviour on the wire.
+ *
+ * Mutation check: hard-code `reasoning_effort: "none"` into either client,
+ * set `client.reasoningEffort = ...` in a route, or drop `bindReasoningEffort`
+ * from `resolveProviderForTest`: each one goes red here.
+ */
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+import { walkSourceFiles } from "./helpers/source-files";
+
+const SRC = join(process.cwd(), "src");
+
+function stripComments(source: string): string {
+ return source
+ .replace(/\/\*[\s\S]*?\*\//g, "")
+ .replace(/(^|[^:])\/\/.*$/gm, "$1");
+}
+
+function code(rel: string): string {
+ return stripComments(readFileSync(join(SRC, rel), "utf8"));
+}
+
+const files = walkSourceFiles(SRC, { floor: 3000 })
+ .filter((p) => !p.startsWith("generated/"))
+ .filter((p) => !p.includes("__tests__"))
+ .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"));
+
+describe("reasoning_effort on the wire", () => {
+ // As an object key: the published descriptions name the field in prose.
+ const KEY = /\breasoning_effort["']?\s*:/g;
+ const sites = files.filter((rel) => new RegExp(KEY).test(code(rel)));
+
+ it("appears in the Local and the OpenAI client only", () => {
+ expect(sites.sort()).toEqual([
+ "lib/ai/local-client.ts",
+ "lib/ai/openai-client.ts",
+ ]);
+ });
+
+ it.each(["lib/ai/local-client.ts", "lib/ai/openai-client.ts"])(
+ "%s sends it once, as the bound value",
+ (rel) => {
+ const source = code(rel);
+ expect(source.match(new RegExp(KEY))?.length).toBe(1);
+ expect(source).toMatch(
+ /\breasoning_effort\s*:\s*this\s*\.\s*reasoningEffort\b/,
+ );
+ },
+ );
+});
+
+/** Where `.reasoningEffort` may be assigned, and why. */
+const ASSIGNMENT_SITES: Record = {
+ "lib/ai/reasoning-effort.ts":
+ "The binder, the one place a provider instance gets its value.",
+ "lib/ai/provider-chain.ts":
+ "Writes the stored chain entry, not a provider instance.",
+};
+
+describe("only the binder assigns it to a provider", () => {
+ it("names every assignment site, with a reason", () => {
+ const sites = files.filter((rel) =>
+ /\.\s*reasoningEffort\s*=(?!=)/.test(code(rel)),
+ );
+ expect(sites.sort()).toEqual(Object.keys(ASSIGNMENT_SITES).sort());
+ });
+});
+
+describe("every exported resolver binds it", () => {
+ const source = code("lib/ai/provider.ts");
+ const resolvers = source
+ .split(/\nexport\s+/)
+ .map((chunk) => {
+ const m = /^async\s+function\s+(\w+)\s*\(/.exec(chunk);
+ if (!m) return null;
+ const header = chunk.slice(0, chunk.indexOf("{\n"));
+ if (
+ !/Promise<\s*(?:AIProvider|ProviderChainResolved\[\])\s*>/.test(header)
+ ) {
+ return null;
+ }
+ const end = chunk.indexOf("\n}\n");
+ return { name: m[1], body: chunk.slice(header.length, end) };
+ })
+ .filter((r): r is { name: string; body: string } => r !== null);
+
+ it("finds the resolvers (an empty set is a failure, not a pass)", () => {
+ expect(resolvers.map((r) => r.name).sort()).toEqual([
+ "resolveProvider",
+ "resolveProviderChain",
+ "resolveProviderForTest",
+ ]);
+ });
+
+ it.each(resolvers.map((r) => r.name))(
+ "%s calls bindReasoningEffort",
+ (name) => {
+ const resolver = resolvers.find((r) => r.name === name);
+ expect(resolver?.body ?? "").toMatch(/\bbindReasoningEffort\s*\(/);
+ },
+ );
+});
diff --git a/src/app/api/insights/provider-chain/__tests__/put.test.ts b/src/app/api/insights/provider-chain/__tests__/put.test.ts
index 510fa5fe7..3413fc0a2 100644
--- a/src/app/api/insights/provider-chain/__tests__/put.test.ts
+++ b/src/app/api/insights/provider-chain/__tests__/put.test.ts
@@ -53,6 +53,8 @@ vi.mock("@/lib/db", () => ({
prisma: {
user: {
update: vi.fn(),
+ // #1126 — the stored chain, read for the reasoning settings it carries.
+ findUnique: vi.fn(async () => null),
},
},
}));
@@ -172,4 +174,93 @@ describe("PUT /api/insights/provider-chain", () => {
{ providerType: "codex", priority: 2, enabled: true },
]);
});
+
+ // #1126 — the reasoning setting rides on the local / openai-compatible
+ // entries. The chain editor only reorders and toggles, so saving the order
+ // must not wipe a value it never sent.
+ describe("reasoning setting", () => {
+ function storedChain(chain: unknown) {
+ vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
+ aiProviderChain: chain,
+ } as never);
+ }
+ async function put(chain: unknown[]) {
+ vi.mocked(prisma.user.update).mockResolvedValueOnce({} as never);
+ return (PUT as (req: Request) => Promise)(
+ jsonRequest({ chain }),
+ );
+ }
+ function persisted() {
+ return vi.mocked(prisma.user.update).mock.calls[0][0].data
+ .aiProviderChain;
+ }
+
+ it("persists a value sent on a local entry", async () => {
+ storedChain(null);
+ const res = await put([
+ { providerType: "local", enabled: true, reasoningEffort: "none" },
+ { providerType: "openai", enabled: true },
+ ]);
+ expect(res.status).toBe(200);
+ expect(persisted()).toEqual([
+ {
+ providerType: "local",
+ priority: 1,
+ enabled: true,
+ reasoningEffort: "none",
+ },
+ { providerType: "openai", priority: 2, enabled: true },
+ ]);
+ });
+
+ it("keeps the stored value when the body leaves it out", async () => {
+ storedChain([
+ { providerType: "openai", priority: 1, enabled: true },
+ {
+ providerType: "openai-compatible",
+ priority: 2,
+ enabled: false,
+ reasoningEffort: "high",
+ },
+ ]);
+ await put([
+ { providerType: "openai-compatible", enabled: true },
+ { providerType: "openai", enabled: true },
+ ]);
+ expect(persisted()).toEqual([
+ {
+ providerType: "openai-compatible",
+ priority: 1,
+ enabled: true,
+ reasoningEffort: "high",
+ },
+ { providerType: "openai", priority: 2, enabled: true },
+ ]);
+ });
+
+ it("clears it on null", async () => {
+ storedChain([
+ {
+ providerType: "local",
+ priority: 1,
+ enabled: true,
+ reasoningEffort: "low",
+ },
+ ]);
+ await put([
+ { providerType: "local", enabled: true, reasoningEffort: null },
+ ]);
+ expect(persisted()).toEqual([
+ { providerType: "local", priority: 1, enabled: true },
+ ]);
+ });
+
+ it("refuses it on an entry that cannot carry it", async () => {
+ const res = await put([
+ { providerType: "anthropic", enabled: true, reasoningEffort: "none" },
+ ]);
+ expect(res.status).toBe(422);
+ expect(prisma.user.update).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/src/app/api/insights/provider-chain/route.ts b/src/app/api/insights/provider-chain/route.ts
index 8ea535990..477cb8159 100644
--- a/src/app/api/insights/provider-chain/route.ts
+++ b/src/app/api/insights/provider-chain/route.ts
@@ -1,6 +1,4 @@
import type { NextRequest } from "next/server";
-import { z } from "zod/v4";
-
import { apiHandler, HttpError, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
@@ -15,10 +13,9 @@ import {
getLastWorkingProvider,
} from "@/lib/ai/provider-runner";
import { prisma } from "@/lib/db";
-import {
- parseProviderChain,
- PROVIDER_CHAIN_TYPES,
-} from "@/lib/ai/provider-chain";
+import { parseProviderChain } from "@/lib/ai/provider-chain";
+import { isReasoningProviderType } from "@/lib/ai/reasoning-effort";
+import { providerChainPutSchema } from "@/lib/validations/ai-provider";
/**
* v1.4.16 phase B5b — read-only chain summary for the Settings → AI
@@ -85,29 +82,6 @@ export const GET = apiHandler(async () => {
});
});
-const chainEntrySchema = z
- .object({
- providerType: z.enum(
- PROVIDER_CHAIN_TYPES as unknown as [string, ...string[]],
- ),
- // Priority is recomputed server-side from insertion order so a stale
- // client cannot persist a chain whose displayed order disagrees with
- // its priority field. The number is accepted (and may be present) but
- // ignored on the wire.
- priority: z.number().int().optional(),
- enabled: z.boolean(),
- })
- .strict();
-
-const chainBodySchema = z
- .object({
- chain: z
- .array(chainEntrySchema)
- .min(1, "Chain must contain at least one provider")
- .max(PROVIDER_CHAIN_TYPES.length, "Too many providers"),
- })
- .strict();
-
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "insights.provider_chain.update" } });
@@ -117,7 +91,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
});
if (error) return error;
- const parsed = chainBodySchema.safeParse(body);
+ const parsed = providerChainPutSchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
"Invalid provider chain payload",
@@ -142,11 +116,35 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// Normalise priority to insertion order (1-based). UI hands us the
// visual order it wants persisted; we make priority match so a later
// GET reflects the same order without depending on the client's math.
- const normalised = parsed.data.chain.map((entry, idx) => ({
- providerType: entry.providerType,
- priority: idx + 1,
- enabled: entry.enabled,
- }));
+ //
+ // #1126 — a reasoning setting the body leaves out is carried over from the
+ // stored entry of the same type, so the chain editor (which only reorders
+ // and toggles) and an older client cannot wipe it by saving the order.
+ // `null` clears it; the schema has already refused it on any type other
+ // than `local` / `openai-compatible`.
+ const stored = await prisma.user.findUnique({
+ where: { id: user.id },
+ select: { aiProviderChain: true },
+ });
+ const storedEffort = new Map(
+ parseProviderChain(stored?.aiProviderChain ?? null).map((e) => [
+ e.providerType,
+ e.reasoningEffort ?? null,
+ ]),
+ );
+ const normalised = parsed.data.chain.map((entry, idx) => {
+ const reasoningEffort = isReasoningProviderType(entry.providerType)
+ ? entry.reasoningEffort === undefined
+ ? (storedEffort.get(entry.providerType) ?? null)
+ : entry.reasoningEffort
+ : null;
+ return {
+ providerType: entry.providerType,
+ priority: idx + 1,
+ enabled: entry.enabled,
+ ...(reasoningEffort ? { reasoningEffort } : {}),
+ };
+ });
await prisma.user.update({
where: { id: user.id },
diff --git a/src/app/api/user/ai-provider/__tests__/route.test.ts b/src/app/api/user/ai-provider/__tests__/route.test.ts
index 9c5e5809b..0eb415494 100644
--- a/src/app/api/user/ai-provider/__tests__/route.test.ts
+++ b/src/app/api/user/ai-provider/__tests__/route.test.ts
@@ -623,3 +623,96 @@ describe("GET /api/user/ai-provider — the shared-provider offer", () => {
}
});
});
+
+// #1126 — the Local and gateway entries' reasoning setting, written from the
+// provider forms onto the chain entry and read back from it.
+describe("PATCH /api/user/ai-provider — reasoning setting (#1126)", () => {
+ const patch = PATCH as (req: Request) => Promise;
+ function storedChain(chain: unknown) {
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ aiProviderChain: chain,
+ } as never);
+ }
+ function written() {
+ return vi.mocked(prisma.user.update).mock.calls[0]?.[0].data as
+ Record | undefined;
+ }
+
+ it("stores Off on the local entry of a chain that was never customised", async () => {
+ storedChain(null);
+ const res = await patch(
+ patchRequest({ provider: "LOCAL", localReasoningEffort: "none" }),
+ );
+ expect(res.status).toBe(200);
+ const chain = written()?.aiProviderChain as Array<{
+ providerType: string;
+ reasoningEffort?: string;
+ }>;
+ expect(chain.find((e) => e.providerType === "local")).toMatchObject({
+ enabled: true,
+ reasoningEffort: "none",
+ });
+ expect(chain.filter((e) => e.reasoningEffort)).toHaveLength(1);
+ });
+
+ it("holds the gateway's value on a disabled entry when the chain lacks it", async () => {
+ storedChain([{ providerType: "local", priority: 1, enabled: true }]);
+ await patch(patchRequest({ compatReasoningEffort: "high" }));
+ expect(written()?.aiProviderChain).toEqual([
+ { providerType: "local", priority: 1, enabled: true },
+ {
+ providerType: "openai-compatible",
+ priority: 2,
+ enabled: false,
+ reasoningEffort: "high",
+ },
+ ]);
+ });
+
+ it("does not touch the chain when Default is restated on a default chain", async () => {
+ storedChain(null);
+ await patch(
+ patchRequest({ provider: "LOCAL", localReasoningEffort: null }),
+ );
+ expect(written()).toEqual({ aiProvider: "LOCAL" });
+ });
+
+ it("answers 200 for a body that only restates Default", async () => {
+ storedChain(null);
+ const res = await patch(patchRequest({ localReasoningEffort: null }));
+ expect(res.status).toBe(200);
+ expect(prisma.user.update).not.toHaveBeenCalled();
+ });
+
+ it("refuses a value outside the five", async () => {
+ const res = await patch(patchRequest({ localReasoningEffort: "max" }));
+ expect(res.status).toBe(422);
+ expect(prisma.user.update).not.toHaveBeenCalled();
+ });
+});
+
+describe("GET /api/user/ai-provider — reasoning setting (#1126)", () => {
+ it("reads both values from the chain, null for Default", async () => {
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ aiProvider: "LOCAL",
+ aiProviderChain: [
+ {
+ providerType: "local",
+ priority: 1,
+ enabled: true,
+ reasoningEffort: "none",
+ },
+ ],
+ } as never);
+ vi.mocked(resolveProviderAvailability).mockResolvedValue({
+ aiAvailable: true,
+ managedBy: "local",
+ });
+ const res = await (GET as () => Promise)();
+ const body = (await res.json()) as {
+ data: { localReasoningEffort: unknown; compatReasoningEffort: unknown };
+ };
+ expect(body.data.localReasoningEffort).toBe("none");
+ expect(body.data.compatReasoningEffort).toBeNull();
+ });
+});
diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts
index 7f19b619b..1b976d5ec 100644
--- a/src/app/api/user/ai-provider/route.ts
+++ b/src/app/api/user/ai-provider/route.ts
@@ -20,6 +20,12 @@ import {
} from "@/lib/sharing/provider-work-authority";
import { annotate } from "@/lib/logging/context";
import { aiProviderPatchSchema } from "@/lib/validations/ai-provider";
+import {
+ chainWithReasoningEffort,
+ parseProviderChain,
+ serializeProviderChain,
+} from "@/lib/ai/provider-chain";
+import { reasoningEffortFor } from "@/lib/ai/reasoning-effort";
export const dynamic = "force-dynamic";
@@ -42,6 +48,8 @@ export const GET = apiHandler(async () => {
aiCompatModel: true,
// v1.22 (#89)
aiResponseTimeoutSeconds: true,
+ // #1126 — the reasoning settings live on the chain entries.
+ aiProviderChain: true,
// v1.38.19 — a managed profile is never the party that gives
// consent for itself; the offer below has to know.
managedProfileAt: true,
@@ -141,6 +149,13 @@ export const GET = apiHandler(async () => {
hasCompatKey: Boolean(u?.aiCompatKeyEncrypted),
// v1.22 (#89) — per-user response timeout, in seconds (null = default).
responseTimeoutSeconds: u?.aiResponseTimeoutSeconds ?? null,
+ // #1126 — the Local and gateway entries' reasoning settings (null =
+ // Default), read from the chain where they are stored.
+ localReasoningEffort: reasoningEffortFor(u?.aiProviderChain, "local"),
+ compatReasoningEffort: reasoningEffortFor(
+ u?.aiProviderChain,
+ "openai-compatible",
+ ),
// v1.38.19 — see the block above. A tri-state and two booleans;
// no count, no timestamp and no other account is inferable from them.
serverProviderHealth,
@@ -297,7 +312,45 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
updates.aiResponseTimeoutSeconds = body.responseTimeoutSeconds;
}
- if (Object.keys(updates).length === 0) {
+ // ── #1126 — reasoning settings of the Local and gateway entries ──
+ // Stored on the entry in `aiProviderChain` rather than in a column of their
+ // own, so the setting is per entry and a chain can mix a thinking model with
+ // one that does not know the field. See `chainWithReasoningEffort` for how a
+ // default chain, or a provider absent from the chain, holds the value.
+ const reasoningTouched =
+ body.localReasoningEffort !== undefined ||
+ body.compatReasoningEffort !== undefined;
+ if (reasoningTouched) {
+ const current = await prisma.user.findUnique({
+ where: { id: user.id },
+ select: { aiProviderChain: true },
+ });
+ let chain: unknown = current?.aiProviderChain ?? null;
+ if (body.localReasoningEffort !== undefined) {
+ chain = chainWithReasoningEffort(
+ chain,
+ "local",
+ body.localReasoningEffort,
+ );
+ }
+ if (body.compatReasoningEffort !== undefined) {
+ chain = chainWithReasoningEffort(
+ chain,
+ "openai-compatible",
+ body.compatReasoningEffort,
+ );
+ }
+ // Written only when it changed: restating Default on a chain that was
+ // never customised must not freeze today's default chain into the row.
+ const before = serializeProviderChain(
+ parseProviderChain(current?.aiProviderChain ?? null),
+ );
+ if (serializeProviderChain(parseProviderChain(chain)) !== before) {
+ updates.aiProviderChain = chain;
+ }
+ }
+
+ if (Object.keys(updates).length === 0 && !reasoningTouched) {
return apiError("No valid fields", 422, {
errorCode: "ai_provider.no_fields",
});
@@ -317,7 +370,9 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
updates.aiBaseUrl = null;
}
- await prisma.user.update({ where: { id: user.id }, data: updates });
+ if (Object.keys(updates).length > 0) {
+ await prisma.user.update({ where: { id: user.id }, data: updates });
+ }
return apiSuccess({ updated: true });
});
diff --git a/src/components/settings/ai/__tests__/reasoning-effort-field.test.tsx b/src/components/settings/ai/__tests__/reasoning-effort-field.test.tsx
new file mode 100644
index 000000000..0f2f2efd2
--- /dev/null
+++ b/src/components/settings/ai/__tests__/reasoning-effort-field.test.tsx
@@ -0,0 +1,106 @@
+import { describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import en from "../../../../../messages/en.json";
+import { CompatProviderForm } from "../compat-provider-form";
+import { LocalProviderForm } from "../local-provider-form";
+import type { UserAIProvider } from "../shared";
+
+/**
+ * #1126 — the Reasoning select on the Local and OpenAI-compatible forms. It
+ * sits right under the model field, offers exactly Default / Off / Low /
+ * Medium / High, and seeds from the value the server stored on the entry.
+ * Saving it is pinned end to end in `e2e/ai-provider-dropdown.spec.ts`.
+ */
+
+function provider(overrides: Partial): UserAIProvider {
+ return {
+ provider: "LOCAL",
+ model: "llama3:8b",
+ baseUrl: "http://ollama.example.org:11434/v1",
+ hasAnthropicKey: false,
+ anthropicKeyPreview: null,
+ hasLocalKey: false,
+ hasOpenaiKey: false,
+ openaiKeyPreview: null,
+ compatBaseUrl: "https://gateway.example.org/v1",
+ compatModel: "qwen3",
+ hasCompatKey: false,
+ responseTimeoutSeconds: null,
+ ...overrides,
+ };
+}
+
+function render(node: React.ReactNode) {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return renderToStaticMarkup(
+
+ {node}
+ ,
+ );
+}
+
+/** The `
+
)}
+