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