mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: fix(openai): guard against null delta in streaming chunks from non-co… (#5734)
* fix(openai): guard against null delta in streaming chunks from non-compliant providers (#5732) * chore: resolve nit and align with project style --------- Co-authored-by: Sergey Borisov <sergey.borisov@dataimpact.io> Co-authored-by: Giles Odigwe <79032838+giles17@users.noreply.github.com>
This commit is contained in:
@@ -728,10 +728,16 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc]
|
||||
|
||||
for choice in chunk.choices:
|
||||
chunk_metadata.update(self._get_metadata_from_chat_choice(choice))
|
||||
contents.extend(self._parse_tool_calls_from_openai(choice))
|
||||
if choice.finish_reason:
|
||||
finish_reason = choice.finish_reason # type: ignore[assignment]
|
||||
|
||||
# Some OpenAI-compatible providers (e.g. Azure) send `"delta": null`
|
||||
# on finish chunks instead of the spec-compliant `"delta": {}`.
|
||||
# Guard here so all content-parsing below can assume delta is present.
|
||||
if choice.delta is None: # pyright: ignore[reportUnnecessaryComparison]
|
||||
continue
|
||||
|
||||
contents.extend(self._parse_tool_calls_from_openai(choice))
|
||||
if text_content := self._parse_text_from_openai(choice):
|
||||
contents.append(text_content)
|
||||
if reasoning_details := getattr(choice.delta, "reasoning_details", None):
|
||||
|
||||
@@ -1828,3 +1828,171 @@ async def test_integration_web_search() -> None:
|
||||
else:
|
||||
response = await client.get_response(**content)
|
||||
assert response.text is not None
|
||||
|
||||
|
||||
# region Tests for #5732 — streaming chunk with null delta
|
||||
|
||||
|
||||
def test_streaming_chunk_with_null_delta_is_skipped(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Regression test for #5732: non-compliant providers send delta=null on finish chunks.
|
||||
|
||||
Some OpenAI-compatible providers (e.g. Azure OpenAI with certain configs) send
|
||||
``"delta": null`` instead of the spec-compliant ``"delta": {}`` on the final
|
||||
finish-reason chunk. This used to raise ``AttributeError: 'NoneType' object
|
||||
has no attribute 'content'``.
|
||||
"""
|
||||
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk, Choice
|
||||
|
||||
client = OpenAIChatCompletionClient()
|
||||
|
||||
# Simulate a finish chunk where delta is None (non-compliant provider behaviour).
|
||||
mock_chunk = ChatCompletionChunk.model_construct(
|
||||
id="test-chunk-finish",
|
||||
object="chat.completion.chunk",
|
||||
created=1234567890,
|
||||
model="gpt-4.1",
|
||||
choices=[
|
||||
Choice.model_construct(
|
||||
index=0,
|
||||
delta=None,
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
usage=None,
|
||||
)
|
||||
|
||||
# Should not raise AttributeError
|
||||
update = client._parse_response_update_from_openai(mock_chunk)
|
||||
|
||||
assert update.finish_reason == "stop"
|
||||
assert update.contents == []
|
||||
|
||||
|
||||
def test_streaming_chunk_with_null_delta_preserves_finish_reason(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""finish_reason must be captured even when delta is None.
|
||||
|
||||
Ensures the ``continue`` guard does not skip finish_reason extraction.
|
||||
"""
|
||||
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk, Choice
|
||||
|
||||
client = OpenAIChatCompletionClient()
|
||||
|
||||
mock_chunk = ChatCompletionChunk.model_construct(
|
||||
id="test-chunk-length",
|
||||
object="chat.completion.chunk",
|
||||
created=1234567890,
|
||||
model="gpt-4.1",
|
||||
choices=[
|
||||
Choice.model_construct(
|
||||
index=0,
|
||||
delta=None,
|
||||
finish_reason="length",
|
||||
)
|
||||
],
|
||||
usage=None,
|
||||
)
|
||||
|
||||
update = client._parse_response_update_from_openai(mock_chunk)
|
||||
|
||||
assert update.finish_reason == "length"
|
||||
assert update.contents == []
|
||||
|
||||
|
||||
def test_streaming_chunk_with_empty_delta_is_not_skipped(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Spec-compliant finish chunks with an empty delta object must still be processed.
|
||||
|
||||
The OpenAI spec sends ``"delta": {}`` (not null) on finish chunks. These
|
||||
should pass through without error and produce no content.
|
||||
"""
|
||||
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk, Choice, ChoiceDelta
|
||||
|
||||
client = OpenAIChatCompletionClient()
|
||||
|
||||
mock_chunk = ChatCompletionChunk(
|
||||
id="test-chunk-empty-delta",
|
||||
object="chat.completion.chunk",
|
||||
created=1234567890,
|
||||
model="gpt-4o",
|
||||
choices=[
|
||||
Choice(
|
||||
index=0,
|
||||
delta=ChoiceDelta(),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
update = client._parse_response_update_from_openai(mock_chunk)
|
||||
|
||||
assert update.finish_reason == "stop"
|
||||
assert update.contents == []
|
||||
|
||||
|
||||
def test_streaming_chunk_with_null_delta_and_usage(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Usage data in the same chunk as a null delta must still be recorded."""
|
||||
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk, Choice
|
||||
from openai.types.completion_usage import CompletionUsage
|
||||
|
||||
client = OpenAIChatCompletionClient()
|
||||
|
||||
mock_chunk = ChatCompletionChunk.model_construct(
|
||||
id="test-chunk-usage-null-delta",
|
||||
object="chat.completion.chunk",
|
||||
created=1234567890,
|
||||
model="gpt-4.1",
|
||||
choices=[
|
||||
Choice.model_construct(
|
||||
index=0,
|
||||
delta=None,
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
usage=CompletionUsage(prompt_tokens=10, completion_tokens=5, total_tokens=15),
|
||||
)
|
||||
|
||||
update = client._parse_response_update_from_openai(mock_chunk)
|
||||
|
||||
assert update.finish_reason == "stop"
|
||||
content_types = [c.type for c in update.contents]
|
||||
assert "usage" in content_types
|
||||
assert "text" not in content_types
|
||||
|
||||
|
||||
def test_streaming_chunk_with_null_delta_no_tool_calls_parsed(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Tool-call parsing must be skipped when delta is None."""
|
||||
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk, Choice
|
||||
|
||||
client = OpenAIChatCompletionClient()
|
||||
|
||||
mock_chunk = ChatCompletionChunk.model_construct(
|
||||
id="test-chunk-no-tools",
|
||||
object="chat.completion.chunk",
|
||||
created=1234567890,
|
||||
model="gpt-4.1",
|
||||
choices=[
|
||||
Choice.model_construct(
|
||||
index=0,
|
||||
delta=None,
|
||||
finish_reason="tool_calls",
|
||||
)
|
||||
],
|
||||
usage=None,
|
||||
)
|
||||
|
||||
update = client._parse_response_update_from_openai(mock_chunk)
|
||||
|
||||
assert update.finish_reason == "tool_calls"
|
||||
assert not any(c.type == "function_call" for c in update.contents)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
Reference in New Issue
Block a user