diff --git a/src/lib/responses/ResponseStream.ts b/src/lib/responses/ResponseStream.ts index 652c81ceda..bfef366e64 100644 --- a/src/lib/responses/ResponseStream.ts +++ b/src/lib/responses/ResponseStream.ts @@ -10,7 +10,11 @@ import { RequestOptions } from '../../internal/request-options'; import { APIUserAbortError, OpenAIError } from '../../error'; import OpenAI from '../../index'; import { type BaseEvents, EventStream } from '../EventStream'; -import { type ResponseFunctionCallArgumentsDeltaEvent, type ResponseTextDeltaEvent } from './EventTypes'; +import { + type ResponseFunctionCallArgumentsDeltaEvent, + type ResponseRefusalDeltaEvent, + type ResponseTextDeltaEvent, +} from './EventTypes'; import { maybeParseResponse, ParseableToolsParams } from '../ResponsesParser'; import { Stream } from '../../streaming'; @@ -278,6 +282,23 @@ export class ResponseStream } break; } + case 'response.refusal.delta': { + const output = snapshot.output[event.output_index]; + if (!output) { + throw new OpenAIError(`missing output at index ${event.output_index}`); + } + if (output.type === 'message') { + const content = output.content[event.content_index]; + if (!content) { + throw new OpenAIError(`missing content at index ${event.content_index}`); + } + if (content.type !== 'refusal') { + throw new OpenAIError(`expected content to be 'refusal', got ${content.type}`); + } + content.refusal += event.delta; + } + break; + } case 'response.completed': { this.#currentResponseSnapshot = event.response; break; diff --git a/tests/lib/ResponseStream.test.ts b/tests/lib/ResponseStream.test.ts index 9797baa6d0..3b541d5a0b 100644 --- a/tests/lib/ResponseStream.test.ts +++ b/tests/lib/ResponseStream.test.ts @@ -31,6 +31,31 @@ describe('.stream()', () => { } }); + it('refusal accumulates correctly', async () => { + const deltas: string[] = []; + + const stream = ( + await makeStreamSnapshotRequest((openai) => + openai.responses.stream({ + model: 'gpt-4o-2024-08-06', + input: 'Do something harmful', + }), + ) + ).on('response.refusal.delta', (e) => { + deltas.push(e.delta); + }); + + const final = await stream.finalResponse(); + // The three deltas must be concatenated into the snapshot's refusal content + expect(deltas).toEqual(['I cannot ', 'help with ', 'that.']); + + const msg = final.output[0]; + expect(msg?.type).toBe('message'); + if (msg?.type === 'message') { + expect(msg.content[0]).toMatchObject({ type: 'refusal', refusal: 'I cannot help with that.' }); + } + }); + it('reasoning works', async () => { const stream = await makeStreamSnapshotRequest((openai) => openai.responses.stream({ diff --git a/tests/lib/__snapshots__/ResponseStream.test.ts.snap b/tests/lib/__snapshots__/ResponseStream.test.ts.snap index 2ebb08c676..3a81f1c1c5 100644 --- a/tests/lib/__snapshots__/ResponseStream.test.ts.snap +++ b/tests/lib/__snapshots__/ResponseStream.test.ts.snap @@ -22,6 +22,30 @@ data: [DONE] " `; +exports[`.stream() refusal accumulates correctly 1`] = ` +"data: {\"response\":{\"id\":\"resp_refusal_1\",\"object\":\"response\",\"created_at\":1723031665,\"model\":\"gpt-4o-2024-08-06\",\"output\":[],\"output_text\":\"\",\"error\":null,\"incomplete_details\":null,\"instructions\":null,\"metadata\":null,\"parallel_tool_calls\":false,\"temperature\":null,\"tools\":[],\"top_p\":null,\"status\":\"in_progress\",\"usage\":{\"input_tokens\":0,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":0}},\"sequence_number\":0,\"type\":\"response.created\"} + +data: {\"item\":{\"id\":\"msg_ref_1\",\"type\":\"message\",\"role\":\"assistant\",\"status\":\"in_progress\",\"content\":[]},\"output_index\":0,\"sequence_number\":1,\"type\":\"response.output_item.added\"} + +data: {\"content_index\":0,\"item_id\":\"msg_ref_1\",\"output_index\":0,\"part\":{\"type\":\"refusal\",\"refusal\":\"\"},\"sequence_number\":2,\"type\":\"response.content_part.added\"} + +data: {\"content_index\":0,\"delta\":\"I cannot \",\"item_id\":\"msg_ref_1\",\"output_index\":0,\"sequence_number\":3,\"type\":\"response.refusal.delta\"} + +data: {\"content_index\":0,\"delta\":\"help with \",\"item_id\":\"msg_ref_1\",\"output_index\":0,\"sequence_number\":4,\"type\":\"response.refusal.delta\"} + +data: {\"content_index\":0,\"delta\":\"that.\",\"item_id\":\"msg_ref_1\",\"output_index\":0,\"sequence_number\":5,\"type\":\"response.refusal.delta\"} + +data: {\"content_index\":0,\"item_id\":\"msg_ref_1\",\"output_index\":0,\"refusal\":\"I cannot help with that.\",\"sequence_number\":6,\"type\":\"response.refusal.done\"} + +data: {\"item\":{\"id\":\"msg_ref_1\",\"type\":\"message\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"refusal\",\"refusal\":\"I cannot help with that.\"}]},\"output_index\":0,\"sequence_number\":7,\"type\":\"response.output_item.done\"} + +data: {\"response\":{\"id\":\"resp_refusal_1\",\"object\":\"response\",\"created_at\":1723031665,\"model\":\"gpt-4o-2024-08-06\",\"output\":[{\"id\":\"msg_ref_1\",\"type\":\"message\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"refusal\",\"refusal\":\"I cannot help with that.\"}]}],\"output_text\":\"\",\"error\":null,\"incomplete_details\":null,\"instructions\":null,\"metadata\":null,\"parallel_tool_calls\":false,\"temperature\":null,\"tools\":[],\"top_p\":null,\"status\":\"completed\",\"usage\":{\"input_tokens\":10,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":5,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":15}},\"sequence_number\":8,\"type\":\"response.completed\"} + +data: [DONE] + +" +`; + exports[`.stream() reasoning works 1`] = ` "data: {\"response\":{\"id\":\"resp_reason_1\",\"object\":\"response\",\"created_at\":1723031666,\"model\":\"o3\",\"output\":[],\"output_text\":\"\",\"error\":null,\"incomplete_details\":null,\"instructions\":null,\"metadata\":null,\"parallel_tool_calls\":false,\"temperature\":null,\"tools\":[],\"top_p\":null,\"status\":\"in_progress\",\"usage\":{\"input_tokens\":0,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":0}},\"sequence_number\":0,\"type\":\"response.created\"}