mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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.
255 lines
7.9 KiB
TypeScript
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 };
|