chore(vendor): import pi 0.85.1

Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
2026-09-14 10:48:47 -04:00
parent 5ebc7018c3
commit 4def357d09
1669 changed files with 397627 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
export interface CombinedAbortSignal {
signal?: AbortSignal;
cleanup: () => void;
}
export function combineAbortSignals(signals: readonly (AbortSignal | undefined)[]): CombinedAbortSignal {
const activeSignals = signals.filter((signal): signal is AbortSignal => signal !== undefined);
if (activeSignals.length === 0) {
return { cleanup: () => {} };
}
if (activeSignals.length === 1) {
return { signal: activeSignals[0], cleanup: () => {} };
}
const controller = new AbortController();
const listeners: Array<{ signal: AbortSignal; listener: () => void }> = [];
const abort = (signal: AbortSignal) => {
if (!controller.signal.aborted) {
controller.abort(signal.reason);
}
};
for (const signal of activeSignals) {
if (signal.aborted) {
abort(signal);
break;
}
const listener = () => abort(signal);
signal.addEventListener("abort", listener, { once: true });
listeners.push({ signal, listener });
}
return {
signal: controller.signal,
cleanup: () => {
for (const { signal, listener } of listeners) {
signal.removeEventListener("abort", listener);
}
},
};
}

View File

@@ -0,0 +1,50 @@
function abortReason(signal: AbortSignal): unknown {
if (signal.reason !== undefined) return signal.reason;
const error = new Error("The operation was aborted");
error.name = "AbortError";
return error;
}
/** Create an operation-local signal for public APIs whose signal is optional. */
export function operationSignal(signal?: AbortSignal): AbortSignal {
return signal ?? new AbortController().signal;
}
/**
* Stop waiting for an operation when its signal aborts while continuing to
* observe the abandoned promise so a later rejection is always handled.
*/
export function raceWithAbortSignal<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) {
void operation.catch(() => {});
return Promise.reject(abortReason(signal));
}
return new Promise<T>((resolve, reject) => {
let settled = false;
const cleanup = () => signal.removeEventListener("abort", onAbort);
const onAbort = () => {
if (settled) return;
settled = true;
cleanup();
reject(abortReason(signal));
};
signal.addEventListener("abort", onAbort, { once: true });
void operation.then(
(value) => {
if (settled) return;
settled = true;
cleanup();
resolve(value);
},
(error: unknown) => {
if (settled) return;
settled = true;
cleanup();
reject(error);
},
);
if (signal.aborted) onAbort();
});
}

View File

