feat: replace the AI Thinking toggle with a per-connection reasoning effort

The Thinking switch had two states: send nothing, or send
`reasoning_effort: "none"`. That suits qwen3, which stops thinking on
"none", but not gpt-oss, which cannot stop reasoning, ignores "none" and
only takes low/medium/high. With Thinking off, gpt-oss:20b reasoned more
than with it on and ran out of the 1024-token categorisation budget three
times as often, and there was no way to ask it for "low".

Each AI connection now has a reasoning effort: the provider's default
(send nothing), none, low, medium or high. A migration keeps every
existing connection sending exactly what it sent before: Thinking on
becomes the provider default, off becomes "none". A provider that answers
400 naming reasoning_effort is still asked again without it, whichever
value was set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Tim MooreandClaude Opus 5.5 committed 2026-10-18 16:16:20 +11:00
1 parent 7abb673524
commit b30b0cefd8
11 files changed
+156 -80

No files matched your search

@@ -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";
+20 -12
View File
@@ -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.
+19 -9
View File
@@ -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));
}),
+8 -5
View File
@@ -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<AiConnection, "baseUrl" | "apiKey" | "model" | "timeoutMs" | "concurrency" | "thinking">;
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<AiProvider, "baseUrl" | "model"> = {
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,
};
}
+7 -6
View File
@@ -178,7 +178,7 @@ export async function askProvider<T>(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<T>(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--;
+31 -16
View File
@@ -141,7 +141,7 @@ async function enable(baseUrl: string, patch: Partial<AiCategorizationPrefs & Ai
model: "local",
timeoutMs: 1000,
concurrency: 1,
thinking: true,
reasoningEffort: null,
...connection,
});
await setAiCategorizationPrefs({ enabled: true, mode, threshold, onImport, sendImage, aiConnectionId: null });
@@ -363,12 +363,18 @@ describe("AI categorization", () => {
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<string, unknown>[] = [];
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`)
+8 -1
View File
@@ -84,7 +84,14 @@ async function stopProvider() {
/** One model, the default, and the feature on with it. */
async function enable(baseUrl: string, patch: Partial<AiDescriptionPrefs> = {}) {
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 });
}
+6 -2
View File
@@ -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 };
+9 -2
View File
@@ -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.",
+9 -2
View File
@@ -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.",
@@ -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
/>
</Stack>
<FormControlLabel
control={
<Switch
checked={draft.thinking}
onChange={(event) => update("thinking", event.target.checked)}
disabled={disabled}
/>
}
label={
<Box>
<Typography variant="body2">{t("adminSettings.ai.connection.thinking")}</Typography>
<Typography variant="caption" color="text.secondary">
{t("adminSettings.ai.connection.thinkingHint")}
</Typography>
</Box>
}
/>
{/* A select can't hold null, so "" stands for sending nothing. */}
<TextField
select
label={t("adminSettings.ai.connection.reasoningEffort")}
value={draft.reasoning_effort ?? ""}
onChange={(event) => update("reasoning_effort", (event.target.value || null) as ReasoningEffort | null)}
helperText={t("adminSettings.ai.connection.reasoningEffortHint")}
disabled={disabled}
fullWidth
>
<MenuItem value="">{t("adminSettings.ai.connection.reasoningEffortDefault")}</MenuItem>
{REASONING_EFFORTS.map((effort) => (
<MenuItem key={effort} value={effort}>
{t(`adminSettings.ai.connection.reasoningEffortLevels.${effort}`)}
</MenuItem>
))}
</TextField>
{status && <Alert severity="success">{status}</Alert>}
{error && <Alert severity="error">{error}</Alert>}
</Stack>