From 2226620e1a6c5f38f663ae3243b85a116d829e76 Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Tue, 20 Oct 2026 07:41:26 -0400 Subject: [PATCH 1/2] fix(openai): keep streamed tool calls without an index apart _extract_streamed_openai_response put every tool-call delta without an index into the latest call. Gemini's OpenAI-compatible API streams each parallel call whole in its own chunk and leaves index unset, so two calls were merged into one: the second id overwrote the first, the first name was kept and the arguments were concatenated into invalid JSON. A delta without an index now starts a new call when it brings a new id, or a name without an id; otherwise it continues the latest one, so argument fragments still merge. Deltas with an index are grouped as before. Co-Authored-By: Claude Sonnet 5.5 --- langfuse/openai.py | 27 +++++++++++--- tests/unit/test_openai.py | 77 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 6 deletions(-) diff --git a/langfuse/openai.py b/langfuse/openai.py index 43e00b54a..73b85d965 100644 --- a/langfuse/openai.py +++ b/langfuse/openai.py @@ -842,19 +842,34 @@ def _extract_streamed_openai_response(resource: Any, chunks: Any) -> Any: for raw_tool_call in delta.get("tool_calls", []): index = _get_attr_or_item(raw_tool_call, "index", None) + tool_call_id = _get_attr_or_item(raw_tool_call, "id", None) + tool_call_type = _get_attr_or_item(raw_tool_call, "type", None) + tool_call_chunk = _get_attr_or_item( + raw_tool_call, "function", None + ) if not isinstance(index, int): - index = len(curr) - 1 if curr else 0 + # Some OpenAI-compatible APIs (e.g. Gemini) omit the index + # and send each parallel call whole in its own chunk: a new + # id, or a name without an id, starts a call; anything else + # continues the latest one. + starts_call = not curr or ( + ( + tool_call_id is not None + and tool_call_id != curr[-1].get("id") + ) + or ( + tool_call_id is None + and _get_attr_or_item(tool_call_chunk, "name", None) + is not None + ) + ) + index = len(curr) if starts_call else len(curr) - 1 while len(curr) <= index: curr.append({"function": {"name": "", "arguments": ""}}) current_tool_call = curr[index] - tool_call_id = _get_attr_or_item(raw_tool_call, "id", None) - tool_call_type = _get_attr_or_item(raw_tool_call, "type", None) - tool_call_chunk = _get_attr_or_item( - raw_tool_call, "function", None - ) if tool_call_id is not None: current_tool_call["id"] = tool_call_id diff --git a/tests/unit/test_openai.py b/tests/unit/test_openai.py index b688fc6f1..b019273d5 100644 --- a/tests/unit/test_openai.py +++ b/tests/unit/test_openai.py @@ -401,6 +401,83 @@ def test_streaming_chat_completion_preserves_tool_calls_after_content(): assert metadata == {"finish_reason": "tool_calls"} +def _tool_call_chunk(*, id=None, name=None, arguments=None, finish_reason=None): + # Gemini's OpenAI-compatible API leaves tool_calls[].index unset + return SimpleNamespace( + model="gemini-3.5-flash", + choices=[ + SimpleNamespace( + delta=SimpleNamespace( + role=None, + content=None, + function_call=None, + tool_calls=[ + SimpleNamespace( + index=None, + id=id, + type="function" if id else None, + function=SimpleNamespace(name=name, arguments=arguments), + ) + ], + ), + finish_reason=finish_reason, + ) + ], + usage=None, + ) + + +def test_streaming_chat_completion_keeps_parallel_tool_calls_without_index_apart(): + _model, completion, _usage, _metadata, _service_tier = ( + lf_openai_module._extract_streamed_openai_response( + SimpleNamespace(type="chat"), + [ + _tool_call_chunk( + id="call_paris", + name="get_weather", + arguments='{"city": "Paris"}', + ), + _tool_call_chunk( + id="call_tokyo", + name="get_weather", + arguments='{"city": "Tokyo"}', + finish_reason="stop", + ), + ], + ) + ) + + assert [ + (call["id"], call["function"]["arguments"]) for call in completion["tool_calls"] + ] == [ + ("call_paris", '{"city": "Paris"}'), + ("call_tokyo", '{"city": "Tokyo"}'), + ] + + +def test_streaming_chat_completion_merges_fragments_of_one_tool_call_without_index(): + _model, completion, _usage, _metadata, _service_tier = ( + lf_openai_module._extract_streamed_openai_response( + SimpleNamespace(type="chat"), + [ + _tool_call_chunk( + id="call_paris", name="get_weather", arguments='{"city"' + ), + _tool_call_chunk(arguments=': "Paris"}'), + _tool_call_chunk(id="call_paris", arguments=""), + ], + ) + ) + + assert completion["tool_calls"] == [ + { + "id": "call_paris", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"city": "Paris"}'}, + } + ] + + def test_response_api_output_serializes_openai_parsed_response_objects(): class ParsedOutput(BaseModel): name: str From 7551195e779ba6485f660a781c1bdb889cc2a89d Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Tue, 20 Oct 2026 08:41:18 -0400 Subject: [PATCH 2/2] test(openai): cover a tool call without index or id that starts a new call The rule that a delta without an index, id or continuation starts a new call when it carries a function name had no test: removing that branch left the suite green. Two calls with the same function name and different arguments, both without index and id, must stay separate; the test fails without the name branch. Co-Authored-By: Claude Sonnet 5.5 --- tests/unit/test_openai.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/unit/test_openai.py b/tests/unit/test_openai.py index b019273d5..01f31e61a 100644 --- a/tests/unit/test_openai.py +++ b/tests/unit/test_openai.py @@ -455,6 +455,26 @@ def test_streaming_chat_completion_keeps_parallel_tool_calls_without_index_apart ] +def test_streaming_chat_completion_keeps_tool_calls_without_index_or_id_apart(): + _model, completion, _usage, _metadata, _service_tier = ( + lf_openai_module._extract_streamed_openai_response( + SimpleNamespace(type="chat"), + [ + _tool_call_chunk(name="get_weather", arguments='{"city": "Paris"}'), + _tool_call_chunk(name="get_weather", arguments='{"city": "Tokyo"}'), + ], + ) + ) + + assert [ + (call["function"]["name"], call["function"]["arguments"]) + for call in completion["tool_calls"] + ] == [ + ("get_weather", '{"city": "Paris"}'), + ("get_weather", '{"city": "Tokyo"}'), + ] + + def test_streaming_chat_completion_merges_fragments_of_one_tool_call_without_index(): _model, completion, _usage, _metadata, _service_tier = ( lf_openai_module._extract_streamed_openai_response(