@@ -0,0 +1,490 @@
import type { AssistantMessage, AssistantMessageEvent, TextContent, ThinkingContent, ToolCall } from "../types.ts";
import { parseStreamingJson } from "./json-parse.ts";
/**
* Compact, replayable assistant-message progress. Terminal settlement is
* intentionally excluded and must be persisted separately.
*/
export type AssistantMessageFrame =
| { type: "start"; partial: AssistantMessage }
| { type: "text_start"; contentIndex: number; content: TextContent }
| { type: "text_delta"; contentIndex: number; delta: string }
| { type: "text_end"; contentIndex: number; content: string; textSignature?: string }
| { type: "thinking_start"; contentIndex: number; content: ThinkingContent }
| { type: "thinking_delta"; contentIndex: number; delta: string }
| {
type: "thinking_end";
contentIndex: number;
content: string;
thinkingSignature?: string;
redacted?: boolean;
}
| { type: "toolcall_start"; contentIndex: number; toolCall: ToolCall }
| { type: "toolcall_checkpoint"; contentIndex: number; json: string }
| { type: "toolcall_delta"; contentIndex: number; delta: string }
| {
type: "toolcall_end";
contentIndex: number;
id: string;
name: string;
arguments: ToolCall["arguments"];
thoughtSignature?: string;
namespace?: string;
};
type EncoderBlockState =
| { kind: "text" | "thinking"; coveredChars: number; deltaChars: number }
| {
kind: "toolCall";
caughtUp: boolean;
catchupJson: string;
snapshotArguments: string;
};
type ReducerBlockState =
| { kind: "text"; ended: boolean }
| { kind: "thinking"; ended: boolean }
| { kind: "toolCall"; ended: boolean; json: string };
function cloneTextContent(content: TextContent): TextContent {
return {
type: "text",
text: content.text,
...(content.textSignature === undefined ? {} : { textSignature: content.textSignature }),
};
}
function cloneThinkingContent(content: ThinkingContent): ThinkingContent {
return {
type: "thinking",
thinking: content.thinking,
...(content.thinkingSignature === undefined ? {} : { thinkingSignature: content.thinkingSignature }),
...(content.redacted === undefined ? {} : { redacted: content.redacted }),
};
}
function cloneToolCall(toolCall: ToolCall): ToolCall {
return {
type: "toolCall",
id: toolCall.id,
name: toolCall.name,
arguments: structuredClone(toolCall.arguments),
...(toolCall.thoughtSignature === undefined ? {} : { thoughtSignature: toolCall.thoughtSignature }),
...(toolCall.namespace === undefined ? {} : { namespace: toolCall.namespace }),
};
}
function cloneStartMessage(message: AssistantMessage): AssistantMessage {
return {
role: "assistant",
content: [],
api: message.api,
provider: message.provider,
model: message.model,
...(message.responseModel === undefined ? {} : { responseModel: message.responseModel }),
...(message.responseId === undefined ? {} : { responseId: message.responseId }),
...(message.providerThinkingLevel === undefined ? {} : { providerThinkingLevel: message.providerThinkingLevel }),
...(message.diagnostics === undefined ? {} : { diagnostics: structuredClone(message.diagnostics) }),
usage: structuredClone(message.usage),
stopReason: "pending",
timestamp: message.timestamp,
};
}
function assertContentIndex(contentIndex: number): void {
if (!Number.isSafeInteger(contentIndex) || contentIndex < 0) {
throw new Error(`Invalid assistant message frame contentIndex: ${contentIndex}`);
}
}
function eventBlock(event: Exclude<AssistantMessageEvent, { type: "start" | "done" | "error" }>) {
assertContentIndex(event.contentIndex);
const block = event.partial.content[event.contentIndex];
if (!block) {
throw new Error(`${event.type} event has no content block at index ${event.contentIndex}`);
}
return block;
}
function serializedArguments(argumentsValue: ToolCall["arguments"]): string {
const serialized = JSON.stringify(argumentsValue);
if (serialized === undefined) throw new Error("Tool-call arguments are not JSON-serializable");
return serialized;
}
const EMPTY_PARSED_TOOL_ARGUMENTS = serializedArguments(parseStreamingJson<ToolCall["arguments"]>(""));
function isJsonPrefix(snapshot: unknown, current: unknown): boolean {
if (typeof snapshot === "string") return typeof current === "string" && current.startsWith(snapshot);
if (Array.isArray(snapshot)) {
return (
Array.isArray(current) &&
snapshot.length <= current.length &&
snapshot.every((value, index) => isJsonPrefix(value, current[index]))
);
}
if (typeof snapshot !== "object" || snapshot === null) return Object.is(snapshot, current);
if (typeof current !== "object" || current === null || Array.isArray(current)) return false;
const currentRecord = current as Record<string, unknown>;
return Object.entries(snapshot).every(
([key, value]) => Object.hasOwn(currentRecord, key) && isJsonPrefix(value, currentRecord[key]),
);
}
/**
* Encodes one assistant stream. `partial` remains a shared live accumulator;
* the encoder uses per-block offsets to avoid replaying deltas already visible
* when an older queued event is consumed.
*/
export class AssistantMessageFrameEncoder {
private started = false;
private terminal = false;
private readonly blocks = new Map<number, EncoderBlockState>();
encode(event: AssistantMessageEvent): AssistantMessageFrame | undefined {
if (this.terminal) throw new Error(`Assistant message event ${event.type} follows a terminal event`);
switch (event.type) {
case "start":
if (this.started) throw new Error("Assistant message stream contains more than one start event");
this.started = true;
return { type: "start", partial: cloneStartMessage(event.partial) };
case "done":
if (!this.started) throw new Error("Assistant message done event appears before start");
this.terminal = true;
return undefined;
case "error":
this.terminal = true;
return undefined;
}
if (!this.started) throw new Error(`Assistant message ${event.type} event appears before start`);
switch (event.type) {
case "text_start": {
const content = eventBlock(event);
if (content.type !== "text") {
throw new Error(`text_start event points to ${content.type} block at index ${event.contentIndex}`);
}
this.startBlock(event.contentIndex, {
kind: "text",
coveredChars: content.text.length,
deltaChars: 0,
});
return { type: "text_start", contentIndex: event.contentIndex, content: cloneTextContent(content) };
}
case "text_delta":
return this.encodeTextDelta(event.contentIndex, event.delta, "text");
case "text_end": {
const content = eventBlock(event);
if (content.type !== "text") {
throw new Error(`text_end event points to ${content.type} block at index ${event.contentIndex}`);
}
this.endBlock(event.contentIndex, "text");
return {
type: "text_end",
contentIndex: event.contentIndex,
content: event.content,
...(content.textSignature === undefined ? {} : { textSignature: content.textSignature }),
};
}
case "thinking_start": {
const content = eventBlock(event);
if (content.type !== "thinking") {
throw new Error(`thinking_start event points to ${content.type} block at index ${event.contentIndex}`);
}
this.startBlock(event.contentIndex, {
kind: "thinking",
coveredChars: content.thinking.length,
deltaChars: 0,
});
return {
type: "thinking_start",
contentIndex: event.contentIndex,
content: cloneThinkingContent(content),
};
}
case "thinking_delta":
return this.encodeTextDelta(event.contentIndex, event.delta, "thinking");
case "thinking_end": {
const content = eventBlock(event);
if (content.type !== "thinking") {
throw new Error(`thinking_end event points to ${content.type} block at index ${event.contentIndex}`);
}
this.endBlock(event.contentIndex, "thinking");
return {
type: "thinking_end",
contentIndex: event.contentIndex,
content: event.content,
...(content.thinkingSignature === undefined ? {} : { thinkingSignature: content.thinkingSignature }),
...(content.redacted === undefined ? {} : { redacted: content.redacted }),
};
}
case "toolcall_start": {
const content = eventBlock(event);
if (content.type !== "toolCall") {
throw new Error(`toolcall_start event points to ${content.type} block at index ${event.contentIndex}`);
}
const snapshotArguments = serializedArguments(content.arguments);
const caughtUp = snapshotArguments === EMPTY_PARSED_TOOL_ARGUMENTS;
this.startBlock(event.contentIndex, {
kind: "toolCall",
caughtUp,
catchupJson: "",
snapshotArguments: caughtUp ? "" : snapshotArguments,
});
return { type: "toolcall_start", contentIndex: event.contentIndex, toolCall: cloneToolCall(content) };
}
case "toolcall_delta": {
const state = this.block(event.contentIndex, "toolCall");
if (state.kind !== "toolCall") throw new Error("Unreachable tool-call encoder state");
if (state.caughtUp) {
return event.delta.length === 0
? undefined
: { type: "toolcall_delta", contentIndex: event.contentIndex, delta: event.delta };
}
state.catchupJson += event.delta;
const argumentsValue = parseStreamingJson<ToolCall["arguments"]>(state.catchupJson);
if (serializedArguments(argumentsValue) !== state.snapshotArguments) {
// Legacy grammar calls include the initial input in toolcall_start, but their
// JSON delta stream still begins at an empty input. Its parsed arguments can
// therefore extend, rather than exactly reproduce, the start snapshot.
const snapshotArguments = parseStreamingJson<ToolCall["arguments"]>(state.snapshotArguments);
if (!isJsonPrefix(snapshotArguments, argumentsValue)) return undefined;
}
state.caughtUp = true;
state.snapshotArguments = "";
const json = state.catchupJson;
state.catchupJson = "";
return json.length === 0
? undefined
: { type: "toolcall_checkpoint", contentIndex: event.contentIndex, json };
}
case "toolcall_end": {
const content = eventBlock(event);
if (content.type !== "toolCall") {
throw new Error(`toolcall_end event points to ${content.type} block at index ${event.contentIndex}`);
}
if (event.toolCall.type !== "toolCall") {
throw new Error(`toolcall_end event has invalid tool call at index ${event.contentIndex}`);
}
this.endBlock(event.contentIndex, "toolCall");
return {
type: "toolcall_end",
contentIndex: event.contentIndex,
id: event.toolCall.id,
name: event.toolCall.name,
arguments: structuredClone(event.toolCall.arguments),
...(event.toolCall.thoughtSignature === undefined
? {}
: { thoughtSignature: event.toolCall.thoughtSignature }),
...(event.toolCall.namespace === undefined ? {} : { namespace: event.toolCall.namespace }),
};
}
}
}
private startBlock(contentIndex: number, state: EncoderBlockState): void {
assertContentIndex(contentIndex);
if (this.blocks.has(contentIndex)) {
throw new Error(`Assistant message block ${contentIndex} starts more than once`);
}
this.blocks.set(contentIndex, state);
}
private block(contentIndex: number, kind: EncoderBlockState["kind"]): EncoderBlockState {
assertContentIndex(contentIndex);
const state = this.blocks.get(contentIndex);
if (state === undefined) throw new Error(`Assistant message ${kind} block ${contentIndex} has not started`);
if (state.kind !== kind) {
throw new Error(`Assistant message block ${contentIndex} is ${state.kind}, not ${kind}`);
}
return state;
}
private endBlock(contentIndex: number, kind: EncoderBlockState["kind"]): void {
this.block(contentIndex, kind);
this.blocks.delete(contentIndex);
}
private encodeTextDelta(
contentIndex: number,
delta: string,
kind: "text" | "thinking",
): AssistantMessageFrame | undefined {
const state = this.block(contentIndex, kind);
if (state.kind === "toolCall") throw new Error("Unreachable text encoder state");
const deltaStart = state.deltaChars;
state.deltaChars += delta.length;
const covered = Math.max(0, state.coveredChars - deltaStart);
if (covered >= delta.length) return undefined;
const uncovered = covered === 0 ? delta : delta.slice(covered);
return kind === "text"
? { type: "text_delta", contentIndex, delta: uncovered }
: { type: "thinking_delta", contentIndex, delta: uncovered };
}
}
function appendBlock(
message: AssistantMessage,
states: Map<number, ReducerBlockState>,
contentIndex: number,
block: TextContent | ThinkingContent | ToolCall,
state: ReducerBlockState,
): void {
assertContentIndex(contentIndex);
if (contentIndex !== message.content.length) {
const reason = contentIndex < message.content.length ? "already exists" : "would leave a gap";
throw new Error(`Cannot start assistant message block at index ${contentIndex}: ${reason}`);
}
message.content.push(structuredClone(block));
states.set(contentIndex, state);
}
function activeBlock(
message: AssistantMessage,
states: Map<number, ReducerBlockState>,
contentIndex: number,
expectedKind: ReducerBlockState["kind"],
frameType: AssistantMessageFrame["type"],
): { block: TextContent | ThinkingContent | ToolCall; state: ReducerBlockState } {
assertContentIndex(contentIndex);
const state = states.get(contentIndex);
const block = message.content[contentIndex];
if (!state || !block) {
throw new Error(`${frameType} frame has no started block at index ${contentIndex}`);
}
if (state.kind !== expectedKind || block.type !== expectedKind) {
throw new Error(
`${frameType} frame expected ${expectedKind} block at index ${contentIndex}, found ${block.type}`,
);
}
if (state.ended) {
throw new Error(`${frameType} frame follows the end of block at index ${contentIndex}`);
}
return { block, state };
}
/**
* Replay compact frames without mutating them. Returns `undefined` when the
* iterable contains no start frame.
*/
export function reduceAssistantMessageFrames(frames: Iterable<AssistantMessageFrame>): AssistantMessage | undefined {
let message: AssistantMessage | undefined;
let frameBeforeStart: AssistantMessageFrame["type"] | undefined;
const states = new Map<number, ReducerBlockState>();
for (const frame of frames) {
if (frame.type === "start") {
if (message) throw new Error("Assistant message frame sequence contains more than one start frame");
if (frameBeforeStart !== undefined)
throw new Error(`${frameBeforeStart} frame appears before the start frame`);
message = structuredClone(frame.partial);
continue;
}
if (!message) {
frameBeforeStart ??= frame.type;
continue;
}
switch (frame.type) {
case "text_start":
if (frame.content.type !== "text") {
throw new Error(`text_start frame contains ${frame.content.type} content`);
}
appendBlock(message, states, frame.contentIndex, frame.content, { kind: "text", ended: false });
break;
case "text_delta": {
const { block } = activeBlock(message, states, frame.contentIndex, "text", frame.type);
if (block.type !== "text") throw new Error("Unreachable text frame state");
block.text += frame.delta;
break;
}
case "text_end": {
const { block, state } = activeBlock(message, states, frame.contentIndex, "text", frame.type);
if (block.type !== "text") throw new Error("Unreachable text frame state");
block.text = frame.content;
delete block.textSignature;
if (frame.textSignature !== undefined) block.textSignature = frame.textSignature;
state.ended = true;
break;
}
case "thinking_start":
if (frame.content.type !== "thinking") {
throw new Error(`thinking_start frame contains ${frame.content.type} content`);
}
appendBlock(message, states, frame.contentIndex, frame.content, {
kind: "thinking",
ended: false,
});
break;
case "thinking_delta": {
const { block } = activeBlock(message, states, frame.contentIndex, "thinking", frame.type);
if (block.type !== "thinking") throw new Error("Unreachable thinking frame state");
block.thinking += frame.delta;
break;
}
case "thinking_end": {
const { block, state } = activeBlock(message, states, frame.contentIndex, "thinking", frame.type);
if (block.type !== "thinking") throw new Error("Unreachable thinking frame state");
block.thinking = frame.content;
delete block.thinkingSignature;
delete block.redacted;
if (frame.thinkingSignature !== undefined) block.thinkingSignature = frame.thinkingSignature;
if (frame.redacted !== undefined) block.redacted = frame.redacted;
state.ended = true;
break;
}
case "toolcall_start":
if (frame.toolCall.type !== "toolCall") {
throw new Error(`toolcall_start frame contains ${frame.toolCall.type} content`);
}
appendBlock(message, states, frame.contentIndex, frame.toolCall, {
kind: "toolCall",
ended: false,
json: "",
});
break;
case "toolcall_checkpoint": {
const { block, state } = activeBlock(message, states, frame.contentIndex, "toolCall", frame.type);
if (block.type !== "toolCall" || state.kind !== "toolCall") {
throw new Error("Unreachable tool-call checkpoint state");
}
state.json = frame.json;
block.arguments = parseStreamingJson<ToolCall["arguments"]>(frame.json);
break;
}
case "toolcall_delta": {
const { block, state } = activeBlock(message, states, frame.contentIndex, "toolCall", frame.type);
if (block.type !== "toolCall" || state.kind !== "toolCall") {
throw new Error("Unreachable tool-call frame state");
}
state.json += frame.delta;
break;
}
case "toolcall_end": {
const { block, state } = activeBlock(message, states, frame.contentIndex, "toolCall", frame.type);
if (block.type !== "toolCall") throw new Error("Unreachable tool-call frame state");
block.id = frame.id;
block.name = frame.name;
block.arguments = structuredClone(frame.arguments);
delete block.thoughtSignature;
delete block.namespace;
if (frame.thoughtSignature !== undefined) block.thoughtSignature = frame.thoughtSignature;
if (frame.namespace !== undefined) block.namespace = frame.namespace;
state.ended = true;
break;
}
}
}
if (!message) return undefined;
for (const [contentIndex, state] of states) {
if (state.kind !== "toolCall" || state.ended || state.json.length === 0) continue;
const block = message.content[contentIndex];
if (block?.type !== "toolCall") throw new Error("Unreachable tool-call frame state");
block.arguments = parseStreamingJson<ToolCall["arguments"]>(state.json);
}
return message;
}

