UNPKG

@earendil-works/pi-ai

Version:

Unified LLM API with automatic model discovery and provider configuration

329 lines • 13.5 kB
/** * pi-messages API implementation. * * Streams pi's own message protocol directly to a backend: the request is a * single POST of `{ model, context, options }` to `<baseUrl>/messages`, the * response is an SSE stream of serialized assistant-message events plus a * terminal `done`/`error` event. This is the wire protocol spoken by the * Radius gateway, but any backend implementing it can be used, e.g. via a * models.json custom provider with `"api": "pi-messages"`. */ import { appendAssistantMessageDiagnostic, createAssistantMessageDiagnostic } from "../utils/diagnostics.js"; import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { headersToRecord, providerHeadersToRecord } from "../utils/headers.js"; import { getIdentityHeaders } from "../utils/identity.js"; import { getProviderEnvValue } from "../utils/provider-env.js"; import { ToolCallArgumentsStream } from "../utils/streaming-json.js"; import { ownedSlice } from "../utils/text.js"; export class PiMessagesResponseError extends Error { code; diagnosticDetails; constructor(message, code, diagnosticDetails) { super(message); this.name = "PiMessagesResponseError"; this.code = code; this.diagnosticDetails = diagnosticDetails; } } function parsePiMessagesErrorBody(body) { try { const parsed = JSON.parse(body); const error = parsed?.error; return parsed && typeof error === "object" && error !== null && !Array.isArray(error) ? parsed : undefined; } catch { return undefined; } } function truncateDiagnosticString(value) { const maxLength = 8192; return value.length > maxLength ? `${ownedSlice(value, 0, maxLength)}…` : value; } function formatPiMessagesResponseError(response, body, errorBody) { const message = typeof errorBody?.error?.message === "string" ? errorBody.error.message : undefined; const code = typeof errorBody?.error?.code === "string" ? errorBody.error.code : undefined; const suffix = message ?? body; const codeSuffix = code ? ` (${code})` : ""; return `${response.status} ${response.statusText}: ${suffix}${codeSuffix}`; } function createPiMessagesResponseError(model, url, response, body) { const errorBody = parsePiMessagesErrorBody(body); const code = typeof errorBody?.error?.code === "string" ? errorBody.error.code : undefined; return new PiMessagesResponseError(formatPiMessagesResponseError(response, body, errorBody), code, { version: 1, provider: model.provider, model: model.id, url: url.toString(), status: response.status, statusText: response.statusText, ...(errorBody?.error === undefined ? {} : { error: errorBody.error }), ...(errorBody ? {} : { body: truncateDiagnosticString(body) }), timestampMs: Date.now(), }); } function createEmptyUsage() { return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; } function appendRewriteDiagnostic(message, rewrite) { if (!rewrite) { return; } appendAssistantMessageDiagnostic(message, { type: "pi_messages_rewrite", timestamp: Date.now(), details: { ...rewrite }, }); } function createEventConverter(model) { const partial = { role: "assistant", content: [], api: model.api, provider: model.provider, model: model.id, usage: createEmptyUsage(), stopReason: "pending", timestamp: Date.now(), }; const toolArguments = new Map(); return (event) => { if ("contentIndex" in event) { // The index comes from the wire and keys the content array: `__proto__` would reach Array.prototype, and a huge // index would make a sparse array of billions of holes. A start adds the next block; other events need one. const index = event.contentIndex; const limit = event.type.endsWith("_start") ? partial.content.length : partial.content.length - 1; if (typeof index !== "number" || !Number.isInteger(index) || index < 0 || index > limit) { throw new Error(`Invalid contentIndex ${JSON.stringify(index)} in pi-messages ${event.type} event`); } } switch (event.type) { case "done": Object.assign(partial, { stopReason: event.reason, usage: event.usage, responseId: event.responseId, }); if (event.responseModel !== undefined) { partial.responseModel = event.responseModel; } if (event.providerThinkingLevel !== undefined) { partial.providerThinkingLevel = event.providerThinkingLevel; } appendRewriteDiagnostic(partial, event.rewrite); return { type: "done", reason: event.reason, message: partial }; case "error": Object.assign(partial, { stopReason: event.reason, usage: event.usage, errorMessage: event.errorMessage, responseId: event.responseId, }); if (event.responseModel !== undefined) { partial.responseModel = event.responseModel; } if (event.providerThinkingLevel !== undefined) { partial.providerThinkingLevel = event.providerThinkingLevel; } appendRewriteDiagnostic(partial, event.rewrite); return { type: "error", reason: event.reason, error: partial }; case "start": break; case "text_start": partial.content[event.contentIndex] = { type: "text", text: "" }; break; case "text_delta": partial.content[event.contentIndex].text += event.delta; break; case "text_end": Object.assign(partial.content[event.contentIndex], { text: event.content, textSignature: event.contentSignature, }); break; case "thinking_start": partial.content[event.contentIndex] = { type: "thinking", thinking: "" }; break; case "thinking_delta": partial.content[event.contentIndex].thinking += event.delta; break; case "thinking_end": Object.assign(partial.content[event.contentIndex], { thinking: event.content, thinkingSignature: event.contentSignature, redacted: event.redacted, }); break; case "toolcall_start": partial.content[event.contentIndex] = { type: "toolCall", id: event.id, name: event.toolName, arguments: {}, }; toolArguments.set(event.contentIndex, new ToolCallArgumentsStream()); break; case "toolcall_delta": { const args = toolArguments.get(event.contentIndex); if (args !== undefined) partial.content[event.contentIndex].arguments = args.append(event.delta); break; } case "toolcall_end": Object.assign(partial.content[event.contentIndex], event.toolCall); toolArguments.delete(event.contentIndex); return { type: "toolcall_end", contentIndex: event.contentIndex, toolCall: partial.content[event.contentIndex], partial, }; } return { ...event, partial }; }; } async function* readPiMessagesEvents(stream) { const decoder = new TextDecoder(); const reader = stream.getReader(); let buffer = ""; try { while (true) { const { done, value } = await reader.read(); buffer += done ? decoder.decode() : decoder.decode(value, { stream: true }); buffer = buffer.replace(/\r\n/g, "\n"); let split = buffer.indexOf("\n\n"); while (split !== -1) { const event = parsePiMessagesEvent(buffer.slice(0, split)); if (event) { yield event; } buffer = buffer.slice(split + 2); split = buffer.indexOf("\n\n"); } if (done) { break; } } if (buffer.trim()) { const event = parsePiMessagesEvent(buffer); if (event) { yield event; } } } finally { reader.releaseLock(); } } function parsePiMessagesEvent(raw) { const data = raw .split("\n") .find((line) => line.startsWith("data:")) ?.slice(5) .trim(); return data && data !== "[DONE]" ? JSON.parse(data) : undefined; } function createErrorEvent(model, error, aborted) { const reason = aborted ? "aborted" : "error"; const assistantMessage = { role: "assistant", content: [], api: model.api, provider: model.provider, model: model.id, usage: createEmptyUsage(), stopReason: reason, errorMessage: error instanceof Error ? error.message : String(error), timestamp: Date.now(), }; if (!aborted && error instanceof PiMessagesResponseError) { appendAssistantMessageDiagnostic(assistantMessage, createAssistantMessageDiagnostic("pi_messages_response_failure", error, error.diagnosticDetails)); } return { type: "error", reason, error: assistantMessage }; } function resolveCacheRetention(cacheRetention, env) { if (cacheRetention) { return cacheRetention; } // Backend defaults apply when unset; only the legacy env opt-in is mapped. return getProviderEnvValue("PI_CACHE_RETENTION", env) === "long" ? "long" : undefined; } export const stream = (model, context, options) => { const eventStream = new AssistantMessageEventStream(); const convertEvent = createEventConverter(model); void (async () => { try { const apiKey = options?.apiKey; if (!apiKey) { throw new Error(`No API key provided for provider "${model.provider}"`); } const url = new URL(`${model.baseUrl.replace(/\/+$/u, "")}/messages`); if (options?.debug) { url.searchParams.set("debug", "1"); } let payload = { model: model.id, context, options: { temperature: options?.temperature, maxTokens: options?.maxTokens, reasoning: options?.reasoning, cacheRetention: resolveCacheRetention(options?.cacheRetention, options?.env), sessionId: options?.sessionId, toolChoice: options?.toolChoice, }, }; const nextPayload = await options?.onPayload?.(payload, model); if (nextPayload !== undefined) { payload = nextPayload; } const response = await (options?.fetch ?? globalThis.fetch)(url, { method: "POST", headers: providerHeadersToRecord(getIdentityHeaders(model, options?.identity), { authorization: `Bearer ${apiKey}`, accept: "text/event-stream", "content-type": "application/json", }, options?.headers), body: JSON.stringify(payload), signal: options?.signal, }); await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); if (!response.ok) { const body = await response.text(); throw createPiMessagesResponseError(model, url, response, body); } if (!response.body) { throw new Error(`${model.provider} response has no body`); } for await (const piEvent of readPiMessagesEvents(response.body)) { await options?.onProviderStreamEvent?.(piEvent, model); const event = convertEvent(piEvent); eventStream.push(event); if (event.type === "done" || event.type === "error") { return; } } throw new Error(`${model.provider} stream ended without a terminal event`); } catch (error) { eventStream.push(createErrorEvent(model, error, options?.signal?.aborted ?? false)); } })(); return eventStream; }; export const streamSimple = (model, context, options) => { const extra = options; return stream(model, context, { ...options, reasoning: options?.reasoning, toolChoice: options?.toolChoice, debug: extra?.debug, }); }; //# sourceMappingURL=pi-messages.js.map