mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix(anthropic): strip first-party tool search on custom upstreams (#2539)
## Description Third-party Anthropic-compatible upstreams can reject Headroom-routed Claude requests before generation starts because the forwarded `tools[]` array still contains the first-party Anthropic server tool type `tool_search_tool_regex_20251119`. That path is valid when the upstream really is Anthropic, but DeepSeek-style Anthropic-compatible gateways reject it with a 400 and never reach model execution. This change strips first-party Anthropic `tool_search_tool_*` entries only when Headroom forwards an Anthropic-wire request to a third-party upstream selected through `anthropic_api_url`. Direct Anthropic behavior stays intact, and unrelated typed or untyped tools keep their existing forwarding contract. Closes #2526. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - add a narrow Anthropic helper that strips first-party `tool_search_tool_*` entries from client-supplied tool lists when the outbound target is a third-party Anthropic-compatible upstream - wire the sanitizer into the Anthropic handler's third-party forwarding path without changing the first-party `HEADROOM_TOOL_SEARCH` injector branch - add focused helper coverage for third-party stripping, first-party preservation, and typed-tool negative space - add a production-path regression through `handle_anthropic_messages()` that captures the custom-upstream request body and verifies the sanitizer wiring ## Testing - [x] Unit tests pass (`uv run pytest tests/test_issue_746_tool_search.py tests/test_anthropic_stage_timings.py -q`) - [x] Linting passes (`uv run ruff check headroom/proxy/helpers.py headroom/proxy/handlers/anthropic.py tests/test_issue_746_tool_search.py tests/test_anthropic_stage_timings.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [x] Manual testing performed ### Test Output ```text uv run pytest tests/test_issue_746_tool_search.py tests/test_anthropic_stage_timings.py -q 50 passed in 0.72s uv run ruff check headroom/proxy/helpers.py headroom/proxy/handlers/anthropic.py tests/test_issue_746_tool_search.py tests/test_anthropic_stage_timings.py All checks passed! uv run ruff format headroom/proxy/helpers.py headroom/proxy/handlers/anthropic.py tests/test_issue_746_tool_search.py tests/test_anthropic_stage_timings.py --check 4 files already formatted git diff --check (no output) ``` ## Real Behavior Proof - Environment: focused Headroom worktree with Anthropic-wire regression tests - Exact command / steps: use the issue reproduction at https://github.com/headroomlabs-ai/headroom/issues/2526, then run the focused helper and handler tests; the handler regression calls `handle_anthropic_messages()` with a DeepSeek-compatible upstream and captures the outbound request body - Observed result: the base repro printed `FAIL issue2526 third-party sanitize -> [{'type': 'tool_search_tool_regex_20251119', 'name': 'tool_search_tool_regex'}, {'name': 'Bash', 'description': 'run a command', 'input_schema': {}}]`, while the head repro printed `PASS issue2526 third-party sanitize -> [{'name': 'Bash', 'description': 'run a command', 'input_schema': {}}]`; the handler-level test captured the same removal while preserving `Bash` and `web_search_20250305`, and the combined focused run passed 50 tests - Not tested: live DeepSeek account on this host ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A - proxy forwarding change only. ## Additional Notes - `CHANGELOG.md` stays untouched because Headroom's release automation generates it from conventional commits. - The narrow slice strips only first-party Anthropic server tool-search entries on third-party Anthropic-compatible upstreams. It does not invent or translate third-party search-tool semantics. --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com> Co-authored-by: JD Davis <jd@jds-macbook-air.tail2a279.ts.net>
This commit is contained in:
parent
5c561bd913
commit
7f6950be34
4 changed files with 152 additions and 4 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -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``.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue