From cc3418e069f39511d1f11702aa5de9c3fcb0f033 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Mon, 17 Aug 2026 10:17:52 -0700 Subject: [PATCH] fix(openai): complete sparse response normalization Signed-off-by: Alex Fournier --- agent/transports/chat_completions.py | 21 +++++-- .../agent/transports/test_chat_completions.py | 59 +++++++++++++++++-- 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index c62bcdf607..b628e22bac 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -756,9 +756,9 @@ class ChatCompletionsTransport(ProviderTransport): preserved for downstream replay. """ choice = response.choices[0] - msg = choice.message + msg = getattr(choice, "message", None) # Poolside returns integer finish_reason (e.g. 24) instead of string - _fr = choice.finish_reason + _fr = getattr(choice, "finish_reason", None) if isinstance(_fr, int): _fr = str(_fr) finish_reason = _fr or "stop" @@ -768,6 +768,13 @@ class ChatCompletionsTransport(ProviderTransport): if message_tool_calls: tool_calls = [] for tc in message_tool_calls: + tc_function = getattr(tc, "function", None) + function_name = getattr(tc_function, "name", None) + # Match Relay's codec: skip absent function/name fields, but + # preserve an explicit blank name for Hermes's recovery path. + if tc_function is None or function_name is None: + continue + function_arguments = getattr(tc_function, "arguments", None) # Preserve provider-specific extras on the tool call. # Gemini 3 thinking models attach extra_content with # thought_signature — without replay on the next turn the API @@ -790,9 +797,13 @@ class ChatCompletionsTransport(ProviderTransport): tc_provider_data["extra_content"] = extra tool_calls.append( ToolCall( - id=tc.id, - name=tc.function.name, - arguments=tc.function.arguments, + id=getattr(tc, "id", None), + name=function_name, + arguments=( + function_arguments + if function_arguments is not None + else "{}" + ), provider_data=tc_provider_data or None, ) ) diff --git a/tests/agent/transports/test_chat_completions.py b/tests/agent/transports/test_chat_completions.py index d8b9dcce5d..64910d93bb 100644 --- a/tests/agent/transports/test_chat_completions.py +++ b/tests/agent/transports/test_chat_completions.py @@ -18,12 +18,46 @@ def transport(): class TestChatCompletionsBasic: - def test_normalize_response_allows_missing_optional_message_fields(self, transport): + @pytest.mark.parametrize( + "choice", + [SimpleNamespace(message=SimpleNamespace()), SimpleNamespace()], + ) + def test_normalize_response_allows_missing_optional_message_fields( + self, transport, choice + ): + response = SimpleNamespace(choices=[choice], usage=None) + + normalized = transport.normalize_response(response) + + assert normalized.content is None + assert normalized.tool_calls is None + assert normalized.finish_reason == "stop" + + def test_normalize_response_allows_sparse_tool_call_fields(self, transport): response = SimpleNamespace( choices=[ SimpleNamespace( - message=SimpleNamespace(), - finish_reason="stop", + message=SimpleNamespace( + tool_calls=[ + SimpleNamespace( + function=SimpleNamespace(arguments='{"city":"Paris"}') + ), + SimpleNamespace(), + SimpleNamespace( + id="call-3", + function=SimpleNamespace(name="lookup"), + ), + SimpleNamespace( + id="call-4", + function=SimpleNamespace(name="", arguments="{}"), + ), + SimpleNamespace( + function=SimpleNamespace( + name="weather", arguments="{}" + ) + ), + ] + ) ) ], usage=None, @@ -31,8 +65,23 @@ class TestChatCompletionsBasic: normalized = transport.normalize_response(response) - assert normalized.content is None - assert normalized.tool_calls is None + assert normalized.finish_reason == "stop" + assert normalized.tool_calls is not None + assert [tool.id for tool in normalized.tool_calls] == [ + "call-3", + "call-4", + None, + ] + assert [tool.name for tool in normalized.tool_calls] == [ + "lookup", + "", + "weather", + ] + assert [tool.arguments for tool in normalized.tool_calls] == [ + "{}", + "{}", + "{}", + ] @pytest.mark.parametrize("provider", ["nous", "openrouter"]) def test_gpt56_ultra_uses_max_wire_effort(self, transport, provider):