Files
HealthLog/src/lib/ai/reasoning-effort.ts
T
Marc-André Bombeck befac59576 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
2026-10-04 10:42:59 +02:00

84 lines
3.1 KiB
TypeScript

/**
* #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;
}