View File

@@ -0,0 +1,39 @@
import type { Context, Tool } from "../types.ts";
type ToolNameNormalizer = (name: string) => string;
const identityToolName: ToolNameNormalizer = (name) => name;
/** Split current tools into prefix and transcript-loaded definitions. */
export function splitDeferredTools(
context: Context,
enabled: boolean,
normalizeName: ToolNameNormalizer = identityToolName,
): { immediate: Tool[]; deferred: Map<string, Tool> } {
const uniqueTools = new Map<string, Tool>();
for (const tool of context.tools ?? []) uniqueTools.set(normalizeName(tool.name), tool);
if (!enabled) return { immediate: [...uniqueTools.values()], deferred: new Map() };
const deferredNames = new Set<string>();
const usedNames = new Set<string>();
for (const message of context.messages) {
if (message.role === "assistant") {
for (const block of message.content) {
if (block.type === "toolCall") usedNames.add(normalizeName(block.name));
}
} else if (message.role === "toolResult") {
for (const name of message.addedToolNames ?? []) {
const normalizedName = normalizeName(name);
if (!usedNames.has(normalizedName)) deferredNames.add(normalizedName);
}
}
}
const immediate: Tool[] = [];
const deferred = new Map<string, Tool>();
for (const [name, tool] of uniqueTools) {
if (deferredNames.has(name)) deferred.set(name, tool);
else immediate.push(tool);
}
return { immediate, deferred };
}

View File

@@ -0,0 +1,45 @@
export interface DiagnosticErrorInfo {
name?: string;
message: string;
stack?: string;
code?: string | number;
}
export interface AssistantMessageDiagnostic {
type: string;
timestamp: number;
error?: DiagnosticErrorInfo;
details?: Record<string, unknown>;
}
export function formatThrownValue(value: unknown): string {
if (value instanceof Error) return value.message || value.name;
if (typeof value === "string") return value;
return String(value);
}
export function extractDiagnosticError(error: unknown): DiagnosticErrorInfo {
if (!(error instanceof Error)) return { name: "ThrownValue", message: formatThrownValue(error) };
const code = (error as Error & { code?: unknown }).code;
return {
name: error.name || undefined,
message: error.message || error.name,
stack: error.stack,
code: typeof code === "string" || typeof code === "number" ? code : undefined,
};
}
export function createAssistantMessageDiagnostic(
type: string,
error: unknown,
details?: Record<string, unknown>,
): AssistantMessageDiagnostic {
return { type, timestamp: Date.now(), error: extractDiagnosticError(error), details };
}
export function appendAssistantMessageDiagnostic<T extends { diagnostics?: AssistantMessageDiagnostic[] }>(
message: T,
diagnostic: AssistantMessageDiagnostic,
): void {
message.diagnostics = [...(message.diagnostics ?? []), diagnostic];
}

View File

@@ -0,0 +1,149 @@
// Shared normalization for provider HTTP error objects.
//
// Endpoints behind a proxy / gateway may return a non-2xx response whose body
// the provider SDK cannot fold into `error.message`. The SDK error object still
// carries the HTTP status and the raw/parsed body, but under SDK-specific field
// names. Provider catch blocks that read only `error.message` therefore drop
// the body and surface opaque messages like `"403 status code (no body)"` or
// collapse to `"Unknown: UnknownError"`.
//
// `normalizeProviderError` probes the known SDK field shapes (Mistral,
// `openai`, `@google/genai`, AWS Bedrock) and returns a struct each provider
// composes into its display string. The `messageCarriesBody` flag captures the
// Anthropic / `@google/genai` happy path where the SDK already folded the body
// into the message, so providers can preserve it without double-printing.
export const MAX_PROVIDER_ERROR_BODY_CHARS = 4000;
export interface NormalizedProviderError {
/** HTTP status code, when one could be extracted from the SDK error object. */
status?: number;
/** Raw HTTP body reason, already trimmed and truncated to the cap. */
body?: string;
/** `error.message`, or `safeJsonStringify(error)` for a non-`Error` throw. */
message: string;
/** True when `message` already contains the body (no separate body to add). */
messageCarriesBody: boolean;
}
type SdkErrorShape = Error & {
statusCode?: unknown;
status?: unknown;
body?: unknown;
error?: unknown;
$metadata?: { httpStatusCode?: unknown };
$response?: { statusCode?: unknown; body?: unknown };
};
export function normalizeProviderError(error: unknown): NormalizedProviderError {
if (!(error instanceof Error)) {
return { message: safeJsonStringify(error), messageCarriesBody: false };
}
const sdkError = error as SdkErrorShape;
const status = extractStatus(sdkError);
const body = extractBody(sdkError);
const messageCarriesBody = body === undefined || error.message.includes(body);
return {
status,
body,
message: error.message,
messageCarriesBody,
} satisfies NormalizedProviderError;
}
/**
* Probe the HTTP status, first numeric hit wins, in SDK-field order:
* `statusCode` (Mistral) → `status` (`openai`, `@google/genai`) →
* `$metadata.httpStatusCode` (Bedrock) → `$response.statusCode` (Bedrock).
*/
function extractStatus(error: SdkErrorShape): number | undefined {
if (typeof error.statusCode === "number") return error.statusCode;
if (typeof error.status === "number") return error.status;
if (typeof error.$metadata?.httpStatusCode === "number") return error.$metadata.httpStatusCode;
if (typeof error.$response?.statusCode === "number") return error.$response.statusCode;
return undefined;
}
/**
* Probe the raw body reason, first usable hit wins, in SDK-field order:
* `body` string (Mistral) → `error` parsed JSON body object (`openai` SDK's
* `this.error`) → `$response.body` (Bedrock). Empty objects and unread response
* streams are treated as no body so they do not surface as `"{}"` or serialized
* stream internals. The chosen body is truncated to the cap.
*/
function extractBody(error: SdkErrorShape): string | undefined {
const bodyText = pickBodyText(error);
if (bodyText === undefined) return undefined;
const trimmed = bodyText.trim();
if (trimmed.length === 0) return undefined;
return truncateErrorText(trimmed, MAX_PROVIDER_ERROR_BODY_CHARS);
}
function pickBodyText(error: SdkErrorShape): string | undefined {
if (typeof error.body === "string") return error.body;
if (isPlainNonEmptyObject(error.error)) return safeJsonStringify(error.error);
const responseBody = error.$response?.body;
if (typeof responseBody === "string") return responseBody;
if (isReadableStreamLike(responseBody)) return undefined;
if (isPlainNonEmptyObject(responseBody)) return safeJsonStringify(responseBody);
return undefined;
}
function isReadableStreamLike(value: unknown): boolean {
return typeof value === "object" && value !== null && "pipe" in value && typeof value.pipe === "function";
}
/**
* Only a PLAIN object counts as an HTTP body. SDK error fields can hold class
* instances instead of parsed bodies — AWS SDK v3's `$response.body` is an
* HTTP stream/response wrapper object, and stringifying one produced garbage
* like `{"_events":...}` as the "body", which then REPLACED `error.message`
* in the composed display string. `error.message` is where the SDK puts the
* real deserialized exception text ("Input is too long...", schema validation
* details, ...), so the one useful string was discarded for noise. A class
* instance yields no body, `messageCarriesBody` stays true, and the real
* message survives. Complements the `pipe` sniffing above: web
* ReadableStreams (pipeTo/pipeThrough, no `pipe`) and non-stream SDK wrapper
* classes fail the prototype check, while parsed JSON bodies (plain objects
* by construction) still pass.
*/
function isPlainNonEmptyObject(value: unknown): boolean {
if (typeof value !== "object" || value === null) return false;
const proto = Object.getPrototypeOf(value);
if (proto !== Object.prototype && proto !== null) return false;
return Object.keys(value).length > 0;
}
/**
* Compose a display string from a normalized error. When the message already
* carries the body (Anthropic / `@google/genai` happy path) or no body/status
* was extracted, the message is returned unchanged. Otherwise the status and
* body are surfaced, with an optional provider prefix.
*
* - no prefix: `"<status>: <body>"`
* - prefix: `"<prefix> (<status>): <body>"`
*/
export function formatProviderError(norm: NormalizedProviderError, prefix?: string): string {
if (norm.messageCarriesBody || norm.status === undefined || norm.body === undefined) {
return prefix !== undefined && norm.status !== undefined
? `${prefix} (${norm.status}): ${norm.message}`
: norm.message;
}
return prefix !== undefined ? `${prefix} (${norm.status}): ${norm.body}` : `${norm.status}: ${norm.body}`;
}
export function truncateErrorText(text: string, maxChars: number): string {
if (text.length <= maxChars) return text;
return `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`;
}
export function safeJsonStringify(value: unknown): string {
try {
const serialized = JSON.stringify(value);
return serialized === undefined ? String(value) : serialized;
} catch {
return String(value);
}
}

View File

