Files
HealthLog/src/lib/ai/openai-wire.ts
T
Marc-André Bombeck 4e777a7e6a refactor(ai): take a structured message array instead of a string pair
Replace the CompletionParams { systemPrompt, userPrompt } string pair with a
structured { system, messages[] } request across all five AI clients. One
refactor unlocks three things: provider prompt-caching (Anthropic cache_control
on the stable system prefix, OpenAI/Codex automatic prefix cache, with
cachedInputTokens surfaced for observability), the tool-calling wire surface,
and clean multi-turn.

Each client maps the new shape to its own wire and degrades unsupported
features silently: the local OpenAI-compatible server never receives a tools
field (most reject unknown keys), and prompt-caching is best-effort everywhere.
CompletionResult gains toolCalls, finishReason and cachedInputTokens; the tool
types and per-provider mapping ship now, the tool-use loop does not.

Vision inputs fold into the user turn's content parts via singleUserTurn, with
each provider's pre-existing block ordering preserved. The corrective-retry
paths append to the last user message rather than a flat string. The Coach
keeps assembling one user turn so its snapshot-once and grounding behaviour is
unchanged. No wire schema visible to API consumers changed.

Behaviour is unchanged across every surface; the full suite stays green.
2026-06-22 07:17:10 +02:00

158 lines
4.5 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;
}
export type { OpenAIChoice, OpenAIResponseJson };