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

+62 -1
View File
@@ -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
@@ -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<string, string> = {
"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*\(/);
},
);
});
@@ -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 });
});
@@ -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<typeof import("@/lib/logging/context")>()),
annotate: vi.fn(),
}));
vi.mock("@/lib/safe-fetch", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/safe-fetch")>()),
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<unknown> {
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)");
});
});
@@ -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<typeof import("@/lib/safe-fetch")>()),
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<typeof okFetch>): 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);
});
});
+9
View File
@@ -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
+11
View File
@@ -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
+51
View File
@@ -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;
}
+33 -15
View File
@@ -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<AIProvider> {
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<AIProvider> {
}
/**
* 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<number | null> {
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<AIProvider> {
// 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,
);
}
+83
View File
@@ -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<P extends AIProvider>(
provider: P,
aiProviderChain: unknown,
): P {
provider.reasoningEffort = reasoningEffortFor(aiProviderChain, provider.type);
return provider;
}
+8
View File
@@ -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<CompletionResult>;
/**
* v1.22 (#89) — optional true-streaming variant. A provider that can emit
@@ -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
+6 -31
View File
@@ -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`) ─────────
+7
View File
@@ -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
+91
View File
@@ -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<typeof aiProviderPatchSchema>;
@@ -115,3 +148,61 @@ export const aiTestOverrideSchema = z
.strict();
export type AiTestOverrideInput = z.infer<typeof aiTestOverrideSchema>;
/**
* 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<typeof providerChainPutSchema>;