@@ -0,0 +1,143 @@
import type { AssistantMessage, Context, ImageContent, Message, TextContent, Tool, Usage } from "../types.ts";
export interface ContextUsageEstimate {
/** Estimated total context tokens. */
tokens: number;
/** Tokens reported by the most recent applicable assistant usage block. */
usageTokens: number;
/** Estimated tokens after the most recent applicable assistant usage block. */
trailingTokens: number;
/** Index of the applicable message that provided usage, or null when none exists. */
lastUsageIndex: number | null;
}
const CHARS_PER_TOKEN = 4;
const ESTIMATED_IMAGE_CHARS = 4800;
export 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: string | Array<TextContent | ImageContent>): number {
if (typeof content === "string") return content.length;
let chars = 0;
for (const block of content) chars += block.type === "text" ? block.text.length : ESTIMATED_IMAGE_CHARS;
return chars;
}
export function estimateTextTokens(text: string): number {
return Math.ceil(text.length / CHARS_PER_TOKEN);
}
export function estimateTextAndImageContentTokens(content: string | Array<TextContent | ImageContent>): number {
return Math.ceil(estimateTextAndImageContentChars(content) / CHARS_PER_TOKEN);
}
export function estimateMessageTokens(message: Message): number {
let chars = 0;
if (message.role === "user") return estimateTextAndImageContentTokens(message.content);
if (message.role === "toolResult") return estimateTextAndImageContentTokens(message.content);
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 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;
// A newer prefix message was inserted after this response (for example, a
// compaction summary), so its usage cannot describe the current prefix.
const usageAppliesToPrefix = assistant.timestamp >= latestPrefixTimestamp;
if (
usageAppliesToPrefix &&
assistant.stopReason !== "aborted" &&
assistant.stopReason !== "error" &&
calculateContextTokens(assistant.usage) > 0
) {
usageInfo = { usage: assistant.usage, index: i };
}
}
latestPrefixTimestamp = Math.max(latestPrefixTimestamp, message.timestamp);
}
return usageInfo;
}
function estimateMessages(messages: readonly Message[]): ContextUsageEstimate {
const usageInfo = getLastAssistantUsageInfo(messages);
if (usageInfo) {
const usageTokens = calculateContextTokens(usageInfo.usage);
let trailingTokens = 0;
for (let i = usageInfo.index + 1; i < messages.length; i++) {
trailingTokens += estimateMessageTokens(messages[i]);
}
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: readonly Tool[] | undefined): number {
if (!tools || tools.length === 0) return 0;
return estimateTextTokens(safeJsonStringify(tools));
}
function isMessageArray(value: Context | readonly Message[]): value is readonly Message[] {
return Array.isArray(value);
}
export function estimateContextTokens(context: Context | readonly Message[]): ContextUsageEstimate {
if (isMessageArray(context)) return estimateMessages(context);
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 {
tokens: estimate.tokens + addedToolTokens,
usageTokens: estimate.usageTokens,
trailingTokens: estimate.trailingTokens + addedToolTokens,
lastUsageIndex: estimate.lastUsageIndex,
};
}
const prefixTokens =
(context.systemPrompt ? estimateTextTokens(context.systemPrompt) : 0) + estimateToolsTokens(context.tools);
return {
tokens: estimate.tokens + prefixTokens,
usageTokens: estimate.usageTokens,
trailingTokens: estimate.trailingTokens + prefixTokens,
lastUsageIndex: estimate.lastUsageIndex,
};
}

View File

@@ -0,0 +1,88 @@
import type { AssistantMessage, AssistantMessageEvent } from "../types.ts";
// Generic event stream class for async iteration
export class EventStream<T, R = T> implements AsyncIterable<T> {
private queue: T[] = [];
private waiting: ((value: IteratorResult<T>) => void)[] = [];
private done = false;
private finalResultPromise: Promise<R>;
private resolveFinalResult!: (result: R) => void;
private isComplete: (event: T) => boolean;
private extractResult: (event: T) => R;
constructor(isComplete: (event: T) => boolean, extractResult: (event: T) => R) {
this.isComplete = isComplete;
this.extractResult = extractResult;
this.finalResultPromise = new Promise((resolve) => {
this.resolveFinalResult = resolve;
});
}
push(event: T): void {
if (this.done) return;
if (this.isComplete(event)) {
this.done = true;
this.resolveFinalResult(this.extractResult(event));
}
// Deliver to waiting consumer or queue it
const waiter = this.waiting.shift();
if (waiter) {
waiter({ value: event, done: false });
} else {
this.queue.push(event);
}
}
end(result?: R): void {
this.done = true;
if (result !== undefined) {
this.resolveFinalResult(result);
}
// Notify all waiting consumers that we're done
while (this.waiting.length > 0) {
const waiter = this.waiting.shift()!;
waiter({ value: undefined as any, done: true });
}
}
async *[Symbol.asyncIterator](): AsyncIterator<T> {
while (true) {
if (this.queue.length > 0) {
yield this.queue.shift()!;
} else if (this.done) {
return;
} else {
const result = await new Promise<IteratorResult<T>>((resolve) => this.waiting.push(resolve));
if (result.done) return;
yield result.value;
}
}
}
result(): Promise<R> {
return this.finalResultPromise;
}
}
export class AssistantMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
constructor() {
super(
(event) => event.type === "done" || event.type === "error",
(event) => {
if (event.type === "done") {
return event.message;
} else if (event.type === "error") {
return event.error;
}
throw new Error("Unexpected event type for final result");
},
);
}
}
/** Factory function for AssistantMessageEventStream (for use in extensions) */
export function createAssistantMessageEventStream(): AssistantMessageEventStream {
return new AssistantMessageEventStream();
}

View File

@@ -0,0 +1,13 @@
/** Fast deterministic hash to shorten long strings */
export function shortHash(str: string): string {
let h1 = 0xdeadbeef;
let h2 = 0x41c6ce57;
for (let i = 0; i < str.length; i++) {
const ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return (h2 >>> 0).toString(36) + (h1 >>> 0).toString(36);
}

View File

@@ -0,0 +1,18 @@
import type { ProviderHeaders } from "../types.ts";
export function headersToRecord(headers: Headers): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of headers.entries()) {
result[key] = value;
}
return result;
}
export function providerHeadersToRecord(headers: ProviderHeaders | undefined): Record<string, string> | undefined {
if (!headers) return undefined;
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
if (value !== null) result[key] = value;
}
return Object.keys(result).length > 0 ? result : undefined;
}

View File

@@ -0,0 +1,124 @@
import { parse as partialParse } from "partial-json";
const VALID_JSON_ESCAPES = new Set(['"', "\\", "/", "b", "f", "n", "r", "t", "u"]);
function isControlCharacter(char: string): boolean {
const codePoint = char.codePointAt(0);
return codePoint !== undefined && codePoint >= 0x00 && codePoint <= 0x1f;
}
function escapeControlCharacter(char: string): string {
switch (char) {
case "\b":
return "\\b";
case "\f":
return "\\f";
case "\n":
return "\\n";
case "\r":
return "\\r";
case "\t":
return "\\t";
default:
return `\\u${char.codePointAt(0)?.toString(16).padStart(4, "0") ?? "0000"}`;
}
}
/**
* Repairs malformed JSON string literals by:
* - escaping raw control characters inside strings
* - doubling backslashes before invalid escape characters
*/
export function repairJson(json: string): string {
let repaired = "";
let inString = false;
for (let index = 0; index < json.length; index++) {
const char = json[index];
if (!inString) {
repaired += char;
if (char === '"') {
inString = true;
}
continue;
}
if (char === '"') {
repaired += char;
inString = false;
continue;
}
if (char === "\\") {
const nextChar = json[index + 1];
if (nextChar === undefined) {
repaired += "\\\\";
continue;
}
if (nextChar === "u") {
const unicodeDigits = json.slice(index + 2, index + 6);
if (/^[0-9a-fA-F]{4}$/.test(unicodeDigits)) {
repaired += `\\u${unicodeDigits}`;
index += 5;
continue;
}
}
if (VALID_JSON_ESCAPES.has(nextChar)) {
repaired += `\\${nextChar}`;
index += 1;
continue;
}
repaired += "\\\\";
continue;
}
repaired += isControlCharacter(char) ? escapeControlCharacter(char) : char;
}
return repaired;
}
export function parseJsonWithRepair<T>(json: string): T {
try {
return JSON.parse(json) as T;
} catch (error) {
const repairedJson = repairJson(json);
if (repairedJson !== json) {
return JSON.parse(repairedJson) as T;
}
throw error;
}
}
/**
* Attempts to parse potentially incomplete JSON during streaming.
* Always returns a valid object, even if the JSON is incomplete.
*
* @param partialJson The partial JSON string from streaming
* @returns Parsed object or empty object if parsing fails
*/
export function parseStreamingJson<T = Record<string, unknown>>(partialJson: string | undefined): T {
if (!partialJson || partialJson.trim() === "") {
return {} as T;
}
try {
return parseJsonWithRepair<T>(partialJson);
} catch {
try {
const result = partialParse(partialJson);
return (result ?? {}) as T;
} catch {
try {
const result = partialParse(repairJson(partialJson));
return (result ?? {}) as T;
} catch {
return {} as T;
}
}
}
}

View File

