Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 16 additions & 1 deletion chatlas/_merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
41 changes: 41 additions & 0 deletions tests/test_utils_merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]}
Loading