fix(ai): preserve responses reasoning for out-of-order items
closes #6009
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed OpenAI Responses streams to preserve reasoning replay state when output items finish out of order ([#6009](https://github.com/earendil-works/pi/issues/6009)).
|
||||||
- Fixed retry classification for provider errors that explicitly tell callers to retry the request ([#6019](https://github.com/earendil-works/pi/issues/6019)).
|
- Fixed retry classification for provider errors that explicitly tell callers to retry the request ([#6019](https://github.com/earendil-works/pi/issues/6019)).
|
||||||
|
|
||||||
## [0.80.2] - 2026-06-23
|
## [0.80.2] - 2026-06-23
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import type {
|
|||||||
Tool as OpenAITool,
|
Tool as OpenAITool,
|
||||||
ResponseCreateParamsStreaming,
|
ResponseCreateParamsStreaming,
|
||||||
ResponseFunctionCallOutputItemList,
|
ResponseFunctionCallOutputItemList,
|
||||||
ResponseFunctionToolCall,
|
|
||||||
ResponseInput,
|
ResponseInput,
|
||||||
ResponseInputContent,
|
ResponseInputContent,
|
||||||
ResponseInputImage,
|
ResponseInputImage,
|
||||||
ResponseInputText,
|
ResponseInputText,
|
||||||
|
ResponseOutputItem,
|
||||||
ResponseOutputMessage,
|
ResponseOutputMessage,
|
||||||
ResponseReasoningItem,
|
ResponseReasoningItem,
|
||||||
ResponseStreamEvent,
|
ResponseStreamEvent,
|
||||||
@@ -285,6 +285,13 @@ export function convertResponsesTools(tools: Tool[], options?: ConvertResponsesT
|
|||||||
// Stream processing
|
// Stream processing
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
type StreamingToolCall = ToolCall & { partialJson: string };
|
||||||
|
|
||||||
|
type ResponsesOutputSlot =
|
||||||
|
| { type: "thinking"; block: ThinkingContent; contentIndex: number }
|
||||||
|
| { type: "text"; block: TextContent; contentIndex: number }
|
||||||
|
| { type: "toolCall"; block: StreamingToolCall; contentIndex: number };
|
||||||
|
|
||||||
export async function processResponsesStream<TApi extends Api>(
|
export async function processResponsesStream<TApi extends Api>(
|
||||||
openaiStream: AsyncIterable<ResponseStreamEvent>,
|
openaiStream: AsyncIterable<ResponseStreamEvent>,
|
||||||
output: AssistantMessage,
|
output: AssistantMessage,
|
||||||
@@ -292,11 +299,59 @@ export async function processResponsesStream<TApi extends Api>(
|
|||||||
model: Model<TApi>,
|
model: Model<TApi>,
|
||||||
options?: OpenAIResponsesStreamOptions,
|
options?: OpenAIResponsesStreamOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let currentItem: ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall | null = null;
|
|
||||||
let currentBlock: ThinkingContent | TextContent | (ToolCall & { partialJson: string }) | null = null;
|
|
||||||
let sawTerminalResponseEvent = false;
|
let sawTerminalResponseEvent = false;
|
||||||
const blocks = output.content;
|
const outputSlots = new Map<number, ResponsesOutputSlot>();
|
||||||
const blockIndex = () => blocks.length - 1;
|
const getSlot = <TType extends ResponsesOutputSlot["type"]>(
|
||||||
|
outputIndex: number,
|
||||||
|
type: TType,
|
||||||
|
): Extract<ResponsesOutputSlot, { type: TType }> | undefined => {
|
||||||
|
const slot = outputSlots.get(outputIndex);
|
||||||
|
return slot?.type === type ? (slot as Extract<ResponsesOutputSlot, { type: TType }>) : undefined;
|
||||||
|
};
|
||||||
|
const createSlot = (outputIndex: number, item: ResponseOutputItem): ResponsesOutputSlot | undefined => {
|
||||||
|
if (item.type === "reasoning") {
|
||||||
|
const block: ThinkingContent = { type: "thinking", thinking: "" };
|
||||||
|
output.content.push(block);
|
||||||
|
const slot = {
|
||||||
|
type: "thinking",
|
||||||
|
block,
|
||||||
|
contentIndex: output.content.length - 1,
|
||||||
|
} satisfies ResponsesOutputSlot;
|
||||||
|
outputSlots.set(outputIndex, slot);
|
||||||
|
stream.push({ type: "thinking_start", contentIndex: slot.contentIndex, partial: output });
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
if (item.type === "message") {
|
||||||
|
const block: TextContent = { type: "text", text: "" };
|
||||||
|
output.content.push(block);
|
||||||
|
const slot = { type: "text", block, contentIndex: output.content.length - 1 } satisfies ResponsesOutputSlot;
|
||||||
|
outputSlots.set(outputIndex, slot);
|
||||||
|
stream.push({ type: "text_start", contentIndex: slot.contentIndex, partial: output });
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
if (item.type === "function_call") {
|
||||||
|
const block: StreamingToolCall = {
|
||||||
|
type: "toolCall",
|
||||||
|
id: `${item.call_id}|${item.id}`,
|
||||||
|
name: item.name,
|
||||||
|
arguments: {},
|
||||||
|
partialJson: item.arguments || "",
|
||||||
|
};
|
||||||
|
output.content.push(block);
|
||||||
|
const slot = {
|
||||||
|
type: "toolCall",
|
||||||
|
block,
|
||||||
|
contentIndex: output.content.length - 1,
|
||||||
|
} satisfies ResponsesOutputSlot;
|
||||||
|
outputSlots.set(outputIndex, slot);
|
||||||
|
stream.push({ type: "toolcall_start", contentIndex: slot.contentIndex, partial: output });
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
const getOrCreateSlot = (outputIndex: number, item: ResponseOutputItem): ResponsesOutputSlot | undefined => {
|
||||||
|
return outputSlots.get(outputIndex) ?? createSlot(outputIndex, item);
|
||||||
|
};
|
||||||
const finalizeResponse = (
|
const finalizeResponse = (
|
||||||
response: Extract<ResponseStreamEvent, { type: "response.completed" | "response.incomplete" }>["response"],
|
response: Extract<ResponseStreamEvent, { type: "response.completed" | "response.incomplete" }>["response"],
|
||||||
): void => {
|
): void => {
|
||||||
@@ -335,195 +390,124 @@ export async function processResponsesStream<TApi extends Api>(
|
|||||||
if (event.type === "response.created") {
|
if (event.type === "response.created") {
|
||||||
output.responseId = event.response.id;
|
output.responseId = event.response.id;
|
||||||
} else if (event.type === "response.output_item.added") {
|
} else if (event.type === "response.output_item.added") {
|
||||||
const item = event.item;
|
createSlot(event.output_index, event.item);
|
||||||
if (item.type === "reasoning") {
|
|
||||||
currentItem = item;
|
|
||||||
currentBlock = { type: "thinking", thinking: "" };
|
|
||||||
output.content.push(currentBlock);
|
|
||||||
stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output });
|
|
||||||
} else if (item.type === "message") {
|
|
||||||
currentItem = item;
|
|
||||||
currentBlock = { type: "text", text: "" };
|
|
||||||
output.content.push(currentBlock);
|
|
||||||
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
|
|
||||||
} else if (item.type === "function_call") {
|
|
||||||
currentItem = item;
|
|
||||||
currentBlock = {
|
|
||||||
type: "toolCall",
|
|
||||||
id: `${item.call_id}|${item.id}`,
|
|
||||||
name: item.name,
|
|
||||||
arguments: {},
|
|
||||||
partialJson: item.arguments || "",
|
|
||||||
};
|
|
||||||
output.content.push(currentBlock);
|
|
||||||
stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output });
|
|
||||||
}
|
|
||||||
} else if (event.type === "response.reasoning_summary_part.added") {
|
|
||||||
if (currentItem && currentItem.type === "reasoning") {
|
|
||||||
currentItem.summary = currentItem.summary || [];
|
|
||||||
currentItem.summary.push(event.part);
|
|
||||||
}
|
|
||||||
} else if (event.type === "response.reasoning_summary_text.delta") {
|
} else if (event.type === "response.reasoning_summary_text.delta") {
|
||||||
if (currentItem?.type === "reasoning" && currentBlock?.type === "thinking") {
|
const slot = getSlot(event.output_index, "thinking");
|
||||||
currentItem.summary = currentItem.summary || [];
|
if (!slot) continue;
|
||||||
const lastPart = currentItem.summary[currentItem.summary.length - 1];
|
slot.block.thinking += event.delta;
|
||||||
if (lastPart) {
|
stream.push({
|
||||||
currentBlock.thinking += event.delta;
|
type: "thinking_delta",
|
||||||
lastPart.text += event.delta;
|
contentIndex: slot.contentIndex,
|
||||||
stream.push({
|
delta: event.delta,
|
||||||
type: "thinking_delta",
|
partial: output,
|
||||||
contentIndex: blockIndex(),
|
});
|
||||||
delta: event.delta,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (event.type === "response.reasoning_summary_part.done") {
|
} else if (event.type === "response.reasoning_summary_part.done") {
|
||||||
if (currentItem?.type === "reasoning" && currentBlock?.type === "thinking") {
|
const slot = getSlot(event.output_index, "thinking");
|
||||||
currentItem.summary = currentItem.summary || [];
|
if (!slot) continue;
|
||||||
const lastPart = currentItem.summary[currentItem.summary.length - 1];
|
slot.block.thinking += "\n\n";
|
||||||
if (lastPart) {
|
stream.push({
|
||||||
currentBlock.thinking += "\n\n";
|
type: "thinking_delta",
|
||||||
lastPart.text += "\n\n";
|
contentIndex: slot.contentIndex,
|
||||||
stream.push({
|
delta: "\n\n",
|
||||||
type: "thinking_delta",
|
partial: output,
|
||||||
contentIndex: blockIndex(),
|
});
|
||||||
delta: "\n\n",
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (event.type === "response.reasoning_text.delta") {
|
} else if (event.type === "response.reasoning_text.delta") {
|
||||||
if (currentItem?.type === "reasoning" && currentBlock?.type === "thinking") {
|
const slot = getSlot(event.output_index, "thinking");
|
||||||
currentBlock.thinking += event.delta;
|
if (!slot) continue;
|
||||||
stream.push({
|
slot.block.thinking += event.delta;
|
||||||
type: "thinking_delta",
|
stream.push({
|
||||||
contentIndex: blockIndex(),
|
type: "thinking_delta",
|
||||||
delta: event.delta,
|
contentIndex: slot.contentIndex,
|
||||||
partial: output,
|
delta: event.delta,
|
||||||
});
|
partial: output,
|
||||||
}
|
});
|
||||||
} else if (event.type === "response.content_part.added") {
|
|
||||||
if (currentItem?.type === "message") {
|
|
||||||
currentItem.content = currentItem.content || [];
|
|
||||||
// Filter out ReasoningText, only accept output_text and refusal
|
|
||||||
if (event.part.type === "output_text" || event.part.type === "refusal") {
|
|
||||||
currentItem.content.push(event.part);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (event.type === "response.output_text.delta") {
|
} else if (event.type === "response.output_text.delta") {
|
||||||
if (currentItem?.type === "message" && currentBlock?.type === "text") {
|
const slot = getSlot(event.output_index, "text");
|
||||||
if (!currentItem.content || currentItem.content.length === 0) {
|
if (!slot) continue;
|
||||||
continue;
|
slot.block.text += event.delta;
|
||||||
}
|
stream.push({
|
||||||
const lastPart = currentItem.content[currentItem.content.length - 1];
|
type: "text_delta",
|
||||||
if (lastPart?.type === "output_text") {
|
contentIndex: slot.contentIndex,
|
||||||
currentBlock.text += event.delta;
|
delta: event.delta,
|
||||||
lastPart.text += event.delta;
|
partial: output,
|
||||||
stream.push({
|
});
|
||||||
type: "text_delta",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
delta: event.delta,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (event.type === "response.refusal.delta") {
|
} else if (event.type === "response.refusal.delta") {
|
||||||
if (currentItem?.type === "message" && currentBlock?.type === "text") {
|
const slot = getSlot(event.output_index, "text");
|
||||||
if (!currentItem.content || currentItem.content.length === 0) {
|
if (!slot) continue;
|
||||||
continue;
|
slot.block.text += event.delta;
|
||||||
}
|
stream.push({
|
||||||
const lastPart = currentItem.content[currentItem.content.length - 1];
|
type: "text_delta",
|
||||||
if (lastPart?.type === "refusal") {
|
contentIndex: slot.contentIndex,
|
||||||
currentBlock.text += event.delta;
|
delta: event.delta,
|
||||||
lastPart.refusal += event.delta;
|
partial: output,
|
||||||
|
});
|
||||||
|
} else if (event.type === "response.function_call_arguments.delta") {
|
||||||
|
const slot = getSlot(event.output_index, "toolCall");
|
||||||
|
if (!slot) continue;
|
||||||
|
slot.block.partialJson += event.delta;
|
||||||
|
slot.block.arguments = parseStreamingJson(slot.block.partialJson);
|
||||||
|
stream.push({
|
||||||
|
type: "toolcall_delta",
|
||||||
|
contentIndex: slot.contentIndex,
|
||||||
|
delta: event.delta,
|
||||||
|
partial: output,
|
||||||
|
});
|
||||||
|
} else if (event.type === "response.function_call_arguments.done") {
|
||||||
|
const slot = getSlot(event.output_index, "toolCall");
|
||||||
|
if (!slot) continue;
|
||||||
|
const previousPartialJson = slot.block.partialJson;
|
||||||
|
slot.block.partialJson = event.arguments;
|
||||||
|
slot.block.arguments = parseStreamingJson(slot.block.partialJson);
|
||||||
|
|
||||||
|
if (event.arguments.startsWith(previousPartialJson)) {
|
||||||
|
const delta = event.arguments.slice(previousPartialJson.length);
|
||||||
|
if (delta.length > 0) {
|
||||||
stream.push({
|
stream.push({
|
||||||
type: "text_delta",
|
type: "toolcall_delta",
|
||||||
contentIndex: blockIndex(),
|
contentIndex: slot.contentIndex,
|
||||||
delta: event.delta,
|
delta,
|
||||||
partial: output,
|
partial: output,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (event.type === "response.function_call_arguments.delta") {
|
|
||||||
if (currentItem?.type === "function_call" && currentBlock?.type === "toolCall") {
|
|
||||||
currentBlock.partialJson += event.delta;
|
|
||||||
currentBlock.arguments = parseStreamingJson(currentBlock.partialJson);
|
|
||||||
stream.push({
|
|
||||||
type: "toolcall_delta",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
delta: event.delta,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (event.type === "response.function_call_arguments.done") {
|
|
||||||
if (currentItem?.type === "function_call" && currentBlock?.type === "toolCall") {
|
|
||||||
const previousPartialJson = currentBlock.partialJson;
|
|
||||||
currentBlock.partialJson = event.arguments;
|
|
||||||
currentBlock.arguments = parseStreamingJson(currentBlock.partialJson);
|
|
||||||
|
|
||||||
if (event.arguments.startsWith(previousPartialJson)) {
|
|
||||||
const delta = event.arguments.slice(previousPartialJson.length);
|
|
||||||
if (delta.length > 0) {
|
|
||||||
stream.push({
|
|
||||||
type: "toolcall_delta",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
delta,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (event.type === "response.output_item.done") {
|
} else if (event.type === "response.output_item.done") {
|
||||||
const item = event.item;
|
const item = event.item;
|
||||||
|
const slot = getOrCreateSlot(event.output_index, item);
|
||||||
|
|
||||||
if (item.type === "reasoning" && currentBlock?.type === "thinking") {
|
if (item.type === "reasoning" && slot?.type === "thinking") {
|
||||||
const summaryText = item.summary?.map((s) => s.text).join("\n\n") || "";
|
const summaryText = item.summary?.map((s) => s.text).join("\n\n") || "";
|
||||||
const contentText = item.content?.map((c) => c.text).join("\n\n") || "";
|
const contentText = item.content?.map((c) => c.text).join("\n\n") || "";
|
||||||
currentBlock.thinking = summaryText || contentText || currentBlock.thinking;
|
slot.block.thinking = summaryText || contentText || slot.block.thinking;
|
||||||
currentBlock.thinkingSignature = JSON.stringify(item);
|
slot.block.thinkingSignature = JSON.stringify(item);
|
||||||
stream.push({
|
stream.push({
|
||||||
type: "thinking_end",
|
type: "thinking_end",
|
||||||
contentIndex: blockIndex(),
|
contentIndex: slot.contentIndex,
|
||||||
content: currentBlock.thinking,
|
content: slot.block.thinking,
|
||||||
partial: output,
|
partial: output,
|
||||||
});
|
});
|
||||||
currentBlock = null;
|
outputSlots.delete(event.output_index);
|
||||||
} else if (item.type === "message" && currentBlock?.type === "text") {
|
} else if (item.type === "message" && slot?.type === "text") {
|
||||||
currentBlock.text =
|
slot.block.text = item.content?.map((c) => (c.type === "output_text" ? c.text : c.refusal)).join("") || "";
|
||||||
item.content?.map((c) => (c.type === "output_text" ? c.text : c.refusal)).join("") || "";
|
slot.block.textSignature = encodeTextSignatureV1(item.id, item.phase ?? undefined);
|
||||||
currentBlock.textSignature = encodeTextSignatureV1(item.id, item.phase ?? undefined);
|
|
||||||
stream.push({
|
stream.push({
|
||||||
type: "text_end",
|
type: "text_end",
|
||||||
contentIndex: blockIndex(),
|
contentIndex: slot.contentIndex,
|
||||||
content: currentBlock.text,
|
content: slot.block.text,
|
||||||
partial: output,
|
partial: output,
|
||||||
});
|
});
|
||||||
currentBlock = null;
|
outputSlots.delete(event.output_index);
|
||||||
} else if (item.type === "function_call") {
|
} else if (item.type === "function_call" && slot?.type === "toolCall") {
|
||||||
const args =
|
slot.block.arguments = parseStreamingJson(item.arguments || slot.block.partialJson || "{}");
|
||||||
currentBlock?.type === "toolCall" && currentBlock.partialJson
|
// Finalize in-place and strip the scratch buffer so replay only
|
||||||
? parseStreamingJson(currentBlock.partialJson)
|
// carries parsed arguments.
|
||||||
: parseStreamingJson(item.arguments || "{}");
|
delete (slot.block as { partialJson?: string }).partialJson;
|
||||||
|
stream.push({
|
||||||
let toolCall: ToolCall;
|
type: "toolcall_end",
|
||||||
if (currentBlock?.type === "toolCall") {
|
contentIndex: slot.contentIndex,
|
||||||
// Finalize in-place and strip the scratch buffer so replay only
|
toolCall: slot.block,
|
||||||
// carries parsed arguments.
|
partial: output,
|
||||||
currentBlock.arguments = args;
|
});
|
||||||
delete (currentBlock as { partialJson?: string }).partialJson;
|
outputSlots.delete(event.output_index);
|
||||||
toolCall = currentBlock;
|
|
||||||
} else {
|
|
||||||
toolCall = {
|
|
||||||
type: "toolCall",
|
|
||||||
id: `${item.call_id}|${item.id}`,
|
|
||||||
name: item.name,
|
|
||||||
arguments: args,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
currentBlock = null;
|
|
||||||
stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
|
|
||||||
}
|
}
|
||||||
} else if (event.type === "response.completed" || event.type === "response.incomplete") {
|
} else if (event.type === "response.completed" || event.type === "response.incomplete") {
|
||||||
finalizeResponse(event.response);
|
finalizeResponse(event.response);
|
||||||
|
|||||||
Reference in New Issue
Block a user