diff --git a/CHANGELOG.md b/CHANGELOG.md index 040d34c8..da095747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `ChatOllama()` can now be created without a `model`, so `ChatOllama().list_models()` works. Sending a message without a model still raises an error that lists the available models. (#166) * `ChatGoogle()` now reports `finish_reason == "content_filter"` for Gemini's `IMAGE_SAFETY`, `IMAGE_PROHIBITED_CONTENT`, `IMAGE_RECITATION` and `LANGUAGE` finish reasons, instead of passing the raw Gemini string through, and reports `"tool_use"` (rather than `"success"`) when a turn ends with a tool request, as `ChatOpenAI()` does. (#473) * The error raised for a tool request with invalid JSON arguments from `ChatOpenAI()` and `ChatOpenAICompletions()` (and the providers built on them) now shows the offending arguments, instead of the literal text `{args}`. +* Streaming tool calls from OpenAI-compatible servers that omit `index` on each chunk (so far, argument fragments were stored as separate tool calls) are now joined into the call they continue, and a chunk with an `index` after one without no longer raises `KeyError`. * `content_image_url()` with a `data:` URL no longer silently drops the `detail` argument: the setting is now carried by inline image content and forwarded to OpenAI's Responses and Chat Completions APIs (regular image URLs already forwarded it). Other providers continue to ignore `detail`. (#467) * `ChatBedrock()` on a Converse model no longer drops redacted reasoning blocks when streaming. They were discarded as they arrived, so a streamed turn lost them, while a non-streamed one kept them. (#468) * `ChatBedrockAnthropic()` now uses native structured outputs for `data_model` on Claude 4.5 and later, as `ChatAnthropic()` does. Its Bedrock model IDs (`anthropic.claude-...`, `us.anthropic.claude-...`) weren't recognized, so it fell back to forcing a tool call, which Claude Opus 5.5 and Sonnet 5.5 reject with a 400. diff --git a/chatlas/_merge.py b/chatlas/_merge.py index b5a4976a..49d81e2a 100644 --- a/chatlas/_merge.py +++ b/chatlas/_merge.py @@ -86,7 +86,8 @@ def merge_lists( to_merge = [ i for i, e_left in enumerate(merged) - if e_left["index"] == e["index"] + if isinstance(e_left, dict) + and e_left.get("index") == e["index"] ] if to_merge: # TODO: Remove this once merge_dict is updated with special @@ -98,6 +99,20 @@ def merge_lists( merged[to_merge[0]] = merge_dicts(merged[to_merge[0]], e) else: merged.append(e) + elif merged and _is_tool_call_fragment(e) and _is_tool_call(merged[-1]): + # Some OpenAI-compatible servers omit `index` on streamed tool + # calls; a fragment without an id or name continues the previous call. + merged[-1] = merge_dicts(merged[-1], e) else: merged.append(e) return merged + + +def _is_tool_call(x: Any) -> bool: + return ( + isinstance(x, dict) and "index" not in x and isinstance(x.get("function"), dict) + ) + + +def _is_tool_call_fragment(x: Any) -> bool: + return _is_tool_call(x) and not x.get("id") and not x["function"].get("name") diff --git a/tests/test_utils_merge.py b/tests/test_utils_merge.py index eabe262d..0c29341a 100644 --- a/tests/test_utils_merge.py +++ b/tests/test_utils_merge.py @@ -48,3 +48,44 @@ def test_respects_index_when_merging_lists(): assert merge_dicts( {"a": [{"index": 0, "b": "a"}]}, {"a": [{"index": 1, "b": "b"}]} ) == {"a": [{"index": 0, "b": "a"}, {"index": 1, "b": "b"}]} + + +def _tool_call_chunk(**fields): + return {"tool_calls": [fields]} + + +def test_tool_call_fragments_without_index_are_joined(): + # Some OpenAI-compatible servers omit `index` on streamed tool calls; the + # argument fragments that follow the first chunk carry no id either. + chunks = [ + _tool_call_chunk(id="call_1", function={"name": "f", "arguments": ""}), + _tool_call_chunk(function={"arguments": '{"x":'}), + _tool_call_chunk(function={"arguments": " 1}"}), + ] + merged = merge_dicts({}, *chunks) + assert merged["tool_calls"] == [ + {"id": "call_1", "function": {"name": "f", "arguments": '{"x": 1}'}} + ] + + +def test_tool_calls_without_index_and_different_ids_stay_separate(): + chunks = [ + _tool_call_chunk(id="call_1", function={"name": "f", "arguments": "{}"}), + _tool_call_chunk(id="call_2", function={"name": "g", "arguments": "{}"}), + ] + merged = merge_dicts({}, *chunks) + assert [c["id"] for c in merged["tool_calls"]] == ["call_1", "call_2"] + + +def test_indexed_chunk_after_unindexed_element_does_not_raise(): + merged = merge_dicts( + {"tool_calls": [{"id": "call_1", "function": {"arguments": "{}"}}]}, + _tool_call_chunk(index=0, function={"arguments": ""}), + ) + assert len(merged["tool_calls"]) == 2 + + +def test_tool_call_fragment_with_empty_left_list_is_appended(): + fragment = {"function": {"arguments": "{"}} + merged = merge_dicts({"tool_calls": []}, {"tool_calls": [fragment]}) + assert merged == {"tool_calls": [fragment]}