mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +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
@@ -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