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 `", start)); +} + +const OPTION_VALUES = ["", "none", "low", "medium", "high"]; + +describe.each([ + { + name: "Local", + id: "ai-local-reasoning", + after: "ai-local-model", + form: (p: UserAIProvider) => , + stored: (v: UserAIProvider["localReasoningEffort"]) => ({ + localReasoningEffort: v, + }), + }, + { + name: "OpenAI-compatible", + id: "ai-compat-reasoning", + after: "ai-compat-model", + form: (p: UserAIProvider) => , + stored: (v: UserAIProvider["compatReasoningEffort"]) => ({ + compatReasoningEffort: v, + provider: "OPENAI_COMPATIBLE", + }), + }, +])("$name form", ({ id, after, form, stored }) => { + it("offers Default, Off, Low, Medium and High under the model field", () => { + const html = render(form(provider({}))); + const select = selectMarkup(html, id); + expect( + [...select.matchAll(/`); + } + expect(html).toContain(` { + const select = selectMarkup(render(form(provider({}))), id); + expect(select).toMatch(/