diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 9721b797584..f64a1334a65 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -790,6 +790,26 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): raise ValueError("defer_loading must be a boolean") returned_tool["defer_loading"] = _defer_loading_function # type: ignore[typeddict-item] + ## check if eager_input_streaming is set in the tool + _eager_input_streaming = tool.get("eager_input_streaming", None) + _eager_input_streaming_function = tool.get("function", {}).get("eager_input_streaming", None) + if returned_tool is not None: + tool_type = returned_tool.get("type", "") + if tool_type not in ( + "tool_search_tool_regex_20251119", + "tool_search_tool_bm25_20251119", + "computer_20241022", + "computer_20250124", + ): + if _eager_input_streaming is not None: + if not isinstance(_eager_input_streaming, bool): + raise ValueError("eager_input_streaming must be a boolean") + returned_tool["eager_input_streaming"] = _eager_input_streaming # type: ignore[typeddict-item] + elif _eager_input_streaming_function is not None: + if not isinstance(_eager_input_streaming_function, bool): + raise ValueError("eager_input_streaming must be a boolean") + returned_tool["eager_input_streaming"] = _eager_input_streaming_function # type: ignore[typeddict-item] + ## check if allowed_callers is set in the tool _allowed_callers = tool.get("allowed_callers", None) _allowed_callers_function = tool.get("function", {}).get("allowed_callers", None) @@ -1411,25 +1431,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): output_key=param, ) elif param == "response_format" and isinstance(value, dict): - if any( - substring in model - for substring in { - "sonnet-4.5", - "sonnet-4-5", - "opus-4.1", - "opus-4-1", - "opus-4.5", - "opus-4-5", - "opus-4.6", - "opus-4-6", - "opus-4.7", - "opus-4-7", - "sonnet-4.6", - "sonnet-4-6", - "sonnet_4.6", - "sonnet_4_6", - } - ): + if self._supports_model_capability(model, "supports_output_config"): _output_format = self.map_response_format_to_anthropic_output_format(value) if _output_format is not None: optional_params["output_format"] = _output_format @@ -1980,6 +1982,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): tool_results: Optional[List[Any]] = None compaction_blocks: Optional[List[Any]] = None for idx, content in enumerate(completion_response["content"]): + text_start = len(text_content) if content["type"] == "text": text_content += content["text"] ## TOOL CALLING @@ -2034,6 +2037,8 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): { **citation, "supported_text": content.get("text", ""), + "supported_text_start_char_index": text_start, + "supported_text_end_char_index": len(text_content), } for citation in content["citations"] ] diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index aa9f4dccbd1..e1b873a34d4 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -50,6 +50,7 @@ class AnthropicMessagesTool(TypedDict, total=False): type: Literal["custom"] cache_control: Optional[Union[dict, ChatCompletionCachedContent]] defer_loading: bool + eager_input_streaming: bool allowed_callers: Optional[List[str]] input_examples: Optional[List[Dict[str, Any]]] diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py index 852479e81e4..06fab279784 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -267,6 +267,8 @@ def test_extract_response_content_with_citations(): "start_char_index": 0, "end_char_index": 20, "supported_text": "the grass is green", + "supported_text_start_char_index": 28, + "supported_text_end_char_index": 46, }, ], [ @@ -278,6 +280,8 @@ def test_extract_response_content_with_citations(): "start_char_index": 20, "end_char_index": 36, "supported_text": "the sky is blue", + "supported_text_start_char_index": 51, + "supported_text_end_char_index": 66, }, ], ] @@ -5733,3 +5737,91 @@ def test_top_k_forwarded_at_transform_on_models_that_accept_it(): ) assert result["top_k"] == 40 + + +def test_opus_4_8_uses_native_structured_output(): + """claude-opus-4-8 has supports_output_config=true in the model map, + so response_format should route to the native output_format path + rather than the tool-call emulation path""" + config = AnthropicConfig() + + response_format = { + "type": "json_schema", + "json_schema": { + "name": "test_schema", + "schema": { + "type": "object", + "properties": {"answer": {"type": "string"}}, + "required": ["answer"], + "additionalProperties": False, + }, + }, + } + + optional_params = config.map_openai_params( + non_default_params={"response_format": response_format}, + optional_params={}, + model="claude-opus-4-8", + drop_params=False, + ) + + assert "output_format" in optional_params + assert optional_params["output_format"]["type"] == "json_schema" + assert "tools" not in optional_params + assert "tool_choice" not in optional_params + assert optional_params.get("json_mode") is True + + +def test_map_tool_helper_eager_input_streaming_passthrough(): + """eager_input_streaming on a custom tool must survive the mapping""" + config = AnthropicConfig() + + tool = { + "type": "function", + "function": { + "name": "my_tool", + "description": "a tool", + "parameters": {"type": "object", "properties": {}}, + "eager_input_streaming": True, + }, + } + + result, _ = config._map_tool_helper(tool) + assert result is not None + assert result.get("eager_input_streaming") is True + + +def test_map_tool_helper_eager_input_streaming_top_level(): + """eager_input_streaming set at top level (not nested in function)""" + config = AnthropicConfig() + + tool = { + "type": "function", + "eager_input_streaming": True, + "function": { + "name": "my_tool", + "description": "a tool", + "parameters": {"type": "object", "properties": {}}, + }, + } + + result, _ = config._map_tool_helper(tool) + assert result is not None + assert result.get("eager_input_streaming") is True + + +def test_map_tool_helper_eager_input_streaming_rejects_non_bool(): + """eager_input_streaming must be a boolean""" + config = AnthropicConfig() + + tool = { + "type": "function", + "function": { + "name": "my_tool", + "parameters": {"type": "object", "properties": {}}, + "eager_input_streaming": "yes", + }, + } + + with pytest.raises(ValueError, match="eager_input_streaming must be a boolean"): + config._map_tool_helper(tool)