feat(ai): send a per-entry reasoning effort to Local and gateway providers

Reasoning models on Ollama and similar servers think by default and can
spend the whole max_tokens budget before the visible answer starts, so
the call comes back empty or times out. The Local and OpenAI-compatible
chain entries now carry an optional reasoning setting: Default sends
nothing, Off sends reasoning_effort "none", Low / Medium / High send
their own value. It is stored on the entry in aiProviderChain, so a
chain can mix a thinking model with one that does not know the field.

- Every exported resolver binds the entry's value onto the client, the
  connection test included, keyed on the instance type so the operator
  provider, OpenAI, Anthropic and Codex never carry it.
- PATCH /api/user/ai-provider accepts localReasoningEffort and
  compatReasoningEffort and GET reports them. A provider absent from
  the chain holds its value on a disabled entry; restating Default on a
  chain that was never customised writes nothing.
- PUT /api/insights/provider-chain accepts reasoningEffort on local and
  openai-compatible entries, refuses it on any other, and keeps the
  stored value when the body leaves it out. Its schema is now shared
  with the OpenAPI registry.
- A Default request body is byte-identical to the one sent before.

Refs #1126
This commit is contained in:
Marc-André Bombeck committed 2026-10-04 10:42:59 +02:00
1 parent 808b2672cd
commit befac59576
18 files changed
+1350 -84

No files matched your search

@@ -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<Response>)(
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();
});
});
});
+33 -35
View File
@@ -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 },
@@ -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<Response>;
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<string, unknown> | 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<Response>)();
const body = (await res.json()) as {
data: { localReasoningEffort: unknown; compatReasoningEffort: unknown };
};
expect(body.data.localReasoningEffort).toBe("none");
expect(body.data.compatReasoningEffort).toBeNull();
});
});
+57 -2
View File
@@ -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 });
});