diff --git a/python/packages/autogen-ext/src/autogen_ext/models/openai/_openai_client.py b/python/packages/autogen-ext/src/autogen_ext/models/openai/_openai_client.py index a80e912534ab..d191ad9baa67 100644 --- a/python/packages/autogen-ext/src/autogen_ext/models/openai/_openai_client.py +++ b/python/packages/autogen-ext/src/autogen_ext/models/openai/_openai_client.py @@ -906,6 +906,11 @@ async def create_stream( # Process the stream of chunks. async for chunk in chunks: + # Guard against None chunks which can be yielded by some endpoints. + # https://github.com/microsoft/autogen/issues/7130 + if chunk is None: + continue + if first_chunk: first_chunk = False # Emit the start event. @@ -946,7 +951,6 @@ async def create_stream( # for liteLLM chunk usage, do the following hack keeping the pervious chunk.stop_reason (if set). # set the stop_reason for the usage chunk to the prior stop_reason stop_reason = choice.finish_reason if chunk.usage is None and stop_reason is None else stop_reason - maybe_model = chunk.model reasoning_content: str | None = None if choice.delta.model_extra is not None and "reasoning_content" in choice.delta.model_extra: diff --git a/python/packages/autogen-ext/tests/models/test_openai_model_client.py b/python/packages/autogen-ext/tests/models/test_openai_model_client.py index ba79795d1ed7..da1418174a07 100644 --- a/python/packages/autogen-ext/tests/models/test_openai_model_client.py +++ b/python/packages/autogen-ext/tests/models/test_openai_model_client.py @@ -423,6 +423,72 @@ async def test_openai_chat_completion_client_create_stream_cancel(monkeypatch: p pass +@pytest.mark.asyncio +async def test_openai_chat_completion_client_create_stream_none_chunk(monkeypatch: pytest.MonkeyPatch) -> None: + """Regression test for https://github.com/microsoft/autogen/issues/7130. + + Ensures that None chunks yielded by some streaming endpoints do not cause + an AttributeError when the loop accesses chunk.model before a None guard. + """ + + async def _mock_create_stream_with_none_chunks( + *args: Any, **kwargs: Any + ) -> AsyncGenerator[ChatCompletionChunk | None, None]: # type: ignore[misc] + model = resolve_model(kwargs.get("model", "gpt-4.1-nano")) + # Yield a None sentinel chunk first (simulates certain streaming endpoints). + yield None # type: ignore[misc] + yield ChatCompletionChunk( + id="id", + choices=[ + ChunkChoice( + finish_reason=None, + index=0, + delta=ChoiceDelta(content="Hello", role="assistant"), + ) + ], + created=0, + model=model, + object="chat.completion.chunk", + usage=None, + ) + # Yield another None between real chunks. + yield None # type: ignore[misc] + yield ChatCompletionChunk( + id="id", + choices=[ + ChunkChoice( + finish_reason="stop", + index=0, + delta=ChoiceDelta(content=None, role="assistant"), + ) + ], + created=0, + model=model, + object="chat.completion.chunk", + usage=None, + ) + + async def _mock_create_with_none_chunks( + *args: Any, **kwargs: Any + ) -> ChatCompletion | AsyncGenerator[ChatCompletionChunk | None, None]: # type: ignore[misc] + if kwargs.get("stream", False): + return _mock_create_stream_with_none_chunks(*args, **kwargs) + return await _mock_create(*args, **kwargs) + + monkeypatch.setattr(AsyncCompletions, "create", _mock_create_with_none_chunks) + client = OpenAIChatCompletionClient(model="gpt-4o", api_key="api_key") + chunks: List[str | CreateResult] = [] + # Should not raise AttributeError when None chunks are in the stream. + async for chunk in client.create_stream( + messages=[UserMessage(content="Hello", source="user")], + ): + chunks.append(chunk) + + assert chunks[0] == "Hello" + assert isinstance(chunks[-1], CreateResult) + assert chunks[-1].content == "Hello" + + @pytest.mark.asyncio async def test_openai_chat_completion_client_count_tokens(monkeypatch: pytest.MonkeyPatch) -> None: client = OpenAIChatCompletionClient(model="gpt-4o", api_key="api_key")