mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
A status run (a metric card, the batched assessment, a derived score, a workout line, a Coach memory pass) is one model call plus the one repeat the Local and gateway clients send when a thinking model answered with reasoning alone, each with the person's response timeout. The run's outer bound was a single call, so the repeat was cut off, and the open card stopped polling after 48 s, so a note from a model that needs minutes never reached the page. The bound is now the per-call timeout times STATUS_MODEL_CALLS, and the card's poll ceiling follows the published response timeout (fast for the first 48 s, every 15 s after), only while the route reports the note in flight; the fallback text stays meanwhile. The status queue is exclusive, so the longer poll cannot queue a second generation beside a running one. The morning refresh is one comprehensive generation of up to five calls in a row, with no outer bound and pg-boss's fifteen-minute expiry, so a large setting had pg-boss fail and retry it while it was still running. It now runs behind the same bound as the warms, and the job carries an expiry derived from that worst case. The per-metric note, the workout line and the Coach memory refresh get expiries derived the same way. queue-runtime.ts gains a bounded class for these jobs: model time read off model-time.ts, an expiry above it, carried on every send. A new guard names every caller of runStatusCompletion and generateComprehensiveInsight with the queues that reach it, and holds each such queue to bounded above its runs or long under a run lock. Refs #1274
219 lines
8.9 KiB
TypeScript
219 lines
8.9 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 {
|
|
AI_RESPONSE_TIMEOUT_MAX_SECONDS,
|
|
AI_RESPONSE_TIMEOUT_MIN_SECONDS,
|
|
} from "@/lib/ai/effective-timeout";
|
|
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(
|
|
AI_RESPONSE_TIMEOUT_MIN_SECONDS,
|
|
"Response timeout must be between 10 and 600 seconds",
|
|
)
|
|
.max(
|
|
AI_RESPONSE_TIMEOUT_MAX_SECONDS,
|
|
"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>;
|