chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import type OpenAI from "openai";
|
||||
import type {
|
||||
Tool as OpenAITool,
|
||||
ResponseConfigurationUpdateItemParam,
|
||||
ResponseCreateParamsStreaming,
|
||||
ResponseInput,
|
||||
ResponseInputContent,
|
||||
@@ -17,21 +18,25 @@ import { calculateCost } from "../models.ts";
|
||||
import type {
|
||||
Api,
|
||||
AssistantMessage,
|
||||
Context,
|
||||
ImageContent,
|
||||
Model,
|
||||
StopReason,
|
||||
StreamOptions,
|
||||
SystemMessage,
|
||||
TextContent,
|
||||
TextSignatureV1,
|
||||
ThinkingContent,
|
||||
Tool,
|
||||
ToolCall,
|
||||
TranscriptContext,
|
||||
Usage,
|
||||
} from "../types.ts";
|
||||
import type { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { shortHash } from "../utils/hash.ts";
|
||||
import { parseStreamingJson } from "../utils/json-parse.ts";
|
||||
import { parseStreamingJson, parseToolCallArguments } from "../utils/json-parse.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import { getSystemMessageText, renderSystemMessageUpdate } from "../utils/text.ts";
|
||||
import { resolveTranscript, resolveTranscriptTools } from "../utils/transcript.ts";
|
||||
import {
|
||||
appendGrammarToolInputJsonDelta,
|
||||
type GrammarToolInputJsonBuffer,
|
||||
@@ -104,6 +109,7 @@ function convertToolResultOutput<TApi extends Api>(
|
||||
}
|
||||
|
||||
export interface OpenAIResponsesStreamOptions {
|
||||
onProviderStreamEvent?: StreamOptions["onProviderStreamEvent"];
|
||||
serviceTier?: ResponseCreateParamsStreaming["service_tier"];
|
||||
grammarToolInputProperties?: ReadonlyMap<string, string>;
|
||||
resolveServiceTier?: (
|
||||
@@ -119,16 +125,28 @@ export interface OpenAIResponsesStreamOptions {
|
||||
export interface ConvertResponsesMessagesOptions {
|
||||
includeSystemPrompt?: boolean;
|
||||
grammarToolInputProperties?: ReadonlyMap<string, string>;
|
||||
deferredTools?: ReadonlyMap<string, Tool>;
|
||||
deferredToolsMode?: "additional-tools" | "tool-search";
|
||||
/** Whether later system messages are sent in place; otherwise they are folded into the leading prompt. */
|
||||
supportsMidConvoSystemMessages?: boolean;
|
||||
supportsAdditionalTools?: boolean;
|
||||
supportsToolSearch?: boolean;
|
||||
toolOptions?: ConvertResponsesToolsOptions;
|
||||
/**
|
||||
* Effort for the reply being generated. When set, the thinking level is sent as
|
||||
* configuration_update items instead of a changing reasoning.effort, which would invalidate the
|
||||
* prompt cache. Each item sets the effort from that point on: one goes before every earlier
|
||||
* reply with the level recorded on it (`providerThinkingLevel`), and one with this effort goes
|
||||
* at the end. Earlier input therefore never changes. OpenAI documents the item before user
|
||||
* messages only. After tool results it was verified on gpt-6-luna (October 2026) to be accepted
|
||||
* and to apply to the next reply.
|
||||
*/
|
||||
configurationUpdateEffort?: string;
|
||||
}
|
||||
|
||||
export interface ConvertResponsesToolsOptions {
|
||||
strict?: boolean | null;
|
||||
supportsStrictMode?: boolean;
|
||||
supportsOpenAIGrammarTools?: boolean;
|
||||
deferLoading?: boolean;
|
||||
toolSearchResult?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -137,12 +155,12 @@ export interface ConvertResponsesToolsOptions {
|
||||
|
||||
export function convertResponsesMessages<TApi extends Api>(
|
||||
model: Model<TApi>,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
allowedToolCallProviders: ReadonlySet<string>,
|
||||
options?: ConvertResponsesMessagesOptions,
|
||||
): ResponseInput {
|
||||
const normalizedContext = resolveTranscript(context, options?.supportsMidConvoSystemMessages);
|
||||
const messages: ResponseInput = [];
|
||||
const loadedToolNames = new Set<string>();
|
||||
|
||||
const normalizeIdPart = (part: string): string => {
|
||||
const sanitized = part.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
@@ -169,21 +187,63 @@ export function convertResponsesMessages<TApi extends Api>(
|
||||
return `${normalizedCallId}|${normalizedItemId}`;
|
||||
};
|
||||
|
||||
const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
|
||||
|
||||
const includeSystemPrompt = options?.includeSystemPrompt ?? true;
|
||||
if (includeSystemPrompt && context.systemPrompt) {
|
||||
const compat = model.compat as { supportsDeveloperRole?: boolean } | undefined;
|
||||
const role = model.reasoning && compat?.supportsDeveloperRole !== false ? "developer" : "system";
|
||||
const transformedMessages = transformMessages(normalizedContext.messages, model, normalizeToolCallId);
|
||||
const transcriptTools = resolveTranscriptTools(
|
||||
normalizedContext.messages,
|
||||
(options?.supportsAdditionalTools ?? false) || (options?.supportsToolSearch ?? false),
|
||||
);
|
||||
const appendSystemToolAdditions = (message: SystemMessage, seed: string): void => {
|
||||
const tools = transcriptTools.anchorsAdditions ? (message.toolsAdded ?? []) : [];
|
||||
if (tools.length === 0) return;
|
||||
if (options?.supportsAdditionalTools) {
|
||||
messages.push({
|
||||
type: "additional_tools",
|
||||
role: "developer",
|
||||
tools: convertResponsesTools(tools, options.toolOptions),
|
||||
} satisfies ResponseInputItem);
|
||||
return;
|
||||
}
|
||||
if (!options?.supportsToolSearch) return;
|
||||
const names = tools.map((tool) => tool.name);
|
||||
const callId = `pi_tool_load_${shortHash(`${seed}:${names.join(",")}`)}`;
|
||||
messages.push({
|
||||
role,
|
||||
content: sanitizeSurrogates(context.systemPrompt),
|
||||
});
|
||||
}
|
||||
type: "tool_search_call",
|
||||
call_id: callId,
|
||||
execution: "client",
|
||||
status: "completed",
|
||||
arguments: { query: names.join(" "), limit: names.length },
|
||||
} satisfies ResponseInputItem);
|
||||
messages.push({
|
||||
type: "tool_search_output",
|
||||
call_id: callId,
|
||||
execution: "client",
|
||||
status: "completed",
|
||||
tools: convertResponsesTools(tools, { ...options.toolOptions, toolSearchResult: true }),
|
||||
} satisfies ResponseToolSearchOutputItemParam);
|
||||
};
|
||||
const includeInitialSystemMessage = options?.includeSystemPrompt ?? true;
|
||||
const pushConfigurationUpdate = (effort: string): void => {
|
||||
messages.push({
|
||||
type: "configuration_update",
|
||||
reasoning: { effort: effort as NonNullable<ResponseConfigurationUpdateItemParam.Reasoning["effort"]> },
|
||||
} satisfies ResponseConfigurationUpdateItemParam);
|
||||
};
|
||||
const compat = model.compat as { supportsDeveloperRole?: boolean } | undefined;
|
||||
const instructionRole = model.reasoning && compat?.supportsDeveloperRole !== false ? "developer" : "system";
|
||||
|
||||
let msgIndex = 0;
|
||||
let sourceIndex = 0;
|
||||
for (const msg of transformedMessages) {
|
||||
if (msg.role === "user") {
|
||||
const isLeadingSystemMessage = sourceIndex++ === 0 && msg.role === "system";
|
||||
if (msg.role === "system") {
|
||||
if (!isLeadingSystemMessage) appendSystemToolAdditions(msg, `system:${msgIndex}`);
|
||||
if (!isLeadingSystemMessage || includeInitialSystemMessage) {
|
||||
const text = isLeadingSystemMessage ? getSystemMessageText(msg) : renderSystemMessageUpdate(msg);
|
||||
if (text.length > 0) {
|
||||
messages.push({ role: instructionRole, content: sanitizeSurrogates(text) });
|
||||
}
|
||||
}
|
||||
} else if (msg.role === "user") {
|
||||
if (typeof msg.content === "string") {
|
||||
messages.push({
|
||||
role: "user",
|
||||
@@ -251,19 +311,16 @@ export function convertResponsesMessages<TApi extends Api>(
|
||||
let itemId: string | undefined = itemIdRaw;
|
||||
|
||||
// For different-model messages, set id to undefined to avoid pairing validation.
|
||||
// OpenAI tracks which fc_xxx IDs were paired with rs_xxx reasoning items.
|
||||
// OpenAI tracks which item IDs were paired with rs_xxx reasoning items.
|
||||
// By omitting the id, we avoid triggering that validation (like cross-provider does).
|
||||
// When replaying custom-tool calls as a function_call, also drop non-fc_* ids such as
|
||||
// ctc_* custom-tool ids because function_call item ids must be fc_*.
|
||||
if (
|
||||
(isDifferentModel && itemId?.startsWith("fc_")) ||
|
||||
(customInputProperty === undefined && !itemId?.startsWith("fc_"))
|
||||
) {
|
||||
// Also drop ids that do not match the replayed item type: function_call ids must be fc_*
|
||||
// and custom_tool_call ids must be ctc_*. Foreign tool call ids are normalized to fc_*, and
|
||||
// a call can switch between the two types when grammar tool support differs.
|
||||
const itemIdPrefix = customInputProperty === undefined ? "fc_" : "ctc_";
|
||||
if (isDifferentModel || !itemId?.startsWith(itemIdPrefix)) {
|
||||
itemId = undefined;
|
||||
}
|
||||
|
||||
const canReplayNamespace = isSameModel || options?.deferredTools?.has(toolCall.name) === true;
|
||||
|
||||
if (customInputProperty !== undefined) {
|
||||
output.push({
|
||||
type: "custom_tool_call",
|
||||
@@ -273,9 +330,7 @@ export function convertResponsesMessages<TApi extends Api>(
|
||||
input: sanitizeSurrogates(
|
||||
getGrammarToolInput(toolCall.name, toolCall.arguments, customInputProperty),
|
||||
),
|
||||
...(canReplayNamespace && toolCall.namespace !== undefined
|
||||
? { namespace: toolCall.namespace }
|
||||
: {}),
|
||||
...(isSameModel && toolCall.namespace !== undefined ? { namespace: toolCall.namespace } : {}),
|
||||
} satisfies ResponseOutputItem);
|
||||
} else {
|
||||
output.push({
|
||||
@@ -284,14 +339,19 @@ export function convertResponsesMessages<TApi extends Api>(
|
||||
call_id: callId,
|
||||
name: toolCall.name,
|
||||
arguments: JSON.stringify(toolCall.arguments),
|
||||
...(canReplayNamespace && toolCall.namespace !== undefined
|
||||
? { namespace: toolCall.namespace }
|
||||
: {}),
|
||||
...(isSameModel && toolCall.namespace !== undefined ? { namespace: toolCall.namespace } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (output.length === 0) continue;
|
||||
if (
|
||||
options?.configurationUpdateEffort !== undefined &&
|
||||
isSameModel &&
|
||||
msg.providerThinkingLevel !== undefined
|
||||
) {
|
||||
pushConfigurationUpdate(msg.providerThinkingLevel);
|
||||
}
|
||||
messages.push(...output);
|
||||
} else if (msg.role === "toolResult") {
|
||||
const [callId] = msg.toolCallId.split("|");
|
||||
@@ -310,44 +370,10 @@ export function convertResponsesMessages<TApi extends Api>(
|
||||
output,
|
||||
});
|
||||
}
|
||||
|
||||
const deferredTools: Tool[] = [];
|
||||
for (const name of msg.addedToolNames ?? []) {
|
||||
const tool = options?.deferredTools?.get(name);
|
||||
if (!tool || loadedToolNames.has(name)) continue;
|
||||
loadedToolNames.add(name);
|
||||
deferredTools.push(tool);
|
||||
}
|
||||
if (deferredTools.length > 0 && options?.deferredToolsMode === "additional-tools") {
|
||||
messages.push({
|
||||
type: "additional_tools",
|
||||
role: "developer",
|
||||
tools: convertResponsesTools(deferredTools, options.toolOptions),
|
||||
} satisfies ResponseInputItem);
|
||||
} else if (deferredTools.length > 0 && options?.deferredToolsMode === "tool-search") {
|
||||
const names = deferredTools.map((tool) => tool.name);
|
||||
const searchCallId = `pi_tool_load_${shortHash(`${msg.toolCallId}:${names.join(",")}`)}`;
|
||||
messages.push({
|
||||
type: "tool_search_call",
|
||||
call_id: searchCallId,
|
||||
execution: "client",
|
||||
status: "completed",
|
||||
arguments: { query: names.join(" "), limit: names.length },
|
||||
} satisfies ResponseInputItem);
|
||||
messages.push({
|
||||
type: "tool_search_output",
|
||||
call_id: searchCallId,
|
||||
execution: "client",
|
||||
status: "completed",
|
||||
tools: convertResponsesTools(deferredTools, {
|
||||
...options.toolOptions,
|
||||
deferLoading: true,
|
||||
}),
|
||||
} satisfies ResponseToolSearchOutputItemParam);
|
||||
}
|
||||
}
|
||||
msgIndex++;
|
||||
if (!isLeadingSystemMessage) msgIndex++;
|
||||
}
|
||||
if (options?.configurationUpdateEffort !== undefined) pushConfigurationUpdate(options.configurationUpdateEffort);
|
||||
|
||||
return messages;
|
||||
}
|
||||
@@ -373,7 +399,7 @@ export function convertResponsesTools(tools: readonly Tool[], options?: ConvertR
|
||||
syntax: grammar.format,
|
||||
definition: grammar.definition,
|
||||
},
|
||||
...(options?.deferLoading ? { defer_loading: true } : {}),
|
||||
...(options?.toolSearchResult ? { defer_loading: true } : {}),
|
||||
} satisfies OpenAITool;
|
||||
}
|
||||
|
||||
@@ -386,7 +412,7 @@ export function convertResponsesTools(tools: readonly Tool[], options?: ConvertR
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: getJsonSchemaToolParameters(tool, strict === true) as Record<string, unknown>,
|
||||
...(options?.deferLoading ? { defer_loading: true } : {}),
|
||||
...(options?.toolSearchResult ? { defer_loading: true } : {}),
|
||||
};
|
||||
if (supportsStrictMode) {
|
||||
functionTool.strict = strict;
|
||||
@@ -596,6 +622,7 @@ export async function processResponsesStream<TApi extends Api>(
|
||||
};
|
||||
|
||||
for await (const event of openaiStream) {
|
||||
await options?.onProviderStreamEvent?.(event, model);
|
||||
if (event.type === "response.created") {
|
||||
output.responseId = event.response.id;
|
||||
} else if (event.type === "response.output_item.added") {
|
||||
@@ -711,7 +738,7 @@ export async function processResponsesStream<TApi extends Api>(
|
||||
slot?.type === "toolCall" &&
|
||||
slot.block.partialJson !== undefined
|
||||
) {
|
||||
slot.block.arguments = parseStreamingJson(item.arguments || slot.block.partialJson || "{}");
|
||||
Object.assign(slot.block, parseToolCallArguments(item.arguments || slot.block.partialJson));
|
||||
if (item.namespace !== undefined) slot.block.namespace = item.namespace;
|
||||
// Finalize in-place and strip the scratch buffer so replay only
|
||||
// carries parsed arguments.
|
||||
@@ -758,6 +785,20 @@ export async function processResponsesStream<TApi extends Api>(
|
||||
if (!sawTerminalResponseEvent) {
|
||||
throw new Error("OpenAI Responses stream ended before a terminal response event");
|
||||
}
|
||||
// The agent runs every tool call in the final message. Refuse to hand over calls whose
|
||||
// output_item.done never arrived: their arguments may be cut off or mixed up, e.g. when a
|
||||
// non-compliant server omits output_index. Finished calls have their scratch buffers removed.
|
||||
if (output.stopReason === "toolUse") {
|
||||
for (const block of output.content) {
|
||||
if (block.type !== "toolCall") continue;
|
||||
const toolCall = block as StreamingToolCall;
|
||||
if (toolCall.partialJson !== undefined || toolCall.customInput !== undefined) {
|
||||
throw new Error(
|
||||
`OpenAI Responses stream completed with an unfinished tool call: ${toolCall.name} (${toolCall.id})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mapStopReason(
|
||||
|
||||
Reference in New Issue
Block a user