@@ -0,0 +1,161 @@
import type { ProviderEnv } from "../types.ts";
import { getProviderEnvValue } from "./provider-env.ts";
const DEFAULT_PROXY_PORTS: Record<string, number> = {
ftp: 21,
gopher: 70,
http: 80,
https: 443,
ws: 80,
wss: 443,
};
function getProxyEnv(key: string, env?: ProviderEnv): string {
const lowercaseKey = key.toLowerCase();
const uppercaseKey = key.toUpperCase();
return (
env?.[lowercaseKey] ||
env?.[uppercaseKey] ||
getProviderEnvValue(lowercaseKey) ||
getProviderEnvValue(uppercaseKey) ||
""
);
}
function parseProxyTargetUrl(targetUrl: string | URL): URL | undefined {
if (targetUrl instanceof URL) {
return targetUrl;
}
try {
return new URL(targetUrl);
} catch {
return undefined;
}
}
function stripBrackets(host: string): string {
return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
}
function parseNoProxyEntry(entry: string): { host: string; port: number } | undefined {
const trimmed = entry.trim().toLowerCase();
if (!trimmed) return undefined;
if (trimmed.startsWith("[")) {
const closingBracket = trimmed.indexOf("]");
if (closingBracket !== -1) {
const host = trimmed.slice(1, closingBracket);
const rest = trimmed.slice(closingBracket + 1);
if (rest.startsWith(":")) {
const port = Number.parseInt(rest.slice(1), 10);
return { host, port: Number.isNaN(port) ? 0 : port };
}
return { host, port: 0 };
}
}
if (trimmed.includes(":") && trimmed.split(":").length > 2) {
return { host: trimmed, port: 0 };
}
const colonIndex = trimmed.lastIndexOf(":");
if (colonIndex !== -1 && colonIndex === trimmed.indexOf(":")) {
const host = trimmed.slice(0, colonIndex);
const port = Number.parseInt(trimmed.slice(colonIndex + 1), 10);
if (!Number.isNaN(port)) {
return { host, port };
}
}
return { host: trimmed, port: 0 };
}
function shouldProxyHostname(hostname: string, port: number, env?: ProviderEnv): boolean {
const noProxy = getProxyEnv("no_proxy", env).toLowerCase();
if (!noProxy) {
return true;
}
if (noProxy === "*") {
return false;
}
const normalizedTargetHost = stripBrackets(hostname.toLowerCase());
return noProxy.split(/[,\s]/).every((entry) => {
const parsed = parseNoProxyEntry(entry);
if (!parsed) {
return true;
}
if (parsed.port && parsed.port !== port) {
return true;
}
let domain = stripBrackets(parsed.host);
if (domain.startsWith("*.")) {
domain = domain.slice(2);
} else if (domain.startsWith(".") || domain.startsWith("*")) {
domain = domain.slice(1);
}
if (!domain) {
return true;
}
if (normalizedTargetHost === domain) {
return false;
}
if (normalizedTargetHost.endsWith(`.${domain}`)) {
return false;
}
return true;
});
}
function getProxyForUrl(targetUrl: string | URL, env?: ProviderEnv): string {
const parsedUrl = parseProxyTargetUrl(targetUrl);
if (!parsedUrl?.protocol || !parsedUrl.host) {
return "";
}
const protocol = parsedUrl.protocol.split(":", 1)[0]!;
const hostname = stripBrackets(parsedUrl.hostname || parsedUrl.host.replace(/:\d*$/, ""));
const port = Number.parseInt(parsedUrl.port, 10) || DEFAULT_PROXY_PORTS[protocol] || 0;
if (!shouldProxyHostname(hostname, port, env)) {
return "";
}
let proxy = getProxyEnv(`${protocol}_proxy`, env) || getProxyEnv("all_proxy", env);
if (proxy && !proxy.includes("://")) {
proxy = `${protocol}://${proxy}`;
}
return proxy;
}
export const UNSUPPORTED_PROXY_PROTOCOL_MESSAGE =
"Unsupported proxy protocol. SOCKS and PAC proxy URLs are not supported; use an HTTP or HTTPS proxy URL.";
export function resolveHttpProxyUrlForTarget(targetUrl: string | URL, env?: ProviderEnv): URL | undefined {
const proxy = getProxyForUrl(targetUrl, env);
if (!proxy) {
return undefined;
}
let proxyUrl: URL;
try {
proxyUrl = new URL(proxy);
} catch (error) {
throw new Error(
`Invalid proxy URL ${JSON.stringify(proxy)}: ${error instanceof Error ? error.message : String(error)}`,
);
}
if (proxyUrl.protocol !== "http:" && proxyUrl.protocol !== "https:") {
throw new Error(`${UNSUPPORTED_PROXY_PROTOCOL_MESSAGE} Got ${proxyUrl.protocol}`);
}
return proxyUrl;
}

View File

@@ -0,0 +1,180 @@
import type { AssistantMessage } from "../types.ts";
/**
* Regex patterns to detect context overflow errors from different providers.
*
* These patterns match error messages returned when the input exceeds
* the model's context window.
*
* Provider-specific patterns (with example error messages):
*
* - Anthropic: "prompt is too long: 213462 tokens > 200000 maximum"
* - Anthropic: "413 {\"error\":{\"type\":\"request_too_large\",\"message\":\"Request exceeds the maximum size\"}}"
* - OpenAI: "Your input exceeds the context window of this model"
* - OpenAI/LiteLLM: "Requested token count exceeds the model's maximum context length of 131072 tokens"
* - OpenAI-compatible: "Input length (265330) exceeds model's maximum context length (262144)."
* - Google: "The input token count (1196265) exceeds the maximum number of tokens allowed (1048575)"
* - xAI: "This model's maximum prompt length is 131072 but the request contains 537812 tokens"
* - Groq: "Please reduce the length of the messages or completion"
* - OpenRouter: "This endpoint's maximum context length is X tokens. However, you requested about Y tokens"
* - OpenRouter/Poolside: "Input length X exceeds the maximum allowed input length of Y tokens."
* - Together AI: "The input (X tokens) is longer than the model's context length (Y tokens)."
* - llama.cpp: "the request exceeds the available context size, try increasing it"
* - LM Studio: "tokens to keep from the initial prompt is greater than the context length"
* - GitHub Copilot: "prompt token count of X exceeds the limit of Y"
* - MiniMax: "invalid params, context window exceeds limit"
* - Kimi For Coding: "Your request exceeded model token limit: X (requested: Y)"
* - DS4: "Prompt has X tokens, but the configured context size is Y tokens"
* - Cerebras: "400/413 status code (no body)"
* - Mistral: "Prompt contains X tokens ... too large for model with Y maximum context length"
* - z.ai: Does NOT error, accepts overflow silently - handled via usage.input > contextWindow
* - Xiaomi MiMo: Truncates input to fill contextWindow exactly, then returns finish_reason "length"
* with output=0 (no room left to generate). Detected via stopReason "length" + zero output +
* input filling the context window.
* - DashScope/Qwen: "Range of input length should be [1, X]" (HTTP 400 invalid_parameter_error)
* - Ollama: Some deployments truncate silently, others return errors like "prompt too long; exceeded max context length by X tokens"
*/
const OVERFLOW_PATTERNS = [
/prompt is too long/i, // Anthropic token overflow
/request_too_large/i, // Anthropic request byte-size overflow (HTTP 413)
/input is too long for requested model/i, // Amazon Bedrock
/exceeds the context window/i, // OpenAI (Completions & Responses API)
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i, // OpenAI-compatible proxies (LiteLLM)
/input token count.*exceeds the maximum/i, // Google (Gemini)
/maximum prompt length is \d+/i, // xAI (Grok)
/reduce the length of the messages/i, // Groq
/maximum context length is \d+ tokens/i, // OpenRouter (most backends)
/exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i, // OpenRouter/Poolside
/input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i, // Together AI
/exceeds the limit of \d+/i, // GitHub Copilot
/exceeds the available context size/i, // llama.cpp server
/greater than the context length/i, // LM Studio
/context window exceeds limit/i, // MiniMax
/exceeded model token limit/i, // Kimi For Coding
/too large for model with \d+ maximum context length/i, // Mistral
/prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i, // DS4 server
/model_context_window_exceeded/i, // z.ai non-standard finish_reason surfaced as error text
/prompt too long; exceeded (?:max )?context length/i, // Ollama explicit overflow error
/range of input length should be/i, // DashScope / Qwen Token Plan
/context[_ ]length[_ ]exceeded/i, // Generic fallback
/too many tokens/i, // Generic fallback
/token limit exceeded/i, // Generic fallback
/^4(?:00|13)\s*(?:status code)?\s*\(no body\)/i, // Cerebras: 400/413 with no body
];
/**
* Patterns that indicate non-overflow errors (e.g. rate limiting, server errors).
* Error messages matching any of these are excluded from overflow detection
* even if they also match an OVERFLOW_PATTERN.
*
* Example: Bedrock formats throttling errors as "ThrottlingException: Too many tokens,
* please wait before trying again." which would match the /too many tokens/i overflow
* pattern without this exclusion.
*/
const NON_OVERFLOW_PATTERNS = [
/^(Throttling error|Service unavailable):/i, // AWS Bedrock non-overflow errors (human-readable prefixes from formatBedrockError)
/rate limit/i, // Generic rate limiting
/too many requests/i, // Generic HTTP 429 style
];
/**
* Check if an assistant message represents a context overflow error.
*
* This handles three cases:
* 1. Error-based overflow: Most providers return stopReason "error" with a
* specific error message pattern.
* 2. Silent overflow: Some providers accept overflow requests and return
* successfully. For these, we check if usage.input exceeds the context window.
* 3. Length-stop overflow: Xiaomi MiMo can return "length" with zero output when
* the input fills the context window.
*
* ## Reliability by Provider
*
* **Reliable detection (returns error with detectable message):**
* - Anthropic: "prompt is too long: X tokens > Y maximum" or "request_too_large"
* - OpenAI (Completions & Responses): "exceeds the context window", "exceeds the model's maximum context length of X tokens", or "exceeds model's maximum context length (X)"
* - Google Gemini: "input token count exceeds the maximum"
* - xAI (Grok): "maximum prompt length is X but request contains Y"
* - Groq: "reduce the length of the messages"
* - Cerebras: 400/413 status code (no body)
* - Mistral: "Prompt contains X tokens ... too large for model with Y maximum context length"
* - OpenRouter (most backends): "maximum context length is X tokens"
* - OpenRouter/Poolside: "Input length X exceeds the maximum allowed input length of Y tokens."
* - Together AI: "The input (X tokens) is longer than the model's context length (Y tokens)."
* - llama.cpp: "exceeds the available context size"
* - LM Studio: "greater than the context length"
* - Kimi For Coding: "exceeded model token limit: X (requested: Y)"
* - DS4: "Prompt has X tokens, but the configured context size is Y tokens"
* - DashScope/Qwen: "Range of input length should be [1, X]"
*
* **Unreliable detection:**
* - z.ai: Sometimes accepts overflow silently (detectable via usage.input > contextWindow),
* sometimes returns rate limit errors. Pass contextWindow param to detect silent overflow.
* - Xiaomi MiMo: Truncates input to fit contextWindow then returns stopReason "length" with
* output=0. Pass contextWindow param to detect via the "filled context + zero output" signal.
* - Ollama: May truncate input silently for some setups, but may also return explicit
* overflow errors that match the patterns above. Silent truncation still cannot be
* detected here because we do not know the expected token count.
*
* ## Custom Providers
*
* If you've added custom models via settings.json, this function may not detect
* overflow errors from those providers. To add support:
*
* 1. Send a request that exceeds the model's context window
* 2. Check the errorMessage in the response
* 3. Create a regex pattern that matches the error
* 4. The pattern should be added to OVERFLOW_PATTERNS in this file, or
* check the errorMessage yourself before calling this function
*
* @param message - The assistant message to check
* @param contextWindow - Optional context window size for detecting silent overflow (z.ai)
* @returns true if the message indicates a context overflow
*/
export function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean {
// Case 1: Check error message patterns
if (message.stopReason === "error" && message.errorMessage) {
// Skip messages matching known non-overflow patterns (e.g. throttling / rate-limit)
const isNonOverflow = NON_OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!));
if (!isNonOverflow && OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!))) {
return true;
}
}
// Case 2: Silent overflow (z.ai style) - successful but usage exceeds context
if (contextWindow && message.stopReason === "stop") {
const inputTokens = message.usage.input + message.usage.cacheRead;
if (inputTokens > contextWindow) {
return true;
}
}
// Case 3: Length-stop overflow (Xiaomi MiMo style) - server truncates oversized input
// to fit the context window, leaving no room for output. Returns stopReason "length"
// with output=0 and input+cacheRead filling the context window.
if (contextWindow && message.stopReason === "length" && message.usage.output === 0) {
const inputTokens = message.usage.input + message.usage.cacheRead;
if (inputTokens >= contextWindow * 0.99) {
return true;
}
}
return false;
}
/**
* Check whether a length stop ended below the caller or model's intended output limit.
* Such responses may be caused by context pressure or provider-side truncation, so callers
* can make one bounded compact-and-retry attempt. `desiredMaxOutput` must be the original
* limit before any context-based clamping.
*/
export function isRecoverableLength(message: AssistantMessage, desiredMaxOutput: number): boolean {
return message.stopReason === "length" && desiredMaxOutput > 0 && message.usage.output < desiredMaxOutput;
}
/**
* Get the overflow patterns for testing purposes.
*/
export function getOverflowPatterns(): RegExp[] {
return [...OVERFLOW_PATTERNS];
}

