mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
808b2672cd
commit
befac59576
18 files changed
+1350
-84
No files matched your search
+62
-1
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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`) ─────────
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
Reference in new issue
Block a user