diff --git a/src/lib/responses/ResponseStream.ts b/src/lib/responses/ResponseStream.ts index 3e9b761ca..0834aede5 100644 --- a/src/lib/responses/ResponseStream.ts +++ b/src/lib/responses/ResponseStream.ts @@ -8,7 +8,7 @@ import type { } from '../../resources/responses/responses'; import type { RequestOptions } from '../../internal/request-options'; import type { ReadableStream } from '../../internal/shim-types'; -import { APIUserAbortError, OpenAIError } from '../../error'; +import { APIError, APIUserAbortError, OpenAIError } from '../../error'; import type OpenAI from '../../index'; import { EventStream } from '../EventStream'; import type { BaseEvents } from '../EventStream'; @@ -69,7 +69,7 @@ type ResponseEvents = BaseEvents & >, ) => void; }, - 'response.output_text.delta' | 'response.function_call_arguments.delta' + 'response.output_text.delta' | 'response.function_call_arguments.delta' | 'error' > & { /** Called for every raw response event that passes the replay sequence filter. */ event: (event: ResponseStreamEvent) => void; @@ -141,6 +141,14 @@ export class ResponseStream } }; + if (event.type === 'error') { + // First-party providers nest their error payload; retain flat compatibility for + // serialized events matching the currently published event schema. + const error = + 'error' in event && typeof event.error === 'object' && event.error !== null ? event.error : event; + throw new APIError(undefined, error, event.message, undefined); + } + const response = accumulateResponse(event, this.#currentResponseSnapshot); this.#currentResponseSnapshot = response; maybeEmit('event', event); @@ -257,58 +265,11 @@ export class ResponseStream /** Iterates over response events; stopping iteration early aborts the underlying request. */ [Symbol.asyncIterator](this: ResponseStream): AsyncIterator { - const pushQueue: ResponseStreamEvent[] = []; - const readQueue: { - resolve: (event: ResponseStreamEvent | undefined) => void; - reject: (err: unknown) => void; - }[] = []; - let done = false; - - this.on('event', (event) => { - const reader = readQueue.shift(); - if (reader) { - reader.resolve(event); - } else { - pushQueue.push(event); - } - }); - - this.on('end', () => { - done = true; - for (const reader of readQueue) { - reader.resolve(undefined); - } - readQueue.length = 0; - }); - - this.on('abort', (err) => { - done = true; - for (const reader of readQueue) { - reader.reject(err); - } - readQueue.length = 0; - }); - - this.on('error', (err) => { - done = true; - for (const reader of readQueue) { - reader.reject(err); - } - readQueue.length = 0; - }); - + const iterator = this.events('event'); return { - next: async (): Promise> => { - if (!pushQueue.length) { - if (done) { - return { value: undefined, done: true }; - } - return new Promise((resolve, reject) => - readQueue.push({ resolve, reject }), - ).then((event) => (event ? { value: event, done: false } : { value: undefined, done: true })); - } - const event = pushQueue.shift()!; - return { value: event, done: false }; + next: async () => { + const result = await iterator.next(); + return result.done ? { value: undefined, done: true } : { value: result.value[0], done: false }; }, return: async () => { this.abort(); diff --git a/tests/lib/ResponseStream.test.ts b/tests/lib/ResponseStream.test.ts index 06488d48a..e6a57dddb 100644 --- a/tests/lib/ResponseStream.test.ts +++ b/tests/lib/ResponseStream.test.ts @@ -1,5 +1,5 @@ import { vi } from 'vitest'; -import OpenAI, { APIUserAbortError } from 'openai'; +import OpenAI, { APIError, APIUserAbortError, OpenAIError } from 'openai'; import { ReadableStreamFrom } from 'openai/internal/shims'; import { ResponseStream } from 'openai/lib/responses/ResponseStream'; import type { Response, ResponseStreamEvent } from 'openai/resources/responses/responses'; @@ -154,6 +154,392 @@ describe('.stream()', () => { }); }); + it('converts an error event into an APIError', async () => { + const events: ResponseStreamEvent[] = [ + { + type: 'response.created', + sequence_number: 0, + response: makeResponse(), + }, + { + type: 'error', + sequence_number: 1, + code: 'server_error', + message: 'The server had an error while processing your request.', + param: null, + }, + ]; + const stream = ResponseStream.fromReadableStream(readableStreamFromEvents(events)); + const listenerErrors: OpenAIError[] = []; + stream.on('error', (error) => listenerErrors.push(error)); + + const rejection = await stream.finalResponse().then( + () => { + throw new Error('expected finalResponse() to reject'); + }, + (error: unknown) => error, + ); + + expect(rejection).toBeInstanceOf(OpenAIError); + expect(rejection).toBeInstanceOf(APIError); + expect((rejection as APIError).message).toBe('The server had an error while processing your request.'); + expect((rejection as APIError).code).toBe('server_error'); + // `.on('error')` must observe the converted error, not the raw stream frame. + expect(listenerErrors).toHaveLength(1); + expect(listenerErrors[0]).toBe(rejection); + }); + + it('converts an initial error event into an APIError', async () => { + const event = { + type: 'error', + sequence_number: 0, + code: 'server_error', + message: 'The server had an error before creating a response.', + param: 'input', + } satisfies ResponseStreamEvent; + const stream = ResponseStream.fromReadableStream(readableStreamFromEvents([event])); + + const rejection = await stream.finalResponse().then( + () => { + throw new Error('expected finalResponse() to reject'); + }, + (error: unknown) => error, + ); + + expect(rejection).toBeInstanceOf(APIError); + expect(rejection).toMatchObject({ + message: 'The server had an error before creating a response.', + code: 'server_error', + param: 'input', + }); + }); + + it.each([false, true])( + 'preserves nested provider errors after a response was created: %s', + async (createdFirst) => { + const created = { + type: 'response.created', + sequence_number: 0, + response: makeResponse(), + } satisfies ResponseStreamEvent; + const payload = { + type: 'invalid_request_error', + code: 'rate_limit_exceeded', + message: 'The provider rejected the streamed response.', + param: 'input', + headers: { 'retry-after': '300' }, + }; + const error = { + type: 'error', + sequence_number: createdFirst ? 1 : 0, + error: payload, + }; + const events = createdFirst ? [created, error] : [error]; + const readable = ReadableStreamFrom( + events.map((event) => new TextEncoder().encode(`${JSON.stringify(event)}\n`)), + ); + const stream = ResponseStream.fromReadableStream(readable); + const emittedEvents: ResponseStreamEvent[] = []; + const emittedErrors: OpenAIError[] = []; + stream.on('event', (event) => emittedEvents.push(event)); + stream.on('error', (streamError) => emittedErrors.push(streamError)); + + const rejection = await stream.finalResponse().then( + () => { + throw new Error('expected finalResponse() to reject'); + }, + (streamError: unknown) => streamError, + ); + + expect(rejection).toBeInstanceOf(APIError); + expect(rejection).toMatchObject({ + message: payload.message, + code: payload.code, + param: payload.param, + type: payload.type, + error: payload, + status: undefined, + headers: undefined, + }); + expect(emittedErrors).toEqual([rejection]); + expect(emittedEvents).toEqual(createdFirst ? [created] : []); + }, + ); + + it('propagates real nested SSE errors to a delayed async iterator', async () => { + const encoder = new TextEncoder(); + let controller!: ReadableStreamDefaultController; + const readable = new ReadableStream({ + start(readableController) { + controller = readableController; + }, + }); + const created = { + type: 'response.created', + sequence_number: 0, + response: makeResponse(), + } satisfies ResponseStreamEvent; + const payload = { + type: 'invalid_request_error', + code: 'rate_limit_exceeded', + message: 'The model is currently over capacity.', + param: 'input', + headers: { 'retry-after': '300' }, + }; + const error = { type: 'error', sequence_number: 1, error: payload }; + const openai = new OpenAI({ + apiKey: 'My API Key', + fetch: async () => + new Response(readable, { + status: 200, + headers: { 'content-type': 'text/event-stream', 'x-request-id': 'req_nested_stream' }, + }), + }); + const stream = openai.responses.stream({ model: 'gpt-4o', input: 'Hello' }); + const errorEmitted = stream.emitted('error'); + const received: ResponseStreamEvent[] = []; + let resolveCreatedRead!: () => void; + const createdRead = new Promise((resolve) => { + resolveCreatedRead = resolve; + }); + let releaseCreatedRead!: () => void; + const createdReadReleased = new Promise((resolve) => { + releaseCreatedRead = resolve; + }); + const consuming = (async () => { + for await (const event of stream) { + received.push(event); + resolveCreatedRead(); + await createdReadReleased; + } + })(); + + controller.enqueue(encoder.encode(`event: response.created\ndata: ${JSON.stringify(created)}\n\n`)); + await createdRead; + controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify(error)}\n\n`)); + const emittedError = await errorEmitted; + releaseCreatedRead(); + + await expect(consuming).rejects.toBe(emittedError); + expect(emittedError).toBeInstanceOf(APIError); + expect(emittedError).toMatchObject({ + message: payload.message, + code: payload.code, + param: payload.param, + type: payload.type, + requestID: 'req_nested_stream', + error: payload, + }); + expect(received).toEqual([created]); + }); + + it('converts documented flat SSE error events before accumulating them', async () => { + const created = { + type: 'response.created', + sequence_number: 0, + response: makeResponse(), + } satisfies ResponseStreamEvent; + const error = { + type: 'error', + sequence_number: 1, + code: 'provider_error', + message: 'The provider returned a documented flat error.', + param: 'model', + } satisfies ResponseStreamEvent; + const openai = new OpenAI({ + apiKey: 'My API Key', + fetch: async () => + new Response([created, error].map((event) => `data: ${JSON.stringify(event)}\n\n`).join(''), { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }), + }); + const stream = openai.responses.stream({ model: 'gpt-4o', input: 'Hello' }); + const received: ResponseStreamEvent[] = []; + stream.on('event', (event) => received.push(event)); + + await expect(stream.finalResponse()).rejects.toMatchObject({ + message: error.message, + code: error.code, + param: error.param, + }); + expect(received).toEqual([created]); + }); + + it('rejects async iteration when an error event arrives with no pending read', async () => { + const encoder = new TextEncoder(); + let controller!: ReadableStreamDefaultController; + const readable = new ReadableStream({ + start(readableController) { + controller = readableController; + }, + }); + const created = { + type: 'response.created', + sequence_number: 0, + response: makeResponse(), + } satisfies ResponseStreamEvent; + const error = { + type: 'error', + sequence_number: 1, + code: 'server_error', + message: 'The server had an error while streaming a response.', + param: null, + } satisfies ResponseStreamEvent; + let resolveCreatedRead!: () => void; + const createdRead = new Promise((resolve) => { + resolveCreatedRead = resolve; + }); + let releaseCreatedRead!: () => void; + const createdReadReleased = new Promise((resolve) => { + releaseCreatedRead = resolve; + }); + const stream = ResponseStream.fromReadableStream(readable); + const errorEmitted = stream.emitted('error'); + const received: ResponseStreamEvent[] = []; + const consuming = (async () => { + for await (const event of stream) { + received.push(event); + resolveCreatedRead(); + await createdReadReleased; + } + })(); + + controller.enqueue(encoder.encode(JSON.stringify(created) + '\n')); + await createdRead; + controller.enqueue(encoder.encode(JSON.stringify(error) + '\n')); + controller.close(); + const emittedError = await errorEmitted; + releaseCreatedRead(); + + await expect(consuming).rejects.toBe(emittedError); + expect(emittedError).toBeInstanceOf(APIError); + expect(emittedError).toMatchObject({ + message: 'The server had an error while streaming a response.', + code: 'server_error', + param: null, + }); + expect(received).toEqual([created]); + }); + + it('drains queued response events before surfacing a nested stream error', async () => { + const created = { + type: 'response.created', + sequence_number: 0, + response: makeResponse(), + } satisfies ResponseStreamEvent; + const inProgress = { + type: 'response.in_progress', + sequence_number: 1, + response: makeResponse(), + } satisfies ResponseStreamEvent; + const payload = { + type: 'server_error', + code: 'server_error', + message: 'The response failed after queued events.', + param: null, + }; + const error = { type: 'error', sequence_number: 2, error: payload }; + const readable = ReadableStreamFrom( + [created, inProgress, error].map((event) => new TextEncoder().encode(`${JSON.stringify(event)}\n`)), + ); + const stream = ResponseStream.fromReadableStream(readable); + const iterator = stream[Symbol.asyncIterator](); + const errorEmitted = stream.emitted('error'); + const emittedError = await errorEmitted; + + await expect(iterator.next()).resolves.toEqual({ value: created, done: false }); + await expect(iterator.next()).resolves.toEqual({ value: inProgress, done: false }); + await expect(iterator.next()).rejects.toBe(emittedError); + await expect(iterator.next()).resolves.toEqual({ value: undefined, done: true }); + }); + + it('finishes immediately when iteration begins after the stream has ended', async () => { + const created = { + type: 'response.created', + sequence_number: 0, + response: makeResponse(), + } satisfies ResponseStreamEvent; + const stream = ResponseStream.fromReadableStream(readableStreamFromEvents([created])); + + await stream.finalResponse(); + + await expect(stream[Symbol.asyncIterator]().next()).resolves.toEqual({ value: undefined, done: true }); + }); + + it('preserves failed response snapshots when no separate error event is sent', async () => { + const created = { + type: 'response.created', + sequence_number: 0, + response: makeResponse(), + } satisfies ResponseStreamEvent; + const failed = { + type: 'response.failed', + sequence_number: 1, + response: makeResponse({ + status: 'failed', + error: { code: 'server_error', message: 'The response failed.' }, + }), + } satisfies ResponseStreamEvent; + const stream = ResponseStream.fromReadableStream(readableStreamFromEvents([created, failed])); + const received: ResponseStreamEvent[] = []; + + for await (const event of stream) { + received.push(event); + } + + await expect(stream.finalResponse()).resolves.toMatchObject({ + status: 'failed', + error: { code: 'server_error', message: 'The response failed.' }, + }); + expect(received).toEqual([created, failed]); + }); + + it('cancels a stalled readable stream when iteration stops early', async () => { + const encoder = new TextEncoder(); + const created = { + type: 'response.created', + sequence_number: 0, + response: makeResponse(), + } satisfies ResponseStreamEvent; + let resolvePullStarted!: () => void; + const pullStarted = new Promise((resolve) => { + resolvePullStarted = resolve; + }); + let resolveCancelled!: () => void; + const cancelled = new Promise((resolve) => { + resolveCancelled = resolve; + }); + const cancel = vi.fn(() => resolveCancelled()); + let pulls = 0; + const readable = new ReadableStream({ + pull(controller) { + if (pulls++ === 0) { + controller.enqueue(encoder.encode(`${JSON.stringify(created)}\n`)); + return; + } + + resolvePullStarted(); + return new Promise(() => {}); + }, + cancel, + }); + const stream = ResponseStream.fromReadableStream(readable); + const aborted = new Promise((resolve) => { + stream.once('abort', () => resolve()); + }); + + const iterator = stream[Symbol.asyncIterator](); + await expect(iterator.next()).resolves.toEqual({ value: created, done: false }); + await pullStarted; + await iterator.return?.(); + + await cancelled; + await aborted; + expect(cancel).toHaveBeenCalledTimes(1); + expect(stream.aborted).toBe(true); + }, 5000); + it('cancels a stalled readable stream when aborted', async () => { const encoder = new TextEncoder(); const created = {