View File

@@ -0,0 +1,19 @@
import type * as NodeOs from "node:os";
type ProcessWithOsBuiltinModule = typeof process & {
getBuiltinModule?: (id: "node:os") => typeof NodeOs;
};
function loadNodeOs(): typeof NodeOs | null {
if (typeof process === "undefined" || !(process.versions?.node || process.versions?.bun)) {
return null;
}
return (process as ProcessWithOsBuiltinModule).getBuiltinModule?.("node:os") ?? null;
}
// Keep runtime OS loading browser-safe. A top-level runtime import of node:os breaks browser/Vite builds.
const nodeOs = loadNodeOs();
export function getPiUserAgent(): string {
return nodeOs ? `pi (${nodeOs.platform()} ${nodeOs.release()}; ${nodeOs.arch()})` : "pi (browser)";
}

View File

@@ -0,0 +1,52 @@
import type { ProviderEnv } from "../types.ts";
let procEnvCache: Map<string, string> | null = null;
/**
* Fallback for https://github.com/oven-sh/bun/issues/27802.
* Bun compiled binaries can expose an empty process.env inside Linux sandboxes
* even though /proc/self/environ contains the environment.
*
* This intentionally duplicates restoreSandboxEnv() in
* packages/coding-agent/src/bun/restore-sandbox-env.ts. The ai package can be
* used directly, without going through that entrypoint, so provider env lookup
* must not depend on process.env having been patched.
*/
function getBunSandboxEnvValue(name: string): string | undefined {
if (typeof process === "undefined" || !process.versions?.bun || Object.keys(process.env).length > 0) {
return undefined;
}
if (procEnvCache === null) {
procEnvCache = new Map();
try {
const { readFileSync } = require("node:fs") as {
readFileSync(path: string, encoding: BufferEncoding): string;
};
const data = readFileSync("/proc/self/environ", "utf-8");
for (const entry of data.split("\0")) {
const idx = entry.indexOf("=");
if (idx > 0) {
procEnvCache.set(entry.slice(0, idx), entry.slice(idx + 1));
}
}
} catch {
// /proc/self/environ may not exist or may not be readable.
}
}
return procEnvCache.get(name);
}
/**
* Resolve a provider env value from scoped overrides, normal process.env, then
* the duplicated Bun sandbox fallback for direct pi-ai consumers.
*/
export function getProviderEnvValue(name: string, env?: ProviderEnv): string | undefined {
return (
env?.[name] ||
(typeof process !== "undefined" ? process.env[name] : undefined) ||
getBunSandboxEnvValue(name) ||
undefined
);
}

View File

@@ -0,0 +1,125 @@
const DEFAULT_MAX_RETRY_DELAY_MS = 60_000;
interface ProviderRetryOptions {
maxRetries?: number;
maxRetryDelayMs?: number;
signal?: AbortSignal;
}
interface ProviderError extends Error {
status: number | undefined;
headers: Headers | undefined;
}
function isProviderError(error: unknown): error is ProviderError {
if (!(error instanceof Error) || !("status" in error) || !("headers" in error)) return false;
return (
(error.status === undefined || typeof error.status === "number") &&
(error.headers === undefined || error.headers instanceof Headers)
);
}
/** Mirrors the pinned OpenAI/Anthropic SDK retry policy; review when either SDK is upgraded. */
function isRetryableProviderError(error: ProviderError): boolean {
const shouldRetry = error.headers?.get("x-should-retry");
if (shouldRetry === "true") return true;
if (shouldRetry === "false") return false;
if (error.status === undefined) return true;
return (
error.status === 408 ||
error.status === 409 ||
error.status === 429 ||
(typeof error.status === "number" && error.status >= 500)
);
}
function validateServerRetryDelayMs(
delayMs: number,
maxRetryDelayMs: number | undefined,
providerErrorMessage: string,
): number {
const maxDelayMs = maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS;
if (maxDelayMs > 0 && delayMs > maxDelayMs) {
throw new Error(
`Server requested ${Math.ceil(delayMs / 1000)}s retry delay (max: ${Math.ceil(maxDelayMs / 1000)}s). ${providerErrorMessage}`,
);
}
return delayMs;
}
function getRetryDelayMs(error: ProviderError, retryIndex: number, maxRetryDelayMs: number | undefined): number {
const retryAfterMs = error.headers?.get("retry-after-ms");
if (retryAfterMs) {
const value = Number.parseFloat(retryAfterMs);
if (!Number.isNaN(value)) return validateServerRetryDelayMs(value, maxRetryDelayMs, error.message);
}
const retryAfter = error.headers?.get("retry-after");
if (retryAfter) {
const seconds = Number.parseFloat(retryAfter);
const delayMs = Number.isNaN(seconds) ? Date.parse(retryAfter) - Date.now() : seconds * 1000;
return validateServerRetryDelayMs(delayMs, maxRetryDelayMs, error.message);
}
const exponentialDelay = Math.min(0.5 * 2 ** retryIndex, 8) * 1000;
return exponentialDelay * (1 - Math.random() * 0.25);
}
function createAbortError(): Error {
const error = new Error("Request aborted");
error.name = "AbortError";
return error;
}
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(createAbortError());
return;
}
const onAbort = () => {
clearTimeout(timeout);
reject(createAbortError());
};
const timeout = setTimeout(
() => {
signal?.removeEventListener("abort", onAbort);
resolve();
},
Math.max(0, ms),
);
signal?.addEventListener("abort", onAbort, { once: true });
});
}
/**
* Reproduce the retry behavior used by the OpenAI and Anthropic SDKs while making
* their backoff sleep interruptible. Their built-in retry timers ignore the
* request AbortSignal, so callers must invoke the SDK with `maxRetries: 0` and
* wrap the request with this helper. Provider-requested delays above
* `maxRetryDelayMs` fail immediately (60 seconds by default); set it to zero to
* disable the limit.
*/
export async function retryProviderRequest<T>(
request: () => Promise<T>,
options: ProviderRetryOptions = {},
): Promise<T> {
const maxRetries = options.maxRetries ?? 0;
let retriesRemaining = maxRetries;
for (;;) {
try {
// Each retry is a fresh SDK request, so X-Stainless-Retry-Count remains zero.
return await request();
} catch (error) {
if (options.signal?.aborted) throw createAbortError();
if (retriesRemaining <= 0 || !isProviderError(error) || !isRetryableProviderError(error)) throw error;
const retryIndex = maxRetries - retriesRemaining;
retriesRemaining--;
await abortableSleep(getRetryDelayMs(error, retryIndex, options.maxRetryDelayMs), options.signal);
}
}
}

View File

