Compare commits

...

3 Commits

Author SHA1 Message Date
Mason Daugherty
5ce99443d6 Merge branch 'master' into mdrxy/fix-completions 2026-03-16 23:59:15 -04:00
Mason Daugherty
f0fee2cb44 chore: trigger ci 2026-03-16 23:40:54 -04:00
Mason Daugherty
21830d01c2 fix(core,openai): fix v1 streaming tool calls dropped for chat completions 2026-03-16 23:27:54 -04:00
4 changed files with 239 additions and 3 deletions

View File

@@ -247,7 +247,11 @@ class AIMessage(BaseMessage):
first before falling back to best-effort parsing. For details, see the property
on `BaseMessage`.
"""
if self.response_metadata.get("output_version") == "v1":
if self.response_metadata.get("output_version") == "v1" and isinstance(
self.content, list
):
# Only short-circuit when content is already a list of ContentBlock
# dicts. See AIMessageChunk.content_blocks for full rationale.
return cast("list[types.ContentBlock]", self.content)
model_provider = self.response_metadata.get("model_provider")
@@ -440,7 +444,16 @@ class AIMessageChunk(AIMessage, BaseMessageChunk):
@property
def content_blocks(self) -> list[types.ContentBlock]:
"""Return standard, typed `ContentBlock` dicts from the message."""
if self.response_metadata.get("output_version") == "v1":
if self.response_metadata.get("output_version") == "v1" and isinstance(
self.content, list
):
# Only short-circuit when content is already a list of ContentBlock
# dicts. Some streaming implementations keep content as a string
# even when output_version="v1" is set (e.g., OpenAI Chat
# Completions), so it must fall through to the model_provider
# translator which builds ContentBlock dicts from tool_calls /
# tool_call_chunks. Without this guard, string content would be
# returned directly, silently dropping tool calls.
return cast("list[types.ContentBlock]", self.content)
model_provider = self.response_metadata.get("model_provider")

View File

@@ -482,6 +482,49 @@ def test_content_blocks() -> None:
]
def test_content_blocks_v1_string_content_falls_through() -> None:
"""Test that content_blocks falls through to translator when content is a string.
When output_version="v1" is set but content is a string (as in Chat
Completions streaming), content_blocks must not short-circuit. It should
fall through to the model_provider translator so tool calls are included.
"""
# AIMessage with string content + tool_calls + v1 metadata
msg = AIMessage(
content="Hello",
tool_calls=[
create_tool_call(name="foo", args={"a": 1}, id="tc_1"),
],
response_metadata={
"output_version": "v1",
"model_provider": "openai",
},
)
blocks = msg.content_blocks
assert isinstance(blocks, list)
# Should contain a text block and a tool_call block, not the raw string
types_found = {b["type"] for b in blocks}
assert "text" in types_found
assert "tool_call" in types_found
# AIMessageChunk with string content + tool_call_chunks + v1 metadata
chunk = AIMessageChunk(
content="Hello",
tool_call_chunks=[
create_tool_call_chunk(name="foo", args='{"a": 1}', id="tc_1", index=0),
],
response_metadata={
"output_version": "v1",
"model_provider": "openai",
},
)
blocks = chunk.content_blocks
assert isinstance(blocks, list)
types_found = {b["type"] for b in blocks}
assert "text" in types_found
assert "tool_call_chunk" in types_found
def test_content_blocks_reasoning_extraction() -> None:
"""Test best-effort reasoning extraction from `additional_kwargs`."""
message = AIMessage(

View File

@@ -1185,8 +1185,13 @@ class BaseChatOpenAI(BaseChatModel):
message=default_chunk_class(content="", usage_metadata=usage_metadata),
generation_info=base_generation_info,
)
# Keep content as "" (the default) rather than converting to [].
# All Chat Completions content chunks arrive as strings. Starting
# with [] causes merge_content to silently drop string content
# (empty list is falsy, so no merge branch applies). The empty
# list also triggers the content_blocks isinstance(list) short-
# circuit, which would return [] and miss tool_call_chunks.
if self.output_version == "v1":
generation_chunk.message.content = []
generation_chunk.message.response_metadata["output_version"] = "v1"
return generation_chunk
@@ -1217,6 +1222,9 @@ class BaseChatOpenAI(BaseChatModel):
message_chunk.usage_metadata = usage_metadata
message_chunk.response_metadata["model_provider"] = "openai"
# Propagate output_version so content_blocks can detect v1 mode.
if self.output_version == "v1":
message_chunk.response_metadata["output_version"] = "v1"
return ChatGenerationChunk(
message=message_chunk, generation_info=generation_info or None
)

View File

@@ -1347,6 +1347,178 @@ def test_output_version_compat() -> None:
assert llm._use_responses_api({}) is True
def test_convert_chunk_to_generation_chunk_v1_keeps_string_content() -> None:
"""Verify _convert_chunk_to_generation_chunk with output_version='v1'."""
llm = ChatOpenAI(model="gpt-4o", output_version="v1")
# Empty-choices chunk (usage-only)
empty_chunk: dict[str, Any] = {
"id": "chatcmpl-test",
"object": "chat.completion.chunk",
"created": 0,
"model": "gpt-4o",
"choices": [],
"usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8},
}
gen = llm._convert_chunk_to_generation_chunk(empty_chunk, AIMessageChunk, None)
assert gen is not None
assert gen.message.content == "" # NOT []
assert gen.message.response_metadata.get("output_version") == "v1"
# Content-bearing chunk with tool_call delta
tool_chunk: dict[str, Any] = {
"id": "chatcmpl-test",
"object": "chat.completion.chunk",
"created": 0,
"model": "gpt-4o",
"choices": [
{
"index": 0,
"delta": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"index": 0,
"id": "call_abc",
"function": {"name": "get_weather", "arguments": ""},
}
],
},
"logprobs": None,
"finish_reason": None,
}
],
"usage": None,
}
gen = llm._convert_chunk_to_generation_chunk(tool_chunk, AIMessageChunk, None)
assert gen is not None
assert isinstance(gen.message.content, str)
assert gen.message.response_metadata.get("output_version") == "v1"
assert gen.message.response_metadata.get("model_provider") == "openai"
def test_v1_streaming_tool_calls_in_content_blocks() -> None:
"""End-to-end: streaming chunks with tool calls produce correct content_blocks."""
stream_chunks: list[dict[str, Any]] = [
# Initial empty-choices chunk
{
"id": "chatcmpl-test",
"object": "chat.completion.chunk",
"created": 0,
"model": "gpt-4o",
"choices": [
{
"index": 0,
"delta": {"role": "assistant", "content": ""},
"logprobs": None,
"finish_reason": None,
}
],
"usage": None,
},
# Tool call start
{
"id": "chatcmpl-test",
"object": "chat.completion.chunk",
"created": 0,
"model": "gpt-4o",
"choices": [
{
"index": 0,
"delta": {
"tool_calls": [
{
"index": 0,
"id": "call_abc",
"function": {
"name": "get_weather",
"arguments": '{"loc',
},
}
]
},
"logprobs": None,
"finish_reason": None,
}
],
"usage": None,
},
# Tool call args continuation
{
"id": "chatcmpl-test",
"object": "chat.completion.chunk",
"created": 0,
"model": "gpt-4o",
"choices": [
{
"index": 0,
"delta": {
"tool_calls": [
{
"index": 0,
"function": {"arguments": 'ation": "SF"}'},
}
]
},
"logprobs": None,
"finish_reason": None,
}
],
"usage": None,
},
# Finish
{
"id": "chatcmpl-test",
"object": "chat.completion.chunk",
"created": 0,
"model": "gpt-4o",
"choices": [
{
"index": 0,
"delta": {},
"logprobs": None,
"finish_reason": "tool_calls",
}
],
"usage": None,
},
# Usage chunk
{
"id": "chatcmpl-test",
"object": "chat.completion.chunk",
"created": 0,
"model": "gpt-4o",
"choices": [],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
},
},
]
llm = ChatOpenAI(model="gpt-4o", output_version="v1")
aggregated: AIMessageChunk | None = None
for raw_chunk in stream_chunks:
gen = llm._convert_chunk_to_generation_chunk(raw_chunk, AIMessageChunk, None)
if gen is None:
continue
chunk = cast(AIMessageChunk, gen.message)
aggregated = chunk if aggregated is None else aggregated + chunk
assert aggregated is not None
# Tool calls should be present
assert len(aggregated.tool_call_chunks) == 1
assert aggregated.tool_call_chunks[0]["name"] == "get_weather"
# content_blocks should include tool_call_chunk blocks
blocks = aggregated.content_blocks
block_types = {b["type"] for b in blocks}
assert "tool_call_chunk" in block_types
def test_verbosity_parameter_payload() -> None:
"""Test verbosity parameter is included in request payload for Responses API."""
llm = ChatOpenAI(model="gpt-5", verbosity="high", use_responses_api=True)