Files
HealthLog/src/lib/ai/openai-wire.ts
T
Marc-André Bombeck 316e52dee3 Widen the Coach turn budget and keep its rounds on the prompt cache
Own-plan turns get 165k tokens, 200 s and 16 rounds, operator turns 55k,
120 s and 8; the final-answer reserve, the concurrent-turn slot and the
step cap move with them.

Codex requests carry a per-conversation prompt_cache_key and a stable
session id, api.openai.com gets the same key, Anthropic usage counts
cache reads and writes in the gross, and local servers report cached
prompt tokens, so the turn budget discounts cached input on every
provider.
2026-10-07 22:48:48 +02:00

257 lines
8.0 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: {
usage?: { prompt_tokens_details?: { cached_tokens?: number } };
}): 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 };