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:
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
52
third_party/pi/packages/ai/src/utils/estimate.ts
vendored
52
third_party/pi/packages/ai/src/utils/estimate.ts
vendored
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) */
|
||||
|
||||
17
third_party/pi/packages/ai/src/utils/headers.ts
vendored
17
third_party/pi/packages/ai/src/utils/headers.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
81
third_party/pi/packages/ai/src/utils/model-operations.ts
vendored
Normal file
81
third_party/pi/packages/ai/src/utils/model-operations.ts
vendored
Normal 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(),
|
||||
};
|
||||
}
|
||||
21
third_party/pi/packages/ai/src/utils/models-error.ts
vendored
Normal file
21
third_party/pi/packages/ai/src/utils/models-error.ts
vendored
Normal 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}`;
|
||||
}
|
||||
109
third_party/pi/packages/ai/src/utils/oauth-page.ts
vendored
Normal file
109
third_party/pi/packages/ai/src/utils/oauth-page.ts
vendored
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
18
third_party/pi/packages/ai/src/utils/overflow.ts
vendored
18
third_party/pi/packages/ai/src/utils/overflow.ts
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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--;
|
||||
|
||||
43
third_party/pi/packages/ai/src/utils/retry.ts
vendored
43
third_party/pi/packages/ai/src/utils/retry.ts
vendored
@@ -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
|
||||
|
||||
30
third_party/pi/packages/ai/src/utils/text.ts
vendored
30
third_party/pi/packages/ai/src/utils/text.ts
vendored
@@ -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");
|
||||
}
|
||||
|
||||
237
third_party/pi/packages/ai/src/utils/transcript.ts
vendored
Normal file
237
third_party/pi/packages/ai/src/utils/transcript.ts
vendored
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user