@@ -0,0 +1,229 @@
import type { AssistantMessage } from "../types.ts";
function buildProviderErrorPattern(patterns: readonly string[]): RegExp {
return new RegExp(patterns.join("|"), "i");
}
const NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN = buildProviderErrorPattern([
// OpenCode Go/free-tier limits returned as 429 JSON error types by OpenCode's
// Zen API. These are subscription/account limits, not transient throttles.
"GoUsageLimitError",
"FreeUsageLimitError",
// OpenCode Go subscription-limit text asks users to enable available-balance
// usage after rolling/weekly/monthly limits are reached.
"Monthly usage limit reached",
"available balance",
// Generic quota/budget/billing exhaustion. `insufficient_quota` is OpenAI's
// quota/billing error code; the other strings cover common gateway wording.
"insufficient_quota",
"out of budget",
"quota exceeded",
"billing",
]);
const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([
// Generic provider load, HTTP status, and server-side transient failures.
"overloaded",
"rate.?limit",
"too many requests",
"429",
"500",
"502",
"503",
"504",
"524",
"service.?unavailable",
"server.?error",
"internal.?error",
// Wrapper/provider text for transient upstream failures, including OpenRouter
// "Provider returned error" responses (#2264).
"provider.?returned.?error",
"exceeded request buffer limit while retrying upstream",
// Network, proxy, and fetch transport failures. This includes OpenAI Codex
// raw-fetch failures such as "upstream connect", "connection refused", and
// "reset before headers" (#733), plus OpenRouter connection drops (#3317).
"network.?error",
"connection.?error",
"connection.?refused",
"connection.?lost",
"other side closed",
"fetch failed",
"getaddrinfo",
"ENOTFOUND",
"EAI_AGAIN",
"upstream.?connect",
"reset before headers",
"socket hang up",
"socket connection was closed",
"timed? out",
"timeout",
"terminated",
// WebSocket transports can report close/error text instead of HTTP/fetch text.
"websocket.?closed",
"websocket.?error",
// Premature stream endings from SDKs and transports. Anthropic can throw
// "stream ended without ..." and "Anthropic stream ended before message_stop"
// (#4433); Bedrock/Smithy can throw an HTTP/2 no-response error (#3594).
"ended without",
"stream ended before message_stop",
"stream ended before a terminal response event",
"http2 request did not get a response",
// Provider-requested retry delay cap failures should flow through the outer
// retry policy so callers can surface/abort the backoff (#1123).
"retry delay",
// Explicit retry guidance emitted mid-stream by OpenAI Responses and Bedrock
// stream exceptions (#6019).
"you can retry your request",
"try your request again",
"please retry your request",
// gRPC based providers (e.g. NVIDIA NIM)
"ResourceExhausted",
]);
/**
* Retry policy: bounded attempts with exponential backoff (`baseDelayMs * 2^(attempt-1)`).
* Matches `settings.retry` (`enabled`, `maxRetries`, `baseDelayMs`) in coding-agent; kept
* here so the classifier and the policy-driven retry loop live together and stay reusable
* by the SDK and other callers.
*/
export interface RetryPolicy {
enabled: boolean;
/** Max retry attempts (0 = no retries). The initial call never counts as a retry. */
maxRetries: number;
/** Base delay in ms. Per-attempt delay is `baseDelayMs * 2^(attempt-1)` before jitter. */
baseDelayMs: number;
}
/** Optional callbacks emitted by {@link retryAssistantCall} around each retry. */
export interface RetryCallbacks {
/** Emitted before the backoff sleep of each retry attempt (1-indexed). */
onRetryScheduled?: (
attempt: number,
maxAttempts: number,
delayMs: number,
errorMessage: string,
) => void | Promise<void>;
/** Emitted after the backoff sleep, immediately before the retried call starts. */
onRetryAttemptStart?: () => void | Promise<void>;
/** Emitted once when the loop ends: success if a later call completed normally. */
onRetryFinished?: (success: boolean, attempt: number, finalError?: string) => void | Promise<void>;
}
class RetrySleepAbortError extends Error {
constructor() {
super("Aborted");
}
}
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new RetrySleepAbortError());
return;
}
const timeout = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timeout);
reject(new RetrySleepAbortError());
},
{ once: true },
);
});
}
/**
* Run a single assistant-producing call with bounded retry on transient errors.
*
* Behavior:
* - A successful response is returned immediately. Aborts are terminal and never
* retried, but reported as unsuccessful if they happen after a retry was scheduled.
* Aborts during the backoff sleep are normalized to an aborted `AssistantMessage`
* too, so callers do not need to care when cancellation happened.
* - A non-retryable error (per {@link isRetryableAssistantError}, including quota/
* billing exhaustion) is returned immediately so deterministic errors fail fast.
* - Otherwise retries up to `maxRetries` times with exponential backoff, emitting
* `onRetryScheduled` before each sleep, `onRetryAttemptStart` after each sleep before
* the retried call starts, and `onRetryFinished` once at the end (whether the loop
* ends in success, exhausted retries, or an aborted backoff).
*
* When `policy` is undefined or disabled, the first response is returned unchanged
* (equivalent to calling `produce()` directly).
*/
export async function retryAssistantCall(
produce: () => Promise<AssistantMessage>,
policy: RetryPolicy | undefined,
signal: AbortSignal | undefined,
callbacks?: RetryCallbacks,
): Promise<AssistantMessage> {
const maxAttempts = policy?.enabled ? policy.maxRetries : 0;
let attempt = 0;
let lastRetry: { attempt: number; errorMessage: string } | undefined;
for (;;) {
const response = await produce();
// Abort: terminal but not successful. Never retry an aborted message.
if (response.stopReason === "aborted") {
if (lastRetry) await callbacks?.onRetryFinished?.(false, lastRetry.attempt);
return response;
}
// Success: non-error, non-abort responses return as-is.
if (response.stopReason !== "error") {
if (lastRetry) await callbacks?.onRetryFinished?.(true, lastRetry.attempt);
return response;
}
// Non-retryable, or budget exhausted: return the final error message.
if (attempt >= maxAttempts || !isRetryableAssistantError(response)) {
if (lastRetry) await callbacks?.onRetryFinished?.(false, lastRetry.attempt, response.errorMessage);
return response;
}
attempt++;
lastRetry = { attempt, errorMessage: response.errorMessage || "Unknown error" };
const delayMs = policy!.baseDelayMs * 2 ** (attempt - 1);
await callbacks?.onRetryScheduled?.(attempt, maxAttempts, delayMs, lastRetry.errorMessage);
// Normalize aborts during retry backoff to the same AssistantMessage shape as
// provider stream aborts, so callers do not need to care when cancellation happened.
try {
await sleep(delayMs, signal);
} catch (error) {
await callbacks?.onRetryFinished?.(false, attempt, lastRetry.errorMessage);
if (error instanceof RetrySleepAbortError) {
const { errorMessage: _errorMessage, ...rest } = response;
return { ...rest, stopReason: "aborted" };
}
throw error;
}
await callbacks?.onRetryAttemptStart?.();
}
}
/**
* Classifies whether a failed assistant message looks like a transient provider
* or transport error, so callers can decide if the last assistant turn should be
* restarted.
*
* This does not implement retry policy. Callers should first handle context
* overflow separately, then apply their own retry budget, backoff, and reporting
* before restarting the assistant turn.
*/
export function isRetryableAssistantError(message: AssistantMessage): boolean {
if (message.stopReason !== "error" || !message.errorMessage) return false;
const errorMessage = message.errorMessage;
if (NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN.test(errorMessage)) return false;
return RETRYABLE_PROVIDER_ERROR_PATTERN.test(errorMessage);
}

View File

@@ -0,0 +1,25 @@
/**
* Removes unpaired Unicode surrogate characters from a string.
*
* Unpaired surrogates (high surrogates 0xD800-0xDBFF without matching low surrogates 0xDC00-0xDFFF,
* or vice versa) cause JSON serialization errors in many API providers.
*
* Valid emoji and other characters outside the Basic Multilingual Plane use properly paired
* surrogates and will NOT be affected by this function.
*
* @param text - The text to sanitize
* @returns The sanitized text with unpaired surrogates removed
*
* @example
* // Valid emoji (properly paired surrogates) are preserved
* sanitizeSurrogates("Hello 🙈 World") // => "Hello 🙈 World"
*
* // Unpaired high surrogate is removed
* const unpaired = String.fromCharCode(0xD83D); // high surrogate without low
* sanitizeSurrogates(`Text ${unpaired} here`) // => "Text here"
*/
export function sanitizeSurrogates(text: string): string {
// Replace unpaired high surrogates (0xD800-0xDBFF not followed by low surrogate)
// Replace unpaired low surrogates (0xDC00-0xDFFF not preceded by high surrogate)
return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "");
}

View File

@@ -0,0 +1,14 @@
export function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
signal.throwIfAborted();
const onAbort = () => {
clearTimeout(timeout);
reject(signal.reason);
};
const timeout = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, ms);
signal.addEventListener("abort", onAbort, { once: true });
});
}

View File

@@ -0,0 +1,12 @@
import type { ImageContent, TextContent, ThinkingContent, ToolCall } from "../types.ts";
type Content = TextContent | ImageContent | ThinkingContent | ToolCall;
/** Extract and join text from message content. */
export function contentText(content: string | readonly Content[], separator = "\n"): string {
if (typeof content === "string") return content;
return content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join(separator);
}

View File

@@ -0,0 +1,24 @@
import { type TUnsafe, Type } from "typebox";
/**
* Creates a string enum schema compatible with Google's API and other providers
* that don't support anyOf/const patterns.
*
* @example
* const OperationSchema = StringEnum(["add", "subtract", "multiply", "divide"], {
* description: "The operation to perform"
* });
*
* type Operation = Static<typeof OperationSchema>; // "add" | "subtract" | "multiply" | "divide"
*/
export function StringEnum<T extends readonly string[]>(
values: T,
options?: { description?: string; default?: T[number] },
): TUnsafe<T[number]> {
return Type.Unsafe<T[number]>({
type: "string",
enum: values as any,
...(options?.description && { description: options.description }),
...(options?.default && { default: options.default }),
});
}

View File

@@ -0,0 +1,45 @@
const MAX_UUID_V7_TIMESTAMP = 0xffffffffffff;
const MAX_SEQUENCE = (1n << 41n) - 1n;
let lastOrdinaryTimestamp = -1;
let sequence: bigint | undefined;
/** Generate a time-ordered UUIDv7. A supplied timestamp is preserved for follower ids. */
export function uuidv7(timestampMs?: number): string {
const requestedTimestamp = timestampMs ?? Date.now();
if (!Number.isInteger(requestedTimestamp) || requestedTimestamp < 0 || requestedTimestamp > MAX_UUID_V7_TIMESTAMP) {
throw new RangeError(`UUIDv7 timestamp must be an integer between 0 and ${MAX_UUID_V7_TIMESTAMP}`);
}
const effectiveTimestamp =
timestampMs === undefined ? Math.max(requestedTimestamp, lastOrdinaryTimestamp) : timestampMs;
if (timestampMs === undefined) lastOrdinaryTimestamp = effectiveTimestamp;
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
if (sequence === undefined) {
sequence =
(BigInt(bytes[1]) << 32n) |
(BigInt(bytes[2]) << 24n) |
(BigInt(bytes[3]) << 16n) |
(BigInt(bytes[4]) << 8n) |
BigInt(bytes[5]);
} else {
if (sequence === MAX_SEQUENCE) throw new RangeError("UUIDv7 generator sequence exhausted");
sequence++;
}
const timestamp = BigInt(effectiveTimestamp);
for (let index = 5; index >= 0; index--) {
bytes[index] = Number(timestamp >> BigInt((5 - index) * 8)) & 0xff;
}
bytes[6] = 0x70 | Number((sequence >> 37n) & 0x0fn);
bytes[7] = Number((sequence >> 29n) & 0xffn);
bytes[8] = 0x80 | Number((sequence >> 23n) & 0x3fn);
bytes[9] = Number((sequence >> 15n) & 0xffn);
bytes[10] = Number((sequence >> 7n) & 0xffn);
bytes[11] = Number((sequence & 0x7fn) << 1n) | (bytes[11] & 0x01);
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
}

