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

@@ -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 });
});