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;