diff --git a/litellm/llms/anthropic/pass_through/adapters/transformation.py b/litellm/llms/anthropic/pass_through/adapters/transformation.py index 1e276b7c9c7..defb1fa8f38 100644 --- a/litellm/llms/anthropic/pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/pass_through/adapters/transformation.py @@ -11,6 +11,7 @@ import litellm from litellm.litellm_core_utils.hidden_params import get_hidden_params from litellm.llms.anthropic.pass_through.utils import ( is_reasoning_auto_summary_enabled, + json_copy_or_original, prompt_cache_key_from_user_id, ) @@ -809,7 +810,7 @@ class LiteLLMAnthropicMessagesAdapter: "eager_input_streaming", ] - for idx, tool in enumerate(tools): + for idx, tool in enumerate(json_copy_or_original(tool) for tool in tools): # Check if this is an Anthropic-native tool that should be kept as-is tool_type = tool.get("type", "") if any(tool_type.startswith(t.value) for t in ANTHROPIC_HOSTED_TOOLS): diff --git a/litellm/llms/anthropic/pass_through/responses_adapters/transformation.py b/litellm/llms/anthropic/pass_through/responses_adapters/transformation.py index a9f7a554041..73978094685 100644 --- a/litellm/llms/anthropic/pass_through/responses_adapters/transformation.py +++ b/litellm/llms/anthropic/pass_through/responses_adapters/transformation.py @@ -26,6 +26,7 @@ from litellm.llms.anthropic.pass_through.messages.utils import ( ) from litellm.llms.anthropic.pass_through.utils import ( is_reasoning_auto_summary_enabled, + json_copy_or_original, prompt_cache_key_from_user_id, ) from litellm.types.llms.anthropic import ( @@ -488,7 +489,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter: if "description" in tool_dict: func_tool["description"] = tool_dict["description"] if "input_schema" in tool_dict: - func_tool["parameters"] = tool_dict["input_schema"] + func_tool["parameters"] = json_copy_or_original(tool_dict["input_schema"]) result.append(func_tool) return result diff --git a/litellm/llms/anthropic/pass_through/utils.py b/litellm/llms/anthropic/pass_through/utils.py index d7fb929646e..f4b587842bf 100644 --- a/litellm/llms/anthropic/pass_through/utils.py +++ b/litellm/llms/anthropic/pass_through/utils.py @@ -2,7 +2,7 @@ import json import os from collections.abc import Mapping from types import MappingProxyType -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING, Final, TypeVar from pydantic import ConfigDict, TypeAdapter, ValidationError from typing_extensions import ReadOnly @@ -27,6 +27,15 @@ _EFFORT_DEGRADATION_CHAIN: Final[Mapping[str, tuple[str, ...]]] = MappingProxyTy ) _THINKING_OFF: Final = "none" +_JsonValue: Final = TypeVar("_JsonValue") + + +def json_copy_or_original(value: _JsonValue) -> _JsonValue: + try: + return json.loads(json.dumps(value, default=dict)) + except (TypeError, ValueError, RecursionError): + return value + class _ClaudeCodeUserId(LiteLLMBaseModel): """The JSON Claude Code packs into ``metadata.user_id``; only ``session_id`` is per conversation.""" diff --git a/tests/integration/messages_endpoint/test_anthropic_messages_caller_tools_wire.py b/tests/integration/messages_endpoint/test_anthropic_messages_caller_tools_wire.py new file mode 100644 index 00000000000..2e8a9d0d7e4 --- /dev/null +++ b/tests/integration/messages_endpoint/test_anthropic_messages_caller_tools_wire.py @@ -0,0 +1,465 @@ +import json +import re +import uuid +from collections.abc import Iterator, Mapping, Sequence +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from functools import reduce +from pathlib import Path +from typing import Final + +import pytest +from integration._support.client import Gateway, eventually, gateway_from_environment, list_value, object_value +from integration._support.database import read_rows +from integration._support.process import owned_proxy +from integration._support.wire import Reply, Request, Wire, wire_server +from pydantic import JsonValue, TypeAdapter + +CLAUDE_MODEL: Final = "caller-tools-claude" +OPENAI_MODEL: Final = "caller-tools-openai" +GEMINI_MODEL: Final = "caller-tools-gemini" +DOWN_MODEL: Final = "caller-tools-gemini-down" +OPENAI_DOWN_MODEL: Final = "caller-tools-openai-down" +TOOL_RESULTS_GUARDRAIL: Final = "caller-tools-results-filter" +CLAUDE_TARGET: Final = "/anthropic/v1/messages" +RESPONSES_TARGET: Final = "/openai/v1/responses" +GEMINI_PREFIX: Final = "/gemini/" +_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue]) +_NONCE: Final = re.compile(rb"nonce-([0-9a-f]{32})") + + +def strict_tool(name: str = "lint") -> dict[str, JsonValue]: + return { + "name": name, + "description": "Lint a file", + "strict": True, + "input_schema": { + "type": "object", + "properties": { + "target": { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"], + "additionalProperties": False, + } + }, + "required": ["target"], + "additionalProperties": False, + }, + "input_examples": [{"target": {"path": "a.py"}}], + } + + +def tool_with_a_property_named_cache_control() -> dict[str, JsonValue]: + return { + "name": "tag", + "description": "Tag a file", + "input_schema": { + "type": "object", + "properties": {"path": {"type": "string"}, "cache_control": {"type": "string"}}, + "required": ["path", "cache_control"], + }, + } + + +def schema_nested(levels: int) -> dict[str, JsonValue]: + return reduce( + lambda inner, level: {"type": "object", "properties": {f"p{level}": inner}}, + range(levels), + {"type": "string"}, + ) + + +def parsed_json(raw: bytes) -> JsonValue: + return json.loads(raw) + + +def json_mapping(value: JsonValue) -> dict[str, JsonValue]: + assert isinstance(value, dict), f"Expected an object, received {type(value).__name__}" + return value + + +def nonce() -> str: + return f"nonce-{uuid.uuid4().hex}" + + +def nonce_hex(body: bytes) -> str: + match: Final = _NONCE.search(body) + assert match is not None, body[:200] + return match.group(1).decode() + + +def claude_reply(request: Request) -> Reply: + identity: Final = nonce_hex(request.body) + message: Final[dict[str, JsonValue]] = { + "id": f"msg_{identity}", + "type": "message", + "role": "assistant", + "model": "claude-haiku-5-5", + "content": [{"type": "text", "text": f"linted {identity}"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 12, "output_tokens": 3}, + } + if json_mapping(parsed_json(request.body)).get("stream") is not True: + return Reply(body=json.dumps(message).encode()) + events: Final = ( + ("message_start", {"type": "message_start", "message": {**message, "content": [], "stop_reason": None}}), + ( + "content_block_start", + {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, + ), + ( + "content_block_delta", + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": f"linted {identity}"}}, + ), + ("content_block_stop", {"type": "content_block_stop", "index": 0}), + ( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": 3}, + }, + ), + ("message_stop", {"type": "message_stop"}), + ) + return Reply( + content_type="text/event-stream", + chunks=tuple(f"event: {event}\ndata: {json.dumps(payload)}\n\n".encode() for event, payload in events), + ) + + +def responses_reply(request: Request) -> Reply: + identity: Final = nonce_hex(request.body) + return Reply( + body=json.dumps( + { + "id": f"resp_{identity}", + "object": "response", + "created_at": 1789788253, + "status": "completed", + "model": "gpt-5.4-mini", + "output": [ + { + "type": "message", + "id": f"msg_{identity}", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": f"linted {identity}", "annotations": []}], + } + ], + "usage": {"input_tokens": 12, "output_tokens": 3, "total_tokens": 15}, + } + ).encode() + ) + + +def gemini_reply(request: Request) -> Reply: + identity: Final = nonce_hex(request.body) + return Reply( + body=json.dumps( + { + "candidates": [ + { + "content": {"parts": [{"text": f"linted {identity}"}], "role": "model"}, + "finishReason": "STOP", + "index": 0, + } + ], + "usageMetadata": {"promptTokenCount": 12, "candidatesTokenCount": 3, "totalTokenCount": 15}, + "modelVersion": "gemini-3.8-flash", + } + ).encode() + ) + + +def respond(request: Request) -> Reply: + if request.method != "POST": + return Reply(body=b'{"object":"list","data":[]}') + if request.target.startswith(CLAUDE_TARGET): + return claude_reply(request) + if request.target.startswith(RESPONSES_TARGET): + return responses_reply(request) + if request.target.startswith(GEMINI_PREFIX): + return gemini_reply(request) + return Reply(status=404, body=json.dumps({"error": {"message": f"no route for {request.target}"}}).encode()) + + +def deployment(name: str, model: str, api_base: str) -> dict[str, JsonValue]: + return { + "model_name": name, + "litellm_params": {"model": model, "api_base": api_base, "api_key": f"synthetic-{name}-key"}, + } + + +def write_config(directory: Path, wire_url: str) -> Path: + path: Final = directory / f"caller-tools-{uuid.uuid4().hex}.json" + path.write_text( + json.dumps( + { + "model_list": [ + deployment(CLAUDE_MODEL, "anthropic/claude-haiku-5-5", f"{wire_url}/anthropic"), + deployment(OPENAI_MODEL, "openai/gpt-5.4-mini", f"{wire_url}/openai/v1"), + deployment(GEMINI_MODEL, "gemini/gemini-3.8-flash", f"{wire_url}/gemini"), + deployment(DOWN_MODEL, "gemini/gemini-3.8-flash", "http://127.0.0.1:9"), + deployment(OPENAI_DOWN_MODEL, "openai/gpt-5.4-mini", "http://127.0.0.1:9"), + ], + "router_settings": {"fallbacks": [{DOWN_MODEL: [CLAUDE_MODEL]}, {OPENAI_DOWN_MODEL: [CLAUDE_MODEL]}]}, + "guardrails": [ + { + "guardrail_name": TOOL_RESULTS_GUARDRAIL, + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "default_on": False, + "scan_only_tool_results": True, + "blocked_words": [{"keyword": "ignore previous instructions", "action": "BLOCK"}], + }, + } + ], + "general_settings": { + "master_key": "os.environ/LITELLM_MASTER_KEY", + "database_url": "os.environ/DATABASE_URL", + "store_prompts_in_spend_logs": True, + "proxy_batch_write_at": 1, + "proxy_batch_polling_interval": 1, + }, + } + ) + ) + return path + + +@dataclass(frozen=True, slots=True) +class Rig: + gateway: Gateway + wire: Wire + + def send( + self, body: Mapping[str, JsonValue], *, streaming: bool = False, close_connection: bool = False + ) -> tuple[int, bytes]: + headers: Final = { + "Authorization": f"Bearer {self.gateway.key}", + **({"Connection": "close"} if close_connection else {}), + } + with self.gateway.client.stream( + "POST", "/v1/messages", json={**body, "stream": streaming}, headers=headers, timeout=120 + ) as response: + return response.status_code, response.read() + + def received(self, request_nonce: str) -> tuple[Request, ...]: + return tuple(request for request in self.wire.drain() if request_nonce.encode() in request.body) + + +@pytest.fixture(scope="module") +def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]: + directory: Final = tmp_path_factory.mktemp("caller-tools") + with ( + gateway_from_environment() as shared, + wire_server(respond) as wire, + owned_proxy(shared, directory, {}, config=write_config(directory, wire.url), workers=2) as gateway, + ): + yield Rig(gateway, wire) + + +def messages_body( + model: str, + request_nonce: str, + *, + tools: JsonValue, + guardrails: Sequence[str] = (TOOL_RESULTS_GUARDRAIL,), +) -> dict[str, JsonValue]: + return { + "model": model, + "max_tokens": 64, + "messages": [{"role": "user", "content": f"lint the repo {request_nonce}"}], + "tools": tools, + "guardrails": list(guardrails), + } + + +def reply_text(status: int, raw: bytes, *, streaming: bool) -> str: + assert status == 200, raw[:500] + if not streaming: + return "".join( + str(object_value(block)["text"]) for block in list_value(_JSON_OBJECT.validate_json(raw)["content"]) + ) + deltas: Final = ( + object_value(_JSON_OBJECT.validate_json(line.removeprefix(b"data: "))["delta"]) + for line in raw.split(b"\n") + if line.startswith(b"data: ") and b'"content_block_delta"' in line + ) + return "".join(str(delta["text"]) for delta in deltas) + + +def sent_body(requests: Sequence[Request], target: str) -> dict[str, JsonValue]: + assert [request.target.split("?")[0] for request in requests] == [target], [r.target for r in requests] + return json_mapping(parsed_json(requests[0].body)) + + +@pytest.mark.parametrize("streaming", [False, True], ids=["json", "stream"]) +def test_claude_behind_a_tool_results_guardrail_receives_the_tools_as_sent(rig: Rig, streaming: bool) -> None: + request_nonce: Final = nonce() + status, raw = rig.send(messages_body(CLAUDE_MODEL, request_nonce, tools=[strict_tool()]), streaming=streaming) + + assert reply_text(status, raw, streaming=streaming) == f"linted {request_nonce.removeprefix('nonce-')}" + assert sent_body(rig.received(request_nonce), CLAUDE_TARGET)["tools"] == [strict_tool()] + + +@pytest.mark.parametrize("guardrails", [(), (TOOL_RESULTS_GUARDRAIL,)], ids=["no_guardrail", "tool_results_guardrail"]) +def test_fallback_after_a_failed_gemini_call_sends_claude_the_tools_as_sent( + rig: Rig, guardrails: tuple[str, ...] +) -> None: + request_nonce: Final = nonce() + status, raw = rig.send(messages_body(DOWN_MODEL, request_nonce, tools=[strict_tool()], guardrails=guardrails)) + + assert reply_text(status, raw, streaming=False) == f"linted {request_nonce.removeprefix('nonce-')}" + assert sent_body(rig.received(request_nonce), CLAUDE_TARGET)["tools"] == [strict_tool()] + + +def test_fallback_after_a_failed_openai_call_sends_claude_a_property_named_cache_control(rig: Rig) -> None: + request_nonce: Final = nonce() + status, raw = rig.send( + messages_body( + OPENAI_DOWN_MODEL, request_nonce, tools=[tool_with_a_property_named_cache_control()], guardrails=() + ) + ) + + assert reply_text(status, raw, streaming=False) == f"linted {request_nonce.removeprefix('nonce-')}" + assert sent_body(rig.received(request_nonce), CLAUDE_TARGET)["tools"] == [ + tool_with_a_property_named_cache_control() + ] + + +def test_openai_bridge_behind_a_tool_results_guardrail_sends_the_schema_as_sent(rig: Rig) -> None: + request_nonce: Final = nonce() + status, raw = rig.send(messages_body(OPENAI_MODEL, request_nonce, tools=[strict_tool()])) + + assert reply_text(status, raw, streaming=False) == f"linted {request_nonce.removeprefix('nonce-')}" + tools: Final = list_value(sent_body(rig.received(request_nonce), RESPONSES_TARGET)["tools"]) + assert [object_value(tool)["name"] for tool in tools] == ["lint"] + assert object_value(tools[0])["parameters"] == strict_tool()["input_schema"] + + +@pytest.mark.parametrize( + ("model", "guardrails"), + [(GEMINI_MODEL, ()), (CLAUDE_MODEL, (TOOL_RESULTS_GUARDRAIL,))], + ids=["gemini_bridge", "claude_tool_results_guardrail"], +) +def test_spend_log_keeps_the_tools_the_caller_sent(rig: Rig, model: str, guardrails: tuple[str, ...]) -> None: + request_nonce: Final = nonce() + status, raw = rig.send(messages_body(model, request_nonce, tools=[strict_tool()], guardrails=guardrails)) + + assert reply_text(status, raw, streaming=False) == f"linted {request_nonce.removeprefix('nonce-')}" + assert len(rig.received(request_nonce)) == 1 + rows: Final = eventually( + lambda: read_rows( + 'SELECT proxy_server_request FROM "LiteLLM_SpendLogs" WHERE strpos(proxy_server_request::text, %s) > 0', + (request_nonce,), + ), + lambda values: len(values) == 1, + seconds=70, + ) + assert object_value(rows[0]["proxy_server_request"])["tools"] == [strict_tool()] + + +@pytest.mark.parametrize("tools", [7, "x" * 5000, ["lint"]], ids=["int", "five_kb", "list_of_str"]) +def test_a_malformed_tools_field_is_answered_with_an_error_and_the_proxy_keeps_serving( + rig: Rig, tools: JsonValue +) -> None: + request_nonce: Final = nonce() + status, raw = rig.send(messages_body(CLAUDE_MODEL, request_nonce, tools=tools), close_connection=True) + + assert status >= 400, raw[:500] + assert object_value(_JSON_OBJECT.validate_json(raw)["error"])["message"], raw[:500] + assert rig.received(request_nonce) == () + control_nonce: Final = nonce() + control_status, control_raw = rig.send(messages_body(CLAUDE_MODEL, control_nonce, tools=[strict_tool()])) + assert reply_text(control_status, control_raw, streaming=False) == f"linted {control_nonce.removeprefix('nonce-')}" + + +def test_an_empty_string_tools_field_reaches_claude_as_sent(rig: Rig) -> None: + request_nonce: Final = nonce() + status, raw = rig.send(messages_body(CLAUDE_MODEL, request_nonce, tools="")) + + assert reply_text(status, raw, streaming=False) == f"linted {request_nonce.removeprefix('nonce-')}" + assert sent_body(rig.received(request_nonce), CLAUDE_TARGET)["tools"] == "" + + +@pytest.mark.parametrize( + ("model", "target"), + [(CLAUDE_MODEL, CLAUDE_TARGET), (OPENAI_MODEL, RESPONSES_TARGET)], + ids=["claude", "openai_bridge"], +) +def test_a_schema_nested_past_the_recursion_limit_reaches_the_provider_as_sent( + rig: Rig, model: str, target: str +) -> None: + request_nonce: Final = nonce() + schema: Final = schema_nested(420) + status, raw = rig.send( + messages_body(model, request_nonce, tools=[{"name": "deep", "description": "Deep", "input_schema": schema}]) + ) + + assert reply_text(status, raw, streaming=False) == f"linted {request_nonce.removeprefix('nonce-')}" + tool: Final = json_mapping(list_value(sent_body(rig.received(request_nonce), target)["tools"])[0]) + assert tool.get("input_schema", tool.get("parameters")) == schema + + +def test_the_same_request_twice_reaches_claude_with_the_tools_as_sent_both_times(rig: Rig) -> None: + request_nonce: Final = nonce() + body: Final = messages_body(CLAUDE_MODEL, request_nonce, tools=[strict_tool()]) + replies: Final = (rig.send(body), rig.send(body)) + + assert [reply_text(status, raw, streaming=False) for status, raw in replies] == [ + f"linted {request_nonce.removeprefix('nonce-')}" + ] * 2 + sent: Final = rig.received(request_nonce) + assert [request.target for request in sent] == [CLAUDE_TARGET] * 2 + assert [_JSON_OBJECT.validate_json(request.body)["tools"] for request in sent] == [[strict_tool()]] * 2 + + +@pytest.mark.parametrize("tools", [None, []], ids=["missing", "empty_list"]) +def test_a_request_without_tools_reaches_claude_without_tools(rig: Rig, tools: list[JsonValue] | None) -> None: + request_nonce: Final = nonce() + body: Final = messages_body(CLAUDE_MODEL, request_nonce, tools=tools) + status, raw = rig.send({key: value for key, value in body.items() if value is not None}) + + assert reply_text(status, raw, streaming=False) == f"linted {request_nonce.removeprefix('nonce-')}" + assert sent_body(rig.received(request_nonce), CLAUDE_TARGET).get("tools", []) == [] + + +def burst_case(index: int) -> tuple[str, str, bool]: + kinds: Final = ( + (CLAUDE_MODEL, CLAUDE_TARGET, False), + (CLAUDE_MODEL, CLAUDE_TARGET, True), + (OPENAI_MODEL, RESPONSES_TARGET, False), + (DOWN_MODEL, CLAUDE_TARGET, False), + ) + return kinds[index % len(kinds)] + + +def test_a_concurrent_mixed_burst_reaches_each_provider_with_its_own_tools(rig: Rig) -> None: + nonces: Final = tuple(nonce() for _ in range(24)) + + def send(index: int) -> tuple[int, bytes]: + model, _, streaming = burst_case(index) + return rig.send(messages_body(model, nonces[index], tools=[strict_tool(f"lint_{index}")]), streaming=streaming) + + with ThreadPoolExecutor(max_workers=12) as pool: + replies: Final = tuple(pool.map(send, range(len(nonces)))) + + assert [reply_text(status, raw, streaming=burst_case(index)[2]) for index, (status, raw) in enumerate(replies)] == [ + f"linted {request_nonce.removeprefix('nonce-')}" for request_nonce in nonces + ] + sent: Final = rig.wire.drain() + assert [sent_tools(sent, request_nonce, burst_case(index)[1]) for index, request_nonce in enumerate(nonces)] == [ + (f"lint_{index}", strict_tool()["input_schema"]) for index in range(len(nonces)) + ] + + +def sent_tools(sent: Sequence[Request], request_nonce: str, target: str) -> tuple[str, JsonValue]: + tools: Final = list_value(sent_body([r for r in sent if request_nonce.encode() in r.body], target)["tools"]) + assert len(tools) == 1, tools + tool: Final = object_value(tools[0]) + return str(tool["name"]), tool.get("input_schema", tool.get("parameters")) diff --git a/tests/unit/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py b/tests/unit/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py index 4f27d9637af..892736b5043 100644 --- a/tests/unit/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py +++ b/tests/unit/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py @@ -2075,6 +2075,45 @@ class TestAnthropicMessagesScanOnlyToolResults: ) assert data["messages"][2]["content"][0]["text"] == "sibling POISON text" + @staticmethod + def _strict_weather_tool() -> dict[str, Any]: + return { + "name": "get_weather", + "strict": True, + "input_schema": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + "additionalProperties": False, + }, + "input_examples": [{"city": "Paris"}], + } + + @pytest.mark.asyncio + async def test_strict_tool_with_input_examples_is_forwarded_as_sent(self): + handler = AnthropicMessagesHandler() + data = { + "model": "claude-sonnet-4-5", + "tools": [self._strict_weather_tool()], + "messages": [ + {"role": "user", "content": "weather in Paris?"}, + { + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "tu1", "name": "get_weather", "input": {"city": "Paris"}} + ], + }, + { + "role": "user", + "content": [{"type": "tool_result", "tool_use_id": "tu1", "content": "sunny"}], + }, + ], + } + + await handler.process_input_messages(data=data, guardrail_to_apply=self._guardrail()) + + assert data["tools"] == [self._strict_weather_tool()] + @pytest.mark.asyncio async def test_guardrail_synthesized_tools_are_appended_without_replacing_request_tools(self): handler = AnthropicMessagesHandler() diff --git a/tests/unit/llms/anthropic/pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/unit/llms/anthropic/pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index 2eee8549c0b..2f0a7ad070b 100644 --- a/tests/unit/llms/anthropic/pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/unit/llms/anthropic/pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -1,5 +1,7 @@ import base64 +import functools import json +from types import MappingProxyType from typing import Any, Final, cast import pytest @@ -43,6 +45,7 @@ from litellm.types.utils import ( StreamingChoices, Usage, ) +from litellm.utils import remove_additional_properties, remove_strict_from_schema def test_translate_openai_response_to_anthropic_empty_choices() -> None: @@ -4426,6 +4429,95 @@ def test_tool_result_plain_text_unchanged_by_openai_transform(): assert _image_urls_in_user_messages(result) == [] +def _caller_tools_with_strict_schemas() -> list[dict[str, object]]: + return [ + { + "type": "custom", + "name": "lint", + "strict": True, + "input_schema": { + "type": "object", + "properties": { + "target": { + "type": "object", + "properties": {"path": {"type": "string"}}, + "additionalProperties": False, + }, + "strict": {"type": "boolean"}, + }, + "additionalProperties": False, + }, + "input_examples": [{"target": {"path": "a.py"}, "strict": True}], + }, + { + "type": "function", + "function": { + "name": "get_weather", + "strict": True, + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "additionalProperties": False, + }, + }, + }, + ] + + +def test_translate_anthropic_tools_to_openai_leaves_caller_tools_untouched_by_schema_cleanup(): + adapter: Final = LiteLLMAnthropicMessagesAdapter() + tools: Final = _caller_tools_with_strict_schemas() + + translated_tools, _ = adapter.translate_anthropic_tools_to_openai(tools=tools) + remove_strict_from_schema(remove_additional_properties(translated_tools)) + + assert tools == _caller_tools_with_strict_schemas() + + +def _schema_nested_past_the_recursion_limit() -> dict[str, object]: + return functools.reduce( + lambda inner, level: {"type": "object", "properties": {f"p{level}": inner}}, + range(600), + cast(dict[str, object], {"type": "string"}), + ) + + +@pytest.mark.parametrize( + "tool", + [ + MappingProxyType( + { + "name": "lint", + "input_schema": {"type": "object", "properties": {"path": {"type": "string"}}}, + "input_examples": [{"path": "a.py"}], + } + ), + {"name": "lint", "input_schema": _schema_nested_past_the_recursion_limit(), "input_examples": [{}]}, + ], + ids=["frozen_mapping", "schema_nested_past_the_recursion_limit"], +) +def test_translate_anthropic_tools_to_openai_leaves_tools_deepcopy_cannot_copy_untouched(tool): + adapter: Final = LiteLLMAnthropicMessagesAdapter() + + translated_tools, _ = adapter.translate_anthropic_tools_to_openai(tools=[tool]) + + assert [translated["function"]["name"] for translated in translated_tools] == ["lint"] + assert translated_tools[0]["function"]["parameters"]["input_examples"] == tool["input_examples"] + assert set(tool["input_schema"]) == {"type", "properties"} + + +def test_translate_anthropic_tools_to_openai_translates_a_tool_json_cannot_copy(): + adapter: Final = LiteLLMAnthropicMessagesAdapter() + properties: Final = {"level": {"enum": {1, 2}}} + + translated_tools, _ = adapter.translate_anthropic_tools_to_openai( + tools=[{"name": "lint", "input_schema": {"type": "object", "properties": properties}}] + ) + + assert [translated["function"]["name"] for translated in translated_tools] == ["lint"] + assert translated_tools[0]["function"]["parameters"]["properties"] == properties + + TOOL_RESULT_PDF_B64 = base64.b64encode(b"%PDF-1.4 minimal regression fixture").decode() diff --git a/tests/unit/llms/anthropic/pass_through/responses_adapters/test_responses_adapters_transformation.py b/tests/unit/llms/anthropic/pass_through/responses_adapters/test_responses_adapters_transformation.py index a4ff39d4c07..5d0e3fa7236 100644 --- a/tests/unit/llms/anthropic/pass_through/responses_adapters/test_responses_adapters_transformation.py +++ b/tests/unit/llms/anthropic/pass_through/responses_adapters/test_responses_adapters_transformation.py @@ -24,6 +24,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import ( from litellm.llms.anthropic.pass_through.responses_adapters.transformation import ( LiteLLMAnthropicToResponsesAPIAdapter, ) +from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig from litellm.types.llms.anthropic import ( AllAnthropicToolsValues, AnthropicMessagesRequest, @@ -897,6 +898,21 @@ class TestTranslateToolsToResponsesAPI: """Empty tools list returns empty list.""" assert _ADAPTER.translate_tools_to_responses_api([]) == [] + def test_cache_control_cleanup_leaves_caller_schema_untouched(self): + tools: List[AllAnthropicToolsValues] = [ + { + "name": "tag", + "input_schema": {"type": "object", "properties": {"cache_control": {"type": "string"}}}, + } + ] + + translated: Final = _ADAPTER.translate_tools_to_responses_api(tools) + OpenAIResponsesAPIConfig().remove_cache_control_flag_from_input_and_tools( + model="gpt-5.5", input="hi", tools=translated + ) + + assert tools[0]["input_schema"] == {"type": "object", "properties": {"cache_control": {"type": "string"}}} + # --------------------------------------------------------------------------- # translate_tool_choice_to_responses_api