mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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
209 lines
8.7 KiB
TypeScript
209 lines
8.7 KiB
TypeScript
/**
|
|
* Request schemas for the AI-provider surfaces: the configuration write
|
|
* (`PATCH /api/user/ai-provider`) and the connection probe
|
|
* (`POST /api/ai/test`).
|
|
*
|
|
* They live outside the route files so the OpenAPI registry can import them (a
|
|
* route module may only export handlers plus the Next.js route config), which
|
|
* keeps the published contract and the runtime parser one object.
|
|
*
|
|
* Both bodies carry plaintext credentials, which puts one constraint on every
|
|
* rule below: a validation message must never interpolate the value it
|
|
* rejected. Zod's built-in type, enum and range messages name the received
|
|
* TYPE and never the received value, so the multi-issue 422 these feed is safe
|
|
* to return. A `.refine()` with a message that quotes its input would break
|
|
* that, and would put a key in an error body.
|
|
*/
|
|
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",
|
|
"ANTHROPIC",
|
|
"LOCAL",
|
|
"OPENAI_COMPATIBLE",
|
|
"CHATGPT_OAUTH",
|
|
] as const;
|
|
|
|
export const aiProviderKindSchema = z.enum(AI_PROVIDER_KINDS);
|
|
|
|
/**
|
|
* A field the caller may set, clear, or leave alone.
|
|
*
|
|
* Three states, and the difference matters on every column here: an OMITTED
|
|
* key leaves the stored value untouched, `null` or `""` clears it, and a
|
|
* non-empty string replaces it. The empty string is not a synonym for "no
|
|
* change" anywhere on this surface — the settings forms send `null` and `""`
|
|
* interchangeably to mean "clear", and both have always cleared.
|
|
*/
|
|
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.
|
|
*
|
|
* The route used to inspect the body key by key with `typeof` guards, which
|
|
* meant a wrongly-typed value (a numeric `model`, a boolean `baseUrl`) was
|
|
* SKIPPED rather than refused: the write went ahead without it, and a body of
|
|
* nothing but such keys came back as "No valid fields" without naming one.
|
|
* This schema is what closes that — every accepted field now states its type,
|
|
* and a mismatch is a named issue in the standard multi-issue 422.
|
|
*
|
|
* The accepted field set is exactly what the route already accepted; this
|
|
* describes the existing contract rather than changing it. In particular the
|
|
* object is deliberately NOT `.strict()`: an unrecognised key has always been
|
|
* ignored here, and refusing one now would break a client sending a field this
|
|
* server does not know yet. Unknown keys are dropped, as before.
|
|
*/
|
|
export const aiProviderPatchSchema = z.object({
|
|
/**
|
|
* The selected provider. `null` / `""` clears it. Unlike every other field
|
|
* this one has always REFUSED an unrecognised value rather than skipping it,
|
|
* and still does.
|
|
*/
|
|
provider: z
|
|
.union([aiProviderKindSchema, z.literal("")])
|
|
.nullable()
|
|
.optional(),
|
|
/** Trimmed on write; whitespace-only clears, like `""`. */
|
|
model: clearableString.optional(),
|
|
/**
|
|
* Custom base URL for the LOCAL provider. Trimmed, then run through the
|
|
* SSRF floor at the route — a private host is refused there, not here,
|
|
* because the allowlist is operator state rather than a shape rule.
|
|
*/
|
|
baseUrl: clearableString.optional(),
|
|
/** The OpenAI-compatible gateway's own base URL. Same SSRF floor. */
|
|
compatBaseUrl: clearableString.optional(),
|
|
compatModel: clearableString.optional(),
|
|
/** Encrypted at rest on write; never echoed back by the read. */
|
|
compatKey: clearableString.optional(),
|
|
anthropicKey: clearableString.optional(),
|
|
localKey: clearableString.optional(),
|
|
openaiKey: clearableString.optional(),
|
|
/**
|
|
* Per-user AI response timeout in seconds. `null` restores the built-in
|
|
* default. The 10 s floor is below what any real backend beats and the
|
|
* 600 s ceiling is generous for a slow self-hosted one; outside that range
|
|
* the value is refused rather than clamped, so a typo is visible.
|
|
*/
|
|
responseTimeoutSeconds: z
|
|
.number()
|
|
.int()
|
|
.min(10, "Response timeout must be between 10 and 600 seconds")
|
|
.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>;
|
|
|
|
export const aiTestOverrideSchema = z
|
|
.object({
|
|
provider: aiProviderKindSchema.optional().nullable(),
|
|
model: z.string().min(1).max(120).optional().nullable(),
|
|
baseUrl: z.string().url().max(2048).optional().nullable(),
|
|
anthropicKey: z.string().min(1).max(500).optional().nullable(),
|
|
localKey: z.string().min(1).max(500).optional().nullable(),
|
|
openaiKey: z.string().min(1).max(500).optional().nullable(),
|
|
// The OpenAI-compatible gateway's own fields, kept separate from
|
|
// `baseUrl` / `openaiKey` so testing an unsaved gateway config can never
|
|
// reach the pinned OpenAI arm and vice versa.
|
|
compatBaseUrl: z.string().url().max(2048).optional().nullable(),
|
|
compatKey: z.string().min(1).max(500).optional().nullable(),
|
|
compatModel: z.string().min(1).max(120).optional().nullable(),
|
|
})
|
|
.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>;
|