/** * Local copy of Pi's streamSimple option recipe. * * Why this is forked: Pi's extension loader aliases `@earendil-works/pi-ai` * to `dist/compat.js`. Deep imports such as * `@earendil-works/pi-ai/api/simple-options` then resolve to * `compat.js/api/simple-options` and fail to load. compat does not re-export * `buildBaseOptions`, so the extension keeps a local recipe and only imports * the loader-safe `@earendil-works/pi-ai/compat` surface. * * Keep this aligned with installed pi-ai `api/simple-options` + * `utils/estimate`. Tests compare the two on representative fixtures. */ import type { Api, AssistantMessage, Context, Message, Model, SimpleStreamOptions, StreamOptions, Usage, } from "@earendil-works/pi-ai/compat"; const CONTEXT_SAFETY_TOKENS = 4096; const MIN_MAX_TOKENS = 1; const CHARS_PER_TOKEN = 4; const ESTIMATED_IMAGE_CHARS = 4800; function calculateContextTokens(usage: Usage): number { return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite; } function safeJsonStringify(value: unknown): string { try { return JSON.stringify(value) ?? "undefined"; } catch { return "[unserializable]"; } } function estimateTextAndImageContentChars(content: unknown): number { if (typeof content === "string") return content.length; if (!Array.isArray(content)) return 0; let chars = 0; for (const block of content) { if (!isRecord(block)) continue; chars += block.type === "text" && typeof block.text === "string" ? block.text.length : ESTIMATED_IMAGE_CHARS; } return chars; } function estimateTextTokens(text: string): number { return Math.ceil(text.length / CHARS_PER_TOKEN); } function estimateTextAndImageContentTokens(content: unknown): number { return Math.ceil(estimateTextAndImageContentChars(content) / CHARS_PER_TOKEN); } function estimateMessageTokens(message: Message): number { if (message.role === "user" || message.role === "toolResult") { return estimateTextAndImageContentTokens(message.content); } let chars = 0; for (const block of message.content) { if (block.type === "text") { chars += block.text.length; } else if (block.type === "thinking") { chars += block.thinking.length; } else { chars += block.name.length + safeJsonStringify(block.arguments).length; } } return Math.ceil(chars / CHARS_PER_TOKEN); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function getLastAssistantUsageInfo(messages: readonly Message[]): { usage: Usage; index: number } | undefined { let latestPrefixTimestamp = Number.NEGATIVE_INFINITY; let usageInfo: { usage: Usage; index: number } | undefined; for (let i = 0; i < messages.length; i++) { const message = messages[i]; if (message?.role === "assistant") { const assistant = message as AssistantMessage; const usageAppliesToPrefix = assistant.timestamp >= latestPrefixTimestamp; if ( usageAppliesToPrefix && assistant.stopReason !== "aborted" && assistant.stopReason !== "error" && calculateContextTokens(assistant.usage) > 0 ) { usageInfo = { usage: assistant.usage, index: i }; } } if (message) latestPrefixTimestamp = Math.max(latestPrefixTimestamp, message.timestamp); } return usageInfo; } function estimateMessages(messages: readonly Message[]): { tokens: number; usageTokens: number; trailingTokens: number; lastUsageIndex: number | null; } { const usageInfo = getLastAssistantUsageInfo(messages); if (usageInfo) { const usageTokens = calculateContextTokens(usageInfo.usage); let trailingTokens = 0; for (let i = usageInfo.index + 1; i < messages.length; i++) { const message = messages[i]; if (message) trailingTokens += estimateMessageTokens(message); } return { tokens: usageTokens + trailingTokens, usageTokens, trailingTokens, lastUsageIndex: usageInfo.index, }; } let tokens = 0; for (const message of messages) tokens += estimateMessageTokens(message); return { tokens, usageTokens: 0, trailingTokens: tokens, lastUsageIndex: null }; } function estimateToolsTokens(tools: Context["tools"]): number { if (!tools || tools.length === 0) return 0; return estimateTextTokens(safeJsonStringify(tools)); } export function estimateContextTokens(context: Context): number { const estimate = estimateMessages(context.messages); if (estimate.lastUsageIndex !== null) { const addedNames = new Set( context.messages .slice(estimate.lastUsageIndex + 1) .filter((message) => message.role === "toolResult") .flatMap((message) => message.addedToolNames ?? []), ); const addedToolTokens = estimateToolsTokens(context.tools?.filter((tool) => addedNames.has(tool.name))); return estimate.tokens + addedToolTokens; } const prefixTokens = (context.systemPrompt ? estimateTextTokens(context.systemPrompt) : 0) + estimateToolsTokens(context.tools); return estimate.tokens + prefixTokens; } export function clampMaxTokensToContext(model: Model, context: Context, maxTokens: number): number { if (model.contextWindow <= 0) return Math.max(MIN_MAX_TOKENS, maxTokens); const available = model.contextWindow - estimateContextTokens(context) - CONTEXT_SAFETY_TOKENS; return Math.min(maxTokens, Math.max(MIN_MAX_TOKENS, available)); } export function buildBaseOptions( model: Model, context: Context, options?: SimpleStreamOptions, apiKey?: string, ): StreamOptions { const samplingParams = model.samplingParams || options?.samplingParams ? { ...model.samplingParams, ...options?.samplingParams } : undefined; return { temperature: options?.temperature, samplingParams, maxTokens: clampMaxTokensToContext(model, context, options?.maxTokens ?? model.maxTokens), signal: options?.signal, telemetryContext: options?.telemetryContext, apiKey: apiKey || options?.apiKey, fetch: options?.fetch, transport: options?.transport, cacheRetention: options?.cacheRetention, sessionId: options?.sessionId, headers: options?.headers, onPayload: options?.onPayload, onResponse: options?.onResponse, timeoutMs: options?.timeoutMs, websocketConnectTimeoutMs: options?.websocketConnectTimeoutMs, maxRetries: options?.maxRetries, maxRetryDelayMs: options?.maxRetryDelayMs, metadata: options?.metadata, env: options?.env, }; }