Files
HealthLog/src/lib/ai/openai-wire.ts
T
Marc-André Bombeck 703ca2744e Surface what an OpenAI-compatible gateway actually refused
A gateway can answer a failed generation with HTTP 200 and put the reason
in the body: a top-level `error` object and no `choices`, or
`choices[0].error` beside `finish_reason: "error"` when a generation dies
mid-flight. Both fell through to the empty-content throw, so a dead credit
balance, a data-policy block, a rate limit and a model that is down all
recorded the same hop: status 0, reason "returned empty content", no
excerpt. Nothing on the provider health card told them apart.

Raise the shape a non-2xx reply raises instead, with the gateway's own
message leading and its `error.code` as the status when that code is an
HTTP error status. The chain classifier, the health ledger and the card
then see 402 / 403 / 429 / 503 and treat each of them the way they treat
the same status on the wire. Message and excerpt are bounded and
key-redacted by the same helper the non-2xx path uses, so remote text is
no more trusted here than it is there.

Gated on the gateway tag: api.openai.com signals every failure with a
status, so the pinned tags parse exactly as before, and a genuine empty
reply stays the empty-content throw it was. The local-endpoint provider
had the same hole on its buffered path, its buffered-reply fallback and
its stream, and gets the same treatment. The body classifier learned the
two account-side refusals a gateway words in its own vocabulary rather
than the vendor's.
2026-09-05 17:56:47 +02:00

255 lines
7.9 KiB
TypeScript

