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..01f31e61a 100644 --- a/tests/unit/test_openai.py +++ b/tests/unit/test_openai.py @@ -401,6 +401,103 @@ 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_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( + 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