chore(vendor): import pi 1.3.0

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: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
2026-10-15 17:58:48 -04:00
parent 719370c8f7
commit 6081e3e325
1743 changed files with 205460 additions and 131705 deletions

View File

@@ -1,490 +0,0 @@
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

@@ -1,39 +0,0 @@
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

@@ -1,3 +1,5 @@
import type { JsonObject } from "../types.ts";
export interface DiagnosticErrorInfo {
name?: string;
message: string;
@@ -9,7 +11,7 @@ export interface AssistantMessageDiagnostic {
type: string;
timestamp: number;
error?: DiagnosticErrorInfo;
details?: Record<string, unknown>;
details?: JsonObject;
}
export function formatThrownValue(value: unknown): string {
@@ -32,7 +34,7 @@ export function extractDiagnosticError(error: unknown): DiagnosticErrorInfo {
export function createAssistantMessageDiagnostic(
type: string,
error: unknown,
details?: Record<string, unknown>,
details?: JsonObject,
): AssistantMessageDiagnostic {
return { type, timestamp: Date.now(), error: extractDiagnosticError(error), details };
}

View File

@@ -1,4 +1,5 @@
import type { AssistantMessage, Context, ImageContent, Message, TextContent, Tool, Usage } from "../types.ts";
import type { AssistantMessage, ImageContent, Message, TextContent, TranscriptContext, Usage } from "../types.ts";
import { getSystemMessageText } from "./text.ts";
export interface ContextUsageEstimate {
/** Estimated total context tokens. */
@@ -11,7 +12,7 @@ export interface ContextUsageEstimate {
lastUsageIndex: number | null;
}
const CHARS_PER_TOKEN = 4;
const CHARS_PER_TOKEN = 3.5;
const ESTIMATED_IMAGE_CHARS = 4800;
export function calculateContextTokens(usage: Usage): number {
@@ -45,6 +46,13 @@ export function estimateTextAndImageContentTokens(content: string | Array<TextCo
export function estimateMessageTokens(message: Message): number {
let chars = 0;
if (message.role === "system") {
return (
estimateTextTokens(getSystemMessageText(message)) +
estimateToolsTokens(message.toolsAdded) +
estimateToolsTokens(message.toolsRemoved)
);
}
if (message.role === "user") return estimateTextAndImageContentTokens(message.content);
if (message.role === "toolResult") return estimateTextAndImageContentTokens(message.content);
@@ -86,7 +94,8 @@ function getLastAssistantUsageInfo(messages: readonly Message[]): { usage: Usage
return usageInfo;
}
function estimateMessages(messages: readonly Message[]): ContextUsageEstimate {
export function estimateContextTokens(context: TranscriptContext | readonly Message[]): ContextUsageEstimate {
const messages = "messages" in context ? context.messages : context;
const usageInfo = getLastAssistantUsageInfo(messages);
if (usageInfo) {
const usageTokens = calculateContextTokens(usageInfo.usage);
@@ -102,42 +111,7 @@ function estimateMessages(messages: readonly Message[]): ContextUsageEstimate {
return { tokens, usageTokens: 0, trailingTokens: tokens, lastUsageIndex: null };
}
function estimateToolsTokens(tools: readonly Tool[] | undefined): number {
function estimateToolsTokens(tools: readonly unknown[] | 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

@@ -1,10 +1,32 @@
import type { AssistantMessage, AssistantMessageEvent } from "../types.ts";
class FifoQueue<T> {
private incoming: T[] = [];
private outgoing: T[] = [];
get length(): number {
return this.incoming.length + this.outgoing.length;
}
enqueue(value: T): void {
this.incoming.push(value);
}
dequeue(): T | undefined {
if (this.outgoing.length === 0) {
while (this.incoming.length > 0) {
this.outgoing.push(this.incoming.pop()!);
}
}
return this.outgoing.pop();
}
}
// 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 queue = new FifoQueue<T>();
private waiting = new FifoQueue<(value: IteratorResult<T>) => void>();
protected done = false;
private finalResultPromise: Promise<R>;
private resolveFinalResult!: (result: R) => void;
private isComplete: (event: T) => boolean;
@@ -27,11 +49,11 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
}
// Deliver to waiting consumer or queue it
const waiter = this.waiting.shift();
const waiter = this.waiting.dequeue();
if (waiter) {
waiter({ value: event, done: false });
} else {
this.queue.push(event);
this.queue.enqueue(event);
}
}
@@ -42,7 +64,7 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
}
// Notify all waiting consumers that we're done
while (this.waiting.length > 0) {
const waiter = this.waiting.shift()!;
const waiter = this.waiting.dequeue()!;
waiter({ value: undefined as any, done: true });
}
}
@@ -50,11 +72,11 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
async *[Symbol.asyncIterator](): AsyncIterator<T> {
while (true) {
if (this.queue.length > 0) {
yield this.queue.shift()!;
yield this.queue.dequeue()!;
} else if (this.done) {
return;
} else {
const result = await new Promise<IteratorResult<T>>((resolve) => this.waiting.push(resolve));
const result = await new Promise<IteratorResult<T>>((resolve) => this.waiting.enqueue(resolve));
if (result.done) return;
yield result.value;
}
@@ -66,7 +88,16 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
}
}
/**
* Event stream of one assistant response. It also times the response: the final message (`done` or `error` event, or
* the result passed to `end()`) gets `durationMs`, measured with a monotonic clock from the stream's creation, unless
* the message already has one or its `timestamp` predates the stream. A stream that forwards a response which started
* elsewhere, such as a deferred result fetched later, therefore leaves it untimed.
*/
export class AssistantMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
readonly #startedAt = Date.now();
readonly #startedAtMonotonic = performance.now();
constructor() {
super(
(event) => event.type === "done" || event.type === "error",
@@ -80,6 +111,22 @@ export class AssistantMessageEventStream extends EventStream<AssistantMessageEve
},
);
}
override push(event: AssistantMessageEvent): void {
if (event.type === "done") this.#time(event.message);
else if (event.type === "error") this.#time(event.error);
super.push(event);
}
override end(result?: AssistantMessage): void {
if (result !== undefined) this.#time(result);
super.end(result);
}
#time(message: AssistantMessage): void {
if (this.done || message.durationMs !== undefined || message.timestamp < this.#startedAt) return;
message.durationMs = Math.max(0, Math.round(performance.now() - this.#startedAtMonotonic));
}
}
/** Factory function for AssistantMessageEventStream (for use in extensions) */

View File

@@ -8,11 +8,16 @@ export function headersToRecord(headers: Headers): Record<string, string> {
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;
export function providerHeadersToRecord(
...headerSources: (ProviderHeaders | undefined)[]
): Record<string, string> | undefined {
const merged = new Map<string, [string, string]>();
for (const source of headerSources) {
for (const [name, value] of Object.entries(source ?? {})) {
const normalizedName = name.toLowerCase();
merged.delete(normalizedName);
if (value !== null) merged.set(normalizedName, [name, value]);
}
}
return Object.keys(result).length > 0 ? result : undefined;
return merged.size > 0 ? Object.fromEntries(merged.values()) : undefined;
}

View File

@@ -1,6 +1,7 @@
import { parse as partialParse } from "partial-json";
import type { ToolCall } from "../types.ts";
const VALID_JSON_ESCAPES = new Set(['"', "\\", "/", "b", "f", "n", "r", "t", "u"]);
const VALID_JSON_ESCAPES = new Set(['"', "\\", "/", "b", "f", "n", "r", "t"]);
function isControlCharacter(char: string): boolean {
const codePoint = char.codePointAt(0);
@@ -94,6 +95,30 @@ export function parseJsonWithRepair<T>(json: string): T {
}
}
/**
* Parses the model's complete arguments text of a finished tool call.
*
* Unlike {@link parseStreamingJson}, this never keeps a partially parsed prefix: once the call is
* finished, text that is not a valid JSON object means the model sent malformed arguments. In that
* case `arguments` is `{}` and `argumentsError` describes the problem, including the text the
* model sent, so tool validation can report it back to the model.
*/
export function parseToolCallArguments(json: string | undefined): Pick<ToolCall, "arguments" | "argumentsError"> {
if (!json?.trim()) return { arguments: {} };
try {
const parsed = parseJsonWithRepair<ToolCall["arguments"] | null>(json);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error("Tool arguments must be a JSON object");
}
return { arguments: parsed };
} catch (error) {
return {
arguments: {},
argumentsError: `${error instanceof Error ? error.message : String(error)}\n\nReceived arguments:\n${json}`,
};
}
}
/**
* Attempts to parse potentially incomplete JSON during streaming.
* Always returns a valid object, even if the JSON is incomplete.

View File

@@ -0,0 +1,81 @@
import type {
AnyModel,
Api,
AssistantImages,
ClassifierApi,
ClassifierContext,
ClassifierModel,
ClassifierResult,
ImageApi,
ImageModel,
Model,
ModelType,
ModelTypeMap,
} from "../types.ts";
import { ModelsError } from "./models-error.ts";
/** The type of a model. Models without `type` are chat models. */
export function getModelType(model: AnyModel): ModelType {
return model.type ?? "chat";
}
/** Runtime-checked model type narrowing, including legacy chat models without `type`. */
export function isModelType<TType extends ModelType>(model: AnyModel, type: TType): model is ModelTypeMap[TType] {
return getModelType(model) === type;
}
export function assertChatModel(model: AnyModel): asserts model is Model<Api> {
if (!isModelType(model, "chat")) {
throw new ModelsError("provider", `Model ${model.provider}/${model.id} is not a chat model`);
}
}
export function assertImageModel(model: AnyModel): asserts model is ImageModel<ImageApi> {
if (!isModelType(model, "image")) {
throw new ModelsError("provider", `Model ${model.provider}/${model.id} is not an image model`);
}
}
export function assertClassifierModel(model: AnyModel): asserts model is ClassifierModel<ClassifierApi> {
if (!isModelType(model, "classifier")) {
throw new ModelsError("provider", `Model ${model.provider}/${model.id} is not a classifier model`);
}
}
/** Rejects classifier images for models whose catalog entry does not accept image input. */
export function assertClassifierInputSupported(
model: ClassifierModel<ClassifierApi>,
context: ClassifierContext,
): void {
if (context.images?.length && !model.input.includes("image")) {
throw new ModelsError("provider", `Model ${model.provider}/${model.id} does not accept image input`);
}
}
export function imageErrorResult(model: ImageModel<ImageApi>, error: unknown, aborted = false): AssistantImages {
return {
api: model.api,
provider: model.provider,
model: model.id,
output: [],
stopReason: aborted ? "aborted" : "error",
errorMessage: error instanceof Error ? error.message : String(error),
timestamp: Date.now(),
};
}
export function classifierErrorResult(
model: ClassifierModel<ClassifierApi>,
error: unknown,
aborted = false,
): ClassifierResult {
return {
api: model.api,
provider: model.provider,
model: model.id,
answers: {},
stopReason: aborted ? "aborted" : "error",
errorMessage: error instanceof Error ? error.message : String(error),
timestamp: Date.now(),
};
}

View File

@@ -0,0 +1,21 @@
import { formatThrownValue } from "./diagnostics.ts";
export type ModelsErrorCode = "model_source" | "model_validation" | "provider" | "stream" | "auth" | "oauth";
export class ModelsError extends Error {
readonly code: ModelsErrorCode;
constructor(code: ModelsErrorCode, message: string, options?: { cause?: unknown }) {
super(withCauseDetail(message, options?.cause), options);
this.name = "ModelsError";
this.code = code;
}
}
/** Callers surface `error.message` only, so keep the underlying reason in it. */
function withCauseDetail(message: string, cause: unknown): string {
if (cause === undefined || cause === null) return message;
const detail = formatThrownValue(cause).trim();
if (!detail || message.includes(detail)) return message;
return `${message}: ${detail}`;
}

View File

@@ -0,0 +1,109 @@
const LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" aria-hidden="true"><path fill="#F09082" d="M165.29 165.29H517.36V400H400V282.65H165.29Z"/><path fill="#4D9ABF" d="M165.29 282.65H282.65V400H400V517.36H282.65V634.72H165.29Z"/><path fill="#F1BE58" d="M517.36 400H634.72V634.72H517.36Z"/></svg>`;
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function renderPage(options: { title: string; heading: string; message: string; details?: string }): string {
const title = escapeHtml(options.title);
const heading = escapeHtml(options.heading);
const message = escapeHtml(options.message);
const details = options.details ? escapeHtml(options.details) : undefined;
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${title}</title>
<style>
:root {
--text: #fafafa;
--text-dim: #a1a1aa;
--page-bg: #09090b;
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
* { box-sizing: border-box; }
html { color-scheme: dark; }
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: var(--page-bg);
color: var(--text);
font-family: var(--font-sans);
text-align: center;
}
main {
width: 100%;
max-width: 560px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.logo {
width: 72px;
height: 72px;
display: block;
margin-bottom: 24px;
}
h1 {
margin: 0 0 10px;
font-size: 28px;
line-height: 1.15;
font-weight: 650;
color: var(--text);
}
p {
margin: 0;
line-height: 1.7;
color: var(--text-dim);
font-size: 15px;
}
.details {
margin-top: 16px;
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-dim);
white-space: pre-wrap;
word-break: break-word;
}
</style>
</head>
<body>
<main>
<div class="logo">${LOGO_SVG}</div>
<h1>${heading}</h1>
<p>${message}</p>
${details ? `<div class="details">${details}</div>` : ""}
</main>
</body>
</html>`;
}
export function oauthSuccessHtml(message: string): string {
return renderPage({
title: "Authentication successful",
heading: "Authentication successful",
message,
});
}
export function oauthErrorHtml(message: string, details?: string): string {
return renderPage({
title: "Authentication failed",
heading: "Authentication failed",
message,
details,
});
}

View File

@@ -27,7 +27,7 @@ import type { AssistantMessage } from "../types.ts";
* - 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
* - z.ai: `{"code":"1261","message":"Prompt too long"}`, `{"code":"1261","message":"Prompt exceeds max length"}` (CN endpoint), or silent overflow 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.
@@ -35,7 +35,8 @@ import type { AssistantMessage } from "../types.ts";
* - 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
/prompt (?:is )?too long/i, // Anthropic and z.ai token overflow
/prompt exceeds max length/i, // z.ai CN endpoint 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)
@@ -59,9 +60,10 @@ const OVERFLOW_PATTERNS = [
/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
];
const CEREBRAS_BODYLESS_OVERFLOW_PATTERN = /^4(?:00|13)\s*(?:status code)?\s*\(no body\)/i;
/**
* Patterns that indicate non-overflow errors (e.g. rate limiting, server errors).
* Error messages matching any of these are excluded from overflow detection
@@ -106,6 +108,7 @@ const NON_OVERFLOW_PATTERNS = [
* - 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]"
* - z.ai: "Prompt too long"
*
* **Unreliable detection:**
* - z.ai: Sometimes accepts overflow silently (detectable via usage.input > contextWindow),
@@ -136,8 +139,13 @@ export function isContextOverflow(message: AssistantMessage, contextWindow?: num
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;
if (!isNonOverflow) {
if (OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!))) {
return true;
}
if (message.provider === "cerebras" && CEREBRAS_BODYLESS_OVERFLOW_PATTERN.test(message.errorMessage)) {
return true;
}
}
}

View File

@@ -4,6 +4,8 @@ interface ProviderRetryOptions {
maxRetries?: number;
maxRetryDelayMs?: number;
signal?: AbortSignal;
/** HTTP statuses that fail at once although the default policy would retry them. */
noRetryStatuses?: readonly number[];
}
interface ProviderError extends Error {
@@ -52,14 +54,14 @@ function getRetryDelayMs(error: ProviderError, retryIndex: number, maxRetryDelay
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);
if (Number.isFinite(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);
if (Number.isFinite(delayMs)) return validateServerRetryDelayMs(delayMs, maxRetryDelayMs, error.message);
}
const exponentialDelay = Math.min(0.5 * 2 ** retryIndex, 8) * 1000;
@@ -116,6 +118,7 @@ export async function retryProviderRequest<T>(
} catch (error) {
if (options.signal?.aborted) throw createAbortError();
if (retriesRemaining <= 0 || !isProviderError(error) || !isRetryableProviderError(error)) throw error;
if (error.status !== undefined && options.noRetryStatuses?.includes(error.status)) throw error;
const retryIndex = maxRetries - retriesRemaining;
retriesRemaining--;

View File

@@ -21,11 +21,19 @@ const NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN = buildProviderErrorPattern([
"out of budget",
"quota exceeded",
"billing",
// Sign in with ChatGPT: the subscription's shared usage limit, which resets
// after hours rather than seconds.
"subscription_sharing_usage_limit_exceeded",
]);
const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([
// Generic provider load, HTTP status, and server-side transient failures.
"overloaded",
"server_busy",
"servers are currently busy",
"currently experiencing high demand",
"model is at capacity",
"rate.?limit",
"too many requests",
"429",
@@ -33,15 +41,21 @@ const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([
"502",
"503",
"504",
"520",
"524",
"service.?unavailable",
// Also matches "The service is temporarily unavailable" (#10965).
"service.?(?:is.?)?(?:temporarily.?)?unavailable",
"server.?error",
"internal.?error",
// Wrapper/provider text for transient upstream failures, including OpenRouter
// "Provider returned error" responses (#2264).
"provider.?returned.?error",
// "Provider returned error" responses (#2264) and gateway wording such as
// "The upstream provider returned an error" (#10842).
"provider.?returned.?(?:an.?)?error",
"exceeded request buffer limit while retrying upstream",
// Gateway failure relaying the upstream response stream, sent as an OpenAI
// Responses `response.failed` event without an error code (#10920).
"unable to forward upstream",
// Network, proxy, and fetch transport failures. This includes OpenAI Codex
// raw-fetch failures such as "upstream connect", "connection refused", and
@@ -74,6 +88,9 @@ const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([
"stream ended before message_stop",
"stream ended before a terminal response event",
"http2 request did not get a response",
// Node ERR_HTTP2_STREAM_CANCEL: the HTTP/2 session died before the request was
// sent, e.g. after the Bedrock SDK's 5-minute session timeout (#10379).
"pending stream has been canceled",
// Provider-requested retry delay cap failures should flow through the outer
// retry policy so callers can surface/abort the backoff (#1123).
@@ -87,11 +104,17 @@ const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([
// gRPC based providers (e.g. NVIDIA NIM)
"ResourceExhausted",
// Sign in with ChatGPT: usage or user data temporarily unavailable. Usage
// failures can arrive mid-stream without an HTTP 503 in the message.
"subscription_sharing_usage_unavailable",
"subscription_sharing_user_unavailable",
]);
/**
* Retry policy: bounded attempts with exponential backoff (`baseDelayMs * 2^(attempt-1)`).
* Matches `settings.retry` (`enabled`, `maxRetries`, `baseDelayMs`) in coding-agent; kept
* `maxAgentDelayMs` caps each computed delay and defaults to 60 seconds.
* Matches `settings.retry` (`enabled`, `maxRetries`, `baseDelayMs`, `maxAgentDelayMs`) 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.
*/
@@ -101,6 +124,16 @@ export interface RetryPolicy {
maxRetries: number;
/** Base delay in ms. Per-attempt delay is `baseDelayMs * 2^(attempt-1)` before jitter. */
baseDelayMs: number;
/** Optional cap for agent-level retry delays in ms. Defaults to 60 seconds. */
maxAgentDelayMs?: number;
}
export const DEFAULT_MAX_AGENT_RETRY_DELAY_MS = 60_000;
export function retryDelayMs(policy: Pick<RetryPolicy, "baseDelayMs" | "maxAgentDelayMs">, attempt: number): number {
const delay = policy.baseDelayMs * 2 ** Math.max(0, attempt - 1);
const safeDelay = Number.isSafeInteger(delay) ? delay : Number.MAX_SAFE_INTEGER;
return Math.min(safeDelay, policy.maxAgentDelayMs ?? DEFAULT_MAX_AGENT_RETRY_DELAY_MS);
}
/** Optional callbacks emitted by {@link retryAssistantCall} around each retry. */
@@ -193,7 +226,7 @@ export async function retryAssistantCall(
attempt++;
lastRetry = { attempt, errorMessage: response.errorMessage || "Unknown error" };
const delayMs = policy!.baseDelayMs * 2 ** (attempt - 1);
const delayMs = retryDelayMs(policy!, attempt);
await callbacks?.onRetryScheduled?.(attempt, maxAttempts, delayMs, lastRetry.errorMessage);
// Normalize aborts during retry backoff to the same AssistantMessage shape as

View File

@@ -1,4 +1,4 @@
import type { ImageContent, TextContent, ThinkingContent, ToolCall } from "../types.ts";
import type { ImageContent, SystemMessage, TextContent, ThinkingContent, ToolCall } from "../types.ts";
type Content = TextContent | ImageContent | ThinkingContent | ToolCall;
@@ -10,3 +10,31 @@ export function contentText(content: string | readonly Content[], separator = "\
.map((block) => block.text)
.join(separator);
}
/** Render a system message as a complete prompt: its content followed by its sections. */
export function getSystemMessageText(message: SystemMessage): string {
const parts = [contentText(message.content)];
for (const text of Object.values(message.sections ?? {})) {
if (text !== null) parts.push(text);
}
return parts.filter((part) => part.length > 0).join("\n\n");
}
/**
* Render a later system message for APIs that accept system messages mid-conversation.
* Section changes are framed by name so the model can relate them to the leading prompt.
* This framing is request-time only and may change between versions.
*/
export function renderSystemMessageUpdate(message: SystemMessage): string {
const parts: string[] = [];
const text = contentText(message.content);
if (text.length > 0) parts.push(text);
for (const [name, value] of Object.entries(message.sections ?? {})) {
parts.push(
value === null
? `Removed system prompt section "${name}".`
: `Updated system prompt section "${name}":\n\n${value}`,
);
}
return parts.join("\n\n");
}

View File

@@ -0,0 +1,237 @@
import type { Context, Message, SystemMessage, Tool, ToolReference, TranscriptContext } from "../types.ts";
import { contentText, getSystemMessageText } from "./text.ts";
export type { TranscriptContext } from "../types.ts";
/**
* Build the leading system message for a prompt and tool set. Returns undefined when
* both are empty, so an empty transcript stays empty.
*/
export function createInitialSystemMessage(
systemPrompt: string | undefined,
tools: Tool[] | undefined,
): SystemMessage | undefined {
const hasSystemPrompt = systemPrompt !== undefined && systemPrompt.length > 0;
const hasTools = tools !== undefined && tools.length > 0;
if (!hasSystemPrompt && !hasTools) return undefined;
return {
role: "system",
content: systemPrompt ?? "",
...(hasTools ? { toolsAdded: tools } : {}),
timestamp: 0,
};
}
/**
* Fold `Context.systemPrompt` and `Context.tools` into a leading system message.
* This is the only entry point that produces a {@link TranscriptContext}; every
* provider-facing function expects the result.
*/
export function normalizeContext(context: Context): TranscriptContext {
const initialMessage = createInitialSystemMessage(context.systemPrompt, context.tools);
const messages = initialMessage ? [initialMessage, ...context.messages] : context.messages;
return { messages } as TranscriptContext;
}
/**
* Any message list. The replay helpers only read entries whose role is `"system"`, so
* agent transcripts that carry custom message roles can be passed without filtering.
*/
export type TranscriptMessages = readonly { role: string }[];
function isSystemMessage(message: { role: string }): message is SystemMessage {
return message.role === "system";
}
/** Return the leading system message, if the transcript starts with one. */
export function getInitialSystemMessage(messages: TranscriptMessages): SystemMessage | undefined {
const first = messages[0];
return first && isSystemMessage(first) ? first : undefined;
}
/** Drop the leading system message for APIs that carry the prompt outside the message list. */
export function withoutInitialSystemMessage(messages: Message[]): Message[] {
return getInitialSystemMessage(messages) ? messages.slice(1) : messages;
}
/** Resolve the tools available after applying every transcript delta in order. */
export function getCurrentTools(messages: TranscriptMessages): Tool[] {
const tools = new Map<string, Tool>();
for (const message of messages) {
if (!isSystemMessage(message)) continue;
for (const tool of message.toolsRemoved ?? []) tools.delete(tool.name);
for (const tool of message.toolsAdded ?? []) tools.set(tool.name, tool);
}
return [...tools.values()];
}
/**
* Replay every system message into one leading system message holding the current
* prompt and tools. Later `content` is appended to the base prompt, `sections` are
* patched by name, and tools are resolved with {@link getCurrentTools}.
*/
export function getCurrentSystemMessage(messages: TranscriptMessages): SystemMessage | undefined {
const content: string[] = [];
const sections = new Map<string, string>();
let timestamp: number | undefined;
for (const message of messages) {
if (!isSystemMessage(message)) continue;
timestamp ??= message.timestamp;
const text = contentText(message.content);
if (text.length > 0) content.push(text);
for (const [name, value] of Object.entries(message.sections ?? {})) {
if (value === null) sections.delete(name);
else sections.set(name, value);
}
}
const tools = getCurrentTools(messages);
if (timestamp === undefined && tools.length === 0) return undefined;
return {
role: "system",
content: content.join("\n\n"),
...(sections.size > 0 ? { sections: Object.fromEntries(sections) } : {}),
...(tools.length > 0 ? { toolsAdded: tools } : {}),
timestamp: timestamp ?? 0,
};
}
/** Render the current system prompt text after replaying every system message. */
export function getCurrentSystemPrompt(messages: TranscriptMessages): string {
const message = getCurrentSystemMessage(messages);
return message ? getSystemMessageText(message) : "";
}
/**
* Rebuild the transcript for APIs without mid-conversation system messages: the replayed
* system message leads, and every later system message is dropped.
*/
export function collapseSystemMessages(context: TranscriptContext): TranscriptContext {
const head = getCurrentSystemMessage(context.messages);
const messages = context.messages.filter((message) => message.role !== "system");
return { messages: head ? [head, ...messages] : messages } as TranscriptContext;
}
/** Keep later system messages in place when the model accepts them; otherwise collapse them. */
export function resolveTranscript(
context: TranscriptContext,
supportsMidConvoSystemMessages: boolean | undefined,
): TranscriptContext {
return supportsMidConvoSystemMessages ? context : collapseSystemMessages(context);
}
/** Strip executable and display-only fields from a tool before transcript comparison or persistence. */
export function toToolDeclaration(tool: Tool): Tool {
return {
name: tool.name,
description: tool.description,
parameters: JSON.parse(JSON.stringify(tool.parameters)) as Tool["parameters"],
...(tool.constrainedSampling === undefined ? {} : { constrainedSampling: tool.constrainedSampling }),
};
}
/**
* Whether two tools declare the same interface to the model.
*
* Both sides go through {@link toToolDeclaration} first: its JSON round-trip drops the
* typebox symbol keys and `undefined` fields that a structural comparison would see, and
* builds both objects with the same key order, so comparing the serialized declarations
* is exact. This avoids a deep-equal dependency in a browser-safe package.
*/
export function declarationsEqual(left: Tool, right: Tool): boolean {
return JSON.stringify(toToolDeclaration(left)) === JSON.stringify(toToolDeclaration(right));
}
export interface ToolStateChanges {
toolsAdded: Tool[];
toolsRemoved: ToolReference[];
}
/** Compare two complete tool states. A changed definition is a removal followed by an addition. */
export function getToolStateChanges(previous: readonly Tool[], current: readonly Tool[]): ToolStateChanges {
const previousTools = new Map(previous.map((tool) => [tool.name, tool]));
const currentTools = new Map(current.map((tool) => [tool.name, tool]));
return {
toolsAdded: current
.filter((tool) => {
const previousTool = previousTools.get(tool.name);
return previousTool === undefined || !declarationsEqual(previousTool, tool);
})
.map(toToolDeclaration),
toolsRemoved: previous
.filter((tool) => {
const currentTool = currentTools.get(tool.name);
return currentTool === undefined || !declarationsEqual(tool, currentTool);
})
.map((tool) => ({ name: tool.name })),
};
}
/** Every definition referenced by transcript tool state, in first-declaration order. */
export function getDeclaredTools(messages: TranscriptMessages): Tool[] {
const definitions = new Map<string, Tool>();
for (const message of messages) {
if (!isSystemMessage(message)) continue;
for (const tool of message.toolsAdded ?? []) definitions.set(tool.name, tool);
}
return [...definitions.values()];
}
/**
* Whether a tool name was declared twice with different definitions. A transport that can
* only reference previously declared tools by name cannot replay such a history.
*
* @deprecated No built-in transport needs this anymore: Anthropic expresses redefinitions with
* inline `tool_definition` blocks. Kept for API compatibility and will be removed in a future release.
*/
export function hasToolRedefinitions(messages: TranscriptMessages): boolean {
const declared = new Map<string, Tool>();
for (const message of messages) {
if (!isSystemMessage(message)) continue;
for (const tool of message.toolsAdded ?? []) {
const previous = declared.get(tool.name);
if (previous !== undefined && !declarationsEqual(previous, tool)) return true;
declared.set(tool.name, tool);
}
}
return false;
}
/** Whether tool history contains a removal or same-name redeclaration that an addition-only transport cannot replay. */
export function hasNonAdditiveToolChanges(messages: TranscriptMessages): boolean {
const declared = new Set<string>();
for (const message of messages) {
if (!isSystemMessage(message)) continue;
if ((message.toolsRemoved?.length ?? 0) > 0) return true;
for (const tool of message.toolsAdded ?? []) {
if (declared.has(tool.name)) return true;
declared.add(tool.name);
}
}
return false;
}
export interface TranscriptTools {
/** Tools sent in the top-level request field. */
requestTools: Tool[];
/**
* Whether later system messages carry their own `toolsAdded` as in-place additions.
* When false, `requestTools` already holds the complete current tool set.
*/
anchorsAdditions: boolean;
}
/**
* Split tool declarations between the top-level request field and in-place additions.
* Transports that can anchor additions at a system message keep the initial tools at the
* top and load later ones where they appear; that only works when no tool was removed or
* redeclared, so everything else sends the current tool list.
*/
export function resolveTranscriptTools(messages: TranscriptMessages, supportsToolAdditions: boolean): TranscriptTools {
const anchorsAdditions = supportsToolAdditions && !hasNonAdditiveToolChanges(messages);
return {
requestTools: anchorsAdditions
? (getInitialSystemMessage(messages)?.toolsAdded ?? [])
: getCurrentTools(messages),
anchorsAdditions,
};
}

View File

@@ -315,6 +315,9 @@ export function validateToolCall(tools: Tool[], toolCall: ToolCall): any {
* @throws Error with formatted message if validation fails
*/
export function validateToolArguments(tool: Tool, toolCall: ToolCall): any {
if (toolCall.argumentsError) {
throw new Error(`Invalid arguments for tool "${toolCall.name}": ${toolCall.argumentsError}`);
}
const args = structuredClone(toolCall.arguments);
normalizeOptionalNulls(args, tool.parameters as JsonSchemaObject);
Value.Convert(tool.parameters, args);