mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +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
@@ -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 });
|
||||
});
|
||||
Reference in new issue
Block a user