@earendil-works/pi-ai
Version:
Unified LLM API with automatic model discovery and provider configuration
329 lines • 13.5 kB
JavaScript
/**
* 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