From 1762e8fabc5f82fe43c873118b241c4f6f4c6bbd Mon Sep 17 00:00:00 2001 From: Hayden Date: Mon, 6 Jul 2026 15:12:07 -0700 Subject: [PATCH] fix: handle Azure filter stream chunks Resolves #1015 --- src/lib/ChatCompletionStream.ts | 21 ++++----- tests/lib/ChatCompletionStream.test.ts | 60 ++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 10 deletions(-) diff --git a/src/lib/ChatCompletionStream.ts b/src/lib/ChatCompletionStream.ts index f63862b84..dd13dce34 100644 --- a/src/lib/ChatCompletionStream.ts +++ b/src/lib/ChatCompletionStream.ts @@ -205,27 +205,28 @@ export class ChatCompletionStream for (const choice of chunk.choices) { const choiceSnapshot = completion.choices[choice.index]!; + const { delta } = choice; if ( - choice.delta.content != null && + delta?.content != null && choiceSnapshot.message?.role === 'assistant' && choiceSnapshot.message?.content ) { - this._emit('content', choice.delta.content, choiceSnapshot.message.content); + this._emit('content', delta.content, choiceSnapshot.message.content); this._emit('content.delta', { - delta: choice.delta.content, + delta: delta.content, snapshot: choiceSnapshot.message.content, parsed: choiceSnapshot.message.parsed, }); } if ( - choice.delta.refusal != null && + delta?.refusal != null && choiceSnapshot.message?.role === 'assistant' && choiceSnapshot.message?.refusal ) { this._emit('refusal.delta', { - delta: choice.delta.refusal, + delta: delta.refusal, snapshot: choiceSnapshot.message.refusal, }); } @@ -254,7 +255,7 @@ export class ChatCompletionStream } } - for (const toolCall of choice.delta.tool_calls ?? []) { + for (const toolCall of delta?.tool_calls ?? []) { if (state.current_tool_call_index !== toolCall.index) { this.#emitContentDoneEvents(choiceSnapshot); @@ -267,7 +268,7 @@ export class ChatCompletionStream state.current_tool_call_index = toolCall.index; } - for (const toolCallDelta of choice.delta.tool_calls ?? []) { + for (const toolCallDelta of delta?.tool_calls ?? []) { const toolCallSnapshot = choiceSnapshot.message.tool_calls?.[toolCallDelta.index]; if (!toolCallSnapshot?.type) { continue; @@ -401,13 +402,13 @@ export class ChatCompletionStream const stream = Stream.fromReadableStream(readableStream, this.controller); let chatId; for await (const chunk of stream) { - if (chatId && chatId !== chunk.id) { + if (chatId && chunk.id && chatId !== chunk.id) { // A new request has been made. this._addChatCompletion(this.#endRequest()); } this.#addChunk(chunk); - chatId = chunk.id; + if (chunk.id) chatId = chunk.id; } if (stream.controller.signal?.aborted) { throw new APIUserAbortError(); @@ -432,7 +433,7 @@ export class ChatCompletionStream ...rest, choices: [], }; - } else { + } else if (chunk.id) { Object.assign(snapshot, rest); } diff --git a/tests/lib/ChatCompletionStream.test.ts b/tests/lib/ChatCompletionStream.test.ts index a7236903b..2c5e7cacd 100644 --- a/tests/lib/ChatCompletionStream.test.ts +++ b/tests/lib/ChatCompletionStream.test.ts @@ -1,7 +1,9 @@ import OpenAI from 'openai'; import { zodResponseFormat } from 'openai/helpers/zod'; import { ChatCompletionStream } from 'openai/lib/ChatCompletionStream'; +import { ChatCompletionStreamingRunner } from 'openai/lib/ChatCompletionStreamingRunner'; import { ChatCompletionTokenLogprob } from 'openai/resources'; +import { Stream } from 'openai/streaming'; import { z } from 'zod/v4'; import { makeStreamSnapshotRequest } from '../utils/mock-snapshots'; @@ -58,6 +60,64 @@ describe('.stream()', () => { expect(removeEventListenerSpy).toHaveBeenCalledWith('abort', abortListener); }); + it('handles Azure async filter chunks without deltas', async () => { + const chunks: OpenAI.Chat.ChatCompletionChunk[] = [ + { + id: 'chatcmpl-test', + object: 'chat.completion.chunk', + created: 1, + model: 'gpt-test', + choices: [ + { + index: 0, + delta: { role: 'assistant', content: 'hello' }, + finish_reason: null, + logprobs: null, + }, + ], + }, + { + id: 'chatcmpl-test', + object: 'chat.completion.chunk', + created: 1, + model: 'gpt-test', + choices: [ + { + index: 0, + delta: {}, + finish_reason: 'stop', + logprobs: null, + }, + ], + }, + { + id: '', + object: '', + created: 0, + model: '', + choices: [ + { + index: 0, + finish_reason: null, + content_filter_results: {}, + }, + ], + } as unknown as OpenAI.Chat.ChatCompletionChunk, + ]; + const readable = new Stream(async function* () { + for (const chunk of chunks) yield chunk; + }, new AbortController()).toReadableStream(); + + const stream = ChatCompletionStreamingRunner.fromReadableStream(readable); + + await expect(stream.finalChatCompletion()).resolves.toMatchObject({ + id: 'chatcmpl-test', + created: 1, + model: 'gpt-test', + choices: [{ message: { content: 'hello' } }], + }); + }); + it('works', async () => { const stream = await makeStreamSnapshotRequest((openai) => openai.chat.completions.stream({