Files
HealthLog/src/lib/ai/openai-client.ts
T
Marc-André Bombeck 0c7059804b feat(ai): resolve an OpenAI-compatible gateway as its own provider
The chain gains a seventh entry. It builds the OpenAI client against the
base URL the user saved, with the bearer they saved for it and the model
they named, and it reads none of the OpenAI columns — the pinned
api.openai.com arm stays pinned.

Host policy is the Local provider's, unchanged: a public host always, a
private one only where the operator allowlisted it, which is what a LAN
LiteLLM or vLLM needs. Egress keeps riding safeFetch.

The JSON-mode dialect the Local client learns per endpoint moves into a
shared module and now serves both clients, so a gateway that rejects
response_format is detected once and retried without it.

The entry stays out of the default chain: it can only resolve against a
base URL somebody configured.
2026-07-26 22:27:48 +02:00

275 lines
12 KiB
TypeScript

import { safeFetch } from "@/lib/safe-fetch";
import { annotate } from "@/lib/logging/context";
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
import { selectOpenAIChatCompletionsCapabilities } from "./openai-capabilities";
import {
hasLearnedJsonModeDialect,
isResponseFormatRejection,
jsonModeDialectFor,
rememberJsonModeDialect,
} from "./json-dialect";
import { requirePublicHostFor } from "./local-host-allowlist";
import {
buildOpenAIMessages,
buildOpenAITools,
mapFinishReason,
parseCachedTokens,
parseOpenAIToolCalls,
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). It is the ONLY tag whose `baseUrl` is not
* a HealthLog-pinned constant, and the two behaviours below hang off it:
* the operator's private-host allowlist may apply, and the JSON-mode
* dialect is learned per endpoint instead of assumed. Every other tag
* keeps the pinned `api.openai.com` posture unchanged.
*/
providerType?: OpenAIProviderType;
}
type OpenAIProviderType = "admin-key" | "codex" | "openai-compatible";
export class OpenAIClient implements AIProvider {
readonly type: OpenAIProviderType;
private config: OpenAIClientConfig;
constructor(config: OpenAIClientConfig) {
this.config = config;
this.type = config.providerType ?? "admin-key";
}
/**
* 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 = buildOpenAIMessages(params.system, params.messages);
// 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 F1 tool loop's
// forced-final round (toolChoice:"none", no tools, 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 tokenBudget = params.maxTokens ?? 1000;
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 } : {}),
}),
},
// 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.
// v1.21.5 — honour the caller's per-request timeout override; default 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 allowlisted it via ALLOW_LOCAL_AI_PRIVATE_HOSTS. Every other
// tag stays pinned to `requirePublicHost: true` — `api.openai.com` is
// public and there is nothing to opt into.
{
timeoutMs: params.timeoutMs ?? 60_000,
requirePublicHost: this.isGateway
? requirePublicHostFor(this.config.baseUrl)
: true,
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 = rawBody
.slice(0, 500)
.replace(/sk-[A-Za-z0-9_-]{8,}/g, "sk-***redacted***")
.replace(/Bearer\s+[A-Za-z0-9_.-]+/gi, "Bearer ***redacted***");
// 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);
}
const err = new Error(
`${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,
});
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;
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;
}
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),
};
}
}