diff --git a/src/lib/responses/ResponseAccumulator.ts b/src/lib/responses/ResponseAccumulator.ts index f3dee49f0..ee6fff4c8 100644 --- a/src/lib/responses/ResponseAccumulator.ts +++ b/src/lib/responses/ResponseAccumulator.ts @@ -1,6 +1,9 @@ import { type Response, + type ResponseOutputItem, + type ResponseOutputRefusal, type ResponseOutputText, + type ResponseReasoningItem, type ResponseStreamEvent, } from '../../resources/responses/responses'; import { OpenAIError } from '../../error'; @@ -11,15 +14,104 @@ type ResponseKeepAliveEvent = { sequence_number: number; }; +/** Makes `K` optional on every member of `T`. */ +type Incremental = T extends unknown ? + Omit & { [P in Extract]?: T[P] } +: never; + +/** Replaces the type of `K` with `V` on every member of `T`. */ +type WithPayload = T extends unknown ? + Omit & { [P in Extract]: V } +: never; + +type OutputItemOfType = Extract; + +type StreamEventOfType = Extract; + +type IncrementalMessageContent = Incremental; + +type IncrementalReasoningContent = Incremental; + +/** A content part that may arrive before its text or refusal has streamed in. */ +export type IncrementalContentPart = IncrementalMessageContent | IncrementalReasoningContent; + +/** A reasoning summary part that may arrive before its text has streamed in. */ +export type IncrementalSummaryPart = Incremental; + +/** An output item that may arrive before the string fields its deltas append to exist. */ +export type IncrementalOutputItem = + | (Omit, 'content'> & { content: Array }) + | (Omit, 'summary' | 'content'> & { + summary: Array; + content?: Array; + }) + | Incremental, 'arguments'> + | Incremental, 'input'> + | Incremental, 'code'> + | Exclude< + ResponseOutputItem, + OutputItemOfType< + 'message' | 'reasoning' | 'function_call' | 'mcp_call' | 'custom_tool_call' | 'code_interpreter_call' + > + >; + +/** A response whose output items may still be incremental. */ +export type IncrementalResponse = Omit & { output: Array }; + +/** + * The wire shape of the events that carry items and parts into the snapshot. + * + * The server emits these events while the model is still producing the response, so an + * item or part can arrive before the string field that its `*.delta` events append to + * exists. `ResponseStreamEvent` describes those payloads with the completed shapes, where + * the same fields are required, so the observed payloads are unrepresentable there. + * + * {@link accumulateResponse} normalizes the incremental shape once, as it enters the + * snapshot, which is what lets every snapshot it returns satisfy the `Response` contract + * and lets the delta handlers stay plain appends. + */ +export type IncrementalResponseStreamEvent = + | WithPayload< + StreamEventOfType<'response.output_item.added' | 'response.output_item.done'>, + 'item', + IncrementalOutputItem + > + | WithPayload< + StreamEventOfType<'response.content_part.added' | 'response.content_part.done'>, + 'part', + IncrementalContentPart + > + | WithPayload< + StreamEventOfType<'response.reasoning_summary_part.added' | 'response.reasoning_summary_part.done'>, + 'part', + IncrementalSummaryPart + > + | WithPayload< + StreamEventOfType< + | 'response.created' + | 'response.queued' + | 'response.in_progress' + | 'response.completed' + | 'response.failed' + | 'response.incomplete' + >, + 'response', + IncrementalResponse + >; + /** * Applies a streaming event to a response snapshot. * * Always use the returned snapshot. Incremental events update the supplied snapshot * in place, while response lifecycle events return a detached replacement. Event * payloads are cloned, so retaining or replaying the raw events is safe. + * + * Items and parts are normalized as they enter the snapshot, so the returned snapshot + * satisfies the `Response` contract even when no delta has arrived yet. See + * {@link IncrementalResponseStreamEvent}. */ export function accumulateResponse( - event: ResponseStreamEvent | ResponseKeepAliveEvent, + event: ResponseStreamEvent | IncrementalResponseStreamEvent | ResponseKeepAliveEvent, snapshot?: Response, ): Response { if (!snapshot) { @@ -33,7 +125,7 @@ export function accumulateResponse( switch (event.type) { case 'response.output_item.added': { - snapshot.output.push(structuredClone(event.item)); + snapshot.output.push(normalizeOutputItem(structuredClone(event.item))); if (event.item.type === 'message') { addOutputText(snapshot); } @@ -41,7 +133,7 @@ export function accumulateResponse( } case 'response.output_item.done': { getOutput(snapshot, event.output_index); - snapshot.output[event.output_index] = structuredClone(event.item); + snapshot.output[event.output_index] = normalizeOutputItem(structuredClone(event.item)); if (event.item.type === 'message') { addOutputText(snapshot); } @@ -50,9 +142,9 @@ export function accumulateResponse( case 'response.content_part.added': { const output = getOutput(snapshot, event.output_index); const type = output.type; - const part = event.part; + const part = normalizeContentPart(structuredClone(event.part)); if (type === 'message' && part.type !== 'reasoning_text') { - output.content.push(structuredClone(part)); + output.content.push(part); if (part.type === 'output_text') { addOutputText(snapshot); } @@ -60,16 +152,16 @@ export function accumulateResponse( if (!output.content) { output.content = []; } - output.content.push(structuredClone(part)); + output.content.push(part); } break; } case 'response.content_part.done': { const output = getOutput(snapshot, event.output_index); - const part = event.part; + const part = normalizeContentPart(structuredClone(event.part)); if (output.type === 'message' && part.type !== 'reasoning_text') { getContent(output.content, event.content_index); - output.content[event.content_index] = structuredClone(part); + output.content[event.content_index] = part; if (part.type === 'output_text') { addOutputText(snapshot); } @@ -79,7 +171,7 @@ export function accumulateResponse( throw new OpenAIError(`missing content at index ${event.content_index}`); } getContent(content, event.content_index); - content[event.content_index] = structuredClone(part); + content[event.content_index] = part; } break; } @@ -187,7 +279,7 @@ export function accumulateResponse( case 'response.reasoning_summary_part.added': { const output = getOutput(snapshot, event.output_index); if (output.type === 'reasoning') { - output.summary.push(structuredClone(event.part)); + output.summary.push(normalizeSummaryPart(structuredClone(event.part))); } break; } @@ -195,7 +287,7 @@ export function accumulateResponse( const output = getOutput(snapshot, event.output_index); if (output.type === 'reasoning') { getContent(output.summary, event.summary_index); - output.summary[event.summary_index] = structuredClone(event.part); + output.summary[event.summary_index] = normalizeSummaryPart(structuredClone(event.part)); } break; } @@ -392,14 +484,69 @@ export function accumulateResponse( return snapshot; } -function cloneResponse(response: Response): Response { - const snapshot = structuredClone(response); +function cloneResponse(response: IncrementalResponse): Response { + const snapshot = structuredClone(response) as Response; + snapshot.output = snapshot.output.map(normalizeOutputItem); if (!Object.getOwnPropertyDescriptor(snapshot, 'output_text') || snapshot.output_text == null) { addOutputText(snapshot); } return snapshot; } +/** + * Fills in the string fields that `*.delta` events append to, so an item taken from the + * wire satisfies its `ResponseOutputItem` contract before any delta arrives. + * + * The item is normalized in place; callers pass a clone they own. + */ +function normalizeOutputItem(item: IncrementalOutputItem): ResponseOutputItem { + switch (item.type) { + case 'message': { + item.content?.forEach(normalizeContentPart); + break; + } + case 'reasoning': { + item.summary?.forEach(normalizeSummaryPart); + item.content?.forEach(normalizeContentPart); + break; + } + case 'function_call': + case 'mcp_call': { + item.arguments ??= ''; + break; + } + case 'custom_tool_call': { + item.input ??= ''; + break; + } + case 'code_interpreter_call': { + // `code` is `string | null` by contract, where `null` means "not available", so an + // omitted `code` normalizes to `null` and its delta handler keeps a nullish default. + item.code ??= null; + break; + } + } + return item as ResponseOutputItem; +} + +/** @see {@link normalizeOutputItem} */ +function normalizeContentPart( + part: IncrementalContentPart, +): ResponseOutputText | ResponseOutputRefusal | ResponseReasoningItem.Content { + if (part.type === 'refusal') { + part.refusal ??= ''; + } else { + part.text ??= ''; + } + return part as ResponseOutputText | ResponseOutputRefusal | ResponseReasoningItem.Content; +} + +/** @see {@link normalizeOutputItem} */ +function normalizeSummaryPart(part: IncrementalSummaryPart): ResponseReasoningItem.Summary { + part.text ??= ''; + return part as ResponseReasoningItem.Summary; +} + function getOutput(snapshot: Response, outputIndex: number): Response['output'][number] { const output = snapshot.output[outputIndex]; if (!output) { diff --git a/tests/lib/ResponseAccumulator.test.ts b/tests/lib/ResponseAccumulator.test.ts index 0fbcb111a..4a588f6d7 100644 --- a/tests/lib/ResponseAccumulator.test.ts +++ b/tests/lib/ResponseAccumulator.test.ts @@ -1,4 +1,10 @@ import { accumulateResponse } from 'openai/lib/responses/ResponseAccumulator'; +import type { + IncrementalContentPart, + IncrementalOutputItem, + IncrementalResponse, + IncrementalResponseStreamEvent, +} from 'openai/lib/responses/ResponseAccumulator'; import type { Response, ResponseStreamEvent } from 'openai/resources/responses/responses'; describe('ResponseAccumulator', () => { @@ -222,9 +228,380 @@ describe('ResponseAccumulator', () => { content: [{ type: 'refusal', refusal: 'I cannot help with that.' }], }); }); + + describe('normalizes incomplete wire shapes', () => { + it.each(incrementalWireCases())('%s', (_name, { ingest, normalized, delta, accumulated }) => { + expect(accumulateEvents(ingest).output[0]).toEqual(normalized); + + if (delta) { + expect(accumulateEvents([...ingest, delta]).output[0]).toEqual(accumulated); + } + }); + + it('keeps output_text a string before the first text delta', () => { + const ingest = messageIngest({ type: 'output_text', annotations: [] }); + + expect(accumulateEvents(ingest).output_text).toBe(''); + expect(accumulateEvents([...ingest, outputTextDelta()]).output_text).toBe('Hello world'); + }); + }); }); -function accumulateEvents(events: ResponseStreamEvent[]): Response { +/** + * One case per event family that can carry an item or part whose delta-driven string + * field has not streamed in yet. Each case asserts `snapshot.output[0]` directly after + * the ingesting event, and again once the matching delta has been applied. + */ +function incrementalWireCases(): Array<[string, IncrementalWireCase]> { + return [ + [ + 'response.output_item.added - function_call arguments', + { + ingest: [ + created(), + outputItemAdded({ id: 'fc_1', type: 'function_call', call_id: 'call_1', name: 'f' }), + ], + normalized: { id: 'fc_1', type: 'function_call', call_id: 'call_1', name: 'f', arguments: '' }, + delta: { + type: 'response.function_call_arguments.delta', + sequence_number: 2, + item_id: 'fc_1', + output_index: 0, + delta: '{"city": "Paris"}', + }, + accumulated: { + id: 'fc_1', + type: 'function_call', + call_id: 'call_1', + name: 'f', + arguments: '{"city": "Paris"}', + }, + }, + ], + [ + 'response.output_item.added - mcp_call arguments', + { + ingest: [ + created(), + outputItemAdded({ id: 'mcp_1', type: 'mcp_call', name: 'f', server_label: 'server' }), + ], + normalized: { id: 'mcp_1', type: 'mcp_call', name: 'f', server_label: 'server', arguments: '' }, + delta: { + type: 'response.mcp_call_arguments.delta', + sequence_number: 2, + item_id: 'mcp_1', + output_index: 0, + delta: '{"query": "docs"}', + }, + accumulated: { + id: 'mcp_1', + type: 'mcp_call', + name: 'f', + server_label: 'server', + arguments: '{"query": "docs"}', + }, + }, + ], + [ + 'response.output_item.added - custom_tool_call input', + { + ingest: [ + created(), + outputItemAdded({ id: 'ctc_1', type: 'custom_tool_call', call_id: 'call_1', name: 'f' }), + ], + normalized: { id: 'ctc_1', type: 'custom_tool_call', call_id: 'call_1', name: 'f', input: '' }, + delta: { + type: 'response.custom_tool_call_input.delta', + sequence_number: 2, + item_id: 'ctc_1', + output_index: 0, + delta: 'echo hi', + }, + accumulated: { + id: 'ctc_1', + type: 'custom_tool_call', + call_id: 'call_1', + name: 'f', + input: 'echo hi', + }, + }, + ], + [ + 'response.content_part.added - output_text text', + { + ingest: messageIngest({ type: 'output_text', annotations: [] }), + normalized: message([{ type: 'output_text', annotations: [], text: '' }]), + delta: outputTextDelta(), + accumulated: message([{ type: 'output_text', annotations: [], text: 'Hello world' }]), + }, + ], + [ + 'response.content_part.added - refusal', + { + ingest: messageIngest({ type: 'refusal' }), + normalized: message([{ type: 'refusal', refusal: '' }]), + delta: { + type: 'response.refusal.delta', + sequence_number: 3, + item_id: 'msg_1', + output_index: 0, + content_index: 0, + delta: 'Permission denied', + }, + accumulated: message([{ type: 'refusal', refusal: 'Permission denied' }]), + }, + ], + [ + 'response.content_part.added - reasoning_text', + { + ingest: [ + created(), + outputItemAdded({ id: 'rs_1', type: 'reasoning', summary: [] }), + { + type: 'response.content_part.added', + sequence_number: 2, + item_id: 'rs_1', + output_index: 0, + content_index: 0, + part: { type: 'reasoning_text' }, + }, + ], + normalized: { + id: 'rs_1', + type: 'reasoning', + summary: [], + content: [{ type: 'reasoning_text', text: '' }], + }, + delta: { + type: 'response.reasoning_text.delta', + sequence_number: 3, + item_id: 'rs_1', + output_index: 0, + content_index: 0, + delta: 'thinking', + }, + accumulated: { + id: 'rs_1', + type: 'reasoning', + summary: [], + content: [{ type: 'reasoning_text', text: 'thinking' }], + }, + }, + ], + [ + 'response.reasoning_summary_part.added - summary_text', + { + ingest: [ + created(), + outputItemAdded({ id: 'rs_1', type: 'reasoning', summary: [] }), + summaryPartAdded(), + ], + normalized: { id: 'rs_1', type: 'reasoning', summary: [{ type: 'summary_text', text: '' }] }, + delta: { + type: 'response.reasoning_summary_text.delta', + sequence_number: 3, + item_id: 'rs_1', + output_index: 0, + summary_index: 0, + delta: 'summarizing', + }, + accumulated: { + id: 'rs_1', + type: 'reasoning', + summary: [{ type: 'summary_text', text: 'summarizing' }], + }, + }, + ], + [ + 'response.output_item.added - code_interpreter_call code', + { + ingest: [ + created(), + outputItemAdded({ + id: 'ci_1', + type: 'code_interpreter_call', + container_id: 'container_1', + outputs: null, + status: 'in_progress', + }), + ], + normalized: { + id: 'ci_1', + type: 'code_interpreter_call', + container_id: 'container_1', + outputs: null, + status: 'in_progress', + code: null, + }, + delta: { + type: 'response.code_interpreter_call_code.delta', + sequence_number: 2, + item_id: 'ci_1', + output_index: 0, + delta: 'print(1)', + }, + accumulated: { + id: 'ci_1', + type: 'code_interpreter_call', + container_id: 'container_1', + outputs: null, + status: 'in_progress', + code: 'print(1)', + }, + }, + ], + [ + 'response.output_item.done - function_call arguments', + { + ingest: [ + created(), + outputItemAdded({ id: 'fc_1', type: 'function_call', call_id: 'call_1', name: 'f' }), + { + type: 'response.output_item.done', + sequence_number: 2, + output_index: 0, + item: { id: 'fc_1', type: 'function_call', call_id: 'call_1', name: 'f', status: 'completed' }, + }, + ], + normalized: { + id: 'fc_1', + type: 'function_call', + call_id: 'call_1', + name: 'f', + status: 'completed', + arguments: '', + }, + }, + ], + [ + 'response.content_part.done - refusal', + { + ingest: [ + ...messageIngest({ type: 'refusal' }), + { + type: 'response.content_part.done', + sequence_number: 3, + item_id: 'msg_1', + output_index: 0, + content_index: 0, + part: { type: 'refusal' }, + }, + ], + normalized: message([{ type: 'refusal', refusal: '' }]), + }, + ], + [ + 'response.reasoning_summary_part.done - summary_text', + { + ingest: [ + created(), + outputItemAdded({ id: 'rs_1', type: 'reasoning', summary: [] }), + summaryPartAdded(), + { + type: 'response.reasoning_summary_part.done', + sequence_number: 3, + item_id: 'rs_1', + output_index: 0, + summary_index: 0, + part: { type: 'summary_text' }, + }, + ], + normalized: { id: 'rs_1', type: 'reasoning', summary: [{ type: 'summary_text', text: '' }] }, + }, + ], + [ + 'response.completed - function_call arguments', + { + ingest: [ + created(), + { + type: 'response.completed', + sequence_number: 1, + response: makeIncrementalResponse({ + status: 'completed', + output: [ + { id: 'fc_1', type: 'function_call', call_id: 'call_1', name: 'f', status: 'completed' }, + ], + }), + }, + ], + normalized: { + id: 'fc_1', + type: 'function_call', + call_id: 'call_1', + name: 'f', + status: 'completed', + arguments: '', + }, + }, + ], + ]; +} + +type IncrementalWireCase = { + /** Events up to and including the one that ingests the incomplete item or part. */ + ingest: AccumulatedEvent[]; + /** `snapshot.output[0]` directly after `ingest`, with no delta applied. */ + normalized: unknown; + /** A delta appending to the field the ingested payload omitted. */ + delta?: AccumulatedEvent; + /** `snapshot.output[0]` after `delta` is applied. */ + accumulated?: unknown; +}; + +type AccumulatedEvent = ResponseStreamEvent | IncrementalResponseStreamEvent; + +function created(): AccumulatedEvent { + return { type: 'response.created', sequence_number: 0, response: makeResponse() }; +} + +function summaryPartAdded(): AccumulatedEvent { + return { + type: 'response.reasoning_summary_part.added', + sequence_number: 2, + item_id: 'rs_1', + output_index: 0, + summary_index: 0, + part: { type: 'summary_text' }, + }; +} + +function outputTextDelta(): AccumulatedEvent { + return { + type: 'response.output_text.delta', + sequence_number: 3, + item_id: 'msg_1', + output_index: 0, + content_index: 0, + delta: 'Hello world', + logprobs: [], + }; +} + +function outputItemAdded(item: IncrementalOutputItem): AccumulatedEvent { + return { type: 'response.output_item.added', sequence_number: 1, output_index: 0, item }; +} + +function messageIngest(part: IncrementalContentPart): AccumulatedEvent[] { + return [ + created(), + outputItemAdded({ id: 'msg_1', type: 'message', role: 'assistant', status: 'in_progress', content: [] }), + { + type: 'response.content_part.added', + sequence_number: 2, + item_id: 'msg_1', + output_index: 0, + content_index: 0, + part, + }, + ]; +} + +function message(content: unknown): unknown { + return { id: 'msg_1', type: 'message', role: 'assistant', status: 'in_progress', content }; +} + +function accumulateEvents(events: AccumulatedEvent[]): Response { let snapshot: Response | undefined; for (const event of events) { snapshot = accumulateResponse(event, snapshot); @@ -265,3 +642,7 @@ function makeResponse(overrides: Partial = {}): Response { ...overrides, } as Response; } + +function makeIncrementalResponse(overrides: Partial = {}): IncrementalResponse { + return { ...makeResponse(), ...overrides }; +}