import type {
AiContentPart,
AiMessage,
AiToolCall,
AiToolDef,
CompletionResult,
} from "./types";
/**
* Shared OpenAI Chat-Completions wire mapping. Both the hosted OpenAI client
* and the local OpenAI-compatible client (Ollama / LM Studio / vLLM) speak this
* shape, so the message → wire translation and the response parsing live here
* once. The two clients differ only in how they treat `responseFormat` and the
* endpoint/auth, not in the message structure.
*/
type OpenAIChatContent =
| string
| Array<
| { type: "text"; text: string }
| { type: "image_url"; image_url: { url: string } }
>;
interface OpenAIChatMessage {
role: "system" | "user" | "assistant" | "tool";
content: OpenAIChatContent | null;
tool_calls?: Array<{
id: string;
type: "function";
function: { name: string; arguments: string };
}>;
tool_call_id?: string;
}
/**
* Map a single `AiMessage` content into the Chat-Completions content shape.
* A plain string stays a string (byte-identical to the text-only wire we shipped
* before the refactor); parts become the multimodal array. PDF document parts
* are dropped — the Chat-Completions wire has no document block (the OCR route
* gates PDFs to Anthropic).
*/
function mapContent(content: string | AiContentPart[]): OpenAIChatContent {
if (typeof content === "string") return content;
const out: Array<
| { type: "text"; text: string }
| { type: "image_url"; image_url: { url: string } }
> = [];
for (const part of content) {
if (part.type === "text") {
out.push({ type: "text", text: part.text });
} else if (part.type === "image") {
out.push({
type: "image_url",
image_url: { url: `data:${part.mediaType};base64,${part.dataBase64}` },
});
}
// `document` parts are not representable on this wire; drop them.
}
return out;
}
/**
* Build the full Chat-Completions `messages[]` array: the system turn first,
* then every conversation turn mapped 1:1. Assistant `toolCalls` and the
* `role:"tool"` result turns map to the OpenAI tool wire so a multi-round
* tool loop (F1) round-trips.
*/
export function buildOpenAIMessages(
system: string,
messages: AiMessage[],
): OpenAIChatMessage[] {
const out: OpenAIChatMessage[] = [{ role: "system", content: system }];
for (const m of messages) {
if (m.role === "tool") {
out.push({
role: "tool",
tool_call_id: m.toolCallId,
content:
typeof m.content === "string" ? m.content : mapContent(m.content),
});
continue;
}
const mapped: OpenAIChatMessage = {
role: m.role,
content: mapContent(m.content),
};
if (m.role === "assistant" && m.toolCalls && m.toolCalls.length > 0) {
mapped.tool_calls = m.toolCalls.map((tc) => ({
id: tc.id,
type: "function",
function: { name: tc.name, arguments: tc.arguments },
}));
}
out.push(mapped);
}
return out;
}
/** Map tool defs into the OpenAI `tools` array. */
export function buildOpenAITools(tools: AiToolDef[]) {
return tools.map((t) => ({
type: "function" as const,
function: {
name: t.name,
description: t.description,
parameters: t.parameters,
},
}));
}
interface OpenAIChoice {
message?: {
content?: string | null;
tool_calls?: Array<{
id?: string;
function?: { name?: string; arguments?: string };
}>;
};
finish_reason?: string;
}
interface OpenAIResponseJson {
choices?: OpenAIChoice[];
usage?: {
total_tokens?: number;
prompt_tokens_details?: { cached_tokens?: number };
};
}
/** Map the OpenAI `finish_reason` onto the provider-neutral finishReason. */
export function mapFinishReason(
reason: string | undefined,
): CompletionResult["finishReason"] {
if (reason === "tool_calls") return "tool_calls";
if (reason === "length") return "length";
if (reason === "stop") return "stop";
return undefined;
}
/** Pull tool calls off an OpenAI choice into the provider-neutral shape. */
export function parseOpenAIToolCalls(
choice: OpenAIChoice | undefined,
): AiToolCall[] | undefined {
const calls = choice?.message?.tool_calls;
if (!calls || calls.length === 0) return undefined;
return calls.map((c) => ({
id: c.id ?? "",
name: c.function?.name ?? "",
arguments: c.function?.arguments ?? "",
}));
}
export function parseCachedTokens(json: OpenAIResponseJson): number | null {
return json.usage?.prompt_tokens_details?.cached_tokens ?? null;
}
/**
* Bound and key-redact an upstream body before it is attached to a thrown
* error. Both clients inlined this next to their non-2xx handling; it lives
* here once so the embedded-error excerpt below is sanitised by the very same
* code rather than by a copy that can drift.
*/
export function sanitiseBodyExcerpt(raw: string): string {
return raw
.slice(0, 500)
.replace(/sk-[A-Za-z0-9_-]{8,}/g, "sk-***redacted***")
.replace(/Bearer\s+[A-Za-z0-9_.-]+/gi, "Bearer ***redacted***");
}
interface EmbeddedError {
message: string;
/** The gateway's own `error.code` when it is an HTTP error status, else 0. */
httpStatus: number;
}
/** Longest slice of a remote error message that reaches a thrown message. */
const EMBEDDED_MESSAGE_MAX = 200;
function coerceEmbeddedError(candidate: unknown): EmbeddedError | null {
if (typeof candidate === "string") {
const trimmed = candidate.trim();
return trimmed.length > 0 ? { message: trimmed, httpStatus: 0 } : null;
}
if (candidate === null || typeof candidate !== "object") return null;
const raw = candidate as { code?: unknown; message?: unknown };
const message =
typeof raw.message === "string" && raw.message.trim().length > 0
? raw.message.trim()
: "gateway reported a failure without a message";
// Only an error-range status is adopted. A gateway that puts a vendor
// string ("insufficient_quota") or a non-status number in `code` keeps the
// 0 sentinel, which the chain classifier already treats as a hard failure.
const code = raw.code;
const httpStatus =
typeof code === "number" &&
Number.isInteger(code) &&
code >= 400 &&
code <= 599
? code
: 0;
return { message, httpStatus };
}
/**
* Read a failure that a 2xx body carries INSIDE itself.
*
* OpenAI-compatible gateways (OpenRouter and friends) answer a provider-side
* refusal with HTTP 200 and either a top-level `error` object and no `choices`
* at all, or — when a generation dies mid-flight — `choices[0].error` beside
* `finish_reason: "error"`. Returns the embedded failure, or null for a normal
* reply. `api.openai.com` never answers this way; every failure there is a
* non-2xx status.
*/
export function readEmbeddedError(json: unknown): EmbeddedError | null {
if (json === null || typeof json !== "object") return null;
const body = json as {
error?: unknown;
choices?: Array<{ error?: unknown } | null> | null;
};
return (
coerceEmbeddedError(body.error) ??
coerceEmbeddedError(body.choices?.[0]?.error)
);
}
/**
* Throw the same shaped error the non-2xx path throws when a 2xx body carries
* an embedded failure; return quietly when it does not. The gateway's own
* message leads so the hop reason says WHY the call failed instead of
* "returned empty content"; it is bounded and key-redacted on the way in,
* because remote text is never trusted to be short or clean.
*/
export function raiseEmbeddedError(
json: unknown,
ctx: { upstream: string; label: string; model: string },
): void {
const embedded = readEmbeddedError(json);
if (!embedded) return;
const message = sanitiseBodyExcerpt(embedded.message).slice(
0,
EMBEDDED_MESSAGE_MAX,
);
const err = new Error(`${message} (${ctx.label})`);
Object.assign(err, {
httpStatus: embedded.httpStatus,
kind: "embedded_error",
upstream: ctx.upstream,
model: ctx.model,
bodyExcerpt: sanitiseBodyExcerpt(JSON.stringify(json) ?? ""),
});
throw err;
}
export type { OpenAIChoice, OpenAIResponseJson };