chore(vendor): import pi 0.85.1
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
41
third_party/pi/packages/ai/src/utils/abort-signals.ts
vendored
Normal file
41
third_party/pi/packages/ai/src/utils/abort-signals.ts
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
export interface CombinedAbortSignal {
|
||||
signal?: AbortSignal;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
export function combineAbortSignals(signals: readonly (AbortSignal | undefined)[]): CombinedAbortSignal {
|
||||
const activeSignals = signals.filter((signal): signal is AbortSignal => signal !== undefined);
|
||||
if (activeSignals.length === 0) {
|
||||
return { cleanup: () => {} };
|
||||
}
|
||||
if (activeSignals.length === 1) {
|
||||
return { signal: activeSignals[0], cleanup: () => {} };
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const listeners: Array<{ signal: AbortSignal; listener: () => void }> = [];
|
||||
const abort = (signal: AbortSignal) => {
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort(signal.reason);
|
||||
}
|
||||
};
|
||||
|
||||
for (const signal of activeSignals) {
|
||||
if (signal.aborted) {
|
||||
abort(signal);
|
||||
break;
|
||||
}
|
||||
const listener = () => abort(signal);
|
||||
signal.addEventListener("abort", listener, { once: true });
|
||||
listeners.push({ signal, listener });
|
||||
}
|
||||
|
||||
return {
|
||||
signal: controller.signal,
|
||||
cleanup: () => {
|
||||
for (const { signal, listener } of listeners) {
|
||||
signal.removeEventListener("abort", listener);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
50
third_party/pi/packages/ai/src/utils/abort.ts
vendored
Normal file
50
third_party/pi/packages/ai/src/utils/abort.ts
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
function abortReason(signal: AbortSignal): unknown {
|
||||
if (signal.reason !== undefined) return signal.reason;
|
||||
const error = new Error("The operation was aborted");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
|
||||
/** Create an operation-local signal for public APIs whose signal is optional. */
|
||||
export function operationSignal(signal?: AbortSignal): AbortSignal {
|
||||
return signal ?? new AbortController().signal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop waiting for an operation when its signal aborts while continuing to
|
||||
* observe the abandoned promise so a later rejection is always handled.
|
||||
*/
|
||||
export function raceWithAbortSignal<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
if (signal.aborted) {
|
||||
void operation.catch(() => {});
|
||||
return Promise.reject(abortReason(signal));
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
||||
const onAbort = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(abortReason(signal));
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
void operation.then(
|
||||
(value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
if (signal.aborted) onAbort();
|
||||
});
|
||||
}
|
||||
490
third_party/pi/packages/ai/src/utils/assistant-message-frame.ts
vendored
Normal file
490
third_party/pi/packages/ai/src/utils/assistant-message-frame.ts
vendored
Normal file
@@ -0,0 +1,490 @@
|
||||
import type { AssistantMessage, AssistantMessageEvent, TextContent, ThinkingContent, ToolCall } from "../types.ts";
|
||||
import { parseStreamingJson } from "./json-parse.ts";
|
||||
|
||||
/**
|
||||
* Compact, replayable assistant-message progress. Terminal settlement is
|
||||
* intentionally excluded and must be persisted separately.
|
||||
*/
|
||||
export type AssistantMessageFrame =
|
||||
| { type: "start"; partial: AssistantMessage }
|
||||
| { type: "text_start"; contentIndex: number; content: TextContent }
|
||||
| { type: "text_delta"; contentIndex: number; delta: string }
|
||||
| { type: "text_end"; contentIndex: number; content: string; textSignature?: string }
|
||||
| { type: "thinking_start"; contentIndex: number; content: ThinkingContent }
|
||||
| { type: "thinking_delta"; contentIndex: number; delta: string }
|
||||
| {
|
||||
type: "thinking_end";
|
||||
contentIndex: number;
|
||||
content: string;
|
||||
thinkingSignature?: string;
|
||||
redacted?: boolean;
|
||||
}
|
||||
| { type: "toolcall_start"; contentIndex: number; toolCall: ToolCall }
|
||||
| { type: "toolcall_checkpoint"; contentIndex: number; json: string }
|
||||
| { type: "toolcall_delta"; contentIndex: number; delta: string }
|
||||
| {
|
||||
type: "toolcall_end";
|
||||
contentIndex: number;
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: ToolCall["arguments"];
|
||||
thoughtSignature?: string;
|
||||
namespace?: string;
|
||||
};
|
||||
|
||||
type EncoderBlockState =
|
||||
| { kind: "text" | "thinking"; coveredChars: number; deltaChars: number }
|
||||
| {
|
||||
kind: "toolCall";
|
||||
caughtUp: boolean;
|
||||
catchupJson: string;
|
||||
snapshotArguments: string;
|
||||
};
|
||||
|
||||
type ReducerBlockState =
|
||||
| { kind: "text"; ended: boolean }
|
||||
| { kind: "thinking"; ended: boolean }
|
||||
| { kind: "toolCall"; ended: boolean; json: string };
|
||||
|
||||
function cloneTextContent(content: TextContent): TextContent {
|
||||
return {
|
||||
type: "text",
|
||||
text: content.text,
|
||||
...(content.textSignature === undefined ? {} : { textSignature: content.textSignature }),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneThinkingContent(content: ThinkingContent): ThinkingContent {
|
||||
return {
|
||||
type: "thinking",
|
||||
thinking: content.thinking,
|
||||
...(content.thinkingSignature === undefined ? {} : { thinkingSignature: content.thinkingSignature }),
|
||||
...(content.redacted === undefined ? {} : { redacted: content.redacted }),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneToolCall(toolCall: ToolCall): ToolCall {
|
||||
return {
|
||||
type: "toolCall",
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
arguments: structuredClone(toolCall.arguments),
|
||||
...(toolCall.thoughtSignature === undefined ? {} : { thoughtSignature: toolCall.thoughtSignature }),
|
||||
...(toolCall.namespace === undefined ? {} : { namespace: toolCall.namespace }),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneStartMessage(message: AssistantMessage): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: message.api,
|
||||
provider: message.provider,
|
||||
model: message.model,
|
||||
...(message.responseModel === undefined ? {} : { responseModel: message.responseModel }),
|
||||
...(message.responseId === undefined ? {} : { responseId: message.responseId }),
|
||||
...(message.providerThinkingLevel === undefined ? {} : { providerThinkingLevel: message.providerThinkingLevel }),
|
||||
...(message.diagnostics === undefined ? {} : { diagnostics: structuredClone(message.diagnostics) }),
|
||||
usage: structuredClone(message.usage),
|
||||
stopReason: "pending",
|
||||
timestamp: message.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function assertContentIndex(contentIndex: number): void {
|
||||
if (!Number.isSafeInteger(contentIndex) || contentIndex < 0) {
|
||||
throw new Error(`Invalid assistant message frame contentIndex: ${contentIndex}`);
|
||||
}
|
||||
}
|
||||
|
||||
function eventBlock(event: Exclude<AssistantMessageEvent, { type: "start" | "done" | "error" }>) {
|
||||
assertContentIndex(event.contentIndex);
|
||||
const block = event.partial.content[event.contentIndex];
|
||||
if (!block) {
|
||||
throw new Error(`${event.type} event has no content block at index ${event.contentIndex}`);
|
||||
}
|
||||
return block;
|
||||
}
|
||||
|
||||
function serializedArguments(argumentsValue: ToolCall["arguments"]): string {
|
||||
const serialized = JSON.stringify(argumentsValue);
|
||||
if (serialized === undefined) throw new Error("Tool-call arguments are not JSON-serializable");
|
||||
return serialized;
|
||||
}
|
||||
|
||||
const EMPTY_PARSED_TOOL_ARGUMENTS = serializedArguments(parseStreamingJson<ToolCall["arguments"]>(""));
|
||||
|
||||
function isJsonPrefix(snapshot: unknown, current: unknown): boolean {
|
||||
if (typeof snapshot === "string") return typeof current === "string" && current.startsWith(snapshot);
|
||||
if (Array.isArray(snapshot)) {
|
||||
return (
|
||||
Array.isArray(current) &&
|
||||
snapshot.length <= current.length &&
|
||||
snapshot.every((value, index) => isJsonPrefix(value, current[index]))
|
||||
);
|
||||
}
|
||||
if (typeof snapshot !== "object" || snapshot === null) return Object.is(snapshot, current);
|
||||
if (typeof current !== "object" || current === null || Array.isArray(current)) return false;
|
||||
const currentRecord = current as Record<string, unknown>;
|
||||
return Object.entries(snapshot).every(
|
||||
([key, value]) => Object.hasOwn(currentRecord, key) && isJsonPrefix(value, currentRecord[key]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes one assistant stream. `partial` remains a shared live accumulator;
|
||||
* the encoder uses per-block offsets to avoid replaying deltas already visible
|
||||
* when an older queued event is consumed.
|
||||
*/
|
||||
export class AssistantMessageFrameEncoder {
|
||||
private started = false;
|
||||
private terminal = false;
|
||||
private readonly blocks = new Map<number, EncoderBlockState>();
|
||||
|
||||
encode(event: AssistantMessageEvent): AssistantMessageFrame | undefined {
|
||||
if (this.terminal) throw new Error(`Assistant message event ${event.type} follows a terminal event`);
|
||||
|
||||
switch (event.type) {
|
||||
case "start":
|
||||
if (this.started) throw new Error("Assistant message stream contains more than one start event");
|
||||
this.started = true;
|
||||
return { type: "start", partial: cloneStartMessage(event.partial) };
|
||||
case "done":
|
||||
if (!this.started) throw new Error("Assistant message done event appears before start");
|
||||
this.terminal = true;
|
||||
return undefined;
|
||||
case "error":
|
||||
this.terminal = true;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!this.started) throw new Error(`Assistant message ${event.type} event appears before start`);
|
||||
|
||||
switch (event.type) {
|
||||
case "text_start": {
|
||||
const content = eventBlock(event);
|
||||
if (content.type !== "text") {
|
||||
throw new Error(`text_start event points to ${content.type} block at index ${event.contentIndex}`);
|
||||
}
|
||||
this.startBlock(event.contentIndex, {
|
||||
kind: "text",
|
||||
coveredChars: content.text.length,
|
||||
deltaChars: 0,
|
||||
});
|
||||
return { type: "text_start", contentIndex: event.contentIndex, content: cloneTextContent(content) };
|
||||
}
|
||||
case "text_delta":
|
||||
return this.encodeTextDelta(event.contentIndex, event.delta, "text");
|
||||
case "text_end": {
|
||||
const content = eventBlock(event);
|
||||
if (content.type !== "text") {
|
||||
throw new Error(`text_end event points to ${content.type} block at index ${event.contentIndex}`);
|
||||
}
|
||||
this.endBlock(event.contentIndex, "text");
|
||||
return {
|
||||
type: "text_end",
|
||||
contentIndex: event.contentIndex,
|
||||
content: event.content,
|
||||
...(content.textSignature === undefined ? {} : { textSignature: content.textSignature }),
|
||||
};
|
||||
}
|
||||
case "thinking_start": {
|
||||
const content = eventBlock(event);
|
||||
if (content.type !== "thinking") {
|
||||
throw new Error(`thinking_start event points to ${content.type} block at index ${event.contentIndex}`);
|
||||
}
|
||||
this.startBlock(event.contentIndex, {
|
||||
kind: "thinking",
|
||||
coveredChars: content.thinking.length,
|
||||
deltaChars: 0,
|
||||
});
|
||||
return {
|
||||
type: "thinking_start",
|
||||
contentIndex: event.contentIndex,
|
||||
content: cloneThinkingContent(content),
|
||||
};
|
||||
}
|
||||
case "thinking_delta":
|
||||
return this.encodeTextDelta(event.contentIndex, event.delta, "thinking");
|
||||
case "thinking_end": {
|
||||
const content = eventBlock(event);
|
||||
if (content.type !== "thinking") {
|
||||
throw new Error(`thinking_end event points to ${content.type} block at index ${event.contentIndex}`);
|
||||
}
|
||||
this.endBlock(event.contentIndex, "thinking");
|
||||
return {
|
||||
type: "thinking_end",
|
||||
contentIndex: event.contentIndex,
|
||||
content: event.content,
|
||||
...(content.thinkingSignature === undefined ? {} : { thinkingSignature: content.thinkingSignature }),
|
||||
...(content.redacted === undefined ? {} : { redacted: content.redacted }),
|
||||
};
|
||||
}
|
||||
case "toolcall_start": {
|
||||
const content = eventBlock(event);
|
||||
if (content.type !== "toolCall") {
|
||||
throw new Error(`toolcall_start event points to ${content.type} block at index ${event.contentIndex}`);
|
||||
}
|
||||
const snapshotArguments = serializedArguments(content.arguments);
|
||||
const caughtUp = snapshotArguments === EMPTY_PARSED_TOOL_ARGUMENTS;
|
||||
this.startBlock(event.contentIndex, {
|
||||
kind: "toolCall",
|
||||
caughtUp,
|
||||
catchupJson: "",
|
||||
snapshotArguments: caughtUp ? "" : snapshotArguments,
|
||||
});
|
||||
return { type: "toolcall_start", contentIndex: event.contentIndex, toolCall: cloneToolCall(content) };
|
||||
}
|
||||
case "toolcall_delta": {
|
||||
const state = this.block(event.contentIndex, "toolCall");
|
||||
if (state.kind !== "toolCall") throw new Error("Unreachable tool-call encoder state");
|
||||
if (state.caughtUp) {
|
||||
return event.delta.length === 0
|
||||
? undefined
|
||||
: { type: "toolcall_delta", contentIndex: event.contentIndex, delta: event.delta };
|
||||
}
|
||||
state.catchupJson += event.delta;
|
||||
const argumentsValue = parseStreamingJson<ToolCall["arguments"]>(state.catchupJson);
|
||||
if (serializedArguments(argumentsValue) !== state.snapshotArguments) {
|
||||
// Legacy grammar calls include the initial input in toolcall_start, but their
|
||||
// JSON delta stream still begins at an empty input. Its parsed arguments can
|
||||
// therefore extend, rather than exactly reproduce, the start snapshot.
|
||||
const snapshotArguments = parseStreamingJson<ToolCall["arguments"]>(state.snapshotArguments);
|
||||
if (!isJsonPrefix(snapshotArguments, argumentsValue)) return undefined;
|
||||
}
|
||||
state.caughtUp = true;
|
||||
state.snapshotArguments = "";
|
||||
const json = state.catchupJson;
|
||||
state.catchupJson = "";
|
||||
return json.length === 0
|
||||
? undefined
|
||||
: { type: "toolcall_checkpoint", contentIndex: event.contentIndex, json };
|
||||
}
|
||||
case "toolcall_end": {
|
||||
const content = eventBlock(event);
|
||||
if (content.type !== "toolCall") {
|
||||
throw new Error(`toolcall_end event points to ${content.type} block at index ${event.contentIndex}`);
|
||||
}
|
||||
if (event.toolCall.type !== "toolCall") {
|
||||
throw new Error(`toolcall_end event has invalid tool call at index ${event.contentIndex}`);
|
||||
}
|
||||
this.endBlock(event.contentIndex, "toolCall");
|
||||
return {
|
||||
type: "toolcall_end",
|
||||
contentIndex: event.contentIndex,
|
||||
id: event.toolCall.id,
|
||||
name: event.toolCall.name,
|
||||
arguments: structuredClone(event.toolCall.arguments),
|
||||
...(event.toolCall.thoughtSignature === undefined
|
||||
? {}
|
||||
: { thoughtSignature: event.toolCall.thoughtSignature }),
|
||||
...(event.toolCall.namespace === undefined ? {} : { namespace: event.toolCall.namespace }),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private startBlock(contentIndex: number, state: EncoderBlockState): void {
|
||||
assertContentIndex(contentIndex);
|
||||
if (this.blocks.has(contentIndex)) {
|
||||
throw new Error(`Assistant message block ${contentIndex} starts more than once`);
|
||||
}
|
||||
this.blocks.set(contentIndex, state);
|
||||
}
|
||||
|
||||
private block(contentIndex: number, kind: EncoderBlockState["kind"]): EncoderBlockState {
|
||||
assertContentIndex(contentIndex);
|
||||
const state = this.blocks.get(contentIndex);
|
||||
if (state === undefined) throw new Error(`Assistant message ${kind} block ${contentIndex} has not started`);
|
||||
if (state.kind !== kind) {
|
||||
throw new Error(`Assistant message block ${contentIndex} is ${state.kind}, not ${kind}`);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
private endBlock(contentIndex: number, kind: EncoderBlockState["kind"]): void {
|
||||
this.block(contentIndex, kind);
|
||||
this.blocks.delete(contentIndex);
|
||||
}
|
||||
|
||||
private encodeTextDelta(
|
||||
contentIndex: number,
|
||||
delta: string,
|
||||
kind: "text" | "thinking",
|
||||
): AssistantMessageFrame | undefined {
|
||||
const state = this.block(contentIndex, kind);
|
||||
if (state.kind === "toolCall") throw new Error("Unreachable text encoder state");
|
||||
const deltaStart = state.deltaChars;
|
||||
state.deltaChars += delta.length;
|
||||
const covered = Math.max(0, state.coveredChars - deltaStart);
|
||||
if (covered >= delta.length) return undefined;
|
||||
const uncovered = covered === 0 ? delta : delta.slice(covered);
|
||||
return kind === "text"
|
||||
? { type: "text_delta", contentIndex, delta: uncovered }
|
||||
: { type: "thinking_delta", contentIndex, delta: uncovered };
|
||||
}
|
||||
}
|
||||
|
||||
function appendBlock(
|
||||
message: AssistantMessage,
|
||||
states: Map<number, ReducerBlockState>,
|
||||
contentIndex: number,
|
||||
block: TextContent | ThinkingContent | ToolCall,
|
||||
state: ReducerBlockState,
|
||||
): void {
|
||||
assertContentIndex(contentIndex);
|
||||
if (contentIndex !== message.content.length) {
|
||||
const reason = contentIndex < message.content.length ? "already exists" : "would leave a gap";
|
||||
throw new Error(`Cannot start assistant message block at index ${contentIndex}: ${reason}`);
|
||||
}
|
||||
message.content.push(structuredClone(block));
|
||||
states.set(contentIndex, state);
|
||||
}
|
||||
|
||||
function activeBlock(
|
||||
message: AssistantMessage,
|
||||
states: Map<number, ReducerBlockState>,
|
||||
contentIndex: number,
|
||||
expectedKind: ReducerBlockState["kind"],
|
||||
frameType: AssistantMessageFrame["type"],
|
||||
): { block: TextContent | ThinkingContent | ToolCall; state: ReducerBlockState } {
|
||||
assertContentIndex(contentIndex);
|
||||
const state = states.get(contentIndex);
|
||||
const block = message.content[contentIndex];
|
||||
if (!state || !block) {
|
||||
throw new Error(`${frameType} frame has no started block at index ${contentIndex}`);
|
||||
}
|
||||
if (state.kind !== expectedKind || block.type !== expectedKind) {
|
||||
throw new Error(
|
||||
`${frameType} frame expected ${expectedKind} block at index ${contentIndex}, found ${block.type}`,
|
||||
);
|
||||
}
|
||||
if (state.ended) {
|
||||
throw new Error(`${frameType} frame follows the end of block at index ${contentIndex}`);
|
||||
}
|
||||
return { block, state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay compact frames without mutating them. Returns `undefined` when the
|
||||
* iterable contains no start frame.
|
||||
*/
|
||||
export function reduceAssistantMessageFrames(frames: Iterable<AssistantMessageFrame>): AssistantMessage | undefined {
|
||||
let message: AssistantMessage | undefined;
|
||||
let frameBeforeStart: AssistantMessageFrame["type"] | undefined;
|
||||
const states = new Map<number, ReducerBlockState>();
|
||||
|
||||
for (const frame of frames) {
|
||||
if (frame.type === "start") {
|
||||
if (message) throw new Error("Assistant message frame sequence contains more than one start frame");
|
||||
if (frameBeforeStart !== undefined)
|
||||
throw new Error(`${frameBeforeStart} frame appears before the start frame`);
|
||||
message = structuredClone(frame.partial);
|
||||
continue;
|
||||
}
|
||||
if (!message) {
|
||||
frameBeforeStart ??= frame.type;
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (frame.type) {
|
||||
case "text_start":
|
||||
if (frame.content.type !== "text") {
|
||||
throw new Error(`text_start frame contains ${frame.content.type} content`);
|
||||
}
|
||||
appendBlock(message, states, frame.contentIndex, frame.content, { kind: "text", ended: false });
|
||||
break;
|
||||
case "text_delta": {
|
||||
const { block } = activeBlock(message, states, frame.contentIndex, "text", frame.type);
|
||||
if (block.type !== "text") throw new Error("Unreachable text frame state");
|
||||
block.text += frame.delta;
|
||||
break;
|
||||
}
|
||||
case "text_end": {
|
||||
const { block, state } = activeBlock(message, states, frame.contentIndex, "text", frame.type);
|
||||
if (block.type !== "text") throw new Error("Unreachable text frame state");
|
||||
block.text = frame.content;
|
||||
delete block.textSignature;
|
||||
if (frame.textSignature !== undefined) block.textSignature = frame.textSignature;
|
||||
state.ended = true;
|
||||
break;
|
||||
}
|
||||
case "thinking_start":
|
||||
if (frame.content.type !== "thinking") {
|
||||
throw new Error(`thinking_start frame contains ${frame.content.type} content`);
|
||||
}
|
||||
appendBlock(message, states, frame.contentIndex, frame.content, {
|
||||
kind: "thinking",
|
||||
ended: false,
|
||||
});
|
||||
break;
|
||||
case "thinking_delta": {
|
||||
const { block } = activeBlock(message, states, frame.contentIndex, "thinking", frame.type);
|
||||
if (block.type !== "thinking") throw new Error("Unreachable thinking frame state");
|
||||
block.thinking += frame.delta;
|
||||
break;
|
||||
}
|
||||
case "thinking_end": {
|
||||
const { block, state } = activeBlock(message, states, frame.contentIndex, "thinking", frame.type);
|
||||
if (block.type !== "thinking") throw new Error("Unreachable thinking frame state");
|
||||
block.thinking = frame.content;
|
||||
delete block.thinkingSignature;
|
||||
delete block.redacted;
|
||||
if (frame.thinkingSignature !== undefined) block.thinkingSignature = frame.thinkingSignature;
|
||||
if (frame.redacted !== undefined) block.redacted = frame.redacted;
|
||||
state.ended = true;
|
||||
break;
|
||||
}
|
||||
case "toolcall_start":
|
||||
if (frame.toolCall.type !== "toolCall") {
|
||||
throw new Error(`toolcall_start frame contains ${frame.toolCall.type} content`);
|
||||
}
|
||||
appendBlock(message, states, frame.contentIndex, frame.toolCall, {
|
||||
kind: "toolCall",
|
||||
ended: false,
|
||||
json: "",
|
||||
});
|
||||
break;
|
||||
case "toolcall_checkpoint": {
|
||||
const { block, state } = activeBlock(message, states, frame.contentIndex, "toolCall", frame.type);
|
||||
if (block.type !== "toolCall" || state.kind !== "toolCall") {
|
||||
throw new Error("Unreachable tool-call checkpoint state");
|
||||
}
|
||||
state.json = frame.json;
|
||||
block.arguments = parseStreamingJson<ToolCall["arguments"]>(frame.json);
|
||||
break;
|
||||
}
|
||||
case "toolcall_delta": {
|
||||
const { block, state } = activeBlock(message, states, frame.contentIndex, "toolCall", frame.type);
|
||||
if (block.type !== "toolCall" || state.kind !== "toolCall") {
|
||||
throw new Error("Unreachable tool-call frame state");
|
||||
}
|
||||
state.json += frame.delta;
|
||||
break;
|
||||
}
|
||||
case "toolcall_end": {
|
||||
const { block, state } = activeBlock(message, states, frame.contentIndex, "toolCall", frame.type);
|
||||
if (block.type !== "toolCall") throw new Error("Unreachable tool-call frame state");
|
||||
block.id = frame.id;
|
||||
block.name = frame.name;
|
||||
block.arguments = structuredClone(frame.arguments);
|
||||
delete block.thoughtSignature;
|
||||
delete block.namespace;
|
||||
if (frame.thoughtSignature !== undefined) block.thoughtSignature = frame.thoughtSignature;
|
||||
if (frame.namespace !== undefined) block.namespace = frame.namespace;
|
||||
state.ended = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!message) return undefined;
|
||||
for (const [contentIndex, state] of states) {
|
||||
if (state.kind !== "toolCall" || state.ended || state.json.length === 0) continue;
|
||||
const block = message.content[contentIndex];
|
||||
if (block?.type !== "toolCall") throw new Error("Unreachable tool-call frame state");
|
||||
block.arguments = parseStreamingJson<ToolCall["arguments"]>(state.json);
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
39
third_party/pi/packages/ai/src/utils/deferred-tools.ts
vendored
Normal file
39
third_party/pi/packages/ai/src/utils/deferred-tools.ts
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Context, Tool } from "../types.ts";
|
||||
|
||||
type ToolNameNormalizer = (name: string) => string;
|
||||
|
||||
const identityToolName: ToolNameNormalizer = (name) => name;
|
||||
|
||||
/** Split current tools into prefix and transcript-loaded definitions. */
|
||||
export function splitDeferredTools(
|
||||
context: Context,
|
||||
enabled: boolean,
|
||||
normalizeName: ToolNameNormalizer = identityToolName,
|
||||
): { immediate: Tool[]; deferred: Map<string, Tool> } {
|
||||
const uniqueTools = new Map<string, Tool>();
|
||||
for (const tool of context.tools ?? []) uniqueTools.set(normalizeName(tool.name), tool);
|
||||
if (!enabled) return { immediate: [...uniqueTools.values()], deferred: new Map() };
|
||||
|
||||
const deferredNames = new Set<string>();
|
||||
const usedNames = new Set<string>();
|
||||
for (const message of context.messages) {
|
||||
if (message.role === "assistant") {
|
||||
for (const block of message.content) {
|
||||
if (block.type === "toolCall") usedNames.add(normalizeName(block.name));
|
||||
}
|
||||
} else if (message.role === "toolResult") {
|
||||
for (const name of message.addedToolNames ?? []) {
|
||||
const normalizedName = normalizeName(name);
|
||||
if (!usedNames.has(normalizedName)) deferredNames.add(normalizedName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const immediate: Tool[] = [];
|
||||
const deferred = new Map<string, Tool>();
|
||||
for (const [name, tool] of uniqueTools) {
|
||||
if (deferredNames.has(name)) deferred.set(name, tool);
|
||||
else immediate.push(tool);
|
||||
}
|
||||
return { immediate, deferred };
|
||||
}
|
||||
45
third_party/pi/packages/ai/src/utils/diagnostics.ts
vendored
Normal file
45
third_party/pi/packages/ai/src/utils/diagnostics.ts
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
export interface DiagnosticErrorInfo {
|
||||
name?: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
code?: string | number;
|
||||
}
|
||||
|
||||
export interface AssistantMessageDiagnostic {
|
||||
type: string;
|
||||
timestamp: number;
|
||||
error?: DiagnosticErrorInfo;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function formatThrownValue(value: unknown): string {
|
||||
if (value instanceof Error) return value.message || value.name;
|
||||
if (typeof value === "string") return value;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function extractDiagnosticError(error: unknown): DiagnosticErrorInfo {
|
||||
if (!(error instanceof Error)) return { name: "ThrownValue", message: formatThrownValue(error) };
|
||||
const code = (error as Error & { code?: unknown }).code;
|
||||
return {
|
||||
name: error.name || undefined,
|
||||
message: error.message || error.name,
|
||||
stack: error.stack,
|
||||
code: typeof code === "string" || typeof code === "number" ? code : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function createAssistantMessageDiagnostic(
|
||||
type: string,
|
||||
error: unknown,
|
||||
details?: Record<string, unknown>,
|
||||
): AssistantMessageDiagnostic {
|
||||
return { type, timestamp: Date.now(), error: extractDiagnosticError(error), details };
|
||||
}
|
||||
|
||||
export function appendAssistantMessageDiagnostic<T extends { diagnostics?: AssistantMessageDiagnostic[] }>(
|
||||
message: T,
|
||||
diagnostic: AssistantMessageDiagnostic,
|
||||
): void {
|
||||
message.diagnostics = [...(message.diagnostics ?? []), diagnostic];
|
||||
}
|
||||
149
third_party/pi/packages/ai/src/utils/error-body.ts
vendored
Normal file
149
third_party/pi/packages/ai/src/utils/error-body.ts
vendored
Normal file
@@ -0,0 +1,149 @@
|
||||
// Shared normalization for provider HTTP error objects.
|
||||
//
|
||||
// Endpoints behind a proxy / gateway may return a non-2xx response whose body
|
||||
// the provider SDK cannot fold into `error.message`. The SDK error object still
|
||||
// carries the HTTP status and the raw/parsed body, but under SDK-specific field
|
||||
// names. Provider catch blocks that read only `error.message` therefore drop
|
||||
// the body and surface opaque messages like `"403 status code (no body)"` or
|
||||
// collapse to `"Unknown: UnknownError"`.
|
||||
//
|
||||
// `normalizeProviderError` probes the known SDK field shapes (Mistral,
|
||||
// `openai`, `@google/genai`, AWS Bedrock) and returns a struct each provider
|
||||
// composes into its display string. The `messageCarriesBody` flag captures the
|
||||
// Anthropic / `@google/genai` happy path where the SDK already folded the body
|
||||
// into the message, so providers can preserve it without double-printing.
|
||||
|
||||
export const MAX_PROVIDER_ERROR_BODY_CHARS = 4000;
|
||||
|
||||
export interface NormalizedProviderError {
|
||||
/** HTTP status code, when one could be extracted from the SDK error object. */
|
||||
status?: number;
|
||||
/** Raw HTTP body reason, already trimmed and truncated to the cap. */
|
||||
body?: string;
|
||||
/** `error.message`, or `safeJsonStringify(error)` for a non-`Error` throw. */
|
||||
message: string;
|
||||
/** True when `message` already contains the body (no separate body to add). */
|
||||
messageCarriesBody: boolean;
|
||||
}
|
||||
|
||||
type SdkErrorShape = Error & {
|
||||
statusCode?: unknown;
|
||||
status?: unknown;
|
||||
body?: unknown;
|
||||
error?: unknown;
|
||||
$metadata?: { httpStatusCode?: unknown };
|
||||
$response?: { statusCode?: unknown; body?: unknown };
|
||||
};
|
||||
|
||||
export function normalizeProviderError(error: unknown): NormalizedProviderError {
|
||||
if (!(error instanceof Error)) {
|
||||
return { message: safeJsonStringify(error), messageCarriesBody: false };
|
||||
}
|
||||
|
||||
const sdkError = error as SdkErrorShape;
|
||||
const status = extractStatus(sdkError);
|
||||
const body = extractBody(sdkError);
|
||||
const messageCarriesBody = body === undefined || error.message.includes(body);
|
||||
|
||||
return {
|
||||
status,
|
||||
body,
|
||||
message: error.message,
|
||||
messageCarriesBody,
|
||||
} satisfies NormalizedProviderError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the HTTP status, first numeric hit wins, in SDK-field order:
|
||||
* `statusCode` (Mistral) → `status` (`openai`, `@google/genai`) →
|
||||
* `$metadata.httpStatusCode` (Bedrock) → `$response.statusCode` (Bedrock).
|
||||
*/
|
||||
function extractStatus(error: SdkErrorShape): number | undefined {
|
||||
if (typeof error.statusCode === "number") return error.statusCode;
|
||||
if (typeof error.status === "number") return error.status;
|
||||
if (typeof error.$metadata?.httpStatusCode === "number") return error.$metadata.httpStatusCode;
|
||||
if (typeof error.$response?.statusCode === "number") return error.$response.statusCode;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the raw body reason, first usable hit wins, in SDK-field order:
|
||||
* `body` string (Mistral) → `error` parsed JSON body object (`openai` SDK's
|
||||
* `this.error`) → `$response.body` (Bedrock). Empty objects and unread response
|
||||
* streams are treated as no body so they do not surface as `"{}"` or serialized
|
||||
* stream internals. The chosen body is truncated to the cap.
|
||||
*/
|
||||
function extractBody(error: SdkErrorShape): string | undefined {
|
||||
const bodyText = pickBodyText(error);
|
||||
if (bodyText === undefined) return undefined;
|
||||
const trimmed = bodyText.trim();
|
||||
if (trimmed.length === 0) return undefined;
|
||||
return truncateErrorText(trimmed, MAX_PROVIDER_ERROR_BODY_CHARS);
|
||||
}
|
||||
|
||||
function pickBodyText(error: SdkErrorShape): string | undefined {
|
||||
if (typeof error.body === "string") return error.body;
|
||||
if (isPlainNonEmptyObject(error.error)) return safeJsonStringify(error.error);
|
||||
const responseBody = error.$response?.body;
|
||||
if (typeof responseBody === "string") return responseBody;
|
||||
if (isReadableStreamLike(responseBody)) return undefined;
|
||||
if (isPlainNonEmptyObject(responseBody)) return safeJsonStringify(responseBody);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isReadableStreamLike(value: unknown): boolean {
|
||||
return typeof value === "object" && value !== null && "pipe" in value && typeof value.pipe === "function";
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a PLAIN object counts as an HTTP body. SDK error fields can hold class
|
||||
* instances instead of parsed bodies — AWS SDK v3's `$response.body` is an
|
||||
* HTTP stream/response wrapper object, and stringifying one produced garbage
|
||||
* like `{"_events":...}` as the "body", which then REPLACED `error.message`
|
||||
* in the composed display string. `error.message` is where the SDK puts the
|
||||
* real deserialized exception text ("Input is too long...", schema validation
|
||||
* details, ...), so the one useful string was discarded for noise. A class
|
||||
* instance yields no body, `messageCarriesBody` stays true, and the real
|
||||
* message survives. Complements the `pipe` sniffing above: web
|
||||
* ReadableStreams (pipeTo/pipeThrough, no `pipe`) and non-stream SDK wrapper
|
||||
* classes fail the prototype check, while parsed JSON bodies (plain objects
|
||||
* by construction) still pass.
|
||||
*/
|
||||
function isPlainNonEmptyObject(value: unknown): boolean {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
if (proto !== Object.prototype && proto !== null) return false;
|
||||
return Object.keys(value).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose a display string from a normalized error. When the message already
|
||||
* carries the body (Anthropic / `@google/genai` happy path) or no body/status
|
||||
* was extracted, the message is returned unchanged. Otherwise the status and
|
||||
* body are surfaced, with an optional provider prefix.
|
||||
*
|
||||
* - no prefix: `"<status>: <body>"`
|
||||
* - prefix: `"<prefix> (<status>): <body>"`
|
||||
*/
|
||||
export function formatProviderError(norm: NormalizedProviderError, prefix?: string): string {
|
||||
if (norm.messageCarriesBody || norm.status === undefined || norm.body === undefined) {
|
||||
return prefix !== undefined && norm.status !== undefined
|
||||
? `${prefix} (${norm.status}): ${norm.message}`
|
||||
: norm.message;
|
||||
}
|
||||
return prefix !== undefined ? `${prefix} (${norm.status}): ${norm.body}` : `${norm.status}: ${norm.body}`;
|
||||
}
|
||||
|
||||
export function truncateErrorText(text: string, maxChars: number): string {
|
||||
if (text.length <= maxChars) return text;
|
||||
return `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`;
|
||||
}
|
||||
|
||||
export function safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
return serialized === undefined ? String(value) : serialized;
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
143
third_party/pi/packages/ai/src/utils/estimate.ts
vendored
Normal file
143
third_party/pi/packages/ai/src/utils/estimate.ts
vendored
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { AssistantMessage, Context, ImageContent, Message, TextContent, Tool, Usage } from "../types.ts";
|
||||
|
||||
export interface ContextUsageEstimate {
|
||||
/** Estimated total context tokens. */
|
||||
tokens: number;
|
||||
/** Tokens reported by the most recent applicable assistant usage block. */
|
||||
usageTokens: number;
|
||||
/** Estimated tokens after the most recent applicable assistant usage block. */
|
||||
trailingTokens: number;
|
||||
/** Index of the applicable message that provided usage, or null when none exists. */
|
||||
lastUsageIndex: number | null;
|
||||
}
|
||||
|
||||
const CHARS_PER_TOKEN = 4;
|
||||
const ESTIMATED_IMAGE_CHARS = 4800;
|
||||
|
||||
export function calculateContextTokens(usage: Usage): number {
|
||||
return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
|
||||
}
|
||||
|
||||
function safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value) ?? "undefined";
|
||||
} catch {
|
||||
return "[unserializable]";
|
||||
}
|
||||
}
|
||||
|
||||
function estimateTextAndImageContentChars(content: string | Array<TextContent | ImageContent>): number {
|
||||
if (typeof content === "string") return content.length;
|
||||
|
||||
let chars = 0;
|
||||
for (const block of content) chars += block.type === "text" ? block.text.length : ESTIMATED_IMAGE_CHARS;
|
||||
return chars;
|
||||
}
|
||||
|
||||
export function estimateTextTokens(text: string): number {
|
||||
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
||||
}
|
||||
|
||||
export function estimateTextAndImageContentTokens(content: string | Array<TextContent | ImageContent>): number {
|
||||
return Math.ceil(estimateTextAndImageContentChars(content) / CHARS_PER_TOKEN);
|
||||
}
|
||||
|
||||
export function estimateMessageTokens(message: Message): number {
|
||||
let chars = 0;
|
||||
|
||||
if (message.role === "user") return estimateTextAndImageContentTokens(message.content);
|
||||
if (message.role === "toolResult") return estimateTextAndImageContentTokens(message.content);
|
||||
|
||||
for (const block of message.content) {
|
||||
if (block.type === "text") {
|
||||
chars += block.text.length;
|
||||
} else if (block.type === "thinking") {
|
||||
chars += block.thinking.length;
|
||||
} else {
|
||||
chars += block.name.length + safeJsonStringify(block.arguments).length;
|
||||
}
|
||||
}
|
||||
return Math.ceil(chars / CHARS_PER_TOKEN);
|
||||
}
|
||||
|
||||
function getLastAssistantUsageInfo(messages: readonly Message[]): { usage: Usage; index: number } | undefined {
|
||||
let latestPrefixTimestamp = Number.NEGATIVE_INFINITY;
|
||||
let usageInfo: { usage: Usage; index: number } | undefined;
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
if (message.role === "assistant") {
|
||||
const assistant = message as AssistantMessage;
|
||||
// A newer prefix message was inserted after this response (for example, a
|
||||
// compaction summary), so its usage cannot describe the current prefix.
|
||||
const usageAppliesToPrefix = assistant.timestamp >= latestPrefixTimestamp;
|
||||
if (
|
||||
usageAppliesToPrefix &&
|
||||
assistant.stopReason !== "aborted" &&
|
||||
assistant.stopReason !== "error" &&
|
||||
calculateContextTokens(assistant.usage) > 0
|
||||
) {
|
||||
usageInfo = { usage: assistant.usage, index: i };
|
||||
}
|
||||
}
|
||||
latestPrefixTimestamp = Math.max(latestPrefixTimestamp, message.timestamp);
|
||||
}
|
||||
|
||||
return usageInfo;
|
||||
}
|
||||
|
||||
function estimateMessages(messages: readonly Message[]): ContextUsageEstimate {
|
||||
const usageInfo = getLastAssistantUsageInfo(messages);
|
||||
if (usageInfo) {
|
||||
const usageTokens = calculateContextTokens(usageInfo.usage);
|
||||
let trailingTokens = 0;
|
||||
for (let i = usageInfo.index + 1; i < messages.length; i++) {
|
||||
trailingTokens += estimateMessageTokens(messages[i]);
|
||||
}
|
||||
return { tokens: usageTokens + trailingTokens, usageTokens, trailingTokens, lastUsageIndex: usageInfo.index };
|
||||
}
|
||||
|
||||
let tokens = 0;
|
||||
for (const message of messages) tokens += estimateMessageTokens(message);
|
||||
return { tokens, usageTokens: 0, trailingTokens: tokens, lastUsageIndex: null };
|
||||
}
|
||||
|
||||
function estimateToolsTokens(tools: readonly Tool[] | undefined): number {
|
||||
if (!tools || tools.length === 0) return 0;
|
||||
return estimateTextTokens(safeJsonStringify(tools));
|
||||
}
|
||||
|
||||
function isMessageArray(value: Context | readonly Message[]): value is readonly Message[] {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
export function estimateContextTokens(context: Context | readonly Message[]): ContextUsageEstimate {
|
||||
if (isMessageArray(context)) return estimateMessages(context);
|
||||
|
||||
const estimate = estimateMessages(context.messages);
|
||||
if (estimate.lastUsageIndex !== null) {
|
||||
const addedNames = new Set(
|
||||
context.messages
|
||||
.slice(estimate.lastUsageIndex + 1)
|
||||
.filter((message) => message.role === "toolResult")
|
||||
.flatMap((message) => message.addedToolNames ?? []),
|
||||
);
|
||||
const addedToolTokens = estimateToolsTokens(context.tools?.filter((tool) => addedNames.has(tool.name)));
|
||||
return {
|
||||
tokens: estimate.tokens + addedToolTokens,
|
||||
usageTokens: estimate.usageTokens,
|
||||
trailingTokens: estimate.trailingTokens + addedToolTokens,
|
||||
lastUsageIndex: estimate.lastUsageIndex,
|
||||
};
|
||||
}
|
||||
|
||||
const prefixTokens =
|
||||
(context.systemPrompt ? estimateTextTokens(context.systemPrompt) : 0) + estimateToolsTokens(context.tools);
|
||||
|
||||
return {
|
||||
tokens: estimate.tokens + prefixTokens,
|
||||
usageTokens: estimate.usageTokens,
|
||||
trailingTokens: estimate.trailingTokens + prefixTokens,
|
||||
lastUsageIndex: estimate.lastUsageIndex,
|
||||
};
|
||||
}
|
||||
88
third_party/pi/packages/ai/src/utils/event-stream.ts
vendored
Normal file
88
third_party/pi/packages/ai/src/utils/event-stream.ts
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { AssistantMessage, AssistantMessageEvent } from "../types.ts";
|
||||
|
||||
// Generic event stream class for async iteration
|
||||
export class EventStream<T, R = T> implements AsyncIterable<T> {
|
||||
private queue: T[] = [];
|
||||
private waiting: ((value: IteratorResult<T>) => void)[] = [];
|
||||
private done = false;
|
||||
private finalResultPromise: Promise<R>;
|
||||
private resolveFinalResult!: (result: R) => void;
|
||||
private isComplete: (event: T) => boolean;
|
||||
private extractResult: (event: T) => R;
|
||||
|
||||
constructor(isComplete: (event: T) => boolean, extractResult: (event: T) => R) {
|
||||
this.isComplete = isComplete;
|
||||
this.extractResult = extractResult;
|
||||
this.finalResultPromise = new Promise((resolve) => {
|
||||
this.resolveFinalResult = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
push(event: T): void {
|
||||
if (this.done) return;
|
||||
|
||||
if (this.isComplete(event)) {
|
||||
this.done = true;
|
||||
this.resolveFinalResult(this.extractResult(event));
|
||||
}
|
||||
|
||||
// Deliver to waiting consumer or queue it
|
||||
const waiter = this.waiting.shift();
|
||||
if (waiter) {
|
||||
waiter({ value: event, done: false });
|
||||
} else {
|
||||
this.queue.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
end(result?: R): void {
|
||||
this.done = true;
|
||||
if (result !== undefined) {
|
||||
this.resolveFinalResult(result);
|
||||
}
|
||||
// Notify all waiting consumers that we're done
|
||||
while (this.waiting.length > 0) {
|
||||
const waiter = this.waiting.shift()!;
|
||||
waiter({ value: undefined as any, done: true });
|
||||
}
|
||||
}
|
||||
|
||||
async *[Symbol.asyncIterator](): AsyncIterator<T> {
|
||||
while (true) {
|
||||
if (this.queue.length > 0) {
|
||||
yield this.queue.shift()!;
|
||||
} else if (this.done) {
|
||||
return;
|
||||
} else {
|
||||
const result = await new Promise<IteratorResult<T>>((resolve) => this.waiting.push(resolve));
|
||||
if (result.done) return;
|
||||
yield result.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result(): Promise<R> {
|
||||
return this.finalResultPromise;
|
||||
}
|
||||
}
|
||||
|
||||
export class AssistantMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
|
||||
constructor() {
|
||||
super(
|
||||
(event) => event.type === "done" || event.type === "error",
|
||||
(event) => {
|
||||
if (event.type === "done") {
|
||||
return event.message;
|
||||
} else if (event.type === "error") {
|
||||
return event.error;
|
||||
}
|
||||
throw new Error("Unexpected event type for final result");
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Factory function for AssistantMessageEventStream (for use in extensions) */
|
||||
export function createAssistantMessageEventStream(): AssistantMessageEventStream {
|
||||
return new AssistantMessageEventStream();
|
||||
}
|
||||
13
third_party/pi/packages/ai/src/utils/hash.ts
vendored
Normal file
13
third_party/pi/packages/ai/src/utils/hash.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
/** Fast deterministic hash to shorten long strings */
|
||||
export function shortHash(str: string): string {
|
||||
let h1 = 0xdeadbeef;
|
||||
let h2 = 0x41c6ce57;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const ch = str.charCodeAt(i);
|
||||
h1 = Math.imul(h1 ^ ch, 2654435761);
|
||||
h2 = Math.imul(h2 ^ ch, 1597334677);
|
||||
}
|
||||
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
||||
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
||||
return (h2 >>> 0).toString(36) + (h1 >>> 0).toString(36);
|
||||
}
|
||||
18
third_party/pi/packages/ai/src/utils/headers.ts
vendored
Normal file
18
third_party/pi/packages/ai/src/utils/headers.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { ProviderHeaders } from "../types.ts";
|
||||
|
||||
export function headersToRecord(headers: Headers): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of headers.entries()) {
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function providerHeadersToRecord(headers: ProviderHeaders | undefined): Record<string, string> | undefined {
|
||||
if (!headers) return undefined;
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (value !== null) result[key] = value;
|
||||
}
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
124
third_party/pi/packages/ai/src/utils/json-parse.ts
vendored
Normal file
124
third_party/pi/packages/ai/src/utils/json-parse.ts
vendored
Normal file
@@ -0,0 +1,124 @@
|
||||
import { parse as partialParse } from "partial-json";
|
||||
|
||||
const VALID_JSON_ESCAPES = new Set(['"', "\\", "/", "b", "f", "n", "r", "t", "u"]);
|
||||
|
||||
function isControlCharacter(char: string): boolean {
|
||||
const codePoint = char.codePointAt(0);
|
||||
return codePoint !== undefined && codePoint >= 0x00 && codePoint <= 0x1f;
|
||||
}
|
||||
|
||||
function escapeControlCharacter(char: string): string {
|
||||
switch (char) {
|
||||
case "\b":
|
||||
return "\\b";
|
||||
case "\f":
|
||||
return "\\f";
|
||||
case "\n":
|
||||
return "\\n";
|
||||
case "\r":
|
||||
return "\\r";
|
||||
case "\t":
|
||||
return "\\t";
|
||||
default:
|
||||
return `\\u${char.codePointAt(0)?.toString(16).padStart(4, "0") ?? "0000"}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Repairs malformed JSON string literals by:
|
||||
* - escaping raw control characters inside strings
|
||||
* - doubling backslashes before invalid escape characters
|
||||
*/
|
||||
export function repairJson(json: string): string {
|
||||
let repaired = "";
|
||||
let inString = false;
|
||||
|
||||
for (let index = 0; index < json.length; index++) {
|
||||
const char = json[index];
|
||||
|
||||
if (!inString) {
|
||||
repaired += char;
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
repaired += char;
|
||||
inString = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "\\") {
|
||||
const nextChar = json[index + 1];
|
||||
if (nextChar === undefined) {
|
||||
repaired += "\\\\";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nextChar === "u") {
|
||||
const unicodeDigits = json.slice(index + 2, index + 6);
|
||||
if (/^[0-9a-fA-F]{4}$/.test(unicodeDigits)) {
|
||||
repaired += `\\u${unicodeDigits}`;
|
||||
index += 5;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (VALID_JSON_ESCAPES.has(nextChar)) {
|
||||
repaired += `\\${nextChar}`;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
repaired += "\\\\";
|
||||
continue;
|
||||
}
|
||||
|
||||
repaired += isControlCharacter(char) ? escapeControlCharacter(char) : char;
|
||||
}
|
||||
|
||||
return repaired;
|
||||
}
|
||||
|
||||
export function parseJsonWithRepair<T>(json: string): T {
|
||||
try {
|
||||
return JSON.parse(json) as T;
|
||||
} catch (error) {
|
||||
const repairedJson = repairJson(json);
|
||||
if (repairedJson !== json) {
|
||||
return JSON.parse(repairedJson) as T;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to parse potentially incomplete JSON during streaming.
|
||||
* Always returns a valid object, even if the JSON is incomplete.
|
||||
*
|
||||
* @param partialJson The partial JSON string from streaming
|
||||
* @returns Parsed object or empty object if parsing fails
|
||||
*/
|
||||
export function parseStreamingJson<T = Record<string, unknown>>(partialJson: string | undefined): T {
|
||||
if (!partialJson || partialJson.trim() === "") {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
try {
|
||||
return parseJsonWithRepair<T>(partialJson);
|
||||
} catch {
|
||||
try {
|
||||
const result = partialParse(partialJson);
|
||||
return (result ?? {}) as T;
|
||||
} catch {
|
||||
try {
|
||||
const result = partialParse(repairJson(partialJson));
|
||||
return (result ?? {}) as T;
|
||||
} catch {
|
||||
return {} as T;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
161
third_party/pi/packages/ai/src/utils/node-http-proxy.ts
vendored
Normal file
161
third_party/pi/packages/ai/src/utils/node-http-proxy.ts
vendored
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { ProviderEnv } from "../types.ts";
|
||||
import { getProviderEnvValue } from "./provider-env.ts";
|
||||
|
||||
const DEFAULT_PROXY_PORTS: Record<string, number> = {
|
||||
ftp: 21,
|
||||
gopher: 70,
|
||||
http: 80,
|
||||
https: 443,
|
||||
ws: 80,
|
||||
wss: 443,
|
||||
};
|
||||
|
||||
function getProxyEnv(key: string, env?: ProviderEnv): string {
|
||||
const lowercaseKey = key.toLowerCase();
|
||||
const uppercaseKey = key.toUpperCase();
|
||||
return (
|
||||
env?.[lowercaseKey] ||
|
||||
env?.[uppercaseKey] ||
|
||||
getProviderEnvValue(lowercaseKey) ||
|
||||
getProviderEnvValue(uppercaseKey) ||
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
function parseProxyTargetUrl(targetUrl: string | URL): URL | undefined {
|
||||
if (targetUrl instanceof URL) {
|
||||
return targetUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(targetUrl);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function stripBrackets(host: string): string {
|
||||
return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
||||
}
|
||||
|
||||
function parseNoProxyEntry(entry: string): { host: string; port: number } | undefined {
|
||||
const trimmed = entry.trim().toLowerCase();
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
if (trimmed.startsWith("[")) {
|
||||
const closingBracket = trimmed.indexOf("]");
|
||||
if (closingBracket !== -1) {
|
||||
const host = trimmed.slice(1, closingBracket);
|
||||
const rest = trimmed.slice(closingBracket + 1);
|
||||
if (rest.startsWith(":")) {
|
||||
const port = Number.parseInt(rest.slice(1), 10);
|
||||
return { host, port: Number.isNaN(port) ? 0 : port };
|
||||
}
|
||||
return { host, port: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.includes(":") && trimmed.split(":").length > 2) {
|
||||
return { host: trimmed, port: 0 };
|
||||
}
|
||||
|
||||
const colonIndex = trimmed.lastIndexOf(":");
|
||||
if (colonIndex !== -1 && colonIndex === trimmed.indexOf(":")) {
|
||||
const host = trimmed.slice(0, colonIndex);
|
||||
const port = Number.parseInt(trimmed.slice(colonIndex + 1), 10);
|
||||
if (!Number.isNaN(port)) {
|
||||
return { host, port };
|
||||
}
|
||||
}
|
||||
|
||||
return { host: trimmed, port: 0 };
|
||||
}
|
||||
|
||||
function shouldProxyHostname(hostname: string, port: number, env?: ProviderEnv): boolean {
|
||||
const noProxy = getProxyEnv("no_proxy", env).toLowerCase();
|
||||
if (!noProxy) {
|
||||
return true;
|
||||
}
|
||||
if (noProxy === "*") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedTargetHost = stripBrackets(hostname.toLowerCase());
|
||||
|
||||
return noProxy.split(/[,\s]/).every((entry) => {
|
||||
const parsed = parseNoProxyEntry(entry);
|
||||
if (!parsed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parsed.port && parsed.port !== port) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let domain = stripBrackets(parsed.host);
|
||||
if (domain.startsWith("*.")) {
|
||||
domain = domain.slice(2);
|
||||
} else if (domain.startsWith(".") || domain.startsWith("*")) {
|
||||
domain = domain.slice(1);
|
||||
}
|
||||
|
||||
if (!domain) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedTargetHost === domain) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalizedTargetHost.endsWith(`.${domain}`)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function getProxyForUrl(targetUrl: string | URL, env?: ProviderEnv): string {
|
||||
const parsedUrl = parseProxyTargetUrl(targetUrl);
|
||||
if (!parsedUrl?.protocol || !parsedUrl.host) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const protocol = parsedUrl.protocol.split(":", 1)[0]!;
|
||||
const hostname = stripBrackets(parsedUrl.hostname || parsedUrl.host.replace(/:\d*$/, ""));
|
||||
const port = Number.parseInt(parsedUrl.port, 10) || DEFAULT_PROXY_PORTS[protocol] || 0;
|
||||
if (!shouldProxyHostname(hostname, port, env)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let proxy = getProxyEnv(`${protocol}_proxy`, env) || getProxyEnv("all_proxy", env);
|
||||
if (proxy && !proxy.includes("://")) {
|
||||
proxy = `${protocol}://${proxy}`;
|
||||
}
|
||||
return proxy;
|
||||
}
|
||||
|
||||
export const UNSUPPORTED_PROXY_PROTOCOL_MESSAGE =
|
||||
"Unsupported proxy protocol. SOCKS and PAC proxy URLs are not supported; use an HTTP or HTTPS proxy URL.";
|
||||
|
||||
export function resolveHttpProxyUrlForTarget(targetUrl: string | URL, env?: ProviderEnv): URL | undefined {
|
||||
const proxy = getProxyForUrl(targetUrl, env);
|
||||
if (!proxy) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let proxyUrl: URL;
|
||||
try {
|
||||
proxyUrl = new URL(proxy);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Invalid proxy URL ${JSON.stringify(proxy)}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (proxyUrl.protocol !== "http:" && proxyUrl.protocol !== "https:") {
|
||||
throw new Error(`${UNSUPPORTED_PROXY_PROTOCOL_MESSAGE} Got ${proxyUrl.protocol}`);
|
||||
}
|
||||
|
||||
return proxyUrl;
|
||||
}
|
||||
180
third_party/pi/packages/ai/src/utils/overflow.ts
vendored
Normal file
180
third_party/pi/packages/ai/src/utils/overflow.ts
vendored
Normal file
@@ -0,0 +1,180 @@
|
||||
import type { AssistantMessage } from "../types.ts";
|
||||
|
||||
/**
|
||||
* Regex patterns to detect context overflow errors from different providers.
|
||||
*
|
||||
* These patterns match error messages returned when the input exceeds
|
||||
* the model's context window.
|
||||
*
|
||||
* Provider-specific patterns (with example error messages):
|
||||
*
|
||||
* - Anthropic: "prompt is too long: 213462 tokens > 200000 maximum"
|
||||
* - Anthropic: "413 {\"error\":{\"type\":\"request_too_large\",\"message\":\"Request exceeds the maximum size\"}}"
|
||||
* - OpenAI: "Your input exceeds the context window of this model"
|
||||
* - OpenAI/LiteLLM: "Requested token count exceeds the model's maximum context length of 131072 tokens"
|
||||
* - OpenAI-compatible: "Input length (265330) exceeds model's maximum context length (262144)."
|
||||
* - Google: "The input token count (1196265) exceeds the maximum number of tokens allowed (1048575)"
|
||||
* - xAI: "This model's maximum prompt length is 131072 but the request contains 537812 tokens"
|
||||
* - Groq: "Please reduce the length of the messages or completion"
|
||||
* - OpenRouter: "This endpoint's maximum context length is X tokens. However, you requested about Y tokens"
|
||||
* - OpenRouter/Poolside: "Input length X exceeds the maximum allowed input length of Y tokens."
|
||||
* - Together AI: "The input (X tokens) is longer than the model's context length (Y tokens)."
|
||||
* - llama.cpp: "the request exceeds the available context size, try increasing it"
|
||||
* - LM Studio: "tokens to keep from the initial prompt is greater than the context length"
|
||||
* - GitHub Copilot: "prompt token count of X exceeds the limit of Y"
|
||||
* - MiniMax: "invalid params, context window exceeds limit"
|
||||
* - Kimi For Coding: "Your request exceeded model token limit: X (requested: Y)"
|
||||
* - DS4: "Prompt has X tokens, but the configured context size is Y tokens"
|
||||
* - Cerebras: "400/413 status code (no body)"
|
||||
* - Mistral: "Prompt contains X tokens ... too large for model with Y maximum context length"
|
||||
* - z.ai: Does NOT error, accepts overflow silently - handled via usage.input > contextWindow
|
||||
* - Xiaomi MiMo: Truncates input to fill contextWindow exactly, then returns finish_reason "length"
|
||||
* with output=0 (no room left to generate). Detected via stopReason "length" + zero output +
|
||||
* input filling the context window.
|
||||
* - DashScope/Qwen: "Range of input length should be [1, X]" (HTTP 400 invalid_parameter_error)
|
||||
* - Ollama: Some deployments truncate silently, others return errors like "prompt too long; exceeded max context length by X tokens"
|
||||
*/
|
||||
const OVERFLOW_PATTERNS = [
|
||||
/prompt is too long/i, // Anthropic token overflow
|
||||
/request_too_large/i, // Anthropic request byte-size overflow (HTTP 413)
|
||||
/input is too long for requested model/i, // Amazon Bedrock
|
||||
/exceeds the context window/i, // OpenAI (Completions & Responses API)
|
||||
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i, // OpenAI-compatible proxies (LiteLLM)
|
||||
/input token count.*exceeds the maximum/i, // Google (Gemini)
|
||||
/maximum prompt length is \d+/i, // xAI (Grok)
|
||||
/reduce the length of the messages/i, // Groq
|
||||
/maximum context length is \d+ tokens/i, // OpenRouter (most backends)
|
||||
/exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i, // OpenRouter/Poolside
|
||||
/input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i, // Together AI
|
||||
/exceeds the limit of \d+/i, // GitHub Copilot
|
||||
/exceeds the available context size/i, // llama.cpp server
|
||||
/greater than the context length/i, // LM Studio
|
||||
/context window exceeds limit/i, // MiniMax
|
||||
/exceeded model token limit/i, // Kimi For Coding
|
||||
/too large for model with \d+ maximum context length/i, // Mistral
|
||||
/prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i, // DS4 server
|
||||
/model_context_window_exceeded/i, // z.ai non-standard finish_reason surfaced as error text
|
||||
/prompt too long; exceeded (?:max )?context length/i, // Ollama explicit overflow error
|
||||
/range of input length should be/i, // DashScope / Qwen Token Plan
|
||||
/context[_ ]length[_ ]exceeded/i, // Generic fallback
|
||||
/too many tokens/i, // Generic fallback
|
||||
/token limit exceeded/i, // Generic fallback
|
||||
/^4(?:00|13)\s*(?:status code)?\s*\(no body\)/i, // Cerebras: 400/413 with no body
|
||||
];
|
||||
|
||||
/**
|
||||
* Patterns that indicate non-overflow errors (e.g. rate limiting, server errors).
|
||||
* Error messages matching any of these are excluded from overflow detection
|
||||
* even if they also match an OVERFLOW_PATTERN.
|
||||
*
|
||||
* Example: Bedrock formats throttling errors as "ThrottlingException: Too many tokens,
|
||||
* please wait before trying again." which would match the /too many tokens/i overflow
|
||||
* pattern without this exclusion.
|
||||
*/
|
||||
const NON_OVERFLOW_PATTERNS = [
|
||||
/^(Throttling error|Service unavailable):/i, // AWS Bedrock non-overflow errors (human-readable prefixes from formatBedrockError)
|
||||
/rate limit/i, // Generic rate limiting
|
||||
/too many requests/i, // Generic HTTP 429 style
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if an assistant message represents a context overflow error.
|
||||
*
|
||||
* This handles three cases:
|
||||
* 1. Error-based overflow: Most providers return stopReason "error" with a
|
||||
* specific error message pattern.
|
||||
* 2. Silent overflow: Some providers accept overflow requests and return
|
||||
* successfully. For these, we check if usage.input exceeds the context window.
|
||||
* 3. Length-stop overflow: Xiaomi MiMo can return "length" with zero output when
|
||||
* the input fills the context window.
|
||||
*
|
||||
* ## Reliability by Provider
|
||||
*
|
||||
* **Reliable detection (returns error with detectable message):**
|
||||
* - Anthropic: "prompt is too long: X tokens > Y maximum" or "request_too_large"
|
||||
* - OpenAI (Completions & Responses): "exceeds the context window", "exceeds the model's maximum context length of X tokens", or "exceeds model's maximum context length (X)"
|
||||
* - Google Gemini: "input token count exceeds the maximum"
|
||||
* - xAI (Grok): "maximum prompt length is X but request contains Y"
|
||||
* - Groq: "reduce the length of the messages"
|
||||
* - Cerebras: 400/413 status code (no body)
|
||||
* - Mistral: "Prompt contains X tokens ... too large for model with Y maximum context length"
|
||||
* - OpenRouter (most backends): "maximum context length is X tokens"
|
||||
* - OpenRouter/Poolside: "Input length X exceeds the maximum allowed input length of Y tokens."
|
||||
* - Together AI: "The input (X tokens) is longer than the model's context length (Y tokens)."
|
||||
* - llama.cpp: "exceeds the available context size"
|
||||
* - LM Studio: "greater than the context length"
|
||||
* - Kimi For Coding: "exceeded model token limit: X (requested: Y)"
|
||||
* - DS4: "Prompt has X tokens, but the configured context size is Y tokens"
|
||||
* - DashScope/Qwen: "Range of input length should be [1, X]"
|
||||
*
|
||||
* **Unreliable detection:**
|
||||
* - z.ai: Sometimes accepts overflow silently (detectable via usage.input > contextWindow),
|
||||
* sometimes returns rate limit errors. Pass contextWindow param to detect silent overflow.
|
||||
* - Xiaomi MiMo: Truncates input to fit contextWindow then returns stopReason "length" with
|
||||
* output=0. Pass contextWindow param to detect via the "filled context + zero output" signal.
|
||||
* - Ollama: May truncate input silently for some setups, but may also return explicit
|
||||
* overflow errors that match the patterns above. Silent truncation still cannot be
|
||||
* detected here because we do not know the expected token count.
|
||||
*
|
||||
* ## Custom Providers
|
||||
*
|
||||
* If you've added custom models via settings.json, this function may not detect
|
||||
* overflow errors from those providers. To add support:
|
||||
*
|
||||
* 1. Send a request that exceeds the model's context window
|
||||
* 2. Check the errorMessage in the response
|
||||
* 3. Create a regex pattern that matches the error
|
||||
* 4. The pattern should be added to OVERFLOW_PATTERNS in this file, or
|
||||
* check the errorMessage yourself before calling this function
|
||||
*
|
||||
* @param message - The assistant message to check
|
||||
* @param contextWindow - Optional context window size for detecting silent overflow (z.ai)
|
||||
* @returns true if the message indicates a context overflow
|
||||
*/
|
||||
export function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean {
|
||||
// Case 1: Check error message patterns
|
||||
if (message.stopReason === "error" && message.errorMessage) {
|
||||
// Skip messages matching known non-overflow patterns (e.g. throttling / rate-limit)
|
||||
const isNonOverflow = NON_OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!));
|
||||
if (!isNonOverflow && OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: Silent overflow (z.ai style) - successful but usage exceeds context
|
||||
if (contextWindow && message.stopReason === "stop") {
|
||||
const inputTokens = message.usage.input + message.usage.cacheRead;
|
||||
if (inputTokens > contextWindow) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Case 3: Length-stop overflow (Xiaomi MiMo style) - server truncates oversized input
|
||||
// to fit the context window, leaving no room for output. Returns stopReason "length"
|
||||
// with output=0 and input+cacheRead filling the context window.
|
||||
if (contextWindow && message.stopReason === "length" && message.usage.output === 0) {
|
||||
const inputTokens = message.usage.input + message.usage.cacheRead;
|
||||
if (inputTokens >= contextWindow * 0.99) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a length stop ended below the caller or model's intended output limit.
|
||||
* Such responses may be caused by context pressure or provider-side truncation, so callers
|
||||
* can make one bounded compact-and-retry attempt. `desiredMaxOutput` must be the original
|
||||
* limit before any context-based clamping.
|
||||
*/
|
||||
export function isRecoverableLength(message: AssistantMessage, desiredMaxOutput: number): boolean {
|
||||
return message.stopReason === "length" && desiredMaxOutput > 0 && message.usage.output < desiredMaxOutput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the overflow patterns for testing purposes.
|
||||
*/
|
||||
export function getOverflowPatterns(): RegExp[] {
|
||||
return [...OVERFLOW_PATTERNS];
|
||||
}
|
||||
19
third_party/pi/packages/ai/src/utils/pi-user-agent.ts
vendored
Normal file
19
third_party/pi/packages/ai/src/utils/pi-user-agent.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
import type * as NodeOs from "node:os";
|
||||
|
||||
type ProcessWithOsBuiltinModule = typeof process & {
|
||||
getBuiltinModule?: (id: "node:os") => typeof NodeOs;
|
||||
};
|
||||
|
||||
function loadNodeOs(): typeof NodeOs | null {
|
||||
if (typeof process === "undefined" || !(process.versions?.node || process.versions?.bun)) {
|
||||
return null;
|
||||
}
|
||||
return (process as ProcessWithOsBuiltinModule).getBuiltinModule?.("node:os") ?? null;
|
||||
}
|
||||
|
||||
// Keep runtime OS loading browser-safe. A top-level runtime import of node:os breaks browser/Vite builds.
|
||||
const nodeOs = loadNodeOs();
|
||||
|
||||
export function getPiUserAgent(): string {
|
||||
return nodeOs ? `pi (${nodeOs.platform()} ${nodeOs.release()}; ${nodeOs.arch()})` : "pi (browser)";
|
||||
}
|
||||
52
third_party/pi/packages/ai/src/utils/provider-env.ts
vendored
Normal file
52
third_party/pi/packages/ai/src/utils/provider-env.ts
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { ProviderEnv } from "../types.ts";
|
||||
|
||||
let procEnvCache: Map<string, string> | null = null;
|
||||
|
||||
/**
|
||||
* Fallback for https://github.com/oven-sh/bun/issues/27802.
|
||||
* Bun compiled binaries can expose an empty process.env inside Linux sandboxes
|
||||
* even though /proc/self/environ contains the environment.
|
||||
*
|
||||
* This intentionally duplicates restoreSandboxEnv() in
|
||||
* packages/coding-agent/src/bun/restore-sandbox-env.ts. The ai package can be
|
||||
* used directly, without going through that entrypoint, so provider env lookup
|
||||
* must not depend on process.env having been patched.
|
||||
*/
|
||||
function getBunSandboxEnvValue(name: string): string | undefined {
|
||||
if (typeof process === "undefined" || !process.versions?.bun || Object.keys(process.env).length > 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (procEnvCache === null) {
|
||||
procEnvCache = new Map();
|
||||
try {
|
||||
const { readFileSync } = require("node:fs") as {
|
||||
readFileSync(path: string, encoding: BufferEncoding): string;
|
||||
};
|
||||
const data = readFileSync("/proc/self/environ", "utf-8");
|
||||
for (const entry of data.split("\0")) {
|
||||
const idx = entry.indexOf("=");
|
||||
if (idx > 0) {
|
||||
procEnvCache.set(entry.slice(0, idx), entry.slice(idx + 1));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// /proc/self/environ may not exist or may not be readable.
|
||||
}
|
||||
}
|
||||
|
||||
return procEnvCache.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a provider env value from scoped overrides, normal process.env, then
|
||||
* the duplicated Bun sandbox fallback for direct pi-ai consumers.
|
||||
*/
|
||||
export function getProviderEnvValue(name: string, env?: ProviderEnv): string | undefined {
|
||||
return (
|
||||
env?.[name] ||
|
||||
(typeof process !== "undefined" ? process.env[name] : undefined) ||
|
||||
getBunSandboxEnvValue(name) ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
125
third_party/pi/packages/ai/src/utils/provider-retry.ts
vendored
Normal file
125
third_party/pi/packages/ai/src/utils/provider-retry.ts
vendored
Normal file
@@ -0,0 +1,125 @@
|
||||
const DEFAULT_MAX_RETRY_DELAY_MS = 60_000;
|
||||
|
||||
interface ProviderRetryOptions {
|
||||
maxRetries?: number;
|
||||
maxRetryDelayMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
interface ProviderError extends Error {
|
||||
status: number | undefined;
|
||||
headers: Headers | undefined;
|
||||
}
|
||||
|
||||
function isProviderError(error: unknown): error is ProviderError {
|
||||
if (!(error instanceof Error) || !("status" in error) || !("headers" in error)) return false;
|
||||
return (
|
||||
(error.status === undefined || typeof error.status === "number") &&
|
||||
(error.headers === undefined || error.headers instanceof Headers)
|
||||
);
|
||||
}
|
||||
|
||||
/** Mirrors the pinned OpenAI/Anthropic SDK retry policy; review when either SDK is upgraded. */
|
||||
function isRetryableProviderError(error: ProviderError): boolean {
|
||||
const shouldRetry = error.headers?.get("x-should-retry");
|
||||
if (shouldRetry === "true") return true;
|
||||
if (shouldRetry === "false") return false;
|
||||
|
||||
if (error.status === undefined) return true;
|
||||
return (
|
||||
error.status === 408 ||
|
||||
error.status === 409 ||
|
||||
error.status === 429 ||
|
||||
(typeof error.status === "number" && error.status >= 500)
|
||||
);
|
||||
}
|
||||
|
||||
function validateServerRetryDelayMs(
|
||||
delayMs: number,
|
||||
maxRetryDelayMs: number | undefined,
|
||||
providerErrorMessage: string,
|
||||
): number {
|
||||
const maxDelayMs = maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS;
|
||||
if (maxDelayMs > 0 && delayMs > maxDelayMs) {
|
||||
throw new Error(
|
||||
`Server requested ${Math.ceil(delayMs / 1000)}s retry delay (max: ${Math.ceil(maxDelayMs / 1000)}s). ${providerErrorMessage}`,
|
||||
);
|
||||
}
|
||||
return delayMs;
|
||||
}
|
||||
|
||||
function getRetryDelayMs(error: ProviderError, retryIndex: number, maxRetryDelayMs: number | undefined): number {
|
||||
const retryAfterMs = error.headers?.get("retry-after-ms");
|
||||
if (retryAfterMs) {
|
||||
const value = Number.parseFloat(retryAfterMs);
|
||||
if (!Number.isNaN(value)) return validateServerRetryDelayMs(value, maxRetryDelayMs, error.message);
|
||||
}
|
||||
|
||||
const retryAfter = error.headers?.get("retry-after");
|
||||
if (retryAfter) {
|
||||
const seconds = Number.parseFloat(retryAfter);
|
||||
const delayMs = Number.isNaN(seconds) ? Date.parse(retryAfter) - Date.now() : seconds * 1000;
|
||||
return validateServerRetryDelayMs(delayMs, maxRetryDelayMs, error.message);
|
||||
}
|
||||
|
||||
const exponentialDelay = Math.min(0.5 * 2 ** retryIndex, 8) * 1000;
|
||||
return exponentialDelay * (1 - Math.random() * 0.25);
|
||||
}
|
||||
|
||||
function createAbortError(): Error {
|
||||
const error = new Error("Request aborted");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
|
||||
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(createAbortError());
|
||||
return;
|
||||
}
|
||||
|
||||
const onAbort = () => {
|
||||
clearTimeout(timeout);
|
||||
reject(createAbortError());
|
||||
};
|
||||
const timeout = setTimeout(
|
||||
() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
},
|
||||
Math.max(0, ms),
|
||||
);
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reproduce the retry behavior used by the OpenAI and Anthropic SDKs while making
|
||||
* their backoff sleep interruptible. Their built-in retry timers ignore the
|
||||
* request AbortSignal, so callers must invoke the SDK with `maxRetries: 0` and
|
||||
* wrap the request with this helper. Provider-requested delays above
|
||||
* `maxRetryDelayMs` fail immediately (60 seconds by default); set it to zero to
|
||||
* disable the limit.
|
||||
*/
|
||||
export async function retryProviderRequest<T>(
|
||||
request: () => Promise<T>,
|
||||
options: ProviderRetryOptions = {},
|
||||
): Promise<T> {
|
||||
const maxRetries = options.maxRetries ?? 0;
|
||||
let retriesRemaining = maxRetries;
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
// Each retry is a fresh SDK request, so X-Stainless-Retry-Count remains zero.
|
||||
return await request();
|
||||
} catch (error) {
|
||||
if (options.signal?.aborted) throw createAbortError();
|
||||
if (retriesRemaining <= 0 || !isProviderError(error) || !isRetryableProviderError(error)) throw error;
|
||||
|
||||
const retryIndex = maxRetries - retriesRemaining;
|
||||
retriesRemaining--;
|
||||
await abortableSleep(getRetryDelayMs(error, retryIndex, options.maxRetryDelayMs), options.signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
229
third_party/pi/packages/ai/src/utils/retry.ts
vendored
Normal file
229
third_party/pi/packages/ai/src/utils/retry.ts
vendored
Normal file
@@ -0,0 +1,229 @@
|
||||
import type { AssistantMessage } from "../types.ts";
|
||||
|
||||
function buildProviderErrorPattern(patterns: readonly string[]): RegExp {
|
||||
return new RegExp(patterns.join("|"), "i");
|
||||
}
|
||||
|
||||
const NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN = buildProviderErrorPattern([
|
||||
// OpenCode Go/free-tier limits returned as 429 JSON error types by OpenCode's
|
||||
// Zen API. These are subscription/account limits, not transient throttles.
|
||||
"GoUsageLimitError",
|
||||
"FreeUsageLimitError",
|
||||
|
||||
// OpenCode Go subscription-limit text asks users to enable available-balance
|
||||
// usage after rolling/weekly/monthly limits are reached.
|
||||
"Monthly usage limit reached",
|
||||
"available balance",
|
||||
|
||||
// Generic quota/budget/billing exhaustion. `insufficient_quota` is OpenAI's
|
||||
// quota/billing error code; the other strings cover common gateway wording.
|
||||
"insufficient_quota",
|
||||
"out of budget",
|
||||
"quota exceeded",
|
||||
"billing",
|
||||
]);
|
||||
|
||||
const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([
|
||||
// Generic provider load, HTTP status, and server-side transient failures.
|
||||
"overloaded",
|
||||
"rate.?limit",
|
||||
"too many requests",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503",
|
||||
"504",
|
||||
"524",
|
||||
"service.?unavailable",
|
||||
"server.?error",
|
||||
"internal.?error",
|
||||
|
||||
// Wrapper/provider text for transient upstream failures, including OpenRouter
|
||||
// "Provider returned error" responses (#2264).
|
||||
"provider.?returned.?error",
|
||||
"exceeded request buffer limit while retrying upstream",
|
||||
|
||||
// Network, proxy, and fetch transport failures. This includes OpenAI Codex
|
||||
// raw-fetch failures such as "upstream connect", "connection refused", and
|
||||
// "reset before headers" (#733), plus OpenRouter connection drops (#3317).
|
||||
"network.?error",
|
||||
"connection.?error",
|
||||
"connection.?refused",
|
||||
"connection.?lost",
|
||||
"other side closed",
|
||||
"fetch failed",
|
||||
"getaddrinfo",
|
||||
"ENOTFOUND",
|
||||
"EAI_AGAIN",
|
||||
"upstream.?connect",
|
||||
"reset before headers",
|
||||
"socket hang up",
|
||||
"socket connection was closed",
|
||||
"timed? out",
|
||||
"timeout",
|
||||
"terminated",
|
||||
|
||||
// WebSocket transports can report close/error text instead of HTTP/fetch text.
|
||||
"websocket.?closed",
|
||||
"websocket.?error",
|
||||
|
||||
// Premature stream endings from SDKs and transports. Anthropic can throw
|
||||
// "stream ended without ..." and "Anthropic stream ended before message_stop"
|
||||
// (#4433); Bedrock/Smithy can throw an HTTP/2 no-response error (#3594).
|
||||
"ended without",
|
||||
"stream ended before message_stop",
|
||||
"stream ended before a terminal response event",
|
||||
"http2 request did not get a response",
|
||||
|
||||
// Provider-requested retry delay cap failures should flow through the outer
|
||||
// retry policy so callers can surface/abort the backoff (#1123).
|
||||
"retry delay",
|
||||
|
||||
// Explicit retry guidance emitted mid-stream by OpenAI Responses and Bedrock
|
||||
// stream exceptions (#6019).
|
||||
"you can retry your request",
|
||||
"try your request again",
|
||||
"please retry your request",
|
||||
|
||||
// gRPC based providers (e.g. NVIDIA NIM)
|
||||
"ResourceExhausted",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Retry policy: bounded attempts with exponential backoff (`baseDelayMs * 2^(attempt-1)`).
|
||||
* Matches `settings.retry` (`enabled`, `maxRetries`, `baseDelayMs`) in coding-agent; kept
|
||||
* here so the classifier and the policy-driven retry loop live together and stay reusable
|
||||
* by the SDK and other callers.
|
||||
*/
|
||||
export interface RetryPolicy {
|
||||
enabled: boolean;
|
||||
/** Max retry attempts (0 = no retries). The initial call never counts as a retry. */
|
||||
maxRetries: number;
|
||||
/** Base delay in ms. Per-attempt delay is `baseDelayMs * 2^(attempt-1)` before jitter. */
|
||||
baseDelayMs: number;
|
||||
}
|
||||
|
||||
/** Optional callbacks emitted by {@link retryAssistantCall} around each retry. */
|
||||
export interface RetryCallbacks {
|
||||
/** Emitted before the backoff sleep of each retry attempt (1-indexed). */
|
||||
onRetryScheduled?: (
|
||||
attempt: number,
|
||||
maxAttempts: number,
|
||||
delayMs: number,
|
||||
errorMessage: string,
|
||||
) => void | Promise<void>;
|
||||
/** Emitted after the backoff sleep, immediately before the retried call starts. */
|
||||
onRetryAttemptStart?: () => void | Promise<void>;
|
||||
/** Emitted once when the loop ends: success if a later call completed normally. */
|
||||
onRetryFinished?: (success: boolean, attempt: number, finalError?: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
class RetrySleepAbortError extends Error {
|
||||
constructor() {
|
||||
super("Aborted");
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new RetrySleepAbortError());
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(resolve, ms);
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
reject(new RetrySleepAbortError());
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single assistant-producing call with bounded retry on transient errors.
|
||||
*
|
||||
* Behavior:
|
||||
* - A successful response is returned immediately. Aborts are terminal and never
|
||||
* retried, but reported as unsuccessful if they happen after a retry was scheduled.
|
||||
* Aborts during the backoff sleep are normalized to an aborted `AssistantMessage`
|
||||
* too, so callers do not need to care when cancellation happened.
|
||||
* - A non-retryable error (per {@link isRetryableAssistantError}, including quota/
|
||||
* billing exhaustion) is returned immediately so deterministic errors fail fast.
|
||||
* - Otherwise retries up to `maxRetries` times with exponential backoff, emitting
|
||||
* `onRetryScheduled` before each sleep, `onRetryAttemptStart` after each sleep before
|
||||
* the retried call starts, and `onRetryFinished` once at the end (whether the loop
|
||||
* ends in success, exhausted retries, or an aborted backoff).
|
||||
*
|
||||
* When `policy` is undefined or disabled, the first response is returned unchanged
|
||||
* (equivalent to calling `produce()` directly).
|
||||
*/
|
||||
export async function retryAssistantCall(
|
||||
produce: () => Promise<AssistantMessage>,
|
||||
policy: RetryPolicy | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
callbacks?: RetryCallbacks,
|
||||
): Promise<AssistantMessage> {
|
||||
const maxAttempts = policy?.enabled ? policy.maxRetries : 0;
|
||||
|
||||
let attempt = 0;
|
||||
let lastRetry: { attempt: number; errorMessage: string } | undefined;
|
||||
for (;;) {
|
||||
const response = await produce();
|
||||
|
||||
// Abort: terminal but not successful. Never retry an aborted message.
|
||||
if (response.stopReason === "aborted") {
|
||||
if (lastRetry) await callbacks?.onRetryFinished?.(false, lastRetry.attempt);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Success: non-error, non-abort responses return as-is.
|
||||
if (response.stopReason !== "error") {
|
||||
if (lastRetry) await callbacks?.onRetryFinished?.(true, lastRetry.attempt);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Non-retryable, or budget exhausted: return the final error message.
|
||||
if (attempt >= maxAttempts || !isRetryableAssistantError(response)) {
|
||||
if (lastRetry) await callbacks?.onRetryFinished?.(false, lastRetry.attempt, response.errorMessage);
|
||||
return response;
|
||||
}
|
||||
|
||||
attempt++;
|
||||
lastRetry = { attempt, errorMessage: response.errorMessage || "Unknown error" };
|
||||
const delayMs = policy!.baseDelayMs * 2 ** (attempt - 1);
|
||||
await callbacks?.onRetryScheduled?.(attempt, maxAttempts, delayMs, lastRetry.errorMessage);
|
||||
|
||||
// Normalize aborts during retry backoff to the same AssistantMessage shape as
|
||||
// provider stream aborts, so callers do not need to care when cancellation happened.
|
||||
try {
|
||||
await sleep(delayMs, signal);
|
||||
} catch (error) {
|
||||
await callbacks?.onRetryFinished?.(false, attempt, lastRetry.errorMessage);
|
||||
if (error instanceof RetrySleepAbortError) {
|
||||
const { errorMessage: _errorMessage, ...rest } = response;
|
||||
return { ...rest, stopReason: "aborted" };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await callbacks?.onRetryAttemptStart?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies whether a failed assistant message looks like a transient provider
|
||||
* or transport error, so callers can decide if the last assistant turn should be
|
||||
* restarted.
|
||||
*
|
||||
* This does not implement retry policy. Callers should first handle context
|
||||
* overflow separately, then apply their own retry budget, backoff, and reporting
|
||||
* before restarting the assistant turn.
|
||||
*/
|
||||
export function isRetryableAssistantError(message: AssistantMessage): boolean {
|
||||
if (message.stopReason !== "error" || !message.errorMessage) return false;
|
||||
const errorMessage = message.errorMessage;
|
||||
if (NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN.test(errorMessage)) return false;
|
||||
return RETRYABLE_PROVIDER_ERROR_PATTERN.test(errorMessage);
|
||||
}
|
||||
25
third_party/pi/packages/ai/src/utils/sanitize-unicode.ts
vendored
Normal file
25
third_party/pi/packages/ai/src/utils/sanitize-unicode.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Removes unpaired Unicode surrogate characters from a string.
|
||||
*
|
||||
* Unpaired surrogates (high surrogates 0xD800-0xDBFF without matching low surrogates 0xDC00-0xDFFF,
|
||||
* or vice versa) cause JSON serialization errors in many API providers.
|
||||
*
|
||||
* Valid emoji and other characters outside the Basic Multilingual Plane use properly paired
|
||||
* surrogates and will NOT be affected by this function.
|
||||
*
|
||||
* @param text - The text to sanitize
|
||||
* @returns The sanitized text with unpaired surrogates removed
|
||||
*
|
||||
* @example
|
||||
* // Valid emoji (properly paired surrogates) are preserved
|
||||
* sanitizeSurrogates("Hello 🙈 World") // => "Hello 🙈 World"
|
||||
*
|
||||
* // Unpaired high surrogate is removed
|
||||
* const unpaired = String.fromCharCode(0xD83D); // high surrogate without low
|
||||
* sanitizeSurrogates(`Text ${unpaired} here`) // => "Text here"
|
||||
*/
|
||||
export function sanitizeSurrogates(text: string): string {
|
||||
// Replace unpaired high surrogates (0xD800-0xDBFF not followed by low surrogate)
|
||||
// Replace unpaired low surrogates (0xDC00-0xDFFF not preceded by high surrogate)
|
||||
return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "");
|
||||
}
|
||||
14
third_party/pi/packages/ai/src/utils/sleep.ts
vendored
Normal file
14
third_party/pi/packages/ai/src/utils/sleep.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
export function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
signal.throwIfAborted();
|
||||
const onAbort = () => {
|
||||
clearTimeout(timeout);
|
||||
reject(signal.reason);
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
12
third_party/pi/packages/ai/src/utils/text.ts
vendored
Normal file
12
third_party/pi/packages/ai/src/utils/text.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { ImageContent, TextContent, ThinkingContent, ToolCall } from "../types.ts";
|
||||
|
||||
type Content = TextContent | ImageContent | ThinkingContent | ToolCall;
|
||||
|
||||
/** Extract and join text from message content. */
|
||||
export function contentText(content: string | readonly Content[], separator = "\n"): string {
|
||||
if (typeof content === "string") return content;
|
||||
return content
|
||||
.filter((block) => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join(separator);
|
||||
}
|
||||
24
third_party/pi/packages/ai/src/utils/typebox-helpers.ts
vendored
Normal file
24
third_party/pi/packages/ai/src/utils/typebox-helpers.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
import { type TUnsafe, Type } from "typebox";
|
||||
|
||||
/**
|
||||
* Creates a string enum schema compatible with Google's API and other providers
|
||||
* that don't support anyOf/const patterns.
|
||||
*
|
||||
* @example
|
||||
* const OperationSchema = StringEnum(["add", "subtract", "multiply", "divide"], {
|
||||
* description: "The operation to perform"
|
||||
* });
|
||||
*
|
||||
* type Operation = Static<typeof OperationSchema>; // "add" | "subtract" | "multiply" | "divide"
|
||||
*/
|
||||
export function StringEnum<T extends readonly string[]>(
|
||||
values: T,
|
||||
options?: { description?: string; default?: T[number] },
|
||||
): TUnsafe<T[number]> {
|
||||
return Type.Unsafe<T[number]>({
|
||||
type: "string",
|
||||
enum: values as any,
|
||||
...(options?.description && { description: options.description }),
|
||||
...(options?.default && { default: options.default }),
|
||||
});
|
||||
}
|
||||
45
third_party/pi/packages/ai/src/utils/uuid.ts
vendored
Normal file
45
third_party/pi/packages/ai/src/utils/uuid.ts
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
const MAX_UUID_V7_TIMESTAMP = 0xffffffffffff;
|
||||
const MAX_SEQUENCE = (1n << 41n) - 1n;
|
||||
|
||||
let lastOrdinaryTimestamp = -1;
|
||||
let sequence: bigint | undefined;
|
||||
|
||||
/** Generate a time-ordered UUIDv7. A supplied timestamp is preserved for follower ids. */
|
||||
export function uuidv7(timestampMs?: number): string {
|
||||
const requestedTimestamp = timestampMs ?? Date.now();
|
||||
if (!Number.isInteger(requestedTimestamp) || requestedTimestamp < 0 || requestedTimestamp > MAX_UUID_V7_TIMESTAMP) {
|
||||
throw new RangeError(`UUIDv7 timestamp must be an integer between 0 and ${MAX_UUID_V7_TIMESTAMP}`);
|
||||
}
|
||||
|
||||
const effectiveTimestamp =
|
||||
timestampMs === undefined ? Math.max(requestedTimestamp, lastOrdinaryTimestamp) : timestampMs;
|
||||
if (timestampMs === undefined) lastOrdinaryTimestamp = effectiveTimestamp;
|
||||
|
||||
const bytes = new Uint8Array(16);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
if (sequence === undefined) {
|
||||
sequence =
|
||||
(BigInt(bytes[1]) << 32n) |
|
||||
(BigInt(bytes[2]) << 24n) |
|
||||
(BigInt(bytes[3]) << 16n) |
|
||||
(BigInt(bytes[4]) << 8n) |
|
||||
BigInt(bytes[5]);
|
||||
} else {
|
||||
if (sequence === MAX_SEQUENCE) throw new RangeError("UUIDv7 generator sequence exhausted");
|
||||
sequence++;
|
||||
}
|
||||
|
||||
const timestamp = BigInt(effectiveTimestamp);
|
||||
for (let index = 5; index >= 0; index--) {
|
||||
bytes[index] = Number(timestamp >> BigInt((5 - index) * 8)) & 0xff;
|
||||
}
|
||||
bytes[6] = 0x70 | Number((sequence >> 37n) & 0x0fn);
|
||||
bytes[7] = Number((sequence >> 29n) & 0xffn);
|
||||
bytes[8] = 0x80 | Number((sequence >> 23n) & 0x3fn);
|
||||
bytes[9] = Number((sequence >> 15n) & 0xffn);
|
||||
bytes[10] = Number((sequence >> 7n) & 0xffn);
|
||||
bytes[11] = Number((sequence & 0x7fn) << 1n) | (bytes[11] & 0x01);
|
||||
|
||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
|
||||
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
|
||||
}
|
||||
350
third_party/pi/packages/ai/src/utils/validation.ts
vendored
Normal file
350
third_party/pi/packages/ai/src/utils/validation.ts
vendored
Normal file
@@ -0,0 +1,350 @@
|
||||
import { Compile } from "typebox/compile";
|
||||
import type { TLocalizedValidationError } from "typebox/error";
|
||||
import { Value } from "typebox/value";
|
||||
import type { Tool, ToolCall } from "../types.ts";
|
||||
|
||||
const validatorCache = new WeakMap<object, ReturnType<typeof Compile>>();
|
||||
const TYPEBOX_KIND = Symbol.for("TypeBox.Kind");
|
||||
|
||||
interface JsonSchemaObject {
|
||||
type?: string | string[];
|
||||
properties?: Record<string, JsonSchemaObject>;
|
||||
required?: string[];
|
||||
items?: JsonSchemaObject | JsonSchemaObject[];
|
||||
additionalProperties?: boolean | JsonSchemaObject;
|
||||
allOf?: JsonSchemaObject[];
|
||||
anyOf?: JsonSchemaObject[];
|
||||
oneOf?: JsonSchemaObject[];
|
||||
}
|
||||
|
||||
function getSchemaTypes(schema: JsonSchemaObject): string[] {
|
||||
if (typeof schema.type === "string") {
|
||||
return [schema.type];
|
||||
}
|
||||
if (Array.isArray(schema.type)) {
|
||||
return schema.type.filter((type): type is string => typeof type === "string");
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function matchesJsonType(value: unknown, type: string): boolean {
|
||||
switch (type) {
|
||||
case "number":
|
||||
return typeof value === "number";
|
||||
case "integer":
|
||||
return typeof value === "number" && Number.isInteger(value);
|
||||
case "boolean":
|
||||
return typeof value === "boolean";
|
||||
case "string":
|
||||
return typeof value === "string";
|
||||
case "null":
|
||||
return value === null;
|
||||
case "array":
|
||||
return Array.isArray(value);
|
||||
case "object":
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getSubSchemaValidator(schema: JsonSchemaObject): ReturnType<typeof Compile> | undefined {
|
||||
try {
|
||||
return getValidator(schema as Tool["parameters"]);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function coercePrimitiveByType(value: unknown, type: string): unknown {
|
||||
switch (type) {
|
||||
case "number": {
|
||||
if (value === null) {
|
||||
return 0;
|
||||
}
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return value ? 1 : 0;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
case "integer": {
|
||||
if (value === null) {
|
||||
return 0;
|
||||
}
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
const parsed = Number(value);
|
||||
if (Number.isInteger(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return value ? 1 : 0;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
case "boolean": {
|
||||
if (value === null) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
if (value === "true") {
|
||||
return true;
|
||||
}
|
||||
if (value === "false") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
if (value === 1) {
|
||||
return true;
|
||||
}
|
||||
if (value === 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
case "string": {
|
||||
if (value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
case "null": {
|
||||
if (value === "" || value === 0 || value === false) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function applySchemaObjectCoercion(value: Record<string, unknown>, schema: JsonSchemaObject): void {
|
||||
const properties = schema.properties;
|
||||
const definedKeys = new Set<string>(properties ? Object.keys(properties) : []);
|
||||
|
||||
if (properties) {
|
||||
for (const [key, propertySchema] of Object.entries(properties)) {
|
||||
if (!(key in value)) {
|
||||
continue;
|
||||
}
|
||||
value[key] = coerceWithJsonSchema(value[key], propertySchema);
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
||||
for (const [key, propertyValue] of Object.entries(value)) {
|
||||
if (definedKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
value[key] = coerceWithJsonSchema(propertyValue, schema.additionalProperties);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applySchemaArrayCoercion(value: unknown[], schema: JsonSchemaObject): void {
|
||||
if (Array.isArray(schema.items)) {
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const itemSchema = schema.items[index];
|
||||
if (!itemSchema) {
|
||||
continue;
|
||||
}
|
||||
value[index] = coerceWithJsonSchema(value[index], itemSchema);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.items && typeof schema.items === "object") {
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
value[index] = coerceWithJsonSchema(value[index], schema.items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function coerceWithUnionSchema(value: unknown, schemas: JsonSchemaObject[]): unknown {
|
||||
for (const schema of schemas) {
|
||||
const validator = getSubSchemaValidator(schema);
|
||||
if (validator?.Check(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
for (const schema of schemas) {
|
||||
const candidate = structuredClone(value);
|
||||
const coerced = coerceWithJsonSchema(candidate, schema);
|
||||
const validator = getSubSchemaValidator(schema);
|
||||
if (validator?.Check(coerced)) {
|
||||
return coerced;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function coerceWithJsonSchema(value: unknown, schema: JsonSchemaObject): unknown {
|
||||
let nextValue = value;
|
||||
|
||||
if (Array.isArray(schema.allOf)) {
|
||||
for (const nested of schema.allOf) {
|
||||
nextValue = coerceWithJsonSchema(nextValue, nested);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.anyOf)) {
|
||||
nextValue = coerceWithUnionSchema(nextValue, schema.anyOf);
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.oneOf)) {
|
||||
nextValue = coerceWithUnionSchema(nextValue, schema.oneOf);
|
||||
}
|
||||
|
||||
const schemaTypes = getSchemaTypes(schema);
|
||||
const matchesUnionMember =
|
||||
schemaTypes.length > 1 && schemaTypes.some((schemaType) => matchesJsonType(nextValue, schemaType));
|
||||
if (schemaTypes.length > 0 && !matchesUnionMember) {
|
||||
for (const schemaType of schemaTypes) {
|
||||
const candidate = coercePrimitiveByType(nextValue, schemaType);
|
||||
if (candidate !== nextValue) {
|
||||
nextValue = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
schemaTypes.includes("object") &&
|
||||
typeof nextValue === "object" &&
|
||||
nextValue !== null &&
|
||||
!Array.isArray(nextValue)
|
||||
) {
|
||||
applySchemaObjectCoercion(nextValue as Record<string, unknown>, schema);
|
||||
}
|
||||
|
||||
if (schemaTypes.includes("array") && Array.isArray(nextValue)) {
|
||||
applySchemaArrayCoercion(nextValue, schema);
|
||||
}
|
||||
|
||||
return nextValue;
|
||||
}
|
||||
|
||||
function normalizeOptionalNulls(value: unknown, schema: JsonSchemaObject): void {
|
||||
if (Array.isArray(value)) {
|
||||
if (Array.isArray(schema.items)) {
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const itemSchema = schema.items[index];
|
||||
if (itemSchema) normalizeOptionalNulls(value[index], itemSchema);
|
||||
}
|
||||
} else if (schema.items) {
|
||||
for (const item of value) normalizeOptionalNulls(item, schema.items);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof value !== "object" || value === null || !schema.properties) return;
|
||||
|
||||
const object = value as Record<string, unknown>;
|
||||
const required = new Set(schema.required ?? []);
|
||||
for (const [key, propertySchema] of Object.entries(schema.properties)) {
|
||||
if (!(key in object)) continue;
|
||||
if (
|
||||
object[key] === null &&
|
||||
!required.has(key) &&
|
||||
typeof (propertySchema as { $ref?: unknown }).$ref !== "string" &&
|
||||
getSubSchemaValidator(propertySchema)?.Check(null) === false
|
||||
) {
|
||||
delete object[key];
|
||||
} else {
|
||||
normalizeOptionalNulls(object[key], propertySchema);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getValidator(schema: Tool["parameters"]): ReturnType<typeof Compile> {
|
||||
const key = schema as object;
|
||||
const cached = validatorCache.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const validator = Compile(schema);
|
||||
validatorCache.set(key, validator);
|
||||
return validator;
|
||||
}
|
||||
|
||||
function formatValidationPath(error: TLocalizedValidationError): string {
|
||||
if (error.keyword === "required") {
|
||||
const requiredProperties = (error.params as { requiredProperties?: string[] }).requiredProperties;
|
||||
const requiredProperty = requiredProperties?.[0];
|
||||
if (requiredProperty) {
|
||||
const basePath = error.instancePath.replace(/^\//, "").replace(/\//g, ".");
|
||||
return basePath ? `${basePath}.${requiredProperty}` : requiredProperty;
|
||||
}
|
||||
}
|
||||
const path = error.instancePath.replace(/^\//, "").replace(/\//g, ".");
|
||||
return path || "root";
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a tool by name and validates the tool call arguments against its TypeBox schema
|
||||
* @param tools Array of tool definitions
|
||||
* @param toolCall The tool call from the LLM
|
||||
* @returns The validated arguments
|
||||
* @throws Error if tool is not found or validation fails
|
||||
*/
|
||||
export function validateToolCall(tools: Tool[], toolCall: ToolCall): any {
|
||||
const tool = tools.find((t) => t.name === toolCall.name);
|
||||
if (!tool) {
|
||||
throw new Error(`Tool "${toolCall.name}" not found`);
|
||||
}
|
||||
return validateToolArguments(tool, toolCall);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates tool call arguments against the tool's TypeBox schema
|
||||
* @param tool The tool definition with TypeBox schema
|
||||
* @param toolCall The tool call from the LLM
|
||||
* @returns The validated (and potentially coerced) arguments
|
||||
* @throws Error with formatted message if validation fails
|
||||
*/
|
||||
export function validateToolArguments(tool: Tool, toolCall: ToolCall): any {
|
||||
const args = structuredClone(toolCall.arguments);
|
||||
normalizeOptionalNulls(args, tool.parameters as JsonSchemaObject);
|
||||
Value.Convert(tool.parameters, args);
|
||||
|
||||
const validator = getValidator(tool.parameters);
|
||||
if (!Object.getOwnPropertySymbols(tool.parameters).includes(TYPEBOX_KIND)) {
|
||||
const coerced = coerceWithJsonSchema(args, tool.parameters as JsonSchemaObject);
|
||||
if (coerced !== args) {
|
||||
if (typeof args === "object" && args !== null && typeof coerced === "object" && coerced !== null) {
|
||||
for (const key of Object.keys(args)) {
|
||||
delete args[key];
|
||||
}
|
||||
Object.assign(args, coerced);
|
||||
} else {
|
||||
return validator.Check(coerced) ? coerced : args;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (validator.Check(args)) {
|
||||
return args;
|
||||
}
|
||||
|
||||
const errors =
|
||||
validator
|
||||
.Errors(args)
|
||||
.map((error) => ` - ${formatValidationPath(error)}: ${error.message}`)
|
||||
.join("\n") || "Unknown validation error";
|
||||
|
||||
const errorMessage = `Validation failed for tool "${toolCall.name}":\n${errors}\n\nReceived arguments:\n${JSON.stringify(toolCall.arguments, null, 2)}`;
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
Reference in New Issue
Block a user