UNPKG

@earendil-works/pi-ai

Version:

Unified LLM API with automatic model discovery and provider configuration

218 lines • 9.79 kB
import { contentText, getSystemMessageText } from "./text.js"; /** * Build the leading system message for a prompt and tool set. Returns undefined when * both are empty, so an empty transcript stays empty. */ export function createInitialSystemMessage(systemPrompt, tools) { const hasSystemPrompt = systemPrompt !== undefined && systemPrompt.length > 0; const hasTools = tools !== undefined && tools.length > 0; if (!hasSystemPrompt && !hasTools) return undefined; return { role: "system", content: systemPrompt ?? "", ...(hasTools ? { toolsAdded: tools } : {}), timestamp: 0, }; } /** * Fold `Context.systemPrompt` and `Context.tools` into a leading system message. * This is the only entry point that produces a {@link TranscriptContext}; every * provider-facing function expects the result. */ export function normalizeContext(context) { const initialMessage = createInitialSystemMessage(context.systemPrompt, context.tools); const messages = initialMessage ? [initialMessage, ...context.messages] : context.messages; return { messages }; } function isSystemMessage(message) { return message.role === "system"; } /** Return the leading system message, if the transcript starts with one. */ export function getInitialSystemMessage(messages) { const first = messages[0]; return first && isSystemMessage(first) ? first : undefined; } /** Drop the leading system message for APIs that carry the prompt outside the message list. */ export function withoutInitialSystemMessage(messages) { return getInitialSystemMessage(messages) ? messages.slice(1) : messages; } /** Resolve the tools available after applying every transcript delta in order. */ export function getCurrentTools(messages) { const tools = new Map(); for (const message of messages) { if (!isSystemMessage(message)) continue; for (const tool of message.toolsRemoved ?? []) tools.delete(tool.name); for (const tool of message.toolsAdded ?? []) tools.set(tool.name, tool); } return [...tools.values()]; } /** * Replay every system message into one leading system message holding the current * prompt and tools. Later `content` is appended to the base prompt, `sections` are * patched by name, and tools are resolved with {@link getCurrentTools}. */ export function getCurrentSystemMessage(messages) { const content = []; const sections = new Map(); let timestamp; for (const message of messages) { if (!isSystemMessage(message)) continue; timestamp ??= message.timestamp; const text = contentText(message.content); if (text.length > 0) content.push(text); for (const [name, value] of Object.entries(message.sections ?? {})) { if (value === null) sections.delete(name); else sections.set(name, value); } } const tools = getCurrentTools(messages); if (timestamp === undefined && tools.length === 0) return undefined; return { role: "system", content: content.join("\n\n"), ...(sections.size > 0 ? { sections: Object.fromEntries(sections) } : {}), ...(tools.length > 0 ? { toolsAdded: tools } : {}), timestamp: timestamp ?? 0, }; } /** Render the current system prompt text after replaying every system message. */ export function getCurrentSystemPrompt(messages) { const message = getCurrentSystemMessage(messages); return message ? getSystemMessageText(message) : ""; } /** * Rebuild the transcript for APIs without mid-conversation system messages: the replayed * system message leads, and every later system message is dropped. */ export function collapseSystemMessages(context) { const head = getCurrentSystemMessage(context.messages); const messages = context.messages.filter((message) => message.role !== "system"); return { messages: head ? [head, ...messages] : messages }; } /** Keep later system messages in place when the model accepts them; otherwise collapse them. */ export function resolveTranscript(context, supportsMidConvoSystemMessages) { return supportsMidConvoSystemMessages ? context : collapseSystemMessages(context); } /** Strip executable and display-only fields from a tool before transcript comparison or persistence. */ export function toToolDeclaration(tool) { return { name: tool.name, description: tool.description, parameters: JSON.parse(JSON.stringify(tool.parameters)), ...(tool.constrainedSampling === undefined ? {} : { constrainedSampling: tool.constrainedSampling }), }; } /** * Whether two tools declare the same interface to the model. * * Both sides go through {@link toToolDeclaration} first: its JSON round-trip drops the * schema metadata keys (TypeBox's non-enumerable kind marks) and `undefined` fields that a structural comparison would see, and * builds both objects with the same key order, so comparing the serialized declarations * is exact. This avoids a deep-equal dependency in a browser-safe package. */ export function declarationsEqual(left, right) { return JSON.stringify(toToolDeclaration(left)) === JSON.stringify(toToolDeclaration(right)); } /** Compare two complete tool states. A changed definition is a removal followed by an addition. */ export function getToolStateChanges(previous, current) { const previousTools = new Map(previous.map((tool) => [tool.name, tool])); const currentTools = new Map(current.map((tool) => [tool.name, tool])); return { toolsAdded: current .filter((tool) => { const previousTool = previousTools.get(tool.name); return previousTool === undefined || !declarationsEqual(previousTool, tool); }) .map(toToolDeclaration), toolsRemoved: previous .filter((tool) => { const currentTool = currentTools.get(tool.name); return currentTool === undefined || !declarationsEqual(tool, currentTool); }) .map((tool) => ({ name: tool.name })), }; } /** Every definition referenced by transcript tool state, in first-declaration order. */ export function getDeclaredTools(messages) { const definitions = new Map(); for (const message of messages) { if (!isSystemMessage(message)) continue; for (const tool of message.toolsAdded ?? []) definitions.set(tool.name, tool); } return [...definitions.values()]; } /** * Whether a tool name was declared twice with different definitions. A transport that can * only reference previously declared tools by name cannot replay such a history. * * @deprecated No built-in transport needs this anymore: Anthropic expresses redefinitions with * inline `tool_definition` blocks. Kept for API compatibility and will be removed in a future release. */ export function hasToolRedefinitions(messages) { const declared = new Map(); for (const message of messages) { if (!isSystemMessage(message)) continue; for (const tool of message.toolsAdded ?? []) { const previous = declared.get(tool.name); if (previous !== undefined && !declarationsEqual(previous, tool)) return true; declared.set(tool.name, tool); } } return false; } /** Whether tool history contains a removal or same-name redeclaration that an addition-only transport cannot replay. */ export function hasNonAdditiveToolChanges(messages) { return findLastNonAdditiveToolChange(messages.filter(isSystemMessage)) >= 0; } /** Index of the last system message with a removal or same-name redeclaration, or -1. */ function findLastNonAdditiveToolChange(systemMessages) { // A name declared before counts as a redeclaration even after its removal: Kimi K3 ignores a // removed tool that a later tool message declares again (verified October 2026). const declared = new Set(); let last = -1; for (let index = 0; index < systemMessages.length; index++) { const message = systemMessages[index]; if ((message.toolsRemoved?.length ?? 0) > 0) last = index; for (const tool of message.toolsAdded ?? []) { if (declared.has(tool.name)) last = index; declared.add(tool.name); } } return last; } /** * Split tool declarations between the top-level request field and in-place changes. * * With `"changes"`, the top-level list stays empty and every system message, including the * leading one, declares its tools in place: an in-place declaration can replace an earlier * in-place one, but not a top-level one. With `"additions"`, a removal or redefinition cannot be * expressed in place, so the top-level list becomes the tool state after the last such change; * later additions stay anchored, and the cached prefix is invalidated once per removal instead * of on every later addition. With `"none"`, the top-level list is the current tool set. */ export function resolveTranscriptTools(messages, support) { const systemMessages = messages.filter(isSystemMessage); if (support === "none") { return { requestTools: getCurrentTools(systemMessages), foldedSystemMessages: systemMessages.length }; } if (support === "changes") return { requestTools: [], foldedSystemMessages: 0 }; const folded = Math.max(getInitialSystemMessage(messages) ? 1 : 0, findLastNonAdditiveToolChange(systemMessages) + 1); return { requestTools: getCurrentTools(systemMessages.slice(0, folded)), foldedSystemMessages: folded }; } //# sourceMappingURL=transcript.js.map