diff --git a/headroom/proxy/handlers/anthropic.py b/headroom/proxy/handlers/anthropic.py index 7e01915e5..26e9f68ee 100644 --- a/headroom/proxy/handlers/anthropic.py +++ b/headroom/proxy/handlers/anthropic.py @@ -2405,6 +2405,29 @@ class AnthropicHandlerMixin: optimized_tokens = tokenizer.count_messages(body["messages"]) tokens_saved = max(0, original_tokens - optimized_tokens) + from headroom.proxy.helpers import ( + anthropic_first_party_tool_search_supported, + strip_first_party_tool_search_tools_for_third_party_upstream, + ) + + _anthropic_target_base_url = upstream_base_url or self.ANTHROPIC_API_URL + _third_party_anthropic_upstream = provider_name == "anthropic" and ( + not anthropic_first_party_tool_search_supported(_anthropic_target_base_url) + ) + if _third_party_anthropic_upstream: + _tools_before_strip = body.get("tools") + _tools_after_strip = strip_first_party_tool_search_tools_for_third_party_upstream( + _tools_before_strip, + _anthropic_target_base_url, + ) + if _tools_after_strip is not _tools_before_strip: + body["tools"] = _tools_after_strip + tools = _tools_after_strip + tags["third_party_tool_search_stripped"] = max( + 0, + len(_tools_before_strip) - len(_tools_after_strip), + ) + # Server-side Tool Search (on by default; HEADROOM_TOOL_SEARCH=0 opts # out — the `coding` savings profile already seeded it on via # seed_proxy_env_defaults, so default-on here just makes the same @@ -2420,12 +2443,13 @@ class AnthropicHandlerMixin: # bytes are excluded from context this turn); the response usage confirms it. # # FIRST-PARTY ANTHROPIC ONLY: the tool_search_tool_* type + defer_loading - # here use the first-party Claude API shape (GA, no beta header). Bedrock - # (``anthropic_backend``) and Vertex/gateway providers gate tool search - # differently, so scope the injection to provider "anthropic" over the - # direct API and leave those paths untouched. + # here use the first-party Claude API shape (GA, no beta header). Custom + # Anthropic-compatible gateways reject that shape, so third-party routes + # strip client-originated tool_search_tool_* entries above and skip + # Headroom's own injector here. if ( provider_name == "anthropic" + and anthropic_first_party_tool_search_supported(_anthropic_target_base_url) and getattr(self, "anthropic_backend", None) is None and os.environ.get("HEADROOM_TOOL_SEARCH", "1").strip().lower() in ("1", "true", "yes", "on", "auto") diff --git a/headroom/proxy/helpers.py b/headroom/proxy/helpers.py index c36daa3d2..26e360552 100644 --- a/headroom/proxy/helpers.py +++ b/headroom/proxy/helpers.py @@ -2284,6 +2284,31 @@ _TOOL_SEARCH_DEFAULT_NAME = "tool_search_tool_regex" _TOOL_SEARCH_MIN_TOOLS = 12 +def anthropic_first_party_tool_search_supported(api_base_url: str | None) -> bool: + """Return whether Anthropic server-side tool search is valid for this upstream.""" + from headroom.providers.claude.runtime import is_custom_anthropic_base_url + + return not is_custom_anthropic_base_url(api_base_url) + + +def strip_first_party_tool_search_tools_for_third_party_upstream( + tools: Any, + api_base_url: str | None, +) -> Any: + """Remove first-party Anthropic tool-search tools when forwarding to a custom upstream.""" + if not isinstance(tools, list) or anthropic_first_party_tool_search_supported(api_base_url): + return tools + filtered = [ + tool + for tool in tools + if not ( + isinstance(tool, dict) + and str(tool.get("type", "")).startswith(_TOOL_SEARCH_TOOL_TYPE_PREFIX) + ) + ] + return filtered if len(filtered) != len(tools) else tools + + def inject_tool_search_deferral( tools: Any, *, diff --git a/tests/test_anthropic_stage_timings.py b/tests/test_anthropic_stage_timings.py index 93b3e1944..56019d096 100644 --- a/tests/test_anthropic_stage_timings.py +++ b/tests/test_anthropic_stage_timings.py @@ -101,18 +101,24 @@ class _DummyAnthropicHandler(AnthropicHandlerMixin): get_last_original_messages=lambda: [], get_last_forwarded_messages=lambda: [], record_request=lambda *a, **k: None, + update_from_response=lambda *a, **k: None, ), resolve_tracker=lambda *a, **k: SimpleNamespace( + _cached_token_count=0, get_frozen_message_count=lambda: 0, get_last_original_messages=lambda: [], get_last_forwarded_messages=lambda: [], record_request=lambda *a, **k: None, + update_from_response=lambda *a, **k: None, ), ) async def _next_request_id(self) -> str: return "req-anth-test" + async def _record_request_outcome(self, outcome) -> None: + return None + def _extract_tags(self, headers): return {} @@ -287,6 +293,50 @@ def test_anthropic_no_optimize_preserves_client_tool_order(): assert [tool["name"] for tool in forwarded_body["tools"]] == ["Read", "Bash"] +def test_anthropic_third_party_upstream_strips_tool_search_tools(): + tools = [ + {"type": "tool_search_tool_regex_20251119", "name": "tool_search_tool_regex"}, + { + "name": "Bash", + "description": "run a command", + "input_schema": {"type": "object", "properties": {}}, + }, + {"type": "web_search_20250305", "name": "web_search"}, + ] + request = _build_request( + { + "model": "claude-3-5-sonnet-latest", + "max_tokens": 100, + "messages": [{"role": "user", "content": "use a tool"}], + "tools": tools, + }, + {"authorization": "Bearer sk-ant-api-test"}, + ) + handler = _DummyAnthropicHandler() + + import headroom.tokenizers as _tk + + orig_get = _tk.get_tokenizer + _tk.get_tokenizer = lambda model: _DummyTokenizer() + try: + response = anyio.run( + handler.handle_anthropic_messages, + request, + "https://api.deepseek.com/anthropic", + ) + finally: + _tk.get_tokenizer = orig_get + + assert response.status_code == 200 + _, forwarded_url, _, forwarded_body = handler.captured + assert forwarded_url == "https://api.deepseek.com/anthropic/v1/messages" + assert forwarded_body["tools"] == [tools[1], tools[2]] + assert not any( + str(tool.get("type", "")).startswith("tool_search_tool_") + for tool in forwarded_body["tools"] + ) + + def test_anthropic_http_invalid_body_still_emits_stage_timings(stage_log_capture): async def receive(): # Invalid JSON — produces ``ValueError`` from ``_read_request_json``. diff --git a/tests/test_issue_746_tool_search.py b/tests/test_issue_746_tool_search.py index 74c0d4b9a..76c31d0ae 100644 --- a/tests/test_issue_746_tool_search.py +++ b/tests/test_issue_746_tool_search.py @@ -176,7 +176,9 @@ from headroom.proxy.helpers import ( # noqa: E402 _TOOL_SEARCH_DEFAULT_NAME, _TOOL_SEARCH_DEFAULT_TYPE, _TOOL_SEARCH_MIN_TOOLS, + anthropic_first_party_tool_search_supported, inject_tool_search_deferral, + strip_first_party_tool_search_tools_for_third_party_upstream, ) @@ -259,6 +261,53 @@ def test_non_dict_and_typed_tools_stay_resident() -> None: assert len(typed) == 1 and typed[0].get("defer_loading") is None +def test_third_party_upstream_strips_first_party_tool_search_from_headroom_issue_2526() -> None: + tools = [ + {"type": "tool_search_tool_regex_20251119", "name": "tool_search_tool_regex"}, + {"name": "Bash", "description": "run a command", "input_schema": {}}, + {"type": "web_search_20250305", "name": "web_search"}, + ] + out = strip_first_party_tool_search_tools_for_third_party_upstream( + tools, + "https://api.deepseek.com/anthropic", + ) + assert out is not tools + assert [tool.get("name") for tool in out if isinstance(tool, dict)] == ["Bash", "web_search"] + assert all( + not str(tool.get("type", "")).startswith("tool_search_tool_") + for tool in out + if isinstance(tool, dict) + ) + + +def test_first_party_anthropic_preserves_client_tool_search_entry() -> None: + tools = [ + {"type": "tool_search_tool_regex_20251119", "name": "tool_search_tool_regex"}, + {"name": "Bash", "description": "run a command", "input_schema": {}}, + ] + assert anthropic_first_party_tool_search_supported("https://api.anthropic.com") + assert ( + strip_first_party_tool_search_tools_for_third_party_upstream( + tools, + "https://api.anthropic.com", + ) + is tools + ) + + +@pytest.mark.parametrize( + ("api_base_url", "expected_supported"), + [ + ("https://api.anthropic.com", True), + ("https://api.anthropic.com/v1", True), + ("https://api.deepseek.com/anthropic", False), + ("http://127.0.0.1:8787", False), + ], +) +def test_third_party_or_first_party_matrix(api_base_url: str, expected_supported: bool) -> None: + assert anthropic_first_party_tool_search_supported(api_base_url) is expected_supported + + # --------------------------------------------------------------------------- # PascalCase clients (Claude Code). The core-tool exemption is spelled in # lowercase, so an exact-match comparison never fired for Claude Code: every