UNPKG

@earendil-works/pi-ai

Version:

Unified LLM API with automatic model discovery and provider configuration

511 lines • 21 kB
import { createProvider } from "../models.js"; import { createAssistantMessageEventStream } from "../utils/event-stream.js"; import { ToolCallArgumentsStream } from "../utils/streaming-json.js"; import { getSystemMessageText } from "../utils/text.js"; const DEFAULT_API = "faux"; const DEFAULT_PROVIDER = "faux"; const DEFAULT_MODEL_ID = "faux-1"; const DEFAULT_MODEL_NAME = "Faux Model"; const DEFAULT_BASE_URL = "http://localhost:0"; const DEFAULT_MIN_TOKEN_SIZE = 3; const DEFAULT_MAX_TOKEN_SIZE = 5; const DEFAULT_USAGE = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; export function fauxText(text) { return { type: "text", text }; } export function fauxThinking(thinking) { return { type: "thinking", thinking }; } export function fauxToolCall(name, arguments_, options = {}) { return { type: "toolCall", id: options.id ?? randomId("tool"), name, arguments: arguments_, }; } function normalizeFauxAssistantContent(content) { if (typeof content === "string") { return [fauxText(content)]; } return Array.isArray(content) ? content : [content]; } export function fauxAssistantMessage(content, options = {}) { return { role: "assistant", content: normalizeFauxAssistantContent(content), api: DEFAULT_API, provider: DEFAULT_PROVIDER, model: DEFAULT_MODEL_ID, usage: DEFAULT_USAGE, stopReason: options.stopReason ?? "stop", ...(options.deferred === undefined ? {} : { deferred: options.deferred }), ...(options.errorMessage === undefined ? {} : { errorMessage: options.errorMessage }), ...(options.responseId === undefined ? {} : { responseId: options.responseId }), timestamp: options.timestamp ?? Date.now(), }; } function estimateTokens(text) { return Math.ceil(text.length / 4); } function randomId(prefix) { return `${prefix}:${Date.now()}:${Math.random().toString(36).slice(2)}`; } function contentToText(content) { if (typeof content === "string") { return content; } return content .map((block) => { if (block.type === "text") { return block.text; } return `[image:${block.mimeType}:${block.data.length}]`; }) .join("\n"); } function assistantContentToText(content) { return content .map((block) => { if (block.type === "text") { return block.text; } if (block.type === "thinking") { return block.thinking; } return `${block.name}:${JSON.stringify(block.arguments)}`; }) .join("\n"); } function toolResultToText(message) { return [message.toolName, ...message.content.map((block) => contentToText([block]))].join("\n"); } function messageToText(message) { if (message.role === "system") { return [ getSystemMessageText(message), ...(message.toolsRemoved?.map((tool) => `tool-:${JSON.stringify(tool)}`) ?? []), ...(message.toolsAdded?.map((tool) => `tool+:${JSON.stringify(tool)}`) ?? []), ] .filter((part) => part.length > 0) .join("\n"); } if (message.role === "user") { return contentToText(message.content); } if (message.role === "assistant") { return assistantContentToText(message.content); } return toolResultToText(message); } /** Length of the prompt text that joins `messages` with blank lines. */ function joinedLength(messages, count = messages.length) { let length = count > 0 ? (count - 1) * 2 : 0; for (let index = 0; index < count; index++) length += messages[index].length; return length; } /** * Length of the common prefix of the two joined prompts. Equal messages are compared whole; characters are compared * only from the first message that differs. */ function commonPromptPrefixLength(previous, current) { let index = 0; while (index < previous.length && index < current.length && previous[index] === current[index]) index++; const rest = (messages) => index === messages.length ? "" : (index > 0 ? "\n\n" : "") + messages.slice(index).join("\n\n"); return joinedLength(previous, index) + commonPrefixLength(rest(previous), rest(current)); } function commonPrefixLength(a, b) { const length = Math.min(a.length, b.length); let index = 0; while (index < length && a[index] === b[index]) { index++; } return index; } function withUsageEstimate(message, context, options, promptCache) { // One text per message; the whole prompt joins them with blank lines. const prompt = context.messages.map((message) => `${message.role}:${messageToText(message)}`); const promptLength = joinedLength(prompt); const promptTokens = Math.ceil(promptLength / 4); const outputTokens = estimateTokens(assistantContentToText(message.content)); let input = promptTokens; let cacheRead = 0; let cacheWrite = 0; const sessionId = options?.sessionId; if (sessionId && options?.cacheRetention !== "none") { const previousPrompt = promptCache.get(sessionId); if (previousPrompt) { const cachedChars = commonPromptPrefixLength(previousPrompt, prompt); cacheRead = Math.ceil(cachedChars / 4); cacheWrite = Math.max(0, promptTokens - cacheRead); } else { cacheWrite = promptTokens; } // Every prompt token is either read from or written to the simulated cache. input = 0; promptCache.set(sessionId, prompt); } return { ...message, usage: { input, output: outputTokens, cacheRead, cacheWrite, totalTokens: input + outputTokens + cacheRead + cacheWrite, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, }; } function splitStringByTokenSize(text, minTokenSize, maxTokenSize) { const chunks = []; let index = 0; while (index < text.length) { const tokenSize = minTokenSize + Math.floor(Math.random() * (maxTokenSize - minTokenSize + 1)); const charSize = Math.max(1, tokenSize * 4); chunks.push(text.slice(index, index + charSize)); index += charSize; } return chunks.length > 0 ? chunks : [""]; } function cloneMessage(message, api, provider, modelId) { const cloned = structuredClone(message); return { ...cloned, api, provider, model: modelId, timestamp: cloned.timestamp ?? Date.now(), usage: cloned.usage ?? DEFAULT_USAGE, }; } function createDeferredMessage(model, handle) { return { role: "assistant", content: [], api: model.api, provider: model.provider, model: model.id, usage: DEFAULT_USAGE, stopReason: "deferred", deferred: handle, timestamp: Date.now(), }; } function createErrorMessage(error, api, provider, modelId) { return { role: "assistant", content: [], api, provider, model: modelId, usage: DEFAULT_USAGE, stopReason: "error", errorMessage: error instanceof Error ? error.message : String(error), timestamp: Date.now(), }; } function createAbortedMessage(partial) { return { ...partial, stopReason: "aborted", errorMessage: "Request was aborted", timestamp: Date.now(), }; } function scheduleChunk(chunk, tokensPerSecond) { if (!tokensPerSecond || tokensPerSecond <= 0) { return new Promise((resolve) => queueMicrotask(resolve)); } const delayMs = (estimateTokens(chunk) / tokensPerSecond) * 1000; return new Promise((resolve) => setTimeout(resolve, delayMs)); } async function streamWithDeltas(stream, message, minTokenSize, maxTokenSize, tokensPerSecond, signal) { const partial = { ...message, content: [], stopReason: "pending" }; if (signal?.aborted) { const aborted = createAbortedMessage(partial); stream.push({ type: "error", reason: "aborted", error: aborted }); stream.end(aborted); return; } stream.push({ type: "start", partial: { ...partial } }); for (let index = 0; index < message.content.length; index++) { if (signal?.aborted) { const aborted = createAbortedMessage(partial); stream.push({ type: "error", reason: "aborted", error: aborted }); stream.end(aborted); return; } const block = message.content[index]; if (block.type === "thinking") { partial.content = [...partial.content, { type: "thinking", thinking: "" }]; stream.push({ type: "thinking_start", contentIndex: index, partial: { ...partial } }); for (const chunk of splitStringByTokenSize(block.thinking, minTokenSize, maxTokenSize)) { await scheduleChunk(chunk, tokensPerSecond); if (signal?.aborted) { const aborted = createAbortedMessage(partial); stream.push({ type: "error", reason: "aborted", error: aborted }); stream.end(aborted); return; } partial.content[index].thinking += chunk; stream.push({ type: "thinking_delta", contentIndex: index, delta: chunk, partial: { ...partial } }); } stream.push({ type: "thinking_end", contentIndex: index, content: block.thinking, partial: { ...partial }, }); continue; } if (block.type === "text") { partial.content = [...partial.content, { type: "text", text: "" }]; stream.push({ type: "text_start", contentIndex: index, partial: { ...partial } }); for (const chunk of splitStringByTokenSize(block.text, minTokenSize, maxTokenSize)) { await scheduleChunk(chunk, tokensPerSecond); if (signal?.aborted) { const aborted = createAbortedMessage(partial); stream.push({ type: "error", reason: "aborted", error: aborted }); stream.end(aborted); return; } partial.content[index].text += chunk; stream.push({ type: "text_delta", contentIndex: index, delta: chunk, partial: { ...partial } }); } stream.push({ type: "text_end", contentIndex: index, content: block.text, partial: { ...partial } }); continue; } partial.content = [...partial.content, { type: "toolCall", id: block.id, name: block.name, arguments: {} }]; stream.push({ type: "toolcall_start", contentIndex: index, partial: { ...partial } }); // Partial arguments come from the same parser real providers use, so consumers see them grow. const toolArguments = new ToolCallArgumentsStream(); for (const chunk of splitStringByTokenSize(JSON.stringify(block.arguments), minTokenSize, maxTokenSize)) { await scheduleChunk(chunk, tokensPerSecond); if (signal?.aborted) { const aborted = createAbortedMessage(partial); stream.push({ type: "error", reason: "aborted", error: aborted }); stream.end(aborted); return; } partial.content[index].arguments = toolArguments.append(chunk); stream.push({ type: "toolcall_delta", contentIndex: index, delta: chunk, partial: { ...partial } }); } partial.content[index].arguments = block.arguments; stream.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: { ...partial } }); } if (message.stopReason === "pending") { throw new Error("Faux response ended without a stop reason"); } if (message.stopReason === "error" || message.stopReason === "aborted") { stream.push({ type: "error", reason: message.stopReason, error: message }); stream.end(message); return; } stream.push({ type: "done", reason: message.stopReason, message }); stream.end(message); } export function createFauxCore(options) { const api = options.api ?? randomId(DEFAULT_API); const provider = options.provider ?? DEFAULT_PROVIDER; const minTokenSize = Math.max(1, Math.min(options.tokenSize?.min ?? DEFAULT_MIN_TOKEN_SIZE, options.tokenSize?.max ?? DEFAULT_MAX_TOKEN_SIZE)); const maxTokenSize = Math.max(minTokenSize, options.tokenSize?.max ?? DEFAULT_MAX_TOKEN_SIZE); let pendingResponses = []; const tokensPerSecond = options.tokensPerSecond; const state = { callCount: 0, deferredFetchCount: 0, cancelledDeferred: [] }; const promptCache = new Map(); const deferredResponses = new Map(); const modelDefinitions = options.models?.length ? options.models : [ { id: DEFAULT_MODEL_ID, name: DEFAULT_MODEL_NAME, reasoning: false, input: ["text", "image"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000, maxTokens: 16384, }, ]; const models = modelDefinitions.map((definition) => ({ id: definition.id, name: definition.name ?? definition.id, api, provider, baseUrl: DEFAULT_BASE_URL, reasoning: definition.reasoning ?? false, input: definition.input ?? ["text", "image"], inputLimits: definition.inputLimits, cost: definition.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: definition.contextWindow ?? 128000, maxTokens: definition.maxTokens ?? 16384, })); const resolveResponse = async (step, context, streamOptions, requestModel) => { const resolved = typeof step === "function" ? await step(context, streamOptions, state, requestModel) : step; return withUsageEstimate(cloneMessage(resolved, api, provider, requestModel.id), context, streamOptions, promptCache); }; const stream = (requestModel, context, streamOptions) => { const outer = createAssistantMessageEventStream(); const step = pendingResponses.shift(); state.callCount++; queueMicrotask(async () => { try { await streamOptions?.onResponse?.({ status: 200, headers: {} }, requestModel); if (!step) { let message = createErrorMessage(new Error("No more faux responses queued"), api, provider, requestModel.id); message = withUsageEstimate(message, context, streamOptions, promptCache); outer.push({ type: "error", reason: "error", error: message }); outer.end(message); return; } if (streamOptions?.deferred) { const handle = { provider: requestModel.provider, modelId: requestModel.id, api: requestModel.api, id: randomId("deferred"), ...(options.deferred?.pollAfterMs !== undefined ? { pollAfterMs: options.deferred.pollAfterMs } : {}), }; deferredResponses.set(handle.id, { handle, step, context, options: streamOptions, model: requestModel, pendingFetches: Math.max(0, Math.floor(options.deferred?.pendingFetches ?? 0)), cancelled: false, }); await streamWithDeltas(outer, createDeferredMessage(requestModel, handle), minTokenSize, maxTokenSize, tokensPerSecond, streamOptions.signal); return; } const message = await resolveResponse(step, context, streamOptions, requestModel); await streamWithDeltas(outer, message, minTokenSize, maxTokenSize, tokensPerSecond, streamOptions?.signal); } catch (error) { const message = createErrorMessage(error, api, provider, requestModel.id); outer.push({ type: "error", reason: "error", error: message }); outer.end(message); } }); return outer; }; const streamSimple = (streamModel, context, streamOptions) => stream(streamModel, context, streamOptions); const fetchDeferred = (requestModel, handle, fetchOptions) => { const outer = createAssistantMessageEventStream(); state.deferredFetchCount++; queueMicrotask(async () => { try { await fetchOptions?.onResponse?.({ status: 200, headers: {} }, requestModel); const entry = deferredResponses.get(handle.id); if (!entry || entry.handle.provider !== handle.provider || entry.handle.modelId !== handle.modelId || entry.handle.api !== handle.api) { throw new Error(`Unknown faux deferred response: ${handle.id}`); } if (entry.cancelled) throw new Error(`Faux deferred response was cancelled: ${handle.id}`); if (entry.pendingFetches > 0) { entry.pendingFetches--; await streamWithDeltas(outer, createDeferredMessage(requestModel, entry.handle), minTokenSize, maxTokenSize, tokensPerSecond, fetchOptions?.signal); return; } if (!entry.final) { const { deferred: _deferred, signal: _submissionSignal, onResponse: _submissionOnResponse, ...submissionOptions } = entry.options ?? {}; try { entry.final = await resolveResponse(entry.step, entry.context, submissionOptions, entry.model); } catch (error) { entry.final = createErrorMessage(error, api, provider, entry.model.id); } } await streamWithDeltas(outer, entry.final, minTokenSize, maxTokenSize, tokensPerSecond, fetchOptions?.signal); } catch (error) { const message = createErrorMessage(error, api, provider, requestModel.id); outer.push({ type: "error", reason: "error", error: message }); outer.end(message); } }); return outer; }; const cancelDeferred = async (requestModel, handle, cancelOptions) => { state.cancelledDeferred.push(structuredClone(handle)); const entry = deferredResponses.get(handle.id); if (entry) entry.cancelled = true; await cancelOptions?.onResponse?.({ status: 200, headers: {} }, requestModel); }; function getModel(requestedModelId) { if (!requestedModelId) { return models[0]; } return models.find((candidate) => candidate.id === requestedModelId); } return { api, provider, models, stream, streamSimple, fetchDeferred, cancelDeferred, getModel, state, setResponses(responses) { pendingResponses = [...responses]; }, appendResponses(responses) { pendingResponses.push(...responses); }, getPendingResponseCount() { return pendingResponses.length; }, }; } /** * Faux provider for tests built on explicit `Models` collections: * * ```ts * const faux = fauxProvider(); * const models = createModels(); * models.setProvider(faux.provider); * faux.setResponses([fauxAssistantMessage("hi")]); * ``` */ export function fauxProvider(options = {}) { const core = createFauxCore(options); const provider = createProvider({ id: core.provider, auth: { apiKey: { name: "Faux", resolve: async () => ({ auth: {} }) } }, models: core.models, api: { stream: core.stream, streamSimple: core.streamSimple, fetchDeferred: core.fetchDeferred, cancelDeferred: core.cancelDeferred, }, }); return { provider, api: core.api, models: core.models, getModel: core.getModel, state: core.state, setResponses: core.setResponses, appendResponses: core.appendResponses, getPendingResponseCount: core.getPendingResponseCount, }; } //# sourceMappingURL=faux.js.map