View File

@@ -0,0 +1,350 @@
import { Compile } from "typebox/compile";
import type { TLocalizedValidationError } from "typebox/error";
import { Value } from "typebox/value";
import type { Tool, ToolCall } from "../types.ts";
const validatorCache = new WeakMap<object, ReturnType<typeof Compile>>();
const TYPEBOX_KIND = Symbol.for("TypeBox.Kind");
interface JsonSchemaObject {
type?: string | string[];
properties?: Record<string, JsonSchemaObject>;
required?: string[];
items?: JsonSchemaObject | JsonSchemaObject[];
additionalProperties?: boolean | JsonSchemaObject;
allOf?: JsonSchemaObject[];
anyOf?: JsonSchemaObject[];
oneOf?: JsonSchemaObject[];
}
function getSchemaTypes(schema: JsonSchemaObject): string[] {
if (typeof schema.type === "string") {
return [schema.type];
}
if (Array.isArray(schema.type)) {
return schema.type.filter((type): type is string => typeof type === "string");
}
return [];
}
function matchesJsonType(value: unknown, type: string): boolean {
switch (type) {
case "number":
return typeof value === "number";
case "integer":
return typeof value === "number" && Number.isInteger(value);
case "boolean":
return typeof value === "boolean";
case "string":
return typeof value === "string";
case "null":
return value === null;
case "array":
return Array.isArray(value);
case "object":
return typeof value === "object" && value !== null && !Array.isArray(value);
default:
return false;
}
}
function getSubSchemaValidator(schema: JsonSchemaObject): ReturnType<typeof Compile> | undefined {
try {
return getValidator(schema as Tool["parameters"]);
} catch {
return undefined;
}
}
function coercePrimitiveByType(value: unknown, type: string): unknown {
switch (type) {
case "number": {
if (value === null) {
return 0;
}
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
if (typeof value === "boolean") {
return value ? 1 : 0;
}
return value;
}
case "integer": {
if (value === null) {
return 0;
}
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
if (Number.isInteger(parsed)) {
return parsed;
}
}
if (typeof value === "boolean") {
return value ? 1 : 0;
}
return value;
}
case "boolean": {
if (value === null) {
return false;
}
if (typeof value === "string") {
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
}
if (typeof value === "number") {
if (value === 1) {
return true;
}
if (value === 0) {
return false;
}
}
return value;
}
case "string": {
if (value === null) {
return "";
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return value;
}
case "null": {
if (value === "" || value === 0 || value === false) {
return null;
}
return value;
}
default:
return value;
}
}
function applySchemaObjectCoercion(value: Record<string, unknown>, schema: JsonSchemaObject): void {
const properties = schema.properties;
const definedKeys = new Set<string>(properties ? Object.keys(properties) : []);
if (properties) {
for (const [key, propertySchema] of Object.entries(properties)) {
if (!(key in value)) {
continue;
}
value[key] = coerceWithJsonSchema(value[key], propertySchema);
}
}
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
for (const [key, propertyValue] of Object.entries(value)) {
if (definedKeys.has(key)) {
continue;
}
value[key] = coerceWithJsonSchema(propertyValue, schema.additionalProperties);
}
}
}
function applySchemaArrayCoercion(value: unknown[], schema: JsonSchemaObject): void {
if (Array.isArray(schema.items)) {
for (let index = 0; index < value.length; index++) {
const itemSchema = schema.items[index];
if (!itemSchema) {
continue;
}
value[index] = coerceWithJsonSchema(value[index], itemSchema);
}
return;
}
if (schema.items && typeof schema.items === "object") {
for (let index = 0; index < value.length; index++) {
value[index] = coerceWithJsonSchema(value[index], schema.items);
}
}
}
function coerceWithUnionSchema(value: unknown, schemas: JsonSchemaObject[]): unknown {
for (const schema of schemas) {
const validator = getSubSchemaValidator(schema);
if (validator?.Check(value)) {
return value;
}
}
for (const schema of schemas) {
const candidate = structuredClone(value);
const coerced = coerceWithJsonSchema(candidate, schema);
const validator = getSubSchemaValidator(schema);
if (validator?.Check(coerced)) {
return coerced;
}
}
return value;
}
function coerceWithJsonSchema(value: unknown, schema: JsonSchemaObject): unknown {
let nextValue = value;
if (Array.isArray(schema.allOf)) {
for (const nested of schema.allOf) {
nextValue = coerceWithJsonSchema(nextValue, nested);
}
}
if (Array.isArray(schema.anyOf)) {
nextValue = coerceWithUnionSchema(nextValue, schema.anyOf);
}
if (Array.isArray(schema.oneOf)) {
nextValue = coerceWithUnionSchema(nextValue, schema.oneOf);
}
const schemaTypes = getSchemaTypes(schema);
const matchesUnionMember =
schemaTypes.length > 1 && schemaTypes.some((schemaType) => matchesJsonType(nextValue, schemaType));
if (schemaTypes.length > 0 && !matchesUnionMember) {
for (const schemaType of schemaTypes) {
const candidate = coercePrimitiveByType(nextValue, schemaType);
if (candidate !== nextValue) {
nextValue = candidate;
break;
}
}
}
if (
schemaTypes.includes("object") &&
typeof nextValue === "object" &&
nextValue !== null &&
!Array.isArray(nextValue)
) {
applySchemaObjectCoercion(nextValue as Record<string, unknown>, schema);
}
if (schemaTypes.includes("array") && Array.isArray(nextValue)) {
applySchemaArrayCoercion(nextValue, schema);
}
return nextValue;
}
function normalizeOptionalNulls(value: unknown, schema: JsonSchemaObject): void {
if (Array.isArray(value)) {
if (Array.isArray(schema.items)) {
for (let index = 0; index < value.length; index++) {
const itemSchema = schema.items[index];
if (itemSchema) normalizeOptionalNulls(value[index], itemSchema);
}
} else if (schema.items) {
for (const item of value) normalizeOptionalNulls(item, schema.items);
}
return;
}
if (typeof value !== "object" || value === null || !schema.properties) return;
const object = value as Record<string, unknown>;
const required = new Set(schema.required ?? []);
for (const [key, propertySchema] of Object.entries(schema.properties)) {
if (!(key in object)) continue;
if (
object[key] === null &&
!required.has(key) &&
typeof (propertySchema as { $ref?: unknown }).$ref !== "string" &&
getSubSchemaValidator(propertySchema)?.Check(null) === false
) {
delete object[key];
} else {
normalizeOptionalNulls(object[key], propertySchema);
}
}
}
function getValidator(schema: Tool["parameters"]): ReturnType<typeof Compile> {
const key = schema as object;
const cached = validatorCache.get(key);
if (cached) {
return cached;
}
const validator = Compile(schema);
validatorCache.set(key, validator);
return validator;
}
function formatValidationPath(error: TLocalizedValidationError): string {
if (error.keyword === "required") {
const requiredProperties = (error.params as { requiredProperties?: string[] }).requiredProperties;
const requiredProperty = requiredProperties?.[0];
if (requiredProperty) {
const basePath = error.instancePath.replace(/^\//, "").replace(/\//g, ".");
return basePath ? `${basePath}.${requiredProperty}` : requiredProperty;
}
}
const path = error.instancePath.replace(/^\//, "").replace(/\//g, ".");
return path || "root";
}
/**
* Finds a tool by name and validates the tool call arguments against its TypeBox schema
* @param tools Array of tool definitions
* @param toolCall The tool call from the LLM
* @returns The validated arguments
* @throws Error if tool is not found or validation fails
*/
export function validateToolCall(tools: Tool[], toolCall: ToolCall): any {
const tool = tools.find((t) => t.name === toolCall.name);
if (!tool) {
throw new Error(`Tool "${toolCall.name}" not found`);
}
return validateToolArguments(tool, toolCall);
}
/**
* Validates tool call arguments against the tool's TypeBox schema
* @param tool The tool definition with TypeBox schema
* @param toolCall The tool call from the LLM
* @returns The validated (and potentially coerced) arguments
* @throws Error with formatted message if validation fails
*/
export function validateToolArguments(tool: Tool, toolCall: ToolCall): any {
const args = structuredClone(toolCall.arguments);
normalizeOptionalNulls(args, tool.parameters as JsonSchemaObject);
Value.Convert(tool.parameters, args);
const validator = getValidator(tool.parameters);
if (!Object.getOwnPropertySymbols(tool.parameters).includes(TYPEBOX_KIND)) {
const coerced = coerceWithJsonSchema(args, tool.parameters as JsonSchemaObject);
if (coerced !== args) {
if (typeof args === "object" && args !== null && typeof coerced === "object" && coerced !== null) {
for (const key of Object.keys(args)) {
delete args[key];
}
Object.assign(args, coerced);
} else {
return validator.Check(coerced) ? coerced : args;
}
}
}
if (validator.Check(args)) {
return args;
}
const errors =
validator
.Errors(args)
.map((error) => ` - ${formatValidationPath(error)}: ${error.message}`)
.join("\n") || "Unknown validation error";
const errorMessage = `Validation failed for tool "${toolCall.name}":\n${errors}\n\nReceived arguments:\n${JSON.stringify(toolCall.arguments, null, 2)}`;
throw new Error(errorMessage);
}