diff --git a/backend/prisma/migrations/20261018120000_ai_reasoning_effort/migration.sql b/backend/prisma/migrations/20261018120000_ai_reasoning_effort/migration.sql new file mode 100644 index 0000000..3ef4c34 --- /dev/null +++ b/backend/prisma/migrations/20261018120000_ai_reasoning_effort/migration.sql @@ -0,0 +1,11 @@ +-- CreateEnum +CREATE TYPE "ReasoningEffort" AS ENUM ('none', 'low', 'medium', 'high'); + +-- AlterTable +ALTER TABLE "AiConnection" ADD COLUMN "reasoningEffort" "ReasoningEffort"; + +-- Thinking on sent no reasoning_effort and off sent "none", so each connection keeps sending what it did. +UPDATE "AiConnection" SET "reasoningEffort" = 'none' WHERE "thinking" = false; + +-- AlterTable +ALTER TABLE "AiConnection" DROP COLUMN "thinking"; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index e81ece4..a259f4c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -19,6 +19,14 @@ enum CategorySource { LEGACY } +/// What an AI connection sends as reasoning_effort, spelled the way providers expect it. +enum ReasoningEffort { + none + low + medium + high +} + model User { id String @id @default(cuid()) email String @unique @@ -530,20 +538,20 @@ model Setting { /// An OpenAI-compatible AI service. Features use the default one unless set to another. model AiConnection { - id String @id @default(cuid()) - baseUrl String + id String @id @default(cuid()) + baseUrl String /// Write-only: the API only ever says whether one is set. - apiKey String? - model String - timeoutMs Int @default(60000) + apiKey String? + model String + timeoutMs Int @default(60000) /// How many requests may wait on this provider at once. - concurrency Int @default(1) - /// Off asks a reasoning model to skip thinking, which on a CPU takes minutes. - thinking Boolean @default(true) - isDefault Boolean @default(false) - position Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + concurrency Int @default(1) + /// Sent as reasoning_effort. Null sends nothing, leaving it to the provider. + reasoningEffort ReasoningEffort? + isDefault Boolean @default(false) + position Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } /// An OpenID Connect identity provider (Authentik, PocketID, ...) people can sign in with. diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index aca9d1d..c004142 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -1,4 +1,4 @@ -import type { AiConnection } from "@prisma/client"; +import type { AiConnection, ReasoningEffort } from "@prisma/client"; import { currentStorageMove, startStorageMove, type StorageMoveRun } from "../services/storageMoveService"; import { assertSavableStorageTemplate, storageTemplateProblems } from "../services/storageTemplateRules"; import { Router } from "express"; @@ -834,7 +834,7 @@ function aiConnectionOut(connection: AiConnection) { has_api_key: Boolean(connection.apiKey), timeout_ms: connection.timeoutMs, concurrency: connection.concurrency, - thinking: connection.thinking, + reasoning_effort: connection.reasoningEffort, is_default: connection.isDefault, }; } @@ -854,13 +854,20 @@ const aiConnectionFields = { api_key: z.string().nullable().optional(), timeout_ms: z.number().int().min(1000).max(300000), concurrency: z.number().int().min(1).max(4), - thinking: z.boolean(), + // Null sends no reasoning_effort at all. + reasoning_effort: z.enum(["none", "low", "medium", "high"]).nullable(), }; /** A saved AI (or the defaults, for a new one) with the form's values on top. */ async function providerFrom( id: string | undefined, - body: Partial<{ base_url: string; model: string; api_key: string | null; timeout_ms: number; thinking: boolean }>, + body: Partial<{ + base_url: string; + model: string; + api_key: string | null; + timeout_ms: number; + reasoning_effort: ReasoningEffort | null; + }>, ) { const saved = id ? await getAiConnection(id) : null; return { @@ -868,7 +875,10 @@ async function providerFrom( model: body.model ?? saved?.model ?? null, apiKey: body.api_key !== undefined ? body.api_key || null : (saved?.apiKey ?? DEFAULT_AI_PROVIDER.apiKey), timeoutMs: body.timeout_ms ?? saved?.timeoutMs ?? DEFAULT_AI_PROVIDER.timeoutMs, - thinking: body.thinking ?? saved?.thinking ?? DEFAULT_AI_PROVIDER.thinking, + reasoningEffort: + body.reasoning_effort !== undefined + ? body.reasoning_effort + : (saved?.reasoningEffort ?? DEFAULT_AI_PROVIDER.reasoningEffort), }; } @@ -884,7 +894,7 @@ const aiConnectionCreateSchema = z ...aiConnectionFields, timeout_ms: aiConnectionFields.timeout_ms.optional(), concurrency: aiConnectionFields.concurrency.optional(), - thinking: aiConnectionFields.thinking.optional(), + reasoning_effort: aiConnectionFields.reasoning_effort.optional(), }) .strict(); router.post( @@ -899,7 +909,7 @@ router.post( model: provider.model!, apiKey: provider.apiKey, timeoutMs: provider.timeoutMs, - thinking: provider.thinking, + reasoningEffort: provider.reasoningEffort, concurrency: body.concurrency ?? DEFAULT_AI_PROVIDER.concurrency, }); res.status(201).json(aiConnectionOut(connection)); @@ -913,7 +923,7 @@ router.patch( asyncHandler(async (req, res) => { const body = parseBody(aiConnectionUpdateSchema, req.body); const provider = await providerFrom(req.params.id, body); - // Concurrency and thinking don't change whether it answers. + // Concurrency and reasoning effort don't change whether it answers. if ( body.base_url !== undefined || body.model !== undefined || @@ -928,7 +938,7 @@ router.patch( ...(body.api_key !== undefined ? { apiKey: provider.apiKey } : {}), ...(body.timeout_ms !== undefined ? { timeoutMs: body.timeout_ms } : {}), ...(body.concurrency !== undefined ? { concurrency: body.concurrency } : {}), - ...(body.thinking !== undefined ? { thinking: body.thinking } : {}), + ...(body.reasoning_effort !== undefined ? { reasoningEffort: body.reasoning_effort } : {}), }); res.json(aiConnectionOut(connection)); }), diff --git a/backend/src/services/aiConnectionService.ts b/backend/src/services/aiConnectionService.ts index 206aa83..b21fd79 100644 --- a/backend/src/services/aiConnectionService.ts +++ b/backend/src/services/aiConnectionService.ts @@ -1,10 +1,13 @@ -import type { AiConnection, Prisma } from "@prisma/client"; +import type { AiConnection, Prisma, ReasoningEffort } from "@prisma/client"; import { prisma } from "../db"; import { HttpError } from "../utils/fileUtils"; import { normalizeAiBaseUrl } from "./settingsService"; /** What a request to a provider needs, whether the connection is saved or only typed into the form. */ -export type AiProvider = Pick; +export type AiProvider = Pick< + AiConnection, + "baseUrl" | "apiKey" | "model" | "timeoutMs" | "concurrency" | "reasoningEffort" +>; export type AiConnectionInput = { baseUrl: string; @@ -12,14 +15,14 @@ export type AiConnectionInput = { model: string; timeoutMs: number; concurrency: number; - thinking: boolean; + reasoningEffort: ReasoningEffort | null; }; export const DEFAULT_AI_PROVIDER: Omit = { apiKey: null, timeoutMs: 60000, concurrency: 1, - thinking: true, + reasoningEffort: null, }; // Any number, as long as only the default's writers take it. Two AIs added at once would otherwise both count no @@ -65,7 +68,7 @@ export async function resolveAiProvider(id: string | null | undefined): Promise< model: connection.model, timeoutMs: connection.timeoutMs, concurrency: connection.concurrency, - thinking: connection.thinking, + reasoningEffort: connection.reasoningEffort, connectionId: connection.id, }; } diff --git a/backend/src/services/aiProviderClient.ts b/backend/src/services/aiProviderClient.ts index 113c9cd..b4a9dd5 100644 --- a/backend/src/services/aiProviderClient.ts +++ b/backend/src/services/aiProviderClient.ts @@ -178,7 +178,7 @@ export async function askProvider(options: { const { settings, content, schemaName, schema, maxTokens, attempts = 3, log: requestLog, noAnswer, read } = options; const { stream } = options; const connection = `${settings.baseUrl}|${settings.model}`; - let skipThinking = !settings.thinking && !reasoningEffortRejected.has(connection); + let reasoningEffort = reasoningEffortRejected.has(connection) ? null : settings.reasoningEffort; for (let attempt = 0; attempt < attempts; attempt++) { const last = attempt === attempts - 1; const started = Date.now(); @@ -208,17 +208,18 @@ export async function askProvider(options: { max_tokens: maxTokens, ...(stream ? { stream: true, stream_options: { include_usage: true } } : {}), response_format: { type: "json_schema", json_schema: { name: schemaName, strict: true, schema } }, - // A reasoning model (qwen3) on a CPU spends minutes thinking; these tasks rarely need it. - ...(skipThinking ? { reasoning_effort: "none" } : {}), + // These tasks rarely need much thought, and a reasoning model spends max_tokens on it before answering. + // Models differ in what they take: "none" stops qwen3 thinking, but gpt-oss ignores it and needs "low". + ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}), messages: [{ role: "user", content }], }), }); if (!response.ok) { // Strict providers reject the field outright, e.g. OpenAI for a model that doesn't reason: ask again // without it, which doesn't count as an attempt. Only when the error names it, so another bad request - // isn't taken for proof that the connection can't skip thinking. - if (response.status === 400 && skipThinking && /reasoning/i.test(await response.clone().text())) { - skipThinking = false; + // isn't taken for proof that the connection can't take a reasoning effort. + if (response.status === 400 && reasoningEffort && /reasoning/i.test(await response.clone().text())) { + reasoningEffort = null; reasoningEffortRejected.add(connection); requestLog.warn("provider rejected reasoning_effort, asking again without it"); attempt--; diff --git a/backend/tests/aiCategorization.test.ts b/backend/tests/aiCategorization.test.ts index 46b0e19..999d94b 100644 --- a/backend/tests/aiCategorization.test.ts +++ b/backend/tests/aiCategorization.test.ts @@ -141,7 +141,7 @@ async function enable(baseUrl: string, patch: Partial { const tuned = await request(app) .patch(`/api/settings/ai-connections/${saved.body.id}`) .set(auth()) - .send({ concurrency: 2, thinking: false }); - expect(tuned.body).toMatchObject({ base_url: liveUrl, concurrency: 2, thinking: false }); + .send({ concurrency: 2, reasoning_effort: "low" }); + expect(tuned.body).toMatchObject({ base_url: liveUrl, concurrency: 2, reasoning_effort: "low" }); }); it("makes exactly one of two AIs added at the same moment the default", async () => { - const fields = { apiKey: null, timeoutMs: 1000, concurrency: 1, thinking: true, baseUrl: "http://127.0.0.1:9/v1" }; + const fields = { + apiKey: null, + timeoutMs: 1000, + concurrency: 1, + reasoningEffort: null, + baseUrl: "http://127.0.0.1:9/v1", + }; const added = await Promise.all([ createAiConnection({ ...fields, model: "first" }), createAiConnection({ ...fields, model: "second" }), @@ -380,7 +386,7 @@ describe("AI categorization", () => { it("makes the first AI the default, moves the role on delete, and lets categorization pick one", async () => { const defaultUrl = await startProvider(() => ({ category_id: null, confidence: 1, reason: "default" })); const first = await enable(defaultUrl); - const fields = { apiKey: null, timeoutMs: 1000, concurrency: 1, thinking: true }; + const fields = { apiKey: null, timeoutMs: 1000, concurrency: 1, reasoningEffort: null }; const second = await createAiConnection({ ...fields, baseUrl: "http://127.0.0.1:9/v1", model: "other" }); expect([first.isDefault, second.isDefault]).toEqual([true, false]); @@ -438,7 +444,7 @@ describe("AI categorization", () => { model: "qwen3:8b", timeoutMs: 1000, concurrency: 1, - thinking: true, + reasoningEffort: null, }); const listed = await request(app).post("/api/settings/ai-connections/models").set(auth()).send({ id: saved.id }); // Sorted, without duplicates or entries that aren't models. @@ -801,7 +807,7 @@ describe("AI categorization", () => { }); }); - it("asks a reasoning model not to think only when thinking is turned off", async () => { + it("sends the connection's reasoning effort, and nothing when it is left to the provider", async () => { const bodies: Record[] = []; const baseUrl = await startProvider((body) => { bodies.push(body); @@ -816,17 +822,26 @@ describe("AI categorization", () => { .send({}) ).status; + const setEffort = (reasoning_effort: unknown) => + request(app).patch(`/api/settings/ai-connections/${connection.id}`).set(auth()).send({ reasoning_effort }); + expect(await recategorize()).toBe(200); expect(bodies[0]).not.toHaveProperty("reasoning_effort"); - const off = await request(app) - .patch(`/api/settings/ai-connections/${connection.id}`) - .set(auth()) - .send({ thinking: false }); - expect(off.status).toBe(200); - expect(off.body.thinking).toBe(false); + // gpt-oss can't stop reasoning and ignores "none", so it needs one of the others. + for (const effort of ["none", "low", "medium", "high"]) { + const set = await setEffort(effort); + expect(set.status).toBe(200); + expect(set.body.reasoning_effort).toBe(effort); + expect(await recategorize()).toBe(200); + expect(bodies.at(-1)?.reasoning_effort).toBe(effort); + } + + expect((await setEffort("max")).status).toBe(400); + const cleared = await setEffort(null); + expect(cleared.body.reasoning_effort).toBeNull(); expect(await recategorize()).toBe(200); - expect(bodies[1].reasoning_effort).toBe("none"); + expect(bodies.at(-1)).not.toHaveProperty("reasoning_effort"); }); it("asks again without reasoning_effort when a strict provider rejects it, and remembers that", async () => { @@ -838,7 +853,7 @@ describe("AI categorization", () => { ? { status: 400, content: "Unsupported parameter: 'reasoning_effort' is not supported with this model." } : { category_id: null, confidence: 1, reason: "none" }; }); - await enable(baseUrl, { thinking: false }); + await enable(baseUrl, { reasoningEffort: "low" }); const recategorize = async () => ( await request(app) @@ -859,7 +874,7 @@ describe("AI categorization", () => { bodies.push(body); return { status: 400, content: "The model `missing` does not exist" }; }); - await enable(baseUrl, { thinking: false }); + await enable(baseUrl, { reasoningEffort: "none" }); for (let i = 0; i < 2; i++) { const failed = await request(app) .post(`/api/print/${(await print()).id}/recategorize`) diff --git a/backend/tests/aiDescriptions.test.ts b/backend/tests/aiDescriptions.test.ts index 259664d..91ff8e9 100644 --- a/backend/tests/aiDescriptions.test.ts +++ b/backend/tests/aiDescriptions.test.ts @@ -84,7 +84,14 @@ async function stopProvider() { /** One model, the default, and the feature on with it. */ async function enable(baseUrl: string, patch: Partial = {}) { await prisma.aiConnection.deleteMany(); - await createAiConnection({ baseUrl, apiKey: null, model: "local", timeoutMs: 2000, concurrency: 1, thinking: true }); + await createAiConnection({ + baseUrl, + apiKey: null, + model: "local", + timeoutMs: 2000, + concurrency: 1, + reasoningEffort: null, + }); await setAiDescriptionPrefs({ ...DEFAULT_AI_DESCRIPTION_PREFS, enabled: true, onImport: false, ...patch }); } diff --git a/frontend/src/api/aiConnections.ts b/frontend/src/api/aiConnections.ts index b7dce25..029b8d3 100644 --- a/frontend/src/api/aiConnections.ts +++ b/frontend/src/api/aiConnections.ts @@ -3,6 +3,10 @@ import { apiBase, readErrorMessage, UnauthorizedError } from "./client"; import { invalidateAiCategorizationStatus } from "./aiCategorization"; import { invalidateAiDescriptionStatus } from "./aiDescriptions"; +/** What a connection may send as reasoning_effort; null sends nothing. */ +export const REASONING_EFFORTS = ["none", "low", "medium", "high"] as const; +export type ReasoningEffort = (typeof REASONING_EFFORTS)[number]; + export type AiConnection = { id: string; base_url: string; @@ -10,7 +14,7 @@ export type AiConnection = { has_api_key: boolean; timeout_ms: number; concurrency: number; - thinking: boolean; + reasoning_effort: ReasoningEffort | null; is_default: boolean; }; @@ -21,7 +25,7 @@ export type AiConnectionInput = { api_key?: string | null; timeout_ms: number; concurrency: number; - thinking: boolean; + reasoning_effort: ReasoningEffort | null; }; export type AiConnectionTest = { ok: boolean; latency_ms?: number; model?: string; error?: string }; diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 096c2e6..edc61a1 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -902,8 +902,15 @@ "keyOptional": "Optional. Local servers such as Ollama usually don't need one.", "concurrency": "Concurrent requests (1-4)", "timeout": "Request timeout (ms)", - "thinking": "Let the model think before answering", - "thinkingHint": "Turn off to speed up a reasoning model (such as qwen3) on slow hardware. If the provider doesn't support turning thinking off, the request is sent without it.", + "reasoningEffort": "Reasoning effort", + "reasoningEffortHint": "Sent to the provider as reasoning_effort. Leave it on the provider's default unless a reasoning model is slow or runs out of tokens: None stops qwen3 thinking, but gpt-oss can't stop and needs Low. If the provider rejects the setting, the request is sent without it.", + "reasoningEffortDefault": "Provider default (send nothing)", + "reasoningEffortLevels": { + "none": "None", + "low": "Low", + "medium": "Medium", + "high": "High" + }, "test": "Test connection", "testing": "Testing…", "testSuccess": "Connected to {{model}} in {{latency}} ms.", diff --git a/frontend/src/i18n/locales/lt.json b/frontend/src/i18n/locales/lt.json index b9203ef..ca26c2b 100644 --- a/frontend/src/i18n/locales/lt.json +++ b/frontend/src/i18n/locales/lt.json @@ -912,8 +912,15 @@ "keyOptional": "Neprivaloma. Vietiniams serveriams, tokiems kaip Ollama, jo paprastai nereikia.", "concurrency": "Lygiagrečios užklausos (1-4)", "timeout": "Užklausos skirtasis laikas (ms)", - "thinking": "Leisti modeliui pagalvoti prieš atsakant", - "thinkingHint": "Išjunkite, kad samprotaujantis modelis (pvz., qwen3) lėtoje įrangoje atsakytų greičiau. Jei tiekėjas nepalaiko samprotavimo išjungimo, užklausa siunčiama be šio nustatymo.", + "reasoningEffort": "Samprotavimo lygis", + "reasoningEffortHint": "Siunčiama tiekėjui kaip reasoning_effort. Palikite tiekėjo numatytąjį, nebent samprotaujantis modelis veikia lėtai arba jam pritrūksta žetonų: „Nėra“ išjungia qwen3 samprotavimą, o gpt-oss jo išjungti negali ir jam reikia „Žemas“. Jei tiekėjas šio nustatymo nepriima, užklausa siunčiama be jo.", + "reasoningEffortDefault": "Tiekėjo numatytasis (nieko nesiųsti)", + "reasoningEffortLevels": { + "none": "Nėra", + "low": "Žemas", + "medium": "Vidutinis", + "high": "Aukštas" + }, "test": "Tikrinti ryšį", "testing": "Tikrinama…", "testSuccess": "Prisijungta prie {{model}} per {{latency}} ms.", diff --git a/frontend/src/pages/AiPage/AiConnectionDialog.tsx b/frontend/src/pages/AiPage/AiConnectionDialog.tsx index d840998..2788c36 100644 --- a/frontend/src/pages/AiPage/AiConnectionDialog.tsx +++ b/frontend/src/pages/AiPage/AiConnectionDialog.tsx @@ -2,21 +2,24 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import Alert from "@mui/material/Alert"; import Autocomplete from "@mui/material/Autocomplete"; -import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import CircularProgress from "@mui/material/CircularProgress"; import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; -import FormControlLabel from "@mui/material/FormControlLabel"; import InputAdornment from "@mui/material/InputAdornment"; +import MenuItem from "@mui/material/MenuItem"; import Stack from "@mui/material/Stack"; -import Switch from "@mui/material/Switch"; import TextField from "@mui/material/TextField"; -import Typography from "@mui/material/Typography"; import { UnauthorizedError } from "../../api/client"; -import { aiConnectionsApi, type AiConnection, type AiConnectionInput } from "../../api/aiConnections"; +import { + aiConnectionsApi, + REASONING_EFFORTS, + type AiConnection, + type AiConnectionInput, + type ReasoningEffort, +} from "../../api/aiConnections"; import { useConfirm } from "../../components/ConfirmProvider"; import { AiLogo } from "./aiLogos"; @@ -37,7 +40,7 @@ const draftOf = (connection: AiConnection | null): Draft => ({ api_key: "", timeout_ms: connection?.timeout_ms ?? 60000, concurrency: connection?.concurrency ?? 1, - thinking: connection?.thinking ?? true, + reasoning_effort: connection?.reasoning_effort ?? null, }); /** Adds or edits one AI. Saving tests it first, as the backend only keeps an AI that answers. */ @@ -108,7 +111,7 @@ export default function AiConnectionDialog({ connection, onClose, onChanged, onU model: draft.model.trim(), timeout_ms: Number(draft.timeout_ms), concurrency: Number(draft.concurrency), - thinking: draft.thinking, + reasoning_effort: draft.reasoning_effort, }; try { if (connection) { @@ -140,7 +143,7 @@ export default function AiConnectionDialog({ connection, onClose, onChanged, onU base_url: draft.base_url.trim(), model: draft.model.trim(), timeout_ms: Number(draft.timeout_ms), - thinking: draft.thinking, + reasoning_effort: draft.reasoning_effort, ...keyPayload, }); if (result.ok) @@ -286,23 +289,23 @@ export default function AiConnectionDialog({ connection, onClose, onChanged, onU fullWidth /> - update("thinking", event.target.checked)} - disabled={disabled} - /> - } - label={ - - {t("adminSettings.ai.connection.thinking")} - - {t("adminSettings.ai.connection.thinkingHint")} - - - } - /> + {/* A select can't hold null, so "" stands for sending nothing. */} + update("reasoning_effort", (event.target.value || null) as ReasoningEffort | null)} + helperText={t("adminSettings.ai.connection.reasoningEffortHint")} + disabled={disabled} + fullWidth + > + {t("adminSettings.ai.connection.reasoningEffortDefault")} + {REASONING_EFFORTS.map((effort) => ( + + {t(`adminSettings.ai.connection.reasoningEffortLevels.${effort}`)} + + ))} + {status && {status}} {error && {error}}