Files
HealthLog/src/lib/ai/openai-client.ts
T
Marc-André Bombeck 27342ee734 fix(ai): name the real cause of failed briefings and stop retrying what cannot heal
The nightly briefing warm completed every night and read "all-failed",
because one run event held every account's annotations and the last
account won. Each candidate now runs in its own wide event and the run
reports every outcome through `reportJobRun`, so ok, dead sign-in,
withheld and failed accounts show side by side.

A dead Codex refresh token threw a bare error with no status, so the chain
read it as a network failure: the health ledger recorded hard_failed, no
"reconnect" hint appeared, and every night tried the same token. The
refresh now throws `CodexRefreshError` with a 401 for a dead grant
(invalid_grant / invalid_refresh_token on a 400 included), as do a missing
or undecryptable stored credential. The ledger records auth_failed, the
chain marks the primary credential expired, and the nightly pass reports
the account as auth_failed without queuing a retry.

A briefing the outbound screen withheld (a causal claim) failed outright,
and the 45-minute provider retry then asked the same question eight times
a night. The generator now makes one bounded repair that names the broken
rule and keeps the repaired payload only if it passes the same parse,
grounding and screen; a withheld outcome queues no retry.

A provider that keeps failing is paused: from five consecutive hard
failures the skip window starts at 30 minutes and doubles per further
failure, capped at a day. A paused provider is walked last and tried again
when the window lifts; one success resumes it. The operator card shows how
many accounts it is paused for and until when. After a 5xx from a
non-canonical OpenAI-compatible endpoint the client asks the free model
listing, and a configured model the endpoint no longer offers is named in
the error, on the hop line and on the card.

Also:
- The tiered context's live day band read `measured_at AT TIME ZONE tz`,
  which treats the stored UTC as local time; a far-from-UTC reader's
  evening readings landed on the next day. It converts from UTC first now.
- Google Health intraday heart rate re-reads two hours per sync instead of
  a day; daily summaries and sleep keep the day (#1023).
- The first filtered daily read that answers 400 and triggers the
  snake_case retry is recorded as a note, not an error (#1023).
- The single derived-score read goes through the derived cache with
  stale-while-revalidate, like the batch beside it.
2026-10-08 09:06:09 +02:00

591 lines
25 KiB
TypeScript

import { safeFetch } from "@/lib/safe-fetch";
import { callTimeoutMs } from "./effective-timeout";
import { annotate } from "@/lib/logging/context";
import { emitSignal } from "@/lib/logging/signal";
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
import {
isCanonicalOpenAIEndpoint,
selectOpenAIChatCompletionsCapabilities,
type OpenAIChatCompletionsCapabilities,
} from "./openai-capabilities";
import {
annotateReasoningDowngrade,
isReasoningParameterRejection,
learnedReasoningDialect,
rememberReasoningDialect,
} from "./reasoning/dialect-cache";
import { REASONING_THINKING_BUDGET } from "./reasoning/levels";
import {
isOpenRouterEndpoint,
levelOfWireEffort,
openAIOffEffort,
openAIReasoningFamily,
reasoningTitleOf,
} from "./reasoning/support";
import {
hasLearnedJsonModeDialect,
isResponseFormatRejection,
jsonModeDialectFor,
rememberJsonModeDialect,
} from "./json-dialect";
import { aiEgressPolicyFor } from "./local-host-allowlist";
import { probeModelListing } from "./model-availability";
import type { ReasoningEffort } from "./reasoning-effort";
import {
buildOpenAIMessages,
buildOpenAITools,
mapFinishReason,
parseCachedTokens,
parseOpenAIToolCalls,
raiseEmbeddedError,
sanitiseBodyExcerpt,
type OpenAIResponseJson,
} from "./openai-wire";
interface OpenAIClientConfig {
/**
* Bearer credential. Empty string means "no bearer" — only the
* `openai-compatible` tag ever constructs that way (a LAN gateway with no
* auth); every OpenAI-keyed call site checks the key's presence first.
*/
apiKey: string;
model: string;
baseUrl: string;
/**
* Override the provider tag used in logs and analytics. Defaults to
* "admin-key". The Codex flow uses the same OpenAI API but the key
* was obtained via the token-exchange grant against a ChatGPT
* subscription — for billing and observability we want that path
* to log as "codex" instead.
*
* v1.33.1 (#470) — `openai-compatible` is the user-configured gateway
* (LiteLLM / OpenRouter / vLLM). Its `baseUrl` always comes from a person,
* and two behaviours hang off that: the operator's private-host allowlist
* may apply, and the JSON-mode dialect is learned per endpoint instead of
* assumed. `admin-key` carries a person-typed base URL in exactly one
* place (the operator's global provider in the admin settings) and shares
* the allowlist policy for it since v1.37.30; the dialect stays assumed.
* `codex` keeps the fully pinned posture.
*/
providerType?: OpenAIProviderType;
/**
* v1.39.3 — true when the base URL belongs to the operator: the
* instance-wide admin provider, or provider settings saved on an admin
* account. Only such a URL is still covered by the deprecated
* `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` (see `local-host-allowlist.ts`).
*/
operatorTrusted?: boolean;
}
type OpenAIProviderType = "admin-key" | "codex" | "openai-compatible";
/**
* v1.41 — the reasoning one request sends.
* - `reasoning_effort`: the Chat Completions field (OpenAI, LiteLLM, vLLM,
* most gateways). https://platform.openai.com/docs/api-reference/chat/create
* - `openrouter`: OpenRouter's `reasoning: { effort }` object, which also
* returns `reasoning_details` to hand back on tool rounds.
* https://openrouter.ai/docs/use-cases/reasoning-tokens
*/
type OpenAIReasoningWire =
| { kind: "none" }
| { kind: "reasoning_effort"; effort: string }
| { kind: "openrouter"; effort: string };
/**
* Assistant-message fields a gateway returns and needs back unmodified on the
* next tool round: OpenRouter's `reasoning_details` and the `thinking_blocks`
* LiteLLM carries for Claude upstreams (https://docs.litellm.ai/docs/reasoning_content).
*/
const ROUND_TRIP_FIELDS = ["reasoning_details", "thinking_blocks"] as const;
export class OpenAIClient implements AIProvider {
readonly type: OpenAIProviderType;
/** See `AIProvider.responseTimeoutSeconds`; stamped by the resolver. */
responseTimeoutSeconds: number | null = null;
/**
* See `AIProvider.reasoningEffort`; stamped by the resolver. Only the
* gateway tag ever sends it (`isGateway` below), whatever is stamped here.
*/
reasoningEffort: ReasoningEffort | null = null;
private config: OpenAIClientConfig;
constructor(config: OpenAIClientConfig) {
this.config = config;
this.type = config.providerType ?? "admin-key";
}
/**
* v1.42 — after a server error from an operator- or person-configured
* endpoint (never the canonical `api.openai.com`, which names a missing
* model in its own 404), check whether it still lists the configured
* model. A 4xx already carries its reason in the body; a 5xx from a proxy
* often carries none. See `model-availability.ts`.
*/
private async modelNoLongerServed(status: number): Promise<boolean> {
if (status < 500) return false;
if (this.type === "codex") return false;
if (isCanonicalOpenAIEndpoint(this.config.baseUrl)) return false;
const listing = await probeModelListing({
baseUrl: this.config.baseUrl,
apiKey: this.config.apiKey,
model: this.config.model,
operatorTrusted: this.config.operatorTrusted,
});
if (listing === "not_listed") {
emitSignal({
action: "ai.provider.model_not_served",
level: "error",
meta: { provider: this.type, model: this.config.model, status },
});
}
return listing === "not_listed";
}
/**
* True only for the user-configured gateway. Read it as "the base URL came
* from a person, not from this repository" — every behaviour that must not
* change for `api.openai.com` is gated on it.
*/
private get isGateway(): boolean {
return this.type === "openai-compatible";
}
async generateCompletion(
params: CompletionParams,
): Promise<CompletionResult> {
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
// System turn first (the stable cache prefix — OpenAI prefix-caches a
// byte-identical ≥1024-token prefix automatically, no API flag), then the
// conversation turns mapped 1:1. Vision parts (Lab-OCR) become the
// multimodal `image_url` content array `gpt-4o`-class models accept. The
// image is framed as untrusted DATA by the system prompt.
const messages = this.withRoundTripState(
buildOpenAIMessages(params.system, params.messages),
params,
);
// v1.20.0 — tool plumbing. The defs map onto the OpenAI `function` tool
// wire; F1 supplies real defs and consumes the parsed `toolCalls`. No F4
// call site sets `tools`, so the text-only body is unchanged.
const hasTools = !!params.tools && params.tools.length > 0;
const tools = hasTools ? buildOpenAITools(params.tools!) : undefined;
// Only force OpenAI's strict JSON mode when the caller actually consumes a
// JSON object AND no tools are in play — mirrors the Anthropic client's
// `usePrefill` gate. JSON mode coerces `message.content` into a valid JSON
// object, which contradicts the Coach prose contract; the tool loop's
// forced-final round (tools with toolChoice:"none", no responseFormat)
// must therefore stay out of JSON mode, and every tool round is non-JSON
// by construction. Insight/extraction callers opt in with `responseFormat:"json"`.
const useJsonFormat = params.responseFormat === "json" && !hasTools;
// v1.33.1 (#470) — a gateway may reject the standard `response_format`
// field. Learn that per endpoint (shared cache with the Local client) and
// retry once without the flag below. `api.openai.com` defines the field,
// so the pinned tags never consult the dialect and never retry.
const jsonDialect = this.isGateway
? jsonModeDialectFor(this.config.baseUrl)
: "response_format";
const sendJsonFormat = useJsonFormat && jsonDialect === "response_format";
const capabilities = selectOpenAIChatCompletionsCapabilities(
this.config.baseUrl,
this.config.model,
);
const reasoningWire = this.planReasoning(params, hasTools, capabilities);
// Reasoning tokens count against the completion budget on every one of
// these wires, so the thinking budget rides on top of the answer budget
// (a ceiling, not a charge). Without it a 600-token Coach round spends
// its whole allowance thinking and returns no answer.
const wireLevel =
reasoningWire.kind === "none" || reasoningWire.effort === "none"
? null
: levelOfWireEffort(reasoningWire.effort);
const thinkingTokens = wireLevel
? REASONING_THINKING_BUDGET[wireLevel === "off" ? "low" : wireLevel]
: 0;
const tokenBudget = (params.maxTokens ?? 1000) + thinkingTokens;
const capabilityParams = capabilities.supportsSamplingControls
? {
[capabilities.tokenBudgetField]: tokenBudget,
temperature: params.temperature ?? 0.3,
...(params.seed !== undefined ? { seed: params.seed } : {}),
}
: { [capabilities.tokenBudgetField]: tokenBudget };
// A gateway that needs no bearer (a LAN LiteLLM without a master key)
// gets no `Authorization` header at all rather than an empty one — an
// empty bearer is a 401 on some proxies. Every OpenAI-keyed construction
// site checks the key's presence, so this branch is gateway-only in
// practice.
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
const trimmedKey = this.config.apiKey?.trim();
if (trimmedKey) headers.Authorization = `Bearer ${trimmedKey}`;
const res = await safeFetch(
url,
{
method: "POST",
headers,
body: JSON.stringify({
model: this.config.model,
messages,
...capabilityParams,
...(sendJsonFormat
? { response_format: { type: "json_object" } }
: {}),
...(tools ? { tools } : {}),
...(params.toolChoice ? { tool_choice: params.toolChoice } : {}),
// v1.41.2 — the prompt-cache routing key `api.openai.com` documents
// (https://platform.openai.com/docs/guides/prompt-caching). Only
// there: a gateway or proxy behind another base URL may refuse a
// field it does not know.
...(params.cacheKey && isCanonicalOpenAIEndpoint(this.config.baseUrl)
? { prompt_cache_key: params.cacheKey }
: {}),
// #1126 — the gateway entry's reasoning setting; Default sends
// nothing. `api.openai.com` and Codex never get it. v1.41 — a call
// that carries its own resolved level (`params.reasoning`) wins.
...(!params.reasoning && this.isGateway && this.reasoningEffort
? { reasoning_effort: this.reasoningEffort }
: {}),
...(reasoningWire.kind === "reasoning_effort"
? { reasoning_effort: reasoningWire.effort }
: reasoningWire.kind === "openrouter"
? { reasoning: { effort: reasoningWire.effort } }
: {}),
}),
},
// 60 s ceiling so a tar-pit upstream cannot pin a worker
// indefinitely. Real completions land well inside this budget.
// v1.11.2 — the base URL is user/admin-overridable (BYO gateway), so pin
// the connect-time DNS check: a base URL resolving to a private/metadata
// address is rejected, closing the SSRF/rebinding surface.
// v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so
// a mid-generation abort tears the upstream call down early.
// The record owner's response-timeout setting, else the surface value, else 60 s.
// v1.33.1 (#470) — the gateway tag reuses the Local provider's host
// policy verbatim: public hosts always; a private host only when the
// operator granted its origin (`AI_PRIVATE_ORIGINS` or the legacy host
// list), and then only through the pinned operator-approved dispatcher.
//
// v1.37.30 — the `admin-key` tag consults the same policy. Its base URL
// is the one other operator-typed URL in this client (an operator can
// point the global provider at a private OpenAI-compatible proxy, e.g.
// an OAuth sidecar), and both ends of the grant are the operator's own:
// the URL comes from the admin settings, the allowlist from the
// operator's environment. Every non-operator construction of this tag
// pins the public `api.openai.com` constant, for which the policy
// resolves to `true` without ever consulting the allowlist — so the
// personal-key posture is unchanged. The `codex` tag keeps the hard
// pin: its base URL is a repository constant, there is nothing an
// operator legitimately redirects.
{
timeoutMs: callTimeoutMs(params, this.responseTimeoutSeconds),
...(this.isGateway || this.type === "admin-key"
? aiEgressPolicyFor(url, {
operatorTrusted: this.config.operatorTrusted,
})
: { requirePublicHost: true as const }),
signal: params.signal,
},
);
if (!res.ok) {
// Pull as much of the body as we can so upstream incidents (OpenAI 5xx,
// model-not-found, quota exceeded) are diagnosable from logs instead of
// surfacing as an opaque "OpenAI request failed (500)". Strip anything
// that looks like an API key from the excerpt before logging.
const rawBody = await res.text().catch(() => "");
const bodyExcerpt = sanitiseBodyExcerpt(rawBody);
// v1.41 — an endpoint or model that refuses the reasoning field is
// asked once more without it and remembers, so the answer never fails
// on a reasoning parameter. Checked before the JSON dialect: a body
// naming the reasoning field must not switch JSON mode off.
if (
reasoningWire.kind !== "none" &&
isReasoningParameterRejection(
res.status,
bodyExcerpt,
/reasoning|thinking/i,
this.config.model,
)
) {
rememberReasoningDialect(this.reasoningDialectKey(), "none");
annotateReasoningDowngrade(this.type, reasoningWire.effort, "none");
return this.generateCompletion(params);
}
// v1.33.1 (#470) — dialect self-heal, gateway only. A 4xx whose body
// names `response_format` (or an unknown parameter) means this endpoint
// rejects the standard JSON flag: learn the no-flag dialect for the base
// URL and retry ONCE without it. The recursion terminates because the
// cached dialect is now "none". Unrelated 4xx/5xx errors are not retried
// and surface as the structured error they always were.
if (
this.isGateway &&
sendJsonFormat &&
isResponseFormatRejection(res.status, bodyExcerpt)
) {
rememberJsonModeDialect(this.config.baseUrl, "none");
annotate({
action: { name: "ai.compat.jsonDialect" },
meta: { dialect: "none", httpStatus: res.status },
});
return this.generateCompletion(params);
}
// v1.42 — a proxy that stopped offering the configured model answers
// with a bare 500 that names nothing. Ask its model listing (free, no
// completion) and say so plainly when the name is gone, so the chain,
// the health card and the log name the actual fix.
const modelNotServed = await this.modelNoLongerServed(res.status);
const err = new Error(
modelNotServed
? `${this.isGateway ? "OpenAI-compatible gateway" : "OpenAI"} request failed (${res.status}): model "${this.config.model}" is not offered by this endpoint`
: `${this.isGateway ? "OpenAI-compatible gateway" : "OpenAI"} request failed (${res.status})`,
);
// Keep the body in a structured field rather than the message so
// Error.message stays short and the excerpt lands in dedicated log
// fields (bodyExcerpt) that can be filtered/truncated centrally.
Object.assign(err, {
httpStatus: res.status,
upstream: this.isGateway ? "openai-compatible" : "openai",
model: this.config.model,
bodyExcerpt,
...(modelNotServed ? { modelNotServed: true } : {}),
});
throw err;
}
// v1.33.1 (#470) — the standard `response_format` request succeeded: pin
// the dialect for this endpoint so a later transient 4xx can never
// silently degrade it.
if (
this.isGateway &&
sendJsonFormat &&
!hasLearnedJsonModeDialect(this.config.baseUrl)
) {
rememberJsonModeDialect(this.config.baseUrl, "response_format");
annotate({
action: { name: "ai.compat.jsonDialect" },
meta: { dialect: "response_format" },
});
}
const json = (await res.json()) as OpenAIResponseJson;
// A 200 that carries the failure inside the body. OpenRouter (and every
// gateway that copies it) answers a provider-side refusal — no credits,
// a data-policy or moderation block, an upstream timeout, a model that is
// down, no provider matching the routing requirements — with HTTP 200 and
// a top-level `error` object instead of `choices`, and a generation that
// died mid-flight with `choices[0].error` beside `finish_reason: "error"`.
// Both used to fall through to the empty-content throw below, so every one
// of those causes collapsed into "returned empty content", status 0, no
// excerpt: neither the person nor the provider-health card could tell them
// apart. Raise the same shaped error a non-2xx reply raises so the chain
// classifier, the health ledger and the card all see the real status.
//
// Gated on the gateway tag because the shape is gateway-only: on
// `api.openai.com` — the `admin-key` and `codex` tags — a failure is
// always a non-2xx status, so their parsing is untouched.
if (this.isGateway) {
raiseEmbeddedError(json, {
upstream: "openai-compatible",
label: "OpenAI-compatible gateway",
model: this.config.model,
});
}
const choice = json.choices?.[0];
let content = choice?.message?.content;
const toolCalls = parseOpenAIToolCalls(choice);
// v1.28.28 (#470) — gateway JSON-mode shim. LiteLLM (and similar
// OpenAI-compatible proxies) translate `response_format: json_object`
// for an Anthropic upstream into a synthesized tool call: the reply
// comes back with `finish_reason: "tool_calls"`, an empty / "{}"
// `message.content`, and the actual JSON object in
// `tool_calls[0].function.arguments`. Since no JSON caller passes
// tools (useJsonFormat requires !hasTools), lift the arguments string
// into `content` so the payload parses instead of silently emptying
// the insight. The real tool loop (Coach F1) always has tools and
// never enters JSON mode, so it is untouched.
const trimmedContent = content?.trim() ?? "";
if (useJsonFormat && (trimmedContent === "" || trimmedContent === "{}")) {
const args = choice?.message?.tool_calls?.[0]?.function?.arguments;
if (typeof args === "string" && args.trim().length > 0) {
content = args;
}
}
// A reply with tool calls and no prose is valid (F1 tool loop); only an
// empty reply with neither content NOR tool calls is an error.
if (!content && !toolCalls) {
// v1.20.1 — tag the empty-reply throw with a sentinel `httpStatus: 0` +
// `kind` so the provider-chain classifier can tell an empty 200-OK reply
// apart from a genuine transport failure (ECONNRESET, DNS, timeout) in
// observability. The cascade is unchanged: `isHardProviderFailure`
// already treats `status <= 0` as a hard failure and walks to the next
// provider.
const err = new Error(
`${this.isGateway ? "OpenAI-compatible gateway" : "OpenAI"} returned empty content`,
);
Object.assign(err, {
httpStatus: 0,
kind: "empty_response",
upstream: this.isGateway ? "openai-compatible" : "openai",
});
throw err;
}
// v1.41 — what the call reasoned. Gateways return the text as
// `reasoning` (OpenRouter, vLLM, Ollama) or `reasoning_content`
// (LiteLLM, llama.cpp); `api.openai.com` returns none on this wire.
const message = (choice?.message ?? {}) as Record<string, unknown>;
const reasoningText = [message.reasoning, message.reasoning_content].find(
(v): v is string => typeof v === "string" && v.trim().length > 0,
);
const summary = reasoningText ? [reasoningText.trim()] : [];
const emit = params.reasoning ? params.onReasoning : undefined;
if (emit) {
for (const text of summary) {
const title = reasoningTitleOf(text);
if (title) emit({ kind: "title", text: title });
emit({ kind: "text", text });
}
emit({ kind: "done", text: "" });
}
const roundTrip = ROUND_TRIP_FIELDS.filter(
(field) => message[field] !== undefined && message[field] !== null,
).map((field) => ({ [field]: message[field] }));
const usage = json.usage as
{ completion_tokens_details?: { reasoning_tokens?: number } } | undefined;
const learnedNone =
learnedReasoningDialect(this.reasoningDialectKey()) === "none";
return {
content: content ?? "",
tokensUsed: json.usage?.total_tokens ?? null,
cachedInputTokens: parseCachedTokens(json),
model: this.config.model,
providerType: this.type,
...(toolCalls ? { toolCalls } : {}),
finishReason: mapFinishReason(choice?.finish_reason),
...(params.reasoning
? {
reasoning: {
summary,
// Billed as completion tokens, already inside `total_tokens`.
tokens:
usage?.completion_tokens_details?.reasoning_tokens ?? null,
...(learnedNone && params.reasoning.effort !== "off"
? { downgradedTo: "off" as const }
: {}),
},
}
: {}),
...(roundTrip.length > 0
? {
providerState: {
providerType: this.type,
model: this.config.model,
items: roundTrip,
},
}
: {}),
};
}
// ── v1.41 reasoning ───────────────────────────────────────────────────────
private reasoningDialectKey() {
return {
provider: "openai",
endpoint: this.config.baseUrl,
model: this.config.model,
};
}
/**
* The reasoning this request sends. Absent `params.reasoning` → none here
* (the #1126 gateway setting is applied separately, unchanged).
*
* `api.openai.com` (operator key, personal key): `reasoning_effort` only for
* a reasoning model, and only on a round without tools. Chat Completions
* has no reasoning items to hand back between tool rounds, and newer
* models restrict reasoning alongside function calls. The background jobs
* carry no tools, which is where it counts; the Coach's final round
* carries its tools with `tool_choice: "none"` (the history holds the
* calls), so here it runs without `reasoning_effort` like its tool rounds.
*
* Gateway: the endpoint's own dialect, tools or not; its reasoning state
* travels in `providerState`, and a refusal is learned per endpoint.
*/
private planReasoning(
params: CompletionParams,
hasTools: boolean,
capabilities: OpenAIChatCompletionsCapabilities,
): OpenAIReasoningWire {
const reasoning = params.reasoning;
if (!reasoning) return { kind: "none" };
if (learnedReasoningDialect(this.reasoningDialectKey()) === "none") {
return { kind: "none" };
}
const level = reasoning.effort;
if (this.isGateway) {
const effort = level === "off" ? "none" : level;
return isOpenRouterEndpoint(this.config.baseUrl)
? { kind: "openrouter", effort }
: { kind: "reasoning_effort", effort };
}
const family = openAIReasoningFamily(this.config.model);
if (
!family ||
hasTools ||
capabilities.tokenBudgetField !== "max_completion_tokens"
) {
return { kind: "none" };
}
return {
kind: "reasoning_effort",
effort: level === "off" ? openAIOffEffort(family) : level,
};
}
/**
* Put the reasoning state of earlier tool rounds back on their assistant
* messages. `buildOpenAIMessages` maps turn `i` to wire message `i + 1`
* (the system turn leads). Only state this client made for this model is
* used; anything else is dropped.
*/
private withRoundTripState<T extends object>(
wire: T[],
params: CompletionParams,
): T[] {
params.messages.forEach((m, i) => {
const state = m.providerState;
if (
m.role !== "assistant" ||
!state ||
state.providerType !== this.type ||
state.model !== this.config.model
) {
return;
}
const target = wire[i + 1] as Record<string, unknown> | undefined;
if (!target) return;
for (const item of state.items) {
if (!item || typeof item !== "object") continue;
for (const field of ROUND_TRIP_FIELDS) {
if (field in item) {
target[field] = (item as Record<string, unknown>)[field];
}
}
}
});
return wire;
}
}