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:
Rod Boev 2026-08-08 02:55:02 -04:00 committed by GitHub
parent 5c561bd913
commit 7f6950be34
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 152 additions and 4 deletions

View file

@ -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")

View file

@ -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,
*,

View file

@ -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``.

View file

@ -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