fix(tools): salvage concatenated JSON tool call arguments (#43260)

* fix(tools): salvage concatenated JSON tool call arguments

* fix(tools): harden concatenated tool-call salvage for review findings

Skip non-dict JSON during split so salvage cannot emit empty tool calls.
Collapse srvtoolu_ expansions to the first object so server results stay paired.
Allocate __concat_n ids that cannot collide with sibling tool call ids.
Propagate cache_control onto every expanded Anthropic tool_use block.
Rename the XML invoke loop variable so the key-leak gate no longer flags {args}

* test(tools): cover concat id bump and srvtoolu array keep

Only collapse srvtoolu_ when concatenated salvage expanded; a valid JSON
array argument stays one server tool input

* revert(anthropic): drop concat expansion from pass-through adapter

Co-authored-by: Techboy bebop  <kumarpriyanshu09@users.noreply.github.com>

* revert(tools): keep concat salvage out of request-side tool converters

Co-authored-by: Techboy bebop  <kumarpriyanshu09@users.noreply.github.com>

* fix(tools): expand strictly salvaged concatenated tool arguments in normalized tool calls

Co-authored-by: Techboy bebop  <kumarpriyanshu09@users.noreply.github.com>

* fix(tools): retain at most the salvage cap while validating concatenated arguments

Co-authored-by: Techboy bebop  <kumarpriyanshu09@users.noreply.github.com>

* test(tools): assert concat sibling ids unique after sanitization

A sibling id that only collides after colon-to-underscore sanitization must force the next concat suffix

Co-authored-by: Techboy bebop  <kumarpriyanshu09@users.noreply.github.com>

* refactor(tools): drop unused strict mode from split_concatenated_json_objects

Strict mode had no production caller. Rejection cases now sit on salvage, and split matches upstream main

Co-authored-by: Techboy bebop  <kumarpriyanshu09@users.noreply.github.com>

---------

Co-authored-by: Techboy bebop <kumarpriyanshu09@users.noreply.github.com>
This commit is contained in:
Techboy bebop 2026-09-27 00:15:12 -04:00 • committed by GitHub
parent 303434d573
commit 21055e3fd8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 527 additions and 318 deletions

View file

@ -2503,6 +2503,61 @@ def split_concatenated_json_objects(raw: str) -> list[dict[str, object]]:
return results
MAX_SALVAGED_TOOL_ARGUMENT_OBJECTS: Final = 8
def salvage_concatenated_tool_arguments(raw: str) -> tuple[dict[str, object], ...]:
"""Return complete concatenated JSON objects that are safe to expand.
Identical objects collapse to the first one and are not capped. More than
``MAX_SALVAGED_TOOL_ARGUMENT_OBJECTS`` objects that are not all identical
returns an empty tuple. Anything that is not a full concatenation of JSON
objects returns an empty tuple. Repeated copies of the first object are not
retained, and once the cap is passed the rest of the string is only checked.
"""
stripped: Final = raw.strip()
if not stripped:
return ()
decoder: Final = json.JSONDecoder()
length: Final = len(stripped)
idx = 0 # rebind-ok: cursor walks the concatenated JSON string
count = 0 # rebind-ok: counts complete objects without retaining duplicates
kept = () # rebind-ok: holds at most one object past the salvage cap
exceeded = False # rebind-ok: cap already passed, the tail is only validated
while idx < length:
while idx < length and stripped[idx] in " \t\n\r":
idx += 1
if idx >= length:
break
try:
obj, end_idx = decoder.raw_decode(stripped, idx)
except json.JSONDecodeError:
return ()
if not isinstance(obj, dict):
return ()
idx = end_idx
if exceeded:
continue
count += 1
if not kept:
kept = (obj,)
continue
if obj == kept[0] and len(kept) == 1:
continue
if len(kept) == 1 and count > 2 and count - 1 > MAX_SALVAGED_TOOL_ARGUMENT_OBJECTS:
exceeded = True
continue
if len(kept) == 1 and count > 2:
kept = (kept[0],) * (count - 1)
if len(kept) >= MAX_SALVAGED_TOOL_ARGUMENT_OBJECTS:
exceeded = True
continue
kept = (*kept, obj)
if exceeded:
return ()
return kept
def text_completion_prompt_to_messages(prompt: object) -> tuple[AllMessageValues, ...]:
"""
Wrap an OpenAI ``/v1/completions`` ``prompt`` into Chat Completion messages.

View file

@ -1,12 +1,14 @@
import base64
import copy
import hashlib
import itertools
import json
import mimetypes
import re
import xml.etree.ElementTree as ET
from collections.abc import Iterator, Mapping, Sequence
from enum import Enum
from types import MappingProxyType
from typing import Any, Final, TypeAlias, TypedDict, cast, overload
from jinja2.sandbox import ImmutableSandboxedEnvironment
@ -52,6 +54,7 @@ from .common_utils import (
is_non_content_values_set,
is_unsignable_thinking_block,
parse_tool_call_arguments,
salvage_concatenated_tool_arguments,
)
from .image_handling import convert_url_to_base64
@ -5381,80 +5384,167 @@ class NormalizedToolCall(TypedDict):
arguments: dict[str, object]
def _parse_tool_call_arguments(raw: object, tool_name: str | None, context: str) -> dict[str, object]:
_ArgumentObjects: TypeAlias = tuple[dict[str, object], ...]
_ParsedToolCall: TypeAlias = tuple[str | None, str | None, _ArgumentObjects]
def _optional_call_id(value: object) -> str | None:
if isinstance(value, str) and value:
return value
return None
def _optional_tool_name(value: object) -> str | None:
if isinstance(value, str):
return value
return None
def _split_tool_call_ids(calls: Sequence[tuple[str | None, int]]) -> tuple[tuple[str | None, ...], ...]:
taken: Final = frozenset(_sanitize_anthropic_tool_use_id(call_id) for call_id, _ in calls if call_id)
def fresh(call_id: str) -> Iterator[str]:
return filter(
lambda candidate: _sanitize_anthropic_tool_use_id(candidate) not in taken,
(f"{call_id}__concat_{n}" for n in itertools.count(1)),
)
suffixes: Final = MappingProxyType(
{_sanitize_anthropic_tool_use_id(call_id): fresh(call_id) for call_id, count in calls if call_id and count > 1}
)
return tuple(
(
call_id,
*(next(suffixes[_sanitize_anthropic_tool_use_id(call_id)]) for _ in range(count - 1)),
)
if call_id
else (None,) * count
for call_id, count in calls
)
def _parse_tool_call_arguments(raw: object, tool_name: str | None, context: str) -> _ArgumentObjects:
# Anthropic's tool_use blocks already carry a parsed dict in "input";
# chat completions and the Responses API carry a JSON string that may be
# truncated by the model, so route those through the repair-aware parser.
if isinstance(raw, dict):
return raw
return (raw,)
if not isinstance(raw, str):
return {}
return ({},)
normalized_raw: Final = "{}" if raw == REDACTED_BY_LITELLM else raw
from litellm.litellm_core_utils.prompt_templates.common_utils import (
parse_tool_call_arguments,
)
try:
parsed: Final = parse_tool_call_arguments(normalized_raw, tool_name=tool_name, context=context)
except ValueError as e:
salvaged: Final = salvage_concatenated_tool_arguments(normalized_raw)
if salvaged:
verbose_logger.warning(
"Recovered %d tool call(s) from concatenated JSON arguments for tool '%s' (%s)",
len(salvaged),
tool_name or "<unknown>",
context,
)
return salvaged
verbose_logger.warning("Failed to parse tool call arguments: %s", e)
return {}
return parsed if isinstance(parsed, dict) else {}
return ({},)
return (parsed,) if isinstance(parsed, dict) else ({},)
def _choice_tool_calls(choice: object) -> tuple[object, ...]:
message: Final = get_attribute_or_key(choice, "message", None)
tool_calls: Final = get_attribute_or_key(message, "tool_calls", None) if message is not None else None
if isinstance(tool_calls, list):
return tuple(tool_calls)
return ()
def _selected_choices(response: object, include_all_choices: bool) -> tuple[object, ...]:
choices: Final = get_attribute_or_key(response, "choices", None)
if not isinstance(choices, list) or not choices:
return ()
if include_all_choices:
return tuple(choices)
return (choices[0],)
def _parsed_chat_tool_call(tool_call: object) -> _ParsedToolCall | None:
function: Final = get_attribute_or_key(tool_call, "function", None)
if function is None:
return None
name: Final = _optional_tool_name(get_attribute_or_key(function, "name"))
return (
_optional_call_id(get_attribute_or_key(tool_call, "id")),
name,
_parse_tool_call_arguments(
get_attribute_or_key(function, "arguments", "{}"),
tool_name=name,
context="chat completions",
),
)
def _parsed_calls_in_choice(choice: object) -> tuple[_ParsedToolCall, ...]:
return tuple(
parsed for tool_call in _choice_tool_calls(choice) if (parsed := _parsed_chat_tool_call(tool_call)) is not None
)
def _parsed_chat_tool_calls(response: object, include_all_choices: bool) -> tuple[_ParsedToolCall, ...]:
grouped: Final = tuple(
_parsed_calls_in_choice(choice) for choice in _selected_choices(response, include_all_choices)
)
return tuple(itertools.chain.from_iterable(grouped))
def _normalized_tool_calls_for_parse(
name: str | None,
call_ids: tuple[str | None, ...],
arguments: _ArgumentObjects,
) -> tuple[NormalizedToolCall, ...]:
return tuple(
NormalizedToolCall(id=call_id, name=name, arguments=argument)
for call_id, argument in zip(call_ids, arguments, strict=True)
)
def _normalized_tool_calls_from_parses(parses: Sequence[_ParsedToolCall]) -> tuple[NormalizedToolCall, ...]:
id_groups: Final = _split_tool_call_ids(tuple((call_id, len(arguments)) for call_id, _, arguments in parses))
grouped: Final = tuple(
_normalized_tool_calls_for_parse(name, call_ids, arguments)
for (_, name, arguments), call_ids in zip(parses, id_groups, strict=True)
)
return tuple(itertools.chain.from_iterable(grouped))
def _tool_calls_from_chat_completion_response(
response: object, include_all_choices: bool = False
) -> list[NormalizedToolCall]:
choices: Final = get_attribute_or_key(response, "choices", None)
if not (isinstance(choices, list) and choices):
return []
tool_calls: Final[list[object]] = []
for choice in choices if include_all_choices else choices[:1]:
message = get_attribute_or_key(choice, "message", None)
choice_tool_calls = get_attribute_or_key(message, "tool_calls", None) if message else None
if isinstance(choice_tool_calls, list):
tool_calls.extend(choice_tool_calls)
result: Final[list[NormalizedToolCall]] = []
for tc in tool_calls:
fn = get_attribute_or_key(tc, "function", None)
if fn is None:
continue
name = get_attribute_or_key(fn, "name")
result.append(
NormalizedToolCall(
id=get_attribute_or_key(tc, "id"),
name=name,
arguments=_parse_tool_call_arguments(
get_attribute_or_key(fn, "arguments", "{}"),
tool_name=name,
context="chat completions",
),
)
)
return result
) -> tuple[NormalizedToolCall, ...]:
return _normalized_tool_calls_from_parses(_parsed_chat_tool_calls(response, include_all_choices))
def _tool_calls_from_responses_api_response(response: object) -> list[NormalizedToolCall]:
def _response_function_calls(response: object) -> tuple[object, ...]:
output: Final = get_attribute_or_key(response, "output", None)
if not isinstance(output, list):
return []
result: Final[list[NormalizedToolCall]] = []
for item in output:
if get_attribute_or_key(item, "type") != "function_call":
continue
name = get_attribute_or_key(item, "name")
result.append(
NormalizedToolCall(
id=get_attribute_or_key(item, "call_id") or get_attribute_or_key(item, "id"),
name=name,
arguments=_parse_tool_call_arguments(
get_attribute_or_key(item, "arguments", "{}"),
tool_name=name,
context="responses API",
),
)
)
return result
return ()
return tuple(item for item in output if get_attribute_or_key(item, "type") == "function_call")
def _parsed_response_tool_call(item: object) -> _ParsedToolCall:
name: Final = _optional_tool_name(get_attribute_or_key(item, "name"))
raw_id: Final = get_attribute_or_key(item, "call_id") or get_attribute_or_key(item, "id")
return (
_optional_call_id(raw_id),
name,
_parse_tool_call_arguments(
get_attribute_or_key(item, "arguments", "{}"),
tool_name=name,
context="responses API",
),
)
def _tool_calls_from_responses_api_response(response: object) -> tuple[NormalizedToolCall, ...]:
parses: Final = tuple(_parsed_response_tool_call(item) for item in _response_function_calls(response))
return _normalized_tool_calls_from_parses(parses)
def _tool_calls_from_anthropic_messages_response(response: object) -> list[NormalizedToolCall]:
@ -5494,16 +5584,18 @@ def get_tool_calls_from_response(response: object, include_all_choices: bool = F
Callers that only care about a specific tool should filter the result by
``name`` themselves -- this returns every tool call found.
"""
chat_tool_calls = _tool_calls_from_chat_completion_response(response, include_all_choices=include_all_choices)
chat_tool_calls: Final = _tool_calls_from_chat_completion_response(
response, include_all_choices=include_all_choices
)
if chat_tool_calls:
return chat_tool_calls
return list(chat_tool_calls)
for extractor in (
_tool_calls_from_responses_api_response,
_tool_calls_from_anthropic_messages_response,
):
tool_calls = extractor(response)
if tool_calls:
return tool_calls
return list(tool_calls)
return []

View file

@ -20,7 +20,9 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
hoist_images_from_tool_messages,
is_encrypted_reasoning_block,
merge_consecutive_system_messages,
parse_tool_call_arguments,
responses_reasoning_items_from_thinking_blocks,
salvage_concatenated_tool_arguments,
split_concatenated_json_objects,
strip_encrypted_reasoning_from_messages,
system_messages_first,
@ -269,6 +271,40 @@ def test_split_concatenated_json_salvages_prefix_before_truncated_tail():
assert result == [{"a": 1}, {"b": 2}]
def test_parse_tool_call_arguments_rejects_concatenated_json() -> None:
with pytest.raises(ValueError, match="Failed to parse tool call arguments"):
parse_tool_call_arguments('{"a":1}{"b":2}')
def _distinct_json_objects(count: int) -> str:
return "".join(json.dumps({"n": index}, separators=(",", ":")) for index in range(count))
@pytest.mark.parametrize(
("raw", "expected"),
(
('{"a":1}{"b":2}', ({"a": 1}, {"b": 2})),
('{"a":1}{"a":1}{"a":1}', ({"a": 1},)),
('{"a":1}{"a":1}{"b":2}', ({"a": 1}, {"a": 1}, {"b": 2})),
(_distinct_json_objects(8), tuple({"n": index} for index in range(8))),
(_distinct_json_objects(9), ()),
(_distinct_json_objects(9) + " junk", ()),
('{"a":1}' * 7 + '{"b":2}', tuple({"a": 1} for _ in range(7)) + ({"b": 2},)),
('{"a":1}' * 8 + '{"b":2}', ()),
('{"a":1}' * 5000, ({"a": 1},)),
('{"a":1}' * 20, ({"a": 1},)),
('{"a":1}{"b":', ()),
('0{"x":1}', ()),
('{"x":1}0', ()),
('[1]{"x":1}', ()),
('{"a":1}{"b":2}}', ()),
('{"a":1} junk', ()),
),
)
def test_salvage_concatenated_tool_arguments(raw: str, expected: tuple[dict[str, object], ...]) -> None:
assert salvage_concatenated_tool_arguments(raw) == expected
# ---------------------------------------------------------------------------
# Regression tests for non-OpenAI file content blocks.
#
@ -1949,6 +1985,8 @@ class TestMergeConsecutiveSystemMessages:
assert merged == [{"role": "system", "content": expected_content}, {"role": "user", "content": "Hello"}]
def test_keeps_the_first_message_when_no_system_message_in_the_run_has_content(self):
merged = merge_consecutive_system_messages([{"role": "system"}, {"role": "system"}, {"role": "user", "content": "Hi"}])
merged = merge_consecutive_system_messages(
[{"role": "system"}, {"role": "system"}, {"role": "user", "content": "Hi"}]
)
assert merged == [{"role": "system"}, {"role": "user", "content": "Hi"}]

View file

@ -1,4 +1,5 @@
import base64
import json
import logging
import os
import re
@ -16,10 +17,12 @@ from litellm.litellm_core_utils.prompt_templates.factory import (
_bedrock_tools_pt,
_rename_duplicate_bedrock_document_names,
_convert_to_bedrock_tool_call_invoke,
_sanitize_anthropic_tool_use_id,
_convert_to_bedrock_tool_call_result,
anthropic_messages_pt,
convert_to_anthropic_tool_result,
convert_to_gemini_tool_call_result,
get_tool_calls_from_response,
make_valid_bedrock_tool_name,
ollama_pt,
sanitize_messages_for_tool_calling,
@ -31,9 +34,7 @@ def _get_gemini_function_response_inline_data_parts(result):
assert isinstance(result, list), "expected Gemini parts list"
assert len(result) == 1, "multimodal function responses should stay in one part"
function_response_part = result[0]
assert (
"inline_data" not in function_response_part
), "inline_data should be nested under function_response.parts"
assert "inline_data" not in function_response_part, "inline_data should be nested under function_response.parts"
function_response = function_response_part["function_response"]
nested_parts = function_response["parts"]
return [part["inline_data"] for part in nested_parts if "inline_data" in part]
@ -49,7 +50,9 @@ def test_ollama_pt_simple_messages():
result = ollama_pt(model="llama2", messages=messages)
expected_prompt = "### System:\nYou are a helpful assistant\n\n### Assistant:\nHow can I help you?\n\n### User:\nHello\n\n"
expected_prompt = (
"### System:\nYou are a helpful assistant\n\n### Assistant:\nHow can I help you?\n\n### User:\nHello\n\n"
)
assert isinstance(result, dict)
assert result["prompt"] == expected_prompt
assert result["images"] == []
@ -104,10 +107,7 @@ async def test_anthropic_bedrock_thinking_blocks_with_none_content():
# verify the result
assert len(result) == 2
assert (
result[1]["content"][0]["reasoningContent"]["reasoningText"]["text"]
== "This is a test thinking block"
)
assert result[1]["content"][0]["reasoningContent"]["reasoningText"]["text"] == "This is a test thinking block"
def test_bedrock_converse_assistant_with_empty_thinking_block_and_tool_calls():
@ -175,11 +175,7 @@ def test_bedrock_converse_assistant_with_empty_thinking_block_and_tool_calls():
assert len(assistant_blocks) == 1
for block in assistant_blocks[0]["content"]:
if "text" in block:
assert block[
"text"
].strip(), (
f"Bedrock Converse rejects blank-text ContentBlocks; got {block!r}"
)
assert block["text"].strip(), f"Bedrock Converse rejects blank-text ContentBlocks; got {block!r}"
# toolUse blocks must still be present
tool_use_blocks = [b for b in assistant_blocks[0]["content"] if "toolUse" in b]
assert len(tool_use_blocks) == 2
@ -220,19 +216,16 @@ def test_anthropic_messages_pt_drops_unsignable_thinking_block(thinking_block):
{"role": "user", "content": "Now what is 3+3?"},
]
result = anthropic_messages_pt(
messages=messages, model="claude-sonnet-4-6", llm_provider="anthropic"
)
result = anthropic_messages_pt(messages=messages, model="claude-sonnet-4-6", llm_provider="anthropic")
assistant = next(m for m in result if m["role"] == "assistant")
content = assistant["content"]
assert all(
block.get("type") not in ("thinking", "redacted_thinking") for block in content
), f"unsignable thinking block must be dropped, got {content!r}"
assert any(
block.get("type") == "text" and block.get("text") == "2+2 equals 4."
for block in content
), f"assistant answer text must be preserved, got {content!r}"
assert all(block.get("type") not in ("thinking", "redacted_thinking") for block in content), (
f"unsignable thinking block must be dropped, got {content!r}"
)
assert any(block.get("type") == "text" and block.get("text") == "2+2 equals 4." for block in content), (
f"assistant answer text must be preserved, got {content!r}"
)
def test_anthropic_messages_pt_keeps_signed_thinking_block():
@ -255,9 +248,7 @@ def test_anthropic_messages_pt_keeps_signed_thinking_block():
{"role": "user", "content": "Now what is 3+3?"},
]
result = anthropic_messages_pt(
messages=messages, model="claude-sonnet-4-6", llm_provider="anthropic"
)
result = anthropic_messages_pt(messages=messages, model="claude-sonnet-4-6", llm_provider="anthropic")
assistant = next(m for m in result if m["role"] == "assistant")
thinking_blocks = [b for b in assistant["content"] if b.get("type") == "thinking"]
@ -373,9 +364,7 @@ def test_bedrock_get_document_format_fallback_mimes():
"""
# Test DOCX fallback
docx_mime = (
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
)
docx_mime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
supported_formats = ["pdf", "docx", "xlsx", "csv"]
# Mock mimetypes.guess_all_extensions to return empty list (simulating Docker container scenario)
@ -399,15 +388,11 @@ def test_bedrock_get_document_format_mimetypes_success():
"""
Test the _get_document_format method when mimetypes.guess_all_extensions works normally.
"""
docx_mime = (
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
)
docx_mime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
supported_formats = ["pdf", "docx", "xlsx", "csv"]
# Test normal mimetypes behavior (should not hit fallback)
result = BedrockImageProcessor._get_document_format(
mime_type=docx_mime, supported_doc_formats=supported_formats
)
result = BedrockImageProcessor._get_document_format(mime_type=docx_mime, supported_doc_formats=supported_formats)
assert result == "docx", f"Expected 'docx', got '{result}'"
@ -623,9 +608,7 @@ async def test_bedrock_process_image_async_factory():
image_url = "data:application/pdf; qs=0.001;base64,JVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0ZURlY29kZT4"
content_block = await BedrockImageProcessor.process_image_async(
image_url=image_url, format=None
)
content_block = await BedrockImageProcessor.process_image_async(image_url=image_url, format=None)
print(f"content_block: {content_block}")
@ -668,9 +651,7 @@ def test_unpack_defs_resolves_nested_ref_inside_anyof_items():
items_schema = schema["properties"]["vatAmounts"]["anyOf"][0]["items"]
# Assertions: items_schema should now be the resolved object, not an empty dict
assert isinstance(
items_schema, dict
), "Items schema should be a dict after unpacking"
assert isinstance(items_schema, dict), "Items schema should be a dict after unpacking"
assert items_schema.get("type") == "object"
# Ensure essential properties are present
assert set(items_schema.get("properties", {}).keys()) == {"vatRate", "vatAmount"}
@ -861,9 +842,7 @@ def test_convert_gemini_tool_call_result_with_multiple_anthropic_image_blocks():
last_message_with_tool_calls=last_message_with_tool_calls,
)
inline_parts = _get_gemini_function_response_inline_data_parts(result)
assert (
len(inline_parts) == 2
), f"expected 2 inline_data parts, got {len(inline_parts)}"
assert len(inline_parts) == 2, f"expected 2 inline_data parts, got {len(inline_parts)}"
mime_types = {p["mime_type"] for p in inline_parts}
assert mime_types == {"image/png", "image/jpeg"}
@ -899,9 +878,7 @@ def test_convert_gemini_tool_call_result_with_data_url_string():
last_message_with_tool_calls=last_message_with_tool_calls,
)
inline_parts = _get_gemini_function_response_inline_data_parts(result)
assert (
len(inline_parts) == 1
), "data-URL image string was not converted to inline_data"
assert len(inline_parts) == 1, "data-URL image string was not converted to inline_data"
assert inline_parts[0]["mime_type"] == "image/png"
assert inline_parts[0]["data"] == tiny_png_b64
@ -937,9 +914,9 @@ def test_convert_gemini_tool_call_result_with_data_url_extra_params():
)
inline_parts = _get_gemini_function_response_inline_data_parts(result)
assert len(inline_parts) == 1
assert (
inline_parts[0]["mime_type"] == "image/png"
), f"expected clean 'image/png', got '{inline_parts[0]['mime_type']}'"
assert inline_parts[0]["mime_type"] == "image/png", (
f"expected clean 'image/png', got '{inline_parts[0]['mime_type']}'"
)
def test_bedrock_tools_unpack_defs():
@ -1036,9 +1013,7 @@ def test_bedrock_tools_pt_strict_parameter():
},
}
]
result = _bedrock_tools_pt(
tools_with_strict, model="anthropic.claude-sonnet-4-5-20250929-v1:0"
)
result = _bedrock_tools_pt(tools_with_strict, model="anthropic.claude-sonnet-4-5-20250929-v1:0")
assert result[0]["toolSpec"]["strict"] is True
assert result[0]["toolSpec"]["inputSchema"]["json"]["additionalProperties"] is False
@ -1060,9 +1035,7 @@ def test_bedrock_tools_pt_strict_parameter():
},
}
]
result = _bedrock_tools_pt(
tools_without_strict, model="anthropic.claude-sonnet-4-5-20250929-v1:0"
)
result = _bedrock_tools_pt(tools_without_strict, model="anthropic.claude-sonnet-4-5-20250929-v1:0")
assert "strict" not in result[0]["toolSpec"]
assert "additionalProperties" not in result[0]["toolSpec"]["inputSchema"]["json"]
@ -1085,9 +1058,7 @@ def test_bedrock_image_processor_content_type_fallback_url_extension():
# Test with .png URL
image_url = "https://example.com/test-image.png"
base64_bytes, content_type = BedrockImageProcessor._post_call_image_processing(
mock_response, image_url
)
base64_bytes, content_type = BedrockImageProcessor._post_call_image_processing(mock_response, image_url)
assert content_type == "image/png"
assert base64_bytes == base64.b64encode(png_content).decode("utf-8")
@ -1111,9 +1082,7 @@ def test_bedrock_image_processor_content_type_fallback_binary_detection():
# Test with URL without extension
image_url = "https://example.com/test-image-without-extension"
base64_bytes, content_type = BedrockImageProcessor._post_call_image_processing(
mock_response, image_url
)
base64_bytes, content_type = BedrockImageProcessor._post_call_image_processing(mock_response, image_url)
assert content_type == "image/jpeg"
assert base64_bytes == base64.b64encode(jpeg_content).decode("utf-8")
@ -1136,9 +1105,7 @@ def test_bedrock_image_processor_content_type_fallback_application_octet_stream(
# Test with .gif URL
image_url = "https://s3.amazonaws.com/bucket/image.gif"
base64_bytes, content_type = BedrockImageProcessor._post_call_image_processing(
mock_response, image_url
)
base64_bytes, content_type = BedrockImageProcessor._post_call_image_processing(mock_response, image_url)
assert content_type == "image/gif"
assert base64_bytes == base64.b64encode(gif_content).decode("utf-8")
@ -1161,9 +1128,7 @@ def test_bedrock_image_processor_content_type_with_query_params():
# Test with URL containing query parameters (common in S3 signed URLs)
image_url = "https://s3.amazonaws.com/bucket/image.webp?AWSAccessKeyId=123&Expires=456&Signature=789"
base64_bytes, content_type = BedrockImageProcessor._post_call_image_processing(
mock_response, image_url
)
base64_bytes, content_type = BedrockImageProcessor._post_call_image_processing(mock_response, image_url)
assert content_type == "image/webp"
assert base64_bytes == base64.b64encode(webp_content).decode("utf-8")
@ -1185,9 +1150,7 @@ def test_bedrock_image_processor_content_type_normal_header():
mock_response.content = png_content
image_url = "https://example.com/test-image.png"
base64_bytes, content_type = BedrockImageProcessor._post_call_image_processing(
mock_response, image_url
)
base64_bytes, content_type = BedrockImageProcessor._post_call_image_processing(mock_response, image_url)
assert content_type == "image/png"
assert base64_bytes == base64.b64encode(png_content).decode("utf-8")
@ -1207,7 +1170,7 @@ def test_bedrock_image_processor_content_type_fallback_failure():
# Test with URL without recognizable extension
image_url = "https://example.com/unknown-file"
with pytest.raises(ValueError, match='Unable to determine content type from URL: https') as excinfo:
with pytest.raises(ValueError, match="Unable to determine content type from URL: https") as excinfo:
BedrockImageProcessor._post_call_image_processing(mock_response, image_url)
assert "Unable to determine content type" in str(excinfo.value)
@ -1227,16 +1190,12 @@ def test_bedrock_image_processor_content_type_jpeg_variants():
# Test with .jpg extension
image_url_jpg = "https://example.com/photo.jpg"
_, content_type_jpg = BedrockImageProcessor._post_call_image_processing(
mock_response, image_url_jpg
)
_, content_type_jpg = BedrockImageProcessor._post_call_image_processing(mock_response, image_url_jpg)
assert content_type_jpg == "image/jpeg"
# Test with .jpeg extension
image_url_jpeg = "https://example.com/photo.jpeg"
_, content_type_jpeg = BedrockImageProcessor._post_call_image_processing(
mock_response, image_url_jpeg
)
_, content_type_jpeg = BedrockImageProcessor._post_call_image_processing(mock_response, image_url_jpeg)
assert content_type_jpeg == "image/jpeg"
@ -1258,9 +1217,7 @@ def test_bedrock_image_processor_content_type_pdf_document():
# Test with .pdf URL
pdf_url = "https://s3.amazonaws.com/bucket/document.pdf"
base64_bytes, content_type = BedrockImageProcessor._post_call_image_processing(
mock_response, pdf_url
)
base64_bytes, content_type = BedrockImageProcessor._post_call_image_processing(mock_response, pdf_url)
assert content_type == "application/pdf"
assert base64_bytes == base64.b64encode(pdf_content).decode("utf-8")
@ -1293,12 +1250,8 @@ def test_bedrock_image_processor_content_type_document_formats():
]
for url, expected_mime in test_cases:
_, content_type = BedrockImageProcessor._post_call_image_processing(
mock_response, url
)
assert (
content_type == expected_mime
), f"Expected {expected_mime} for {url}, got {content_type}"
_, content_type = BedrockImageProcessor._post_call_image_processing(mock_response, url)
assert content_type == expected_mime, f"Expected {expected_mime} for {url}, got {content_type}"
def test_bedrock_image_processor_content_type_s3_pdf_with_query():
@ -1317,9 +1270,7 @@ def test_bedrock_image_processor_content_type_s3_pdf_with_query():
# S3 signed URL with query parameters
s3_url = "https://my-bucket.s3.us-east-1.amazonaws.com/documents/report.pdf?AWSAccessKeyId=AKIAIOSFODNN7EXAMPLE&Expires=1234567890&Signature=abcdef123456"
base64_bytes, content_type = BedrockImageProcessor._post_call_image_processing(
mock_response, s3_url
)
base64_bytes, content_type = BedrockImageProcessor._post_call_image_processing(mock_response, s3_url)
assert content_type == "application/pdf"
assert base64_bytes == base64.b64encode(pdf_content).decode("utf-8")
@ -1428,12 +1379,8 @@ def test_bedrock_create_bedrock_block_normalized_base64():
base64_content = base64.b64encode(pdf_content).decode("utf-8")
# Create versions with different whitespace
base64_with_newlines = "\n".join(
[base64_content[i : i + 64] for i in range(0, len(base64_content), 64)]
)
base64_with_spaces = " ".join(
[base64_content[i : i + 32] for i in range(0, len(base64_content), 32)]
)
base64_with_newlines = "\n".join([base64_content[i : i + 64] for i in range(0, len(base64_content), 64)])
base64_with_spaces = " ".join([base64_content[i : i + 32] for i in range(0, len(base64_content), 32)])
# Create blocks
block1 = BedrockImageProcessor._create_bedrock_block(
@ -1565,9 +1512,7 @@ def test_bedrock_create_bedrock_block_document_name_format():
# Check format: DocumentPDFmessages_{16_hex_chars}_{format}
pattern = r"^DocumentPDFmessages_[0-9a-f]{16}_pdf$"
assert re.match(
pattern, document_name
), f"Document name format mismatch: {document_name}"
assert re.match(pattern, document_name), f"Document name format mismatch: {document_name}"
def test_bedrock_create_bedrock_block_different_document_formats():
@ -1620,9 +1565,7 @@ def test_bedrock_nova_web_search_options_mapping():
assert system_tool["name"] == "nova_grounding"
# Test with search_context_size (should be ignored for Nova)
result2 = config._map_web_search_options(
{"search_context_size": "high"}, "us.amazon.nova-premier-v1:0"
)
result2 = config._map_web_search_options({"search_context_size": "high"}, "us.amazon.nova-premier-v1:0")
assert result2 is not None
system_tool2 = result2.get("systemTool")
@ -1688,9 +1631,7 @@ def test_bedrock_tools_pt_drops_unmappable_responses_builtin_tools():
{"type": "custom", "name": "free_form"},
]
result = _bedrock_tools_pt(
tools=tools, model="anthropic.claude-sonnet-4-5-20250929-v1:0"
)
result = _bedrock_tools_pt(tools=tools, model="anthropic.claude-sonnet-4-5-20250929-v1:0")
names = [block["toolSpec"]["name"] for block in result if "toolSpec" in block]
assert names == ["noop"]
@ -1720,9 +1661,7 @@ def test_bedrock_tools_pt_keeps_anthropic_input_schema_tools():
},
]
result = _bedrock_tools_pt(
tools=tools, model="anthropic.claude-sonnet-4-5-20250929-v1:0"
)
result = _bedrock_tools_pt(tools=tools, model="anthropic.claude-sonnet-4-5-20250929-v1:0")
names = [block["toolSpec"]["name"] for block in result if "toolSpec" in block]
assert names == ["lookup"]
@ -1924,9 +1863,7 @@ def test_anthropic_messages_pt_server_tool_use_passthrough():
"tool_use_id": "srvtoolu_01ABC123",
"content": {
"type": "tool_search_tool_search_result",
"tool_references": [
{"type": "tool_reference", "tool_name": "get_time"}
],
"tool_references": [{"type": "tool_reference", "tool_name": "get_time"}],
},
},
{"type": "text", "text": "I found the time tool. How can I help you?"},
@ -1954,20 +1891,14 @@ def test_anthropic_messages_pt_server_tool_use_passthrough():
# Verify server_tool_use block is preserved
assert "server_tool_use" in content_types
server_tool_use_block = next(
b for b in assistant_msg["content"] if b.get("type") == "server_tool_use"
)
server_tool_use_block = next(b for b in assistant_msg["content"] if b.get("type") == "server_tool_use")
assert server_tool_use_block["id"] == "srvtoolu_01ABC123"
assert server_tool_use_block["name"] == "tool_search_tool_regex"
assert server_tool_use_block["input"] == {"query": ".*time.*"}
# Verify tool_search_tool_result block is preserved
assert "tool_search_tool_result" in content_types
tool_result_block = next(
b
for b in assistant_msg["content"]
if b.get("type") == "tool_search_tool_result"
)
tool_result_block = next(b for b in assistant_msg["content"] if b.get("type") == "tool_search_tool_result")
assert tool_result_block["tool_use_id"] == "srvtoolu_01ABC123"
assert tool_result_block["content"]["type"] == "tool_search_tool_search_result"
assert tool_result_block["content"]["tool_references"][0]["tool_name"] == "get_time"
@ -2019,9 +1950,7 @@ def test_bedrock_tools_unpack_defs_no_oom_with_nested_refs():
"anyOf": [
{"$ref": "#/$defs/Literal"},
{"$ref": "#/$defs/FieldRef"},
{
"$ref": "#/$defs/Expression"
}, # Circular: Operand -> Expression -> Operand
{"$ref": "#/$defs/Expression"}, # Circular: Operand -> Expression -> Operand
],
},
"Literal": {
@ -2155,9 +2084,7 @@ def test_anthropic_messages_pt_file_block_cache_control_with_explicit_provider()
file_block = content_blocks[0]
assert file_block["type"] == "document"
assert (
"cache_control" in file_block
), "cache_control should be preserved on file/document content blocks"
assert "cache_control" in file_block, "cache_control should be preserved on file/document content blocks"
assert file_block["cache_control"]["type"] == "ephemeral"
text_block = content_blocks[1]
@ -2365,22 +2292,16 @@ def test_bedrock_tool_call_invoke_concatenated_json():
# First block keeps original tool id
assert result[0]["toolUse"]["toolUseId"] == "tooluse_L7I3TewYAUhoheJZQEuwVN"
assert result[0]["toolUse"]["name"] == "shell"
assert result[0]["toolUse"]["input"] == {
"command": ["curl", "-i", "http://localhost:9009", "-m", "10"]
}
assert result[0]["toolUse"]["input"] == {"command": ["curl", "-i", "http://localhost:9009", "-m", "10"]}
# Subsequent blocks get suffixed ids
assert result[1]["toolUse"]["toolUseId"] == "tooluse_L7I3TewYAUhoheJZQEuwVN_1"
assert result[1]["toolUse"]["name"] == "shell"
assert result[1]["toolUse"]["input"] == {
"command": ["curl", "-i", "http://localhost:9009/robots.txt", "-m", "5"]
}
assert result[1]["toolUse"]["input"] == {"command": ["curl", "-i", "http://localhost:9009/robots.txt", "-m", "5"]}
assert result[2]["toolUse"]["toolUseId"] == "tooluse_L7I3TewYAUhoheJZQEuwVN_2"
assert result[2]["toolUse"]["name"] == "shell"
assert result[2]["toolUse"]["input"] == {
"command": ["curl", "-i", "http://localhost:9009/sitemap.xml", "-m", "5"]
}
assert result[2]["toolUse"]["input"] == {"command": ["curl", "-i", "http://localhost:9009/sitemap.xml", "-m", "5"]}
def test_bedrock_tool_call_invoke_concatenated_json_with_cache_control():
@ -2535,9 +2456,7 @@ def test_bedrock_tool_call_invoke_unconvertible_raises_non_retryable_bad_request
def test_make_valid_bedrock_tool_name_preserves_hyphens():
assert make_valid_bedrock_tool_name("my-tool") == "my-tool"
assert (
make_valid_bedrock_tool_name(
"CreateCaseKnowledgeArticle_foTWsqR6yDt-OnSsvR5e6Q"
)
make_valid_bedrock_tool_name("CreateCaseKnowledgeArticle_foTWsqR6yDt-OnSsvR5e6Q")
== "CreateCaseKnowledgeArticle_foTWsqR6yDt-OnSsvR5e6Q"
)
@ -2564,9 +2483,7 @@ def test_bedrock_tool_name_sanitized_consistently_in_tools_and_tool_use():
"function": {"name": raw_name, "arguments": "{}"},
}
]
tool_use_name = _convert_to_bedrock_tool_call_invoke(tool_calls)[0]["toolUse"][
"name"
]
tool_use_name = _convert_to_bedrock_tool_call_invoke(tool_calls)[0]["toolUse"]["name"]
assert tool_spec_name == "foo_bar"
assert tool_use_name == tool_spec_name
@ -2589,15 +2506,8 @@ def test_bedrock_converse_messages_pt_tool_use_matches_tool_spec_hyphen_name():
],
},
]
translated = _bedrock_converse_messages_pt(
messages=messages, model="", llm_provider=""
)
tool_use_blocks = [
block
for msg in translated
for block in msg.get("content", [])
if "toolUse" in block
]
translated = _bedrock_converse_messages_pt(messages=messages, model="", llm_provider="")
tool_use_blocks = [block for msg in translated for block in msg.get("content", []) if "toolUse" in block]
assert len(tool_use_blocks) == 1
assert tool_use_blocks[0]["toolUse"]["name"] == tool_name
@ -2694,11 +2604,7 @@ def test_sanitize_messages_deduplicates_tool_results():
result = sanitize_messages_for_tool_calling(messages)
# Count tool messages with this ID — should be exactly 1
tool_results = [
m
for m in result
if m.get("role") == "tool" and m.get("tool_call_id") == "call_abc123"
]
tool_results = [m for m in result if m.get("role") == "tool" and m.get("tool_call_id") == "call_abc123"]
assert len(tool_results) == 1
# Should keep the LAST occurrence (most complete)
assert tool_results[0]["content"] == '{"temperature": 72, "condition": "sunny"}'
@ -2833,11 +2739,7 @@ def test_sanitize_messages_dedup_scoped_per_turn_preserves_cross_turn():
result = sanitize_messages_for_tool_calling(messages)
# Both tool results must survive — one per turn
tool_results = [
m
for m in result
if m.get("role") == "tool" and m.get("tool_call_id") == "call_X"
]
tool_results = [m for m in result if m.get("role") == "tool" and m.get("tool_call_id") == "call_X"]
assert len(tool_results) == 2, (
f"Expected 2 tool results (one per turn), got {len(tool_results)}. "
"Dedup may be global instead of per-turn scoped."
@ -2891,32 +2793,26 @@ def test_sanitize_messages_combined_case_a_and_case_d():
tool_results = [m for m in result if m.get("role") in ("tool", "function")]
# Case A: call_missing should have a dummy result injected
missing_results = [
m for m in tool_results if m.get("tool_call_id") == "call_missing"
]
assert (
len(missing_results) == 1
), f"Expected 1 dummy result for call_missing (Case A), got {len(missing_results)}"
missing_results = [m for m in tool_results if m.get("tool_call_id") == "call_missing"]
assert len(missing_results) == 1, (
f"Expected 1 dummy result for call_missing (Case A), got {len(missing_results)}"
)
# Case D: call_duped should have exactly 1 result (the fresh one)
duped_results = [
m for m in tool_results if m.get("tool_call_id") == "call_duped"
]
assert (
len(duped_results) == 1
), f"Expected 1 result for call_duped after dedup (Case D), got {len(duped_results)}"
assert (
duped_results[0]["content"] == "fresh_result"
), f"Expected last-wins 'fresh_result', got '{duped_results[0]['content']}'"
duped_results = [m for m in tool_results if m.get("tool_call_id") == "call_duped"]
assert len(duped_results) == 1, (
f"Expected 1 result for call_duped after dedup (Case D), got {len(duped_results)}"
)
assert duped_results[0]["content"] == "fresh_result", (
f"Expected last-wins 'fresh_result', got '{duped_results[0]['content']}'"
)
# Verify tool results immediately follow the assistant message
asst_idx = next(i for i, m in enumerate(result) if m.get("role") == "assistant")
tool_msgs_after_asst = [
m for m in result[asst_idx + 1 :] if m.get("role") in ("tool", "function")
]
assert (
len(tool_msgs_after_asst) == 2
), f"Expected 2 tool results after assistant, got {len(tool_msgs_after_asst)}"
tool_msgs_after_asst = [m for m in result[asst_idx + 1 :] if m.get("role") in ("tool", "function")]
assert len(tool_msgs_after_asst) == 2, (
f"Expected 2 tool results after assistant, got {len(tool_msgs_after_asst)}"
)
# Both tool_call_ids should be present (order may vary)
tool_ids = {m["tool_call_id"] for m in tool_msgs_after_asst}
assert tool_ids == {
@ -2958,9 +2854,7 @@ def test_anthropic_messages_pt_file_block_preserves_cache_control():
}
]
result = anthropic_messages_pt(
messages, model="claude-sonnet-4-20250514", llm_provider="anthropic"
)
result = anthropic_messages_pt(messages, model="claude-sonnet-4-20250514", llm_provider="anthropic")
content_blocks = result[0]["content"]
assert len(content_blocks) == 2
@ -2968,9 +2862,7 @@ def test_anthropic_messages_pt_file_block_preserves_cache_control():
# Document block (from file) should preserve cache_control
doc_block = content_blocks[0]
assert doc_block["type"] == "document"
assert (
"cache_control" in doc_block
), "cache_control was dropped from file/document block"
assert "cache_control" in doc_block, "cache_control was dropped from file/document block"
assert doc_block["cache_control"]["type"] == "ephemeral"
# Text block should also preserve cache_control
@ -3013,9 +2905,7 @@ def test_add_cache_point_tool_block_passes_ttl_for_claude_4_5(monkeypatch):
}
# Claude 4.5 model: ttl should be preserved
result = add_cache_point_tool_block(
tool_with_1h, model="jp.anthropic.claude-opus-4-7"
)
result = add_cache_point_tool_block(tool_with_1h, model="jp.anthropic.claude-opus-4-7")
assert result is not None
assert result["cachePoint"]["type"] == "default"
assert result["cachePoint"]["ttl"] == "1h"
@ -3024,16 +2914,12 @@ def test_add_cache_point_tool_block_passes_ttl_for_claude_4_5(monkeypatch):
tool_with_5m = {
"cache_control": {"type": "ephemeral", "ttl": "5m"},
}
result_5m = add_cache_point_tool_block(
tool_with_5m, model="jp.anthropic.claude-opus-4-7"
)
result_5m = add_cache_point_tool_block(tool_with_5m, model="jp.anthropic.claude-opus-4-7")
assert result_5m is not None
assert result_5m["cachePoint"]["ttl"] == "5m"
# Older model: ttl should be stripped
result_old = add_cache_point_tool_block(
tool_with_1h, model="anthropic.claude-3-5-sonnet-20241022-v2:0"
)
result_old = add_cache_point_tool_block(tool_with_1h, model="anthropic.claude-3-5-sonnet-20241022-v2:0")
assert result_old is not None
assert result_old["cachePoint"]["type"] == "default"
assert "ttl" not in result_old["cachePoint"]
@ -3052,9 +2938,7 @@ def test_add_cache_point_tool_block_passes_ttl_for_claude_4_5(monkeypatch):
# cache_control without ttl: returns default cachePoint (unchanged behavior)
tool_no_ttl = {"cache_control": {"type": "ephemeral"}}
result_no_ttl = add_cache_point_tool_block(
tool_no_ttl, model="us.anthropic.claude-sonnet-4-5-20250929-v1:0"
)
result_no_ttl = add_cache_point_tool_block(tool_no_ttl, model="us.anthropic.claude-sonnet-4-5-20250929-v1:0")
assert result_no_ttl is not None
assert result_no_ttl["cachePoint"]["type"] == "default"
assert "ttl" not in result_no_ttl["cachePoint"]
@ -3127,9 +3011,7 @@ def test_bedrock_tools_pt_passes_ttl_for_claude_4_5(monkeypatch):
assert cache_blocks[0]["cachePoint"]["ttl"] == "1h"
# Older model: cachePoint should not have ttl
result_old = _bedrock_tools_pt(
tools, model="anthropic.claude-3-5-sonnet-20241022-v2:0"
)
result_old = _bedrock_tools_pt(tools, model="anthropic.claude-3-5-sonnet-20241022-v2:0")
cache_blocks_old = [b for b in result_old if "cachePoint" in b]
assert len(cache_blocks_old) == 1
assert "ttl" not in cache_blocks_old[0]["cachePoint"]
@ -3204,9 +3086,7 @@ def test_bedrock_converse_messages_pt_document_various_formats():
}
]
result = _bedrock_converse_messages_pt(
messages, "anthropic.claude-sonnet-4-6", "bedrock"
)
result = _bedrock_converse_messages_pt(messages, "anthropic.claude-sonnet-4-6", "bedrock")
doc_block = result[0]["content"][0]
assert doc_block["document"]["format"] == expected_format, (
@ -3233,12 +3113,8 @@ def test_bedrock_converse_messages_pt_document_deterministic_name():
}
]
result1 = _bedrock_converse_messages_pt(
messages, "anthropic.claude-sonnet-4-6", "bedrock"
)
result2 = _bedrock_converse_messages_pt(
messages, "anthropic.claude-sonnet-4-6", "bedrock"
)
result1 = _bedrock_converse_messages_pt(messages, "anthropic.claude-sonnet-4-6", "bedrock")
result2 = _bedrock_converse_messages_pt(messages, "anthropic.claude-sonnet-4-6", "bedrock")
name1 = result1[0]["content"][0]["document"]["name"]
name2 = result2[0]["content"][0]["document"]["name"]
@ -3272,34 +3148,18 @@ def test_bedrock_converse_messages_pt_renames_duplicate_document_names():
},
]
result1 = _bedrock_converse_messages_pt(
messages, "anthropic.claude-sonnet-4-6", "bedrock"
)
result2 = _bedrock_converse_messages_pt(
messages, "anthropic.claude-sonnet-4-6", "bedrock"
)
result1 = _bedrock_converse_messages_pt(messages, "anthropic.claude-sonnet-4-6", "bedrock")
result2 = _bedrock_converse_messages_pt(messages, "anthropic.claude-sonnet-4-6", "bedrock")
names1 = [
block["document"]["name"]
for message in result1
for block in message["content"]
if "document" in block
]
names2 = [
block["document"]["name"]
for message in result2
for block in message["content"]
if "document" in block
]
names1 = [block["document"]["name"] for message in result1 for block in message["content"] if "document" in block]
names2 = [block["document"]["name"] for message in result2 for block in message["content"] if "document" in block]
assert len(names1) == 2
assert len(set(names1)) == 2
assert names1[1] == f"{names1[0]}_2"
assert names1 == names2
single_turn = _bedrock_converse_messages_pt(
[messages[0]], "anthropic.claude-sonnet-4-6", "bedrock"
)
single_turn = _bedrock_converse_messages_pt([messages[0]], "anthropic.claude-sonnet-4-6", "bedrock")
assert names1[0] == single_turn[0]["content"][0]["document"]["name"]
@ -3321,14 +3181,10 @@ def test_rename_duplicate_bedrock_document_names_skips_organic_suffixes():
def _names(contents):
return [block["document"]["name"] for block in contents[0]["content"]]
organic_first = _rename_duplicate_bedrock_document_names(
_contents(["report", "report_2", "report"])
)
organic_first = _rename_duplicate_bedrock_document_names(_contents(["report", "report_2", "report"]))
assert _names(organic_first) == ["report", "report_2", "report_3"]
organic_last = _rename_duplicate_bedrock_document_names(
_contents(["report", "report", "report_2"])
)
organic_last = _rename_duplicate_bedrock_document_names(_contents(["report", "report", "report_2"]))
assert _names(organic_last) == ["report", "report_3", "report_2"]
@ -3350,18 +3206,11 @@ def test_bedrock_converse_messages_pt_document_rejects_url_source():
]
with pytest.raises(ValueError, match="only supports base64-encoded"):
_bedrock_converse_messages_pt(
messages, "anthropic.claude-sonnet-4-6", "bedrock"
)
_bedrock_converse_messages_pt(messages, "anthropic.claude-sonnet-4-6", "bedrock")
def _collect_cache_points(blocks):
return [
block["cachePoint"]
for message in blocks
for block in message["content"]
if "cachePoint" in block
]
return [block["cachePoint"] for message in blocks for block in message["content"] if "cachePoint" in block]
@pytest.mark.parametrize(
@ -3527,6 +3376,189 @@ def test_get_tool_calls_from_response_warns_for_malformed_arguments(caplog):
assert "Failed to parse tool call arguments" in caplog.text
def _concatenated_json(*payloads: dict[str, object]) -> str:
return "".join(json.dumps(payload, separators=(",", ":")) for payload in payloads)
def _function_tool_call(call_id: str | None, name: str, arguments: str) -> dict[str, object]:
return {"id": call_id, "function": {"name": name, "arguments": arguments}}
def _chat_tool_response(*tool_calls: dict[str, object]) -> dict[str, object]:
return {"choices": [{"message": {"tool_calls": list(tool_calls)}}]}
def test_get_tool_calls_from_response_expands_distinct_concatenated_arguments(caplog):
raw = '{"flag":true}{"box":"A","limit":50}'
response: Final = _chat_tool_response(_function_tool_call("call_move", "move", raw))
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
tool_calls: Final = get_tool_calls_from_response(response)
assert tool_calls == [
{"id": "call_move", "name": "move", "arguments": {"flag": True}},
{"id": "call_move__concat_1", "name": "move", "arguments": {"box": "A", "limit": 50}},
]
assert "Recovered 2 tool call(s)" in caplog.text
assert "move" in caplog.text
assert "flag" not in caplog.text
def test_get_tool_calls_from_response_expands_responses_api_concatenated_arguments():
response: Final = {
"output": [
{
"type": "function_call",
"call_id": "call_move",
"name": "move",
"arguments": '{"flag":true}{"box":"A","limit":50}',
}
]
}
assert get_tool_calls_from_response(response) == [
{"id": "call_move", "name": "move", "arguments": {"flag": True}},
{"id": "call_move__concat_1", "name": "move", "arguments": {"box": "A", "limit": 50}},
]
def test_get_tool_calls_from_response_collapses_identical_concatenated_arguments():
response: Final = _chat_tool_response(_function_tool_call("call_move", "move", '{"flag":true}' * 3))
assert get_tool_calls_from_response(response) == [
{"id": "call_move", "name": "move", "arguments": {"flag": True}},
]
def test_get_tool_calls_from_response_does_not_expand_a_valid_json_array():
response: Final = _chat_tool_response(_function_tool_call("call_batch", "batch", '[{"a":1},{"b":2}]'))
assert get_tool_calls_from_response(response) == [
{"id": "call_batch", "name": "batch", "arguments": {}},
]
@pytest.mark.parametrize("arguments", ('{"a":1}{"b":', '0{"x":1}'))
def test_get_tool_calls_from_response_drops_partial_concatenated_arguments(arguments: str, caplog):
response: Final = _chat_tool_response(_function_tool_call("call_move", "move", arguments))
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
tool_calls: Final = get_tool_calls_from_response(response)
assert tool_calls == [{"id": "call_move", "name": "move", "arguments": {}}]
assert "Failed to parse tool call arguments" in caplog.text
@pytest.mark.parametrize(("count", "expands"), ((8, True), (9, False)))
def test_get_tool_calls_from_response_caps_distinct_concatenated_arguments(count: int, expands: bool):
raw = _concatenated_json(*({"n": index} for index in range(count)))
response: Final = _chat_tool_response(_function_tool_call("call", "move", raw))
tool_calls: Final = get_tool_calls_from_response(response)
if expands:
assert [call["id"] for call in tool_calls] == ["call", *(f"call__concat_{index}" for index in range(1, count))]
assert [call["arguments"] for call in tool_calls] == [{"n": index} for index in range(count)]
return
assert tool_calls == [{"id": "call", "name": "move", "arguments": {}}]
def test_get_tool_calls_from_response_skips_concat_ids_taken_by_a_sibling():
raw = _concatenated_json({"a": 1}, {"b": 2})
response: Final = _chat_tool_response(
_function_tool_call("call", "move", raw),
_function_tool_call("call__concat_1", "look", '{"x":1}'),
)
assert [call["id"] for call in get_tool_calls_from_response(response)] == [
"call",
"call__concat_2",
"call__concat_1",
]
def test_get_tool_calls_from_response_keeps_sanitized_concat_ids_distinct():
raw = _concatenated_json({"a": 1}, {"b": 2})
response: Final = _chat_tool_response(
_function_tool_call("a:b", "move", raw),
_function_tool_call("a_b__concat_1", "look", '{"x":1}'),
)
ids: Final = [call["id"] for call in get_tool_calls_from_response(response)]
sanitized: Final = [_sanitize_anthropic_tool_use_id(call_id) for call_id in ids if isinstance(call_id, str)]
assert len(sanitized) == len(set(sanitized))
assert ids == ["a:b", "a:b__concat_2", "a_b__concat_1"]
def test_get_tool_calls_from_response_bumps_suffix_when_sibling_sanitizes_onto_it():
raw = _concatenated_json({"a": 1}, {"b": 2})
response: Final = _chat_tool_response(
_function_tool_call("a_b", "move", raw),
_function_tool_call("a:b__concat_1", "look", '{"x":1}'),
)
ids: Final = [call["id"] for call in get_tool_calls_from_response(response)]
sanitized: Final = [_sanitize_anthropic_tool_use_id(call_id) for call_id in ids if isinstance(call_id, str)]
assert len(ids) == len(sanitized)
assert len(sanitized) == len(set(sanitized))
assert ids == ["a_b", "a_b__concat_2", "a:b__concat_1"]
def test_get_tool_calls_from_response_continues_concat_suffixes_per_sanitized_base():
raw = _concatenated_json({"a": 1}, {"b": 2})
response: Final = _chat_tool_response(
_function_tool_call("x", "move", raw),
_function_tool_call("x", "move", raw),
)
assert [call["id"] for call in get_tool_calls_from_response(response)] == [
"x",
"x__concat_1",
"x",
"x__concat_2",
]
def test_get_tool_calls_from_response_skips_a_run_of_reserved_concat_ids():
raw = _concatenated_json({"a": 1}, {"b": 2})
siblings: Final = tuple(_function_tool_call(f"call__concat_{index}", "look", '{"x":1}') for index in range(1, 51))
response: Final = _chat_tool_response(_function_tool_call("call", "move", raw), *siblings)
ids: Final = [call["id"] for call in get_tool_calls_from_response(response)]
assert ids[0] == "call"
assert ids[1] == "call__concat_51"
def test_get_tool_calls_from_response_reserves_concat_ids_across_choices():
raw = _concatenated_json({"a": 1}, {"b": 2})
response: Final = {
"choices": [
{"message": {"tool_calls": [_function_tool_call("call", "move", raw)]}},
{"message": {"tool_calls": [_function_tool_call("call__concat_1", "look", '{"x":1}')]}},
]
}
assert [call["id"] for call in get_tool_calls_from_response(response, include_all_choices=True)] == [
"call",
"call__concat_2",
"call__concat_1",
]
def test_get_tool_calls_from_response_does_not_invent_ids_for_a_missing_call_id():
raw = _concatenated_json({"a": 1}, {"b": 2})
response: Final = _chat_tool_response(_function_tool_call(None, "move", raw))
tool_calls: Final = get_tool_calls_from_response(response)
assert len(tool_calls) == 2
assert all(call["id"] is None for call in tool_calls)
assert [call["arguments"] for call in tool_calls] == [{"a": 1}, {"b": 2}]
def test_group_tool_exchanges_pairs_assistant_with_its_tool_rows():
from litellm.litellm_core_utils.prompt_templates.factory import group_tool_exchanges
@ -3625,9 +3657,7 @@ def test_bedrock_converse_pdf_only_user_message_gets_text_block():
}
]
result = _bedrock_converse_messages_pt(
messages, "anthropic.claude-haiku-4-5", "bedrock"
)
result = _bedrock_converse_messages_pt(messages, "anthropic.claude-haiku-4-5", "bedrock")
assert len(result) == 1
assert any("document" in block for block in result[0]["content"])
@ -3645,9 +3675,7 @@ def test_bedrock_converse_document_with_text_gets_no_extra_text_block():
}
]
result = _bedrock_converse_messages_pt(
messages, "anthropic.claude-haiku-4-5", "bedrock"
)
result = _bedrock_converse_messages_pt(messages, "anthropic.claude-haiku-4-5", "bedrock")
assert _text_blocks(result[0]) == ["summarize this"]
@ -3660,9 +3688,7 @@ def test_bedrock_converse_image_only_user_message_gets_no_text_block():
}
]
result = _bedrock_converse_messages_pt(
messages, "anthropic.claude-haiku-4-5", "bedrock"
)
result = _bedrock_converse_messages_pt(messages, "anthropic.claude-haiku-4-5", "bedrock")
assert any("image" in block for block in result[0]["content"])
assert _text_blocks(result[0]) == []
@ -3705,9 +3731,7 @@ def test_bedrock_converse_tool_round_trip_document_injects_text_before_cache_poi
},
]
result = _bedrock_converse_messages_pt(
messages, "anthropic.claude-haiku-4-5", "bedrock"
)
result = _bedrock_converse_messages_pt(messages, "anthropic.claude-haiku-4-5", "bedrock")
assert _text_blocks(result[0]) == ["read the pdf"]
document_message = result[-1]