diff --git a/.changeset/twelve-emus-cheat.md b/.changeset/twelve-emus-cheat.md new file mode 100644 index 00000000..25409162 --- /dev/null +++ b/.changeset/twelve-emus-cheat.md @@ -0,0 +1,5 @@ +--- +"@openai/agents-openai": patch +--- + +Fix Azure streaming annotation handling diff --git a/packages/agents-openai/src/openaiChatCompletionsStreaming.ts b/packages/agents-openai/src/openaiChatCompletionsStreaming.ts index 51732605..832d6601 100644 --- a/packages/agents-openai/src/openaiChatCompletionsStreaming.ts +++ b/packages/agents-openai/src/openaiChatCompletionsStreaming.ts @@ -43,7 +43,7 @@ export async function* convertChatCompletionsStreamToResponses( // This is always set by the OpenAI API, but not by others e.g. LiteLLM usage = (chunk as any).usage || undefined; - if (!chunk.choices || !chunk.choices[0].delta) continue; + if (!chunk.choices?.[0]?.delta) continue; const delta = chunk.choices[0].delta; // Handle text diff --git a/packages/agents-openai/test/openaiChatCompletionsStreaming.test.ts b/packages/agents-openai/test/openaiChatCompletionsStreaming.test.ts index b4cfcaf8..27a4304f 100644 --- a/packages/agents-openai/test/openaiChatCompletionsStreaming.test.ts +++ b/packages/agents-openai/test/openaiChatCompletionsStreaming.test.ts @@ -1,7 +1,10 @@ import { describe, it, expect } from 'vitest'; import { convertChatCompletionsStreamToResponses } from '../src/openaiChatCompletionsStreaming'; import { FAKE_ID } from '../src/openaiChatCompletionsModel'; -import type { ChatCompletion, ChatCompletionChunk } from 'openai/resources/chat'; +import type { + ChatCompletion, + ChatCompletionChunk, +} from 'openai/resources/chat'; function makeChunk(delta: any, usage?: any) { return { @@ -14,7 +17,6 @@ function makeChunk(delta: any, usage?: any) { } as any; } - describe('convertChatCompletionsStreamToResponses', () => { it('emits protocol events for streamed chat completions', async () => { const response: ChatCompletion = { @@ -81,13 +83,23 @@ describe('convertChatCompletionsStreamToResponses', () => { } const events = [] as any[]; - for await (const ev of convertChatCompletionsStreamToResponses(response, fakeStream() as any)) { + for await (const ev of convertChatCompletionsStreamToResponses( + response, + fakeStream() as any, + )) { events.push(ev); } - expect(events[0]).toEqual({ type: 'response_started', providerData: { ...chunk1 } }); + expect(events[0]).toEqual({ + type: 'response_started', + providerData: { ...chunk1 }, + }); expect(events[1]).toEqual({ type: 'model', event: chunk1 }); - expect(events[2]).toEqual({ type: 'output_text_delta', delta: 'hello', providerData: { ...chunk1 } }); + expect(events[2]).toEqual({ + type: 'output_text_delta', + delta: 'hello', + providerData: { ...chunk1 }, + }); expect(events[3]).toEqual({ type: 'model', event: chunk2 }); expect(events[4]).toEqual({ type: 'model', event: chunk3 }); @@ -109,7 +121,11 @@ describe('convertChatCompletionsStreamToResponses', () => { type: 'message', status: 'completed', content: [ - { type: 'output_text', text: 'hello', providerData: { annotations: [] } }, + { + type: 'output_text', + text: 'hello', + providerData: { annotations: [] }, + }, { type: 'refusal', refusal: 'nope' }, ], }, @@ -126,29 +142,51 @@ describe('convertChatCompletionsStreamToResponses', () => { }); }); - describe('convertChatCompletionsStreamToResponses', () => { it('converts chunks to protocol events', async () => { - async function* stream(): AsyncGenerator { + async function* stream(): AsyncGenerator< + ChatCompletionChunk, + void, + unknown + > { yield makeChunk({ content: 'he' }); - yield makeChunk({ content: 'llo' }, { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 }); - yield makeChunk({ tool_calls: [{ index: 0, id: 'call', function: { name: 'fn', arguments: 'a' } }] }); + yield makeChunk( + { content: 'llo' }, + { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 }, + ); + yield makeChunk({ + tool_calls: [ + { index: 0, id: 'call', function: { name: 'fn', arguments: 'a' } }, + ], + }); } const resp = { id: 'r' } as any; const events: any[] = []; - for await (const e of convertChatCompletionsStreamToResponses(resp, stream() as any)) { + for await (const e of convertChatCompletionsStreamToResponses( + resp, + stream() as any, + )) { events.push(e); } - expect(events[0]).toEqual({ type: 'response_started', providerData: makeChunk({ content: 'he' }) }); + expect(events[0]).toEqual({ + type: 'response_started', + providerData: makeChunk({ content: 'he' }), + }); // last event should be final response const final = events[events.length - 1]; expect(final.type).toBe('response_done'); expect(final.response.output).toEqual([ { id: FAKE_ID, - content: [{ text: 'hello', type: 'output_text', providerData: { annotations: [] } }], + content: [ + { + text: 'hello', + type: 'output_text', + providerData: { annotations: [] }, + }, + ], role: 'assistant', type: 'message', status: 'completed', @@ -163,4 +201,36 @@ describe('convertChatCompletionsStreamToResponses', () => { ]); expect(final.response.usage.totalTokens).toBe(0); }); + + it('ignores chunks with empty choices', async () => { + const emptyChunk: ChatCompletionChunk = { + id: 'e', + created: 0, + model: 'm', + object: 'chat.completion.chunk', + choices: [], + } as any; + + async function* stream(): AsyncGenerator< + ChatCompletionChunk, + void, + unknown + > { + yield emptyChunk; + yield makeChunk({ content: 'hi' }); + } + + const resp = { id: 'r' } as any; + const events: any[] = []; + for await (const e of convertChatCompletionsStreamToResponses( + resp, + stream() as any, + )) { + events.push(e); + } + + const deltas = events.filter((ev) => ev.type === 'output_text_delta'); + expect(deltas).toHaveLength(1); + expect(deltas[0].delta).toBe('hi'); + }); });