UNPKG

@earendil-works/pi-ai

Version:

Unified LLM API with automatic model discovery and provider configuration

214 lines • 9.6 kB
const NON_VISION_USER_IMAGE_PLACEHOLDER = "(image omitted: model does not support images)"; const NON_VISION_TOOL_IMAGE_PLACEHOLDER = "(tool image omitted: model does not support images)"; const ERROR_TOOL_IMAGE_PLACEHOLDER = "(image attached after the tool results)"; /** * Split the images out of an error tool result, for APIs that reject images in error results * (Anthropic, and Claude on Bedrock). Send the returned content as the tool result and the caption and * images after all tool results of the turn, in the same user message. Returns undefined for results * that need no split (#10794). */ export function splitErrorToolResultImages(msg) { if (!msg.isError) return undefined; const images = msg.content.filter((block) => block.type === "image"); if (images.length === 0) return undefined; return { content: replaceImagesWithPlaceholder(msg.content, ERROR_TOOL_IMAGE_PLACEHOLDER), caption: `Images from the error result of tool call ${msg.toolCallId} (${msg.toolName}):`, images, }; } function replaceImagesWithPlaceholder(content, placeholder) { const result = []; let previousWasPlaceholder = false; for (const block of content) { if (block.type === "image") { if (!previousWasPlaceholder) { result.push({ type: "text", text: placeholder }); } previousWasPlaceholder = true; continue; } result.push(block); previousWasPlaceholder = block.text === placeholder; } return result; } function downgradeUnsupportedImages(messages, model) { if (model.input.includes("image")) { return messages; } return messages.map((msg) => { if (msg.role === "user" && Array.isArray(msg.content)) { return { ...msg, content: replaceImagesWithPlaceholder(msg.content, NON_VISION_USER_IMAGE_PLACEHOLDER), }; } if (msg.role === "toolResult") { return { ...msg, content: replaceImagesWithPlaceholder(msg.content, NON_VISION_TOOL_IMAGE_PLACEHOLDER), }; } return msg; }); } /** * Normalize tool call ID for cross-provider compatibility. * OpenAI Responses API generates IDs that are 450+ chars with special characters like `|`. * Anthropic APIs require IDs matching ^[a-zA-Z0-9_-]+$ (max 64 chars). */ export function transformMessages(messages, model, normalizeToolCallId) { // Build a map of original tool call IDs to normalized IDs const toolCallIdMap = new Map(); // Normalize null/undefined content from untyped callers (custom tools, hand-built // histories, old session files) so downstream code can rely on the type contract. const normalizedMessages = messages.map((msg) => (msg.content == null ? { ...msg, content: [] } : msg)); const imageAwareMessages = downgradeUnsupportedImages(normalizedMessages, model); // First pass: transform messages (unsupported image downgrade, thinking blocks, tool call ID normalization) const transformed = imageAwareMessages.map((msg) => { // System and user messages pass through unchanged if (msg.role === "system" || msg.role === "user") { return msg; } // Handle toolResult messages - normalize toolCallId if we have a mapping if (msg.role === "toolResult") { const normalizedId = toolCallIdMap.get(msg.toolCallId); if (normalizedId && normalizedId !== msg.toolCallId) { return { ...msg, toolCallId: normalizedId }; } return msg; } // Assistant messages need transformation check if (msg.role === "assistant") { const assistantMsg = msg; const isSameModel = assistantMsg.provider === model.provider && assistantMsg.api === model.api && assistantMsg.model === model.id; const transformedContent = assistantMsg.content.flatMap((block) => { if (block.type === "thinking") { // Thinking from another model is dropped. Signatures and encrypted reasoning are // only valid for the model that produced them, and sending readable thinking as // plain text makes the new model treat it as part of the visible reply. if (!isSameModel) return []; // Keep redacted and signed blocks even if the thinking text is empty // (OpenAI encrypted reasoning); they are needed for replay. if (block.redacted || block.thinkingSignature) return block; if (!block.thinking || block.thinking.trim() === "") return []; return block; } if (block.type === "text") { if (isSameModel) return block; return { type: "text", text: block.text, }; } if (block.type === "toolCall") { const toolCall = block; let normalizedToolCall = toolCall; if (!isSameModel && toolCall.thoughtSignature) { normalizedToolCall = { ...toolCall }; delete normalizedToolCall.thoughtSignature; } if (!isSameModel && normalizeToolCallId) { const normalizedId = normalizeToolCallId(toolCall.id, model, assistantMsg); if (normalizedId !== toolCall.id) { toolCallIdMap.set(toolCall.id, normalizedId); normalizedToolCall = { ...normalizedToolCall, id: normalizedId }; } } return normalizedToolCall; } return block; }); return { ...assistantMsg, content: transformedContent, }; } return msg; }); // Second pass: insert synthetic empty tool results for orphaned tool calls // This preserves thinking signatures and satisfies API requirements const result = []; let pendingToolCalls = []; let existingToolResultIds = new Set(); // System messages are transparent to tool-call accounting: one that lands between a tool // call and its results is held back and emitted after the results (synthetic ones // included), so it never causes a duplicate result for a call that is answered later. const heldSystemMessages = []; const closePendingToolCalls = () => { if (pendingToolCalls.length > 0) { for (const tc of pendingToolCalls) { if (!existingToolResultIds.has(tc.id)) { result.push({ role: "toolResult", toolCallId: tc.id, toolName: tc.name, content: [{ type: "text", text: "No result provided" }], isError: true, timestamp: Date.now(), }); } } pendingToolCalls = []; existingToolResultIds = new Set(); } result.push(...heldSystemMessages); heldSystemMessages.length = 0; }; for (let i = 0; i < transformed.length; i++) { const msg = transformed[i]; if (msg.role === "assistant") { // If we have pending orphaned tool calls from a previous assistant, insert synthetic results now closePendingToolCalls(); // Skip errored/aborted assistant messages entirely. // These are incomplete turns that shouldn't be replayed: // - May have partial content (reasoning without message, incomplete tool calls) // - Replaying them can cause API errors (e.g., OpenAI "reasoning without following item") // - The model should retry from the last valid state const assistantMsg = msg; if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") { continue; } // Track tool calls from this assistant message const toolCalls = assistantMsg.content.filter((b) => b.type === "toolCall"); if (toolCalls.length > 0) { pendingToolCalls = toolCalls; existingToolResultIds = new Set(); } result.push(msg); } else if (msg.role === "toolResult") { existingToolResultIds.add(msg.toolCallId); result.push(msg); } else if (msg.role === "system") { if (pendingToolCalls.length > 0) { heldSystemMessages.push(msg); } else { result.push(msg); } } else if (msg.role === "user") { // A new user turn interrupts tool flow - insert synthetic results for orphaned calls closePendingToolCalls(); result.push(msg); } else { result.push(msg); } } // If the conversation ends with unresolved tool calls, synthesize results now. closePendingToolCalls(); return result; } //# sourceMappingURL=transform-messages.js.map