From befac59576457653181d72d0d1ef81ab2f435188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 10:42:59 +0200 Subject: [PATCH 1/3] feat(ai): send a per-entry reasoning effort to Local and gateway providers Reasoning models on Ollama and similar servers think by default and can spend the whole max_tokens budget before the visible answer starts, so the call comes back empty or times out. The Local and OpenAI-compatible chain entries now carry an optional reasoning setting: Default sends nothing, Off sends reasoning_effort "none", Low / Medium / High send their own value. It is stored on the entry in aiProviderChain, so a chain can mix a thinking model with one that does not know the field. - Every exported resolver binds the entry's value onto the client, the connection test included, keyed on the instance type so the operator provider, OpenAI, Anthropic and Codex never carry it. - PATCH /api/user/ai-provider accepts localReasoningEffort and compatReasoningEffort and GET reports them. A provider absent from the chain holds its value on a disabled entry; restating Default on a chain that was never customised writes nothing. - PUT /api/insights/provider-chain accepts reasoningEffort on local and openai-compatible entries, refuses it on any other, and keeps the stored value when the body leaves it out. Its schema is now shared with the OpenAPI registry. - A Default request body is byte-identical to the one sent before. Refs #1126 --- docs/api/openapi.yaml | 63 ++- .../ai-reasoning-effort-guard.test.ts | 124 ++++++ .../provider-chain/__tests__/put.test.ts | 91 +++++ src/app/api/insights/provider-chain/route.ts | 68 ++-- .../user/ai-provider/__tests__/route.test.ts | 93 +++++ src/app/api/user/ai-provider/route.ts | 59 ++- .../provider-reasoning-effort.test.ts | 192 +++++++++ src/lib/ai/__tests__/reasoning-effort.test.ts | 366 ++++++++++++++++++ src/lib/ai/local-client.ts | 9 + src/lib/ai/openai-client.ts | 11 + src/lib/ai/provider-chain.ts | 51 +++ src/lib/ai/provider.ts | 48 ++- src/lib/ai/reasoning-effort.ts | 83 ++++ src/lib/ai/types.ts | 8 + .../__tests__/account-settings-backup.test.ts | 33 ++ src/lib/openapi/routes/insights/schemas.ts | 37 +- src/lib/openapi/routes/profile.ts | 7 + src/lib/validations/ai-provider.ts | 91 +++++ 18 files changed, 1350 insertions(+), 84 deletions(-) create mode 100644 src/__tests__/ai-reasoning-effort-guard.test.ts create mode 100644 src/lib/ai/__tests__/provider-reasoning-effort.test.ts create mode 100644 src/lib/ai/__tests__/reasoning-effort.test.ts create mode 100644 src/lib/ai/reasoning-effort.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 25431a0bd..e92efe758 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -25855,6 +25855,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 @@ -27071,6 +27093,7 @@ components: properties: chain: minItems: 1 + maxItems: 7 type: array items: type: object @@ -27096,15 +27119,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: @@ -48543,6 +48581,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: @@ -48587,6 +48646,8 @@ components: - compatModel - hasCompatKey - responseTimeoutSeconds + - localReasoningEffort + - compatReasoningEffort - serverProviderHealth - serverProviderOffer - serverProviderConsent 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/lib/ai/__tests__/provider-reasoning-effort.test.ts b/src/lib/ai/__tests__/provider-reasoning-effort.test.ts new file mode 100644 index 000000000..0da8b93c1 --- /dev/null +++ b/src/lib/ai/__tests__/provider-reasoning-effort.test.ts @@ -0,0 +1,192 @@ +/** + * #1126 — every exported resolver hands out providers carrying their own + * chain entry's reasoning setting, and the request each one then sends + * carries exactly that value. A chain mixing a thinking model with one that + * does not know the field is the case the setting exists for. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn(), update: vi.fn() }, + appSettings: { findUnique: vi.fn() }, + }, +})); +vi.mock("@/lib/crypto", () => ({ + decrypt: vi.fn((v: string) => `decrypted:${v}`), + encrypt: vi.fn((v: string) => `encrypted:${v}`), +})); +vi.mock("@/lib/logging/context", async (importOriginal) => ({ + ...(await importOriginal()), + annotate: vi.fn(), +})); +vi.mock("@/lib/safe-fetch", async (importOriginal) => ({ + ...(await importOriginal()), + safeFetch: (url: string, init: RequestInit) => fetch(url, init), +})); + +import { prisma } from "@/lib/db"; +import { + resolveProvider, + resolveProviderChain, + resolveProviderForTest, +} from "../provider"; +import { resetLocalJsonDialectCache } from "../local-client"; +import { singleUserTurn, type AIProvider } from "../types"; + +const ROW = { + aiProvider: "LOCAL", + aiModel: "gemma4:12b", + aiBaseUrl: "https://ollama.example.org/v1", + aiAnthropicKeyEncrypted: null, + aiLocalKeyEncrypted: null, + aiOpenaiKeyEncrypted: "openai-key", + aiCompatBaseUrl: "https://gateway.example.org/v1", + aiCompatKeyEncrypted: null, + aiCompatModel: "llama3", + role: "USER", + useCentralCodex: false, + managedProfileAt: null, + aiResponseTimeoutSeconds: null, +}; + +function withChain(aiProviderChain: unknown) { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + ...ROW, + aiProviderChain, + } as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); +} + +const MIXED = [ + { + providerType: "local", + priority: 1, + enabled: true, + reasoningEffort: "none", + }, + { providerType: "openai-compatible", priority: 2, enabled: true }, + { providerType: "openai", priority: 3, enabled: true }, +]; + +/** The `reasoning_effort` the provider actually puts on the wire. */ +async function sentEffort(provider: AIProvider): Promise { + const mock = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + choices: [{ message: { content: "ok" }, finish_reason: "stop" }], + usage: { total_tokens: 1 }, + }), + }); + vi.stubGlobal("fetch", mock); + await provider.generateCompletion(singleUserTurn({ system: "s", user: "u" })); + vi.unstubAllGlobals(); + const body = JSON.parse(mock.mock.calls[0][1].body as string); + return "reasoning_effort" in body ? body.reasoning_effort : "(absent)"; +} + +beforeEach(() => { + vi.resetAllMocks(); + resetLocalJsonDialectCache(); +}); + +describe("resolveProviderChain binds each entry's own setting", () => { + it("a mixed chain sends none, nothing, nothing", async () => { + withChain(MIXED); + const chain = await resolveProviderChain("user-1"); + expect(chain.map((e) => e.providerType)).toEqual([ + "local", + "openai-compatible", + "openai", + ]); + expect(chain.map((e) => e.instance.reasoningEffort)).toEqual([ + "none", + null, + null, + ]); + const sent = []; + for (const entry of chain) sent.push(await sentEffort(entry.instance)); + expect(sent).toEqual(["none", "(absent)", "(absent)"]); + }); + + it("the gateway's own value reaches the gateway", async () => { + withChain([ + { providerType: "local", priority: 1, enabled: true }, + { + providerType: "openai-compatible", + priority: 2, + enabled: true, + reasoningEffort: "high", + }, + ]); + const chain = await resolveProviderChain("user-1"); + expect(await sentEffort(chain[0].instance)).toBe("(absent)"); + expect(await sentEffort(chain[1].instance)).toBe("high"); + }); +}); + +describe("resolveProvider", () => { + it("binds the selected Local provider's setting", async () => { + withChain(MIXED); + const provider = await resolveProvider("user-1"); + expect(provider.type).toBe("local"); + expect(await sentEffort(provider)).toBe("none"); + }); + + it("reaches a selected gateway whose value sits on a disabled entry", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + ...ROW, + aiProvider: "OPENAI_COMPATIBLE", + aiProviderChain: [ + { providerType: "codex", priority: 1, enabled: true }, + { + providerType: "openai-compatible", + priority: 2, + enabled: false, + reasoningEffort: "low", + }, + ], + } as never); + const provider = await resolveProvider("user-1"); + expect(provider.type).toBe("openai-compatible"); + expect(await sentEffort(provider)).toBe("low"); + }); +}); + +describe("resolveProviderForTest — the test button runs with the setting", () => { + it("testing the saved config (empty body) uses the saved provider's entry", async () => { + withChain(MIXED); + const provider = await resolveProviderForTest("user-1", {}); + expect(provider.type).toBe("local"); + expect(await sentEffort(provider)).toBe("none"); + }); + + it("with no provider selected, the test runs the chain head with its value", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + ...ROW, + aiProvider: null, + aiProviderChain: MIXED, + } as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + const provider = await resolveProviderForTest("user-1", {}); + expect(provider.type).toBe("local"); + expect(await sentEffort(provider)).toBe("none"); + }); + + it("testing a named Local provider uses its entry's value", async () => { + withChain(MIXED); + const provider = await resolveProviderForTest("user-1", { + provider: "LOCAL", + }); + expect(await sentEffort(provider)).toBe("none"); + }); + + it("testing the personal OpenAI key never sends it", async () => { + withChain(MIXED); + const provider = await resolveProviderForTest("user-1", { + provider: "OPENAI", + }); + expect(await sentEffort(provider)).toBe("(absent)"); + }); +}); diff --git a/src/lib/ai/__tests__/reasoning-effort.test.ts b/src/lib/ai/__tests__/reasoning-effort.test.ts new file mode 100644 index 000000000..420c88bbb --- /dev/null +++ b/src/lib/ai/__tests__/reasoning-effort.test.ts @@ -0,0 +1,366 @@ +/** + * #1126 — the per-entry reasoning setting on the wire and in the schemas. + * + * Default must leave the request body byte-identical to what a setup that + * never touched the setting has always sent, so the Default cases compare the + * whole body string against a literal captured before the setting existed. + * Every other choice adds exactly one key, `reasoning_effort`, and only on the + * Local client and the OpenAI-compatible gateway. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../local-host-allowlist", () => ({ + aiEgressPolicyFor: () => ({}), + isLocalAiHostAllowed: () => false, +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +// The pinned tags dial through undici's own fetch; route every call to the +// stubbed global so the body can be read whatever the egress policy. +vi.mock("@/lib/safe-fetch", async (importOriginal) => ({ + ...(await importOriginal()), + safeFetch: (url: string, init: RequestInit) => fetch(url, init), +})); + +import { bindReasoningEffort, reasoningEffortFor } from "../reasoning-effort"; +import { + LocalOpenAICompatibleClient, + resetLocalJsonDialectCache, +} from "../local-client"; +import { OpenAIClient } from "../openai-client"; +import { AnthropicClient } from "../anthropic-client"; +import { + chainWithReasoningEffort, + parseProviderChain, + PROVIDER_CHAIN_DEFAULT, + serializeProviderChain, +} from "../provider-chain"; +import { singleUserTurn, type AIProvider } from "../types"; +import { + aiProviderPatchSchema, + providerChainEntrySchema, + providerChainPutSchema, +} from "@/lib/validations/ai-provider"; + +const PARAMS = { + ...singleUserTurn({ system: "s", user: "u" }), + maxTokens: 250, +}; + +function okFetch(content = "ok") { + const mock = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + choices: [{ message: { content }, finish_reason: "stop" }], + usage: { total_tokens: 5 }, + }), + }); + vi.stubGlobal("fetch", mock); + return mock; +} + +function sentBody(mock: ReturnType): string { + return mock.mock.calls[0][1].body as string; +} + +function localClient() { + return new LocalOpenAICompatibleClient({ + apiKey: null, + model: "gemma4:12b", + baseUrl: "http://ollama.example.org:11434/v1", + }); +} + +function gatewayClient() { + return new OpenAIClient({ + apiKey: "", + model: "qwen3", + baseUrl: "https://gateway.example.org/v1", + providerType: "openai-compatible", + }); +} + +// Captured from the clients before the setting existed. Default must keep +// sending exactly this. +const LOCAL_BODY_BEFORE = + '{"model":"gemma4:12b","messages":[{"role":"system","content":"s"},{"role":"user","content":"Return strict JSON only, no markdown, no commentary.\\n\\nu"}],"temperature":0.3,"max_tokens":250}'; +const GATEWAY_BODY_BEFORE = + '{"model":"qwen3","messages":[{"role":"system","content":"s"},{"role":"user","content":"u"}],"max_tokens":250,"temperature":0.3}'; + +beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + resetLocalJsonDialectCache(); +}); + +describe("Local client", () => { + it("Default sends the body it always sent, byte for byte", async () => { + const mock = okFetch(); + await localClient().generateCompletion(PARAMS); + expect(sentBody(mock)).toBe(LOCAL_BODY_BEFORE); + expect(JSON.parse(sentBody(mock))).not.toHaveProperty("reasoning_effort"); + }); + + it("Off sends reasoning_effort none and nothing else new", async () => { + const mock = okFetch(); + const client = localClient(); + client.reasoningEffort = "none"; + await client.generateCompletion(PARAMS); + const body = JSON.parse(sentBody(mock)); + expect(body.reasoning_effort).toBe("none"); + const { reasoning_effort: _dropped, ...rest } = body; + expect(JSON.stringify(rest)).toBe(LOCAL_BODY_BEFORE); + }); + + it.each(["low", "medium", "high"] as const)("%s is sent as is", async (v) => { + const mock = okFetch(); + const client = localClient(); + client.reasoningEffort = v; + await client.generateCompletion(PARAMS); + expect(JSON.parse(sentBody(mock)).reasoning_effort).toBe(v); + }); + + it("the streaming request carries it too", async () => { + const mock = okFetch(); + const client = localClient(); + client.reasoningEffort = "none"; + // A buffered answer to a stream request falls back cleanly; only the + // first request body matters here. + await client.generateCompletionStream(PARAMS, () => {}).catch(() => {}); + const body = JSON.parse(sentBody(mock)); + expect(body.stream).toBe(true); + expect(body.reasoning_effort).toBe("none"); + }); +}); + +describe("OpenAI-compatible gateway", () => { + it("Default sends the body it always sent, byte for byte", async () => { + const mock = okFetch(); + await gatewayClient().generateCompletion(PARAMS); + expect(sentBody(mock)).toBe(GATEWAY_BODY_BEFORE); + }); + + it("Off sends reasoning_effort none", async () => { + const mock = okFetch(); + const client = gatewayClient(); + client.reasoningEffort = "none"; + await client.generateCompletion(PARAMS); + expect(JSON.parse(sentBody(mock)).reasoning_effort).toBe("none"); + }); + + it("the OpenAI and Codex tags never send it, whatever is stamped", async () => { + for (const providerType of ["admin-key", "codex"] as const) { + vi.unstubAllGlobals(); + const mock = okFetch(); + const client = new OpenAIClient({ + apiKey: "k", + model: "gpt-4o", + baseUrl: "https://gateway.example.org/v1", + providerType, + }); + client.reasoningEffort = "high"; + await client.generateCompletion(PARAMS); + expect(JSON.parse(sentBody(mock))).not.toHaveProperty("reasoning_effort"); + } + }); +}); + +describe("bindReasoningEffort", () => { + const chain = [ + { + providerType: "local", + priority: 1, + enabled: true, + reasoningEffort: "none", + }, + { + providerType: "openai-compatible", + priority: 2, + enabled: true, + reasoningEffort: "high", + }, + { providerType: "openai", priority: 3, enabled: true }, + ]; + + it("stamps each entry's own value, keyed on the instance type", () => { + expect(bindReasoningEffort(localClient(), chain).reasoningEffort).toBe( + "none", + ); + expect(bindReasoningEffort(gatewayClient(), chain).reasoningEffort).toBe( + "high", + ); + }); + + it("stamps null on every other provider", () => { + const openai = new OpenAIClient({ + apiKey: "k", + model: "gpt-4o", + baseUrl: "https://api.openai.com/v1", + }); + const anthropic: AIProvider = new AnthropicClient({ + apiKey: "k", + model: "m", + }); + expect(bindReasoningEffort(openai, chain).reasoningEffort).toBeNull(); + expect(bindReasoningEffort(anthropic, chain).reasoningEffort).toBeNull(); + }); + + it("stamps null for a chain that was never customised", () => { + expect(bindReasoningEffort(localClient(), null).reasoningEffort).toBeNull(); + }); +}); + +describe("the stored chain", () => { + it("keeps a valid value on local / openai-compatible and drops it elsewhere", () => { + const parsed = parseProviderChain([ + { + providerType: "local", + priority: 1, + enabled: true, + reasoningEffort: "low", + }, + { + providerType: "openai", + priority: 2, + enabled: true, + reasoningEffort: "low", + }, + { + providerType: "openai-compatible", + priority: 3, + enabled: true, + reasoningEffort: "max", + }, + ]); + expect(parsed.map((e) => e.reasoningEffort)).toEqual([ + "low", + undefined, + undefined, + ]); + expect(serializeProviderChain(parsed)).toBe( + '[{"providerType":"local","priority":1,"enabled":true,"reasoningEffort":"low"},{"providerType":"openai","priority":2,"enabled":true},{"providerType":"openai-compatible","priority":3,"enabled":true}]', + ); + }); + + it("serialises a chain without the setting exactly as before", () => { + expect(serializeProviderChain(PROVIDER_CHAIN_DEFAULT)).not.toContain( + "reasoningEffort", + ); + }); + + it("materialises the default chain to hold a value", () => { + const next = chainWithReasoningEffort(null, "local", "none"); + expect(next.map((e) => e.providerType)).toEqual( + PROVIDER_CHAIN_DEFAULT.map((e) => e.providerType), + ); + expect(reasoningEffortFor(next, "local")).toBe("none"); + }); + + it("holds a value for a provider absent from the chain on a disabled entry", () => { + const stored = [{ providerType: "codex", priority: 7, enabled: true }]; + const next = chainWithReasoningEffort(stored, "openai-compatible", "high"); + expect(next).toEqual([ + { providerType: "codex", priority: 7, enabled: true }, + { + providerType: "openai-compatible", + priority: 8, + enabled: false, + reasoningEffort: "high", + }, + ]); + }); + + it("clears without adding an entry", () => { + const stored = [ + { + providerType: "local", + priority: 1, + enabled: true, + reasoningEffort: "low", + }, + ]; + expect(chainWithReasoningEffort(stored, "local", null)).toEqual([ + { providerType: "local", priority: 1, enabled: true }, + ]); + expect(chainWithReasoningEffort(stored, "openai-compatible", null)).toEqual( + [ + { + providerType: "local", + priority: 1, + enabled: true, + reasoningEffort: "low", + }, + ], + ); + }); +}); + +describe("schemas", () => { + const VALUES = [null, "none", "low", "medium", "high"] as const; + + it.each(["local", "openai-compatible"])( + "a %s chain entry accepts Default, Off, Low, Medium and High", + (providerType) => { + for (const reasoningEffort of VALUES) { + expect( + providerChainEntrySchema.safeParse({ + providerType, + enabled: true, + reasoningEffort, + }).success, + ).toBe(true); + } + }, + ); + + it("refuses a value outside the five", () => { + expect( + providerChainEntrySchema.safeParse({ + providerType: "local", + enabled: true, + reasoningEffort: "max", + }).success, + ).toBe(false); + }); + + it.each(["openai", "anthropic", "codex", "admin-openai", "admin-codex"])( + "refuses the setting on a %s entry, null included", + (providerType) => { + for (const reasoningEffort of VALUES) { + const result = providerChainEntrySchema.safeParse({ + providerType, + enabled: true, + reasoningEffort, + }); + expect(result.success).toBe(false); + expect(result.error?.issues[0].path).toEqual(["reasoningEffort"]); + } + expect( + providerChainEntrySchema.safeParse({ providerType, enabled: true }) + .success, + ).toBe(true); + }, + ); + + it("the chain body still accepts a chain without the setting", () => { + expect( + providerChainPutSchema.safeParse({ + chain: [{ providerType: "local", priority: 1, enabled: true }], + }).success, + ).toBe(true); + }); + + it("the provider PATCH accepts the five values for both entries", () => { + for (const v of VALUES) { + expect( + aiProviderPatchSchema.safeParse({ + localReasoningEffort: v, + compatReasoningEffort: v, + }).success, + ).toBe(true); + } + expect( + aiProviderPatchSchema.safeParse({ localReasoningEffort: "max" }).success, + ).toBe(false); + }); +}); diff --git a/src/lib/ai/local-client.ts b/src/lib/ai/local-client.ts index 27c96df89..e753050ef 100644 --- a/src/lib/ai/local-client.ts +++ b/src/lib/ai/local-client.ts @@ -23,6 +23,7 @@ import { resetJsonModeDialectCache, type JsonModeDialect, } from "./json-dialect"; +import type { ReasoningEffort } from "./reasoning-effort"; interface LocalClientConfig { apiKey?: string | null; @@ -108,6 +109,8 @@ export class LocalOpenAICompatibleClient implements AIProvider { readonly type = "local" as const; /** See `AIProvider.responseTimeoutSeconds`; stamped by the resolver. */ responseTimeoutSeconds: number | null = null; + /** See `AIProvider.reasoningEffort`; stamped by the resolver. */ + reasoningEffort: ReasoningEffort | null = null; /** Local servers commonly reject an unknown `tools` field — never send it. */ readonly supportsTools = false; private config: LocalClientConfig; @@ -164,6 +167,12 @@ export class LocalOpenAICompatibleClient implements AIProvider { // most OpenAI-compatible local servers honour it; servers that // ignore it simply disregard the field. Omitted when unset. ...(params.seed !== undefined ? { seed: params.seed } : {}), + // #1126 — the entry's reasoning setting. Default sends nothing, so the + // body of a setup that never touched it stays byte-identical; "Off" is + // `none`, which stops a thinking model spending the answer budget. + ...(this.reasoningEffort + ? { reasoning_effort: this.reasoningEffort } + : {}), // v1.28.28 (#470) — the JSON surfaces opt in via `responseFormat: // "json"`. Default dialect sends the STANDARD OpenAI field (the // top-level Ollama-native `format: "json"` this client used to send diff --git a/src/lib/ai/openai-client.ts b/src/lib/ai/openai-client.ts index 63464563c..f54ea1bf1 100644 --- a/src/lib/ai/openai-client.ts +++ b/src/lib/ai/openai-client.ts @@ -10,6 +10,7 @@ import { rememberJsonModeDialect, } from "./json-dialect"; import { aiEgressPolicyFor } from "./local-host-allowlist"; +import type { ReasoningEffort } from "./reasoning-effort"; import { buildOpenAIMessages, buildOpenAITools, @@ -62,6 +63,11 @@ export class OpenAIClient implements AIProvider { readonly type: OpenAIProviderType; /** See `AIProvider.responseTimeoutSeconds`; stamped by the resolver. */ responseTimeoutSeconds: number | null = null; + /** + * See `AIProvider.reasoningEffort`; stamped by the resolver. Only the + * gateway tag ever sends it (`isGateway` below), whatever is stamped here. + */ + reasoningEffort: ReasoningEffort | null = null; private config: OpenAIClientConfig; constructor(config: OpenAIClientConfig) { @@ -150,6 +156,11 @@ export class OpenAIClient implements AIProvider { : {}), ...(tools ? { tools } : {}), ...(params.toolChoice ? { tool_choice: params.toolChoice } : {}), + // #1126 — the gateway entry's reasoning setting; Default sends + // nothing. `api.openai.com` and Codex never get it. + ...(this.isGateway && this.reasoningEffort + ? { reasoning_effort: this.reasoningEffort } + : {}), }), }, // 60 s ceiling so a tar-pit upstream cannot pin a worker diff --git a/src/lib/ai/provider-chain.ts b/src/lib/ai/provider-chain.ts index 6a92ad27a..ccfa8409d 100644 --- a/src/lib/ai/provider-chain.ts +++ b/src/lib/ai/provider-chain.ts @@ -18,6 +18,13 @@ * `PROVIDER_CHAIN_DEFAULT`. */ +import { + isReasoningEffort, + isReasoningProviderType, + type ReasoningEffort, + type ReasoningProviderType, +} from "./reasoning-effort"; + /** * Provider tags used in the chain. `admin-openai` is the legacy * `AppSettings.adminAiKeyEncrypted` fallback (so misconfigured users @@ -63,6 +70,8 @@ export interface ProviderChainEntry { providerType: ProviderChainType; priority: number; enabled: boolean; + /** Only ever set on a `REASONING_PROVIDER_TYPES` entry. Absent = Default. */ + reasoningEffort?: ReasoningEffort; } /** @@ -113,6 +122,7 @@ export function parseProviderChain( providerType?: unknown; priority?: unknown; enabled?: unknown; + reasoningEffort?: unknown; }; const providerType = candidate.providerType; if (typeof providerType !== "string") continue; @@ -128,10 +138,18 @@ export function parseProviderChain( : valid.length + 1; const enabled = typeof candidate.enabled === "boolean" ? candidate.enabled : true; + // A reasoning effort survives only on an entry that may carry one and + // only with a known value; anything else reads as Default. + const reasoningEffort = + isReasoningProviderType(providerType) && + isReasoningEffort(candidate.reasoningEffort) + ? candidate.reasoningEffort + : undefined; valid.push({ providerType: providerType as ProviderChainType, priority, enabled, + ...(reasoningEffort ? { reasoningEffort } : {}), }); } @@ -153,6 +171,39 @@ export function serializeProviderChain( providerType: e.providerType, priority: e.priority, enabled: e.enabled, + ...(e.reasoningEffort ? { reasoningEffort: e.reasoningEffort } : {}), })), ); } + +/** + * The chain to persist after setting (or, with `null`, clearing) one entry's + * reasoning effort from that provider's own settings form. + * + * The setting lives on the chain entry, so a chain that was never customised + * is materialised from the default first. A provider that is not in the chain + * at all gets a DISABLED entry to hold the value: the setting must reach the + * provider when it is resolved as the selected one, and a disabled entry is + * inert everywhere else (every reader filters on `enabled`). Clearing on an + * absent entry writes nothing new. + */ +export function chainWithReasoningEffort( + rawChain: unknown, + providerType: ReasoningProviderType, + effort: ReasoningEffort | null, +): ProviderChainEntry[] { + const chain = parseProviderChain(rawChain).map((e) => ({ ...e })); + const entry = chain.find((e) => e.providerType === providerType); + if (entry) { + if (effort) entry.reasoningEffort = effort; + else delete entry.reasoningEffort; + } else if (effort) { + chain.push({ + providerType, + priority: Math.max(0, ...chain.map((e) => e.priority)) + 1, + enabled: false, + reasoningEffort: effort, + }); + } + return chain; +} diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index 89bc856a0..a88e6e5ad 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -1,5 +1,6 @@ import type { AIProvider, CompletionResult } from "./types"; import { bindResponseTimeout } from "./effective-timeout"; +import { bindReasoningEffort } from "./reasoning-effort"; import { prisma } from "@/lib/db"; import { decrypt } from "@/lib/crypto"; import { CodexClient, resolveCodexVisionSlug } from "./codex-client"; @@ -501,13 +502,19 @@ export async function resolveProvider(userId: string): Promise { role: true, managedProfileAt: true, aiResponseTimeoutSeconds: true, + aiProviderChain: true, }, }); // The record's response-timeout setting rides on whatever this returns, on // every branch, the operator-key fallback for a managed profile included: - // the person waiting on the record is who the call serves. + // the person waiting on the record is who the call serves. The reasoning + // effort (#1126) rides the same way; it only lands on a Local or gateway + // instance, so the operator fallback never picks one up. const bind = (provider: AIProvider): AIProvider => - bindResponseTimeout(provider, userRow?.aiResponseTimeoutSeconds); + bindReasoningEffort( + bindResponseTimeout(provider, userRow?.aiResponseTimeoutSeconds), + userRow?.aiProviderChain, + ); const policy = providerCredentialPolicy( authority, userRow?.managedProfileAt ?? null, @@ -533,18 +540,23 @@ export async function resolveProvider(userId: string): Promise { } /** - * The record owner's `aiResponseTimeoutSeconds`, for `resolveProviderForTest`, - * whose many branches (saved config, unsaved override, the chain) are bound in - * one place on the way out. The other two resolvers read it with their row. + * The record owner's `aiResponseTimeoutSeconds` and `aiProviderChain`, for + * `resolveProviderForTest`, whose many branches (saved config, unsaved + * override, the chain) are bound in one place on the way out. The other two + * resolvers read them with their row. */ -async function readResponseTimeoutSeconds( - userId: string, -): Promise { +async function readBoundSettings(userId: string): Promise<{ + aiResponseTimeoutSeconds: number | null; + aiProviderChain: unknown; +}> { const row = await prisma.user.findUnique({ where: { id: userId }, - select: { aiResponseTimeoutSeconds: true }, + select: { aiResponseTimeoutSeconds: true, aiProviderChain: true }, }); - return row?.aiResponseTimeoutSeconds ?? null; + return { + aiResponseTimeoutSeconds: row?.aiResponseTimeoutSeconds ?? null, + aiProviderChain: row?.aiProviderChain ?? null, + }; } /** @@ -595,9 +607,12 @@ export async function resolveProviderChain( aiResponseTimeoutSeconds: true, }, }); - // See `resolveProvider`: every instance carries the record's setting. + // See `resolveProvider`: every instance carries the record's settings. const bind = (provider: AIProvider): AIProvider => - bindResponseTimeout(provider, userRow?.aiResponseTimeoutSeconds); + bindReasoningEffort( + bindResponseTimeout(provider, userRow?.aiResponseTimeoutSeconds), + userRow?.aiProviderChain, + ); const policy = providerCredentialPolicy( authority, userRow?.managedProfileAt ?? null, @@ -1184,10 +1199,13 @@ export async function resolveProviderForTest( ): Promise { // The test runs under the same timeout generation would, so a slow local // model that needs a raised setting is not reported as dead by the test. + // The same holds for the entry's reasoning effort (#1126): a thinking model + // that only answers with reasoning switched off must test green with it off. const provider = await resolveProviderForTestUnbound(userId, override); - return bindResponseTimeout( - provider, - await readResponseTimeoutSeconds(userId), + const settings = await readBoundSettings(userId); + return bindReasoningEffort( + bindResponseTimeout(provider, settings.aiResponseTimeoutSeconds), + settings.aiProviderChain, ); } diff --git a/src/lib/ai/reasoning-effort.ts b/src/lib/ai/reasoning-effort.ts new file mode 100644 index 000000000..ccf1f6906 --- /dev/null +++ b/src/lib/ai/reasoning-effort.ts @@ -0,0 +1,83 @@ +/** + * #1126 — the per-entry reasoning setting of the Local and OpenAI-compatible + * providers, and the binder that stamps it onto a resolved provider. + * + * A leaf on purpose: `types.ts` and the binder are reachable from workers that + * must not reach the provider machinery (`data-arrival-provider-isolation`), + * so nothing here imports `provider-chain.ts`. The stored entry is read from + * the raw chain directly, first match wins, the same rule + * `parseProviderChain` dedupes by. + */ +import type { AIProvider } from "./types"; + +/** + * The `reasoning_effort` values a chain entry may carry. "Off" in the settings + * is `none` on the wire. The absence of the key is "Default": nothing is sent + * and the model decides, which is what every chain stored before the setting + * existed keeps doing. + */ +export const REASONING_EFFORTS = ["none", "low", "medium", "high"] as const; + +export type ReasoningEffort = (typeof REASONING_EFFORTS)[number]; + +/** + * The entries that may carry a reasoning effort: the two whose wire is a + * person-chosen `/chat/completions` endpoint serving a model of their choice. + * OpenAI, Anthropic and Codex pin their own model families and parameters, + * so the setting is refused for them rather than silently dropped. + */ +export const REASONING_PROVIDER_TYPES = ["local", "openai-compatible"] as const; + +export type ReasoningProviderType = (typeof REASONING_PROVIDER_TYPES)[number]; + +export function isReasoningProviderType( + providerType: string, +): providerType is ReasoningProviderType { + return (REASONING_PROVIDER_TYPES as readonly string[]).includes(providerType); +} + +export function isReasoningEffort(value: unknown): value is ReasoningEffort { + return ( + typeof value === "string" && + (REASONING_EFFORTS as readonly string[]).includes(value) + ); +} + +/** + * The reasoning effort stored on a provider type's entry in a persisted chain, + * enabled or not. `null` is Default: no chain, no entry, no valid value, or a + * type that cannot carry one. + */ +export function reasoningEffortFor( + rawChain: unknown, + providerType: string, +): ReasoningEffort | null { + if (!isReasoningProviderType(providerType) || !Array.isArray(rawChain)) { + return null; + } + const entry = rawChain.find( + (item): item is { reasoningEffort?: unknown } => + item != null && + typeof item === "object" && + (item as { providerType?: unknown }).providerType === providerType, + ); + return isReasoningEffort(entry?.reasoningEffort) + ? entry.reasoningEffort + : null; +} + +/** + * Stamp the record owner's reasoning effort onto a resolved provider. Keyed on + * the instance's runtime type, so only a Local client or an OpenAI-compatible + * gateway can ever carry one: the operator's provider, the personal OpenAI + * key, Anthropic and Codex resolve to `null` whatever the stored chain says. + * Called by every exported resolver in `provider.ts` next to + * `bindResponseTimeout`, with the raw `User.aiProviderChain` value. + */ +export function bindReasoningEffort

( + provider: P, + aiProviderChain: unknown, +): P { + provider.reasoningEffort = reasoningEffortFor(aiProviderChain, provider.type); + return provider; +} diff --git a/src/lib/ai/types.ts b/src/lib/ai/types.ts index 541dcbb24..7ad8be174 100644 --- a/src/lib/ai/types.ts +++ b/src/lib/ai/types.ts @@ -1,3 +1,4 @@ +import type { ReasoningEffort } from "./reasoning-effort"; import { z } from "zod/v4"; // ─── Insight Result Schema ───────────────────────────────── @@ -123,6 +124,13 @@ export interface AIProvider { * `null` / absent → the surface's `timeoutMs`, else the 60 s default. */ responseTimeoutSeconds?: number | null; + /** + * #1126 — the `reasoning_effort` the record owner chose for this provider's + * chain entry, stamped by the resolver (`bindReasoningEffort`). Only the + * Local client and the OpenAI-compatible gateway read it, and only they are + * ever stamped with a value. `null` / absent → Default: the key is not sent. + */ + reasoningEffort?: ReasoningEffort | null; generateCompletion(params: CompletionParams): Promise; /** * v1.22 (#89) — optional true-streaming variant. A provider that can emit diff --git a/src/lib/export/__tests__/account-settings-backup.test.ts b/src/lib/export/__tests__/account-settings-backup.test.ts index b0768b7e9..3414b51d1 100644 --- a/src/lib/export/__tests__/account-settings-backup.test.ts +++ b/src/lib/export/__tests__/account-settings-backup.test.ts @@ -19,6 +19,7 @@ import { buildAccountSettingsBackupSection, } from "@/lib/export/account-settings-backup"; import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; +import { reasoningEffortFor } from "@/lib/ai/reasoning-effort"; /** A 1x1 PNG, the smallest image the avatar upload accepts. */ const PNG_1X1 = @@ -190,6 +191,38 @@ describe("admitAccountSettings", () => { ]); }); + // #1126 — the reasoning setting lives on the provider chain entries, so + // the chain column carries it through a backup unchanged. + it("carries the provider chain's reasoning settings through a round trip", async () => { + const chain = [ + { + providerType: "local", + priority: 1, + enabled: true, + reasoningEffort: "none", + }, + { + providerType: "openai-compatible", + priority: 2, + enabled: false, + reasoningEffort: "high", + }, + { providerType: "openai", priority: 3, enabled: true }, + ]; + const { accountSettings } = await buildAccountSettingsBackupSection( + prismaReturning(rowWith({ aiProviderChain: chain })), + "u1", + { purpose: "disaster-recovery" }, + ); + const restored = admitAccountSettings( + JSON.parse(JSON.stringify(accountSettings)), + ctx, + ).data.aiProviderChain; + expect(restored).toEqual(chain); + expect(reasoningEffortFor(restored, "local")).toBe("none"); + expect(reasoningEffortFor(restored, "openai-compatible")).toBe("high"); + }); + it("writes a database null for a JSON setting the file clears", () => { expect( admitAccountSettings({ dashboardWidgetsJson: null }, ctx).data diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index cc49e7eca..16b65fd5b 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -18,7 +18,7 @@ import { SPARKLINE_MAX_POINTS, } from "@/lib/insights/derived/types"; import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta"; -import { PROVIDER_CHAIN_TYPES } from "@/lib/ai/provider-chain"; +import { providerChainPutSchema } from "@/lib/validations/ai-provider"; import { PERIOD_DAYS } from "@/lib/insights/narrative/period-narrative"; import { aiCapabilityState } from "../profile"; @@ -2531,36 +2531,11 @@ export const providerChainResponse = z "The account's AI-provider chain as the settings surface reads it. Provider TYPES and boolean state only — no key, token or account id is ever on this wire.", }); -export const providerChainPutRequest = z - .object({ - chain: z - .array( - z.object({ - providerType: z - .enum(PROVIDER_CHAIN_TYPES as unknown as [string, ...string[]]) - .describe( - "Closed allow-list. The mock provider is excluded from it structurally, which is what keeps production from reaching one.", - ), - priority: z - .number() - .int() - .optional() - .describe( - "ACCEPTED AND IGNORED. Priority is recomputed from the array's insertion order, so a stale client cannot persist a chain whose displayed order disagrees with its stored one. Send the order you want as the order of the array.", - ), - enabled: z.boolean(), - }), - ) - .min(1) - .describe( - "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.", - ), - }) - .meta({ - id: "ProviderChainPutRequest", - 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.", - }); +export const providerChainPutRequest = providerChainPutSchema.meta({ + id: "ProviderChainPutRequest", + description: + "Replaces the account's provider chain wholesale. No credential is written or read here — only which provider types exist, in what order, which are enabled, and the reasoning setting of the Local and OpenAI-compatible entries.", +}); // ── Read-only advisor + generation (`/api/insights/generate`) ───────── diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 257f01428..261cd63a5 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -36,6 +36,7 @@ import { } from "@/lib/validations/user-prefs"; import { aiProviderPatchSchema, + reasoningEffortSchema, aiTestOverrideSchema, } from "@/lib/validations/ai-provider"; import { @@ -674,6 +675,12 @@ const aiProviderResponse = z .describe( "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: reasoningEffortSchema.describe( + "#1126 — reasoning setting of the Local provider entry, sent as `reasoning_effort` (`none` = Off). Null = Default: the key is not sent.", + ), + compatReasoningEffort: reasoningEffortSchema.describe( + "#1126 — the same for the OpenAI-compatible gateway entry.", + ), // v1.38.19 — the setup flow's last screen used to promise // that insights "work for you right away" as soon as `managedBy` read // `server`. That is a presence read; it never knew whether the key diff --git a/src/lib/validations/ai-provider.ts b/src/lib/validations/ai-provider.ts index 7b51bcffe..dc8f92802 100644 --- a/src/lib/validations/ai-provider.ts +++ b/src/lib/validations/ai-provider.ts @@ -16,6 +16,12 @@ */ import { z } from "zod/v4"; +import { PROVIDER_CHAIN_TYPES } from "@/lib/ai/provider-chain"; +import { + isReasoningProviderType, + REASONING_EFFORTS, +} from "@/lib/ai/reasoning-effort"; + /** The five provider kinds a user may select. */ export const AI_PROVIDER_KINDS = [ "OPENAI", @@ -38,6 +44,17 @@ export const aiProviderKindSchema = z.enum(AI_PROVIDER_KINDS); */ const clearableString = z.string().nullable(); +/** + * #1126 — a provider entry's reasoning setting. `null` is "Default": nothing + * is sent and the model decides. `none` is "Off". + */ +export const reasoningEffortSchema = z + .enum(REASONING_EFFORTS) + .nullable() + .describe( + "Reasoning setting for a Local or OpenAI-compatible provider, sent as `reasoning_effort` in the `/chat/completions` body. `none` switches reasoning off, which stops a thinking model (Gemma, Qwen and the like) spending the answer budget on reasoning. `null` is Default: the key is not sent at all and the model decides.", + ); + /** * `PATCH /api/user/ai-provider` — a partial update of the provider config. * @@ -93,6 +110,22 @@ export const aiProviderPatchSchema = z.object({ .max(600, "Response timeout must be between 10 and 600 seconds") .nullable() .optional(), + /** + * #1126 — the reasoning setting of the Local entry. Stored on the entry in + * the provider chain; a chain never customised is materialised from the + * default to hold it. `null` restores Default. + */ + localReasoningEffort: reasoningEffortSchema + .optional() + .describe( + "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.", + ), + /** #1126 — the same for the OpenAI-compatible gateway entry. */ + compatReasoningEffort: reasoningEffortSchema + .optional() + .describe( + "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.", + ), }); export type AiProviderPatchInput = z.infer; @@ -115,3 +148,61 @@ export const aiTestOverrideSchema = z .strict(); export type AiTestOverrideInput = z.infer; + +/** + * One entry of `PUT /api/insights/provider-chain`. Shared by the route and the + * OpenAPI registry, so the published contract and the parser are one object. + */ +export const providerChainEntrySchema = z + .object({ + providerType: z + .enum(PROVIDER_CHAIN_TYPES) + .describe( + "Closed allow-list. The mock provider is excluded from it structurally, which is what keeps production from reaching one.", + ), + // 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() + .describe( + "ACCEPTED AND IGNORED. Priority is recomputed from the array's insertion order, so a stale client cannot persist a chain whose displayed order disagrees with its stored one. Send the order you want as the order of the array.", + ), + enabled: z.boolean(), + reasoningEffort: reasoningEffortSchema + .optional() + .describe( + "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`.", + ), + }) + .strict() + .superRefine((entry, ctx) => { + if ( + entry.reasoningEffort !== undefined && + !isReasoningProviderType(entry.providerType) + ) { + ctx.addIssue({ + code: "custom", + path: ["reasoningEffort"], + message: + "A reasoning setting applies to the local and openai-compatible providers only", + }); + } + }); + +export const providerChainPutSchema = z + .object({ + chain: z + .array(providerChainEntrySchema) + .min(1, "Chain must contain at least one provider") + .max(PROVIDER_CHAIN_TYPES.length, "Too many providers") + .describe( + "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.", + ), + }) + .strict(); + +export type ProviderChainPutInput = z.infer; From 39a5862d5447cbf13dee60411c2927aeb249dd20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 10:43:04 +0200 Subject: [PATCH 2/3] feat(settings): add a Reasoning select to the Local and gateway forms One select under the model field of the Local and OpenAI-compatible provider forms: Default, Off, Low, Medium, High, with a one-line hint that Off leaves the answer budget for the answer itself. It reuses the NativeSelect, label and hint layout of the field above it, seeds from the stored entry, and saves with the form. Strings in all seven locales. Refs #1126 --- e2e/ai-provider-dropdown.spec.ts | 112 ++++++++++++++++++ messages/de.json | 11 ++ messages/en.json | 11 ++ messages/es.json | 11 ++ messages/fr.json | 11 ++ messages/it.json | 11 ++ messages/ko.json | 11 ++ messages/pl.json | 11 ++ .../__tests__/reasoning-effort-field.test.tsx | 106 +++++++++++++++++ .../settings/ai/compat-provider-form.tsx | 17 ++- .../settings/ai/local-provider-form.tsx | 13 +- .../settings/ai/reasoning-effort-field.tsx | 52 ++++++++ src/components/settings/ai/shared.ts | 9 ++ 13 files changed, 383 insertions(+), 3 deletions(-) create mode 100644 src/components/settings/ai/__tests__/reasoning-effort-field.test.tsx create mode 100644 src/components/settings/ai/reasoning-effort-field.tsx 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/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(/

+
)} +