Skip to content

Commit a932247

Browse files
authored
fix: preserve anthropic passthrough tool order (headroomlabs-ai#1427)
## Description Preserves Anthropic `tools` order when Headroom is forwarding a passthrough/no-optimize request. This fixes a Claude Code style `tool_result` continuation failure against stricter Anthropic-compatible upstreams that treat the client's original tool ordering as part of the conversation state. Closes headroomlabs-ai#1417 ## 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 - Preserve client-provided Anthropic `tools` order when `optimize=False` or the request is explicitly in Headroom passthrough/bypass mode. - Keep deterministic tool sorting for optimized requests where Headroom may rewrite the body for cache stability. - Avoid sorting batch-request tools before the no-optimize passthrough branch. - Add regression coverage for the Anthropic HTTP path to prove no-optimize forwarding keeps `Read`, then `Bash` tool order. - Update existing cache-stability and byte-faithful forwarding tests so no-optimize/passthrough expects preserved client order while optimized mode still proves deterministic sorting. ## Testing - [x] Focused unit tests pass (`pytest` on touched proxy test files) - [x] Linting passes (`ruff check` and `ruff format --check` on touched files) - [x] Type checking passes (`mypy headroom`) - [x] New regression tests added - [x] Manual testing performed ### Test Output ```text $ uv run --no-project --python /opt/homebrew/opt/python@3.13/bin/python3.13 --with pytest --with pytest-asyncio --with anyio --with 'httpx[http2]' --with fastapi --with pydantic --with tiktoken --with click --with rich --with opentelemetry-api --with opentelemetry-sdk --with zstandard --with openai --with mcp --with uvicorn pytest tests/test_proxy_handler_helpers.py tests/test_anthropic_stage_timings.py tests/test_proxy_anthropic_cache_stability.py tests/test_proxy_byte_faithful_forwarding.py -q ============================= test session starts ============================== platform darwin -- Python 3.13.11, pytest-9.1.1, pluggy-1.6.0 rootdir: /Users/vinaygupta/Desktop/git/headroom-fix-anthropic-tool-order configfile: pyproject.toml plugins: anyio-4.14.1, asyncio-1.4.0 asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function collected 87 items tests/test_proxy_handler_helpers.py .......................... [ 29%] tests/test_anthropic_stage_timings.py .... [ 34%] tests/test_proxy_anthropic_cache_stability.py ......................... [ 63%] tests/test_proxy_byte_faithful_forwarding.py ........................... [ 94%] ..... [100%] =============================== warnings summary =============================== .../fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. ======================== 87 passed, 1 warning in 5.13s ========================= $ uv run --no-project --python /opt/homebrew/opt/python@3.13/bin/python3.13 --with ruff==0.15.17 ruff format --check headroom/proxy/handlers/anthropic.py tests/test_proxy_handler_helpers.py tests/test_anthropic_stage_timings.py tests/test_proxy_anthropic_cache_stability.py tests/test_proxy_byte_faithful_forwarding.py 5 files already formatted $ uv run --no-project --python /opt/homebrew/opt/python@3.13/bin/python3.13 --with ruff==0.15.17 ruff check headroom/proxy/handlers/anthropic.py tests/test_proxy_handler_helpers.py tests/test_anthropic_stage_timings.py tests/test_proxy_anthropic_cache_stability.py tests/test_proxy_byte_faithful_forwarding.py All checks passed! ``` ## Real Behavior Proof - Environment: macOS, Python 3.13.11, local fake Anthropic-compatible upstream, local Headroom proxy launched with `--no-optimize --no-cache --no-rate-limit --stateless`. - Exact command / steps: ran a local reproduction harness that starts a fake `/v1/messages` upstream and Headroom proxy, then sends a Claude Code style two-turn flow: first assistant `Bash` `tool_use`, then user `tool_result`. - Observed result: after this patch, both direct and proxied flows returned `200` for `first_tool_use` and `second_tool_result`. The fake upstream log showed the proxied `tools` array remained `["Read", "Bash"]` on both turns. ```text DIRECT first_tool_use: 200 second_tool_result: 200 PROXIED first_tool_use: 200 second_tool_result: 200 UPSTREAM REQUEST LOG proxied first turn tools: ["Read", "Bash"] proxied tool_result turn tools: ["Read", "Bash"] ``` - Not tested: full `pytest`, full-repo `ruff check .`, `mypy headroom`, or a live third-party Anthropic-compatible provider. ## 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 - [x] 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 ## Additional Notes - This PR intentionally does not add documentation because it fixes passthrough behavior rather than introducing a new user-facing option. - The code-comment checklist item is left unchecked because the change is covered by a small helper docstring and regression tests; no extra inline comments seemed necessary. - `CHANGELOG.md` is left unchanged because this is a narrowly scoped bug fix. - Local pytest collection for these proxy tests required a local `headroom._core` extension symlink, which was removed before committing.
1 parent 27a5468 commit a932247

5 files changed

Lines changed: 173 additions & 21 deletions

File tree

headroom/proxy/handlers/anthropic.py

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,18 @@ def _sort_tools_deterministically(
149149
return tools
150150
return sorted(tools, key=cls._tool_sort_key)
151151

152+
@classmethod
153+
def _tools_for_forwarding(
154+
cls,
155+
tools: list[dict[str, Any]] | None,
156+
*,
157+
preserve_order: bool,
158+
) -> list[dict[str, Any]] | None:
159+
"""Return upstream tools, preserving client order for passthrough requests."""
160+
if preserve_order:
161+
return tools
162+
return cls._sort_tools_deterministically(tools)
163+
152164
@staticmethod
153165
def _compress_latest_user_turn_images_cache_safe(
154166
messages: list[dict[str, Any]],
@@ -663,6 +675,7 @@ async def _finalize_pre_upstream() -> None:
663675
request.headers.get("x-headroom-bypass", "").lower() == "true"
664676
or request.headers.get("x-headroom-mode", "").lower() == "passthrough"
665677
)
678+
preserve_tool_order = _bypass or not self.config.optimize
666679
if _bypass:
667680
logger.info(f"[{request_id}] Bypass: skipping compression (header)")
668681

@@ -1843,9 +1856,12 @@ class _DeferredCompressionResult:
18431856
# Update body
18441857
body["messages"] = optimized_messages
18451858
if tools or _original_tools is not None:
1846-
sorted_tools = self._sort_tools_deterministically(tools)
1847-
if sorted_tools != tools:
1848-
tools = sorted_tools
1859+
forwarded_tools = self._tools_for_forwarding(
1860+
tools,
1861+
preserve_order=preserve_tool_order,
1862+
)
1863+
if forwarded_tools != tools:
1864+
tools = forwarded_tools
18491865
if tools != _original_tools:
18501866
body["tools"] = tools
18511867

@@ -1865,11 +1881,10 @@ class _DeferredCompressionResult:
18651881
optimized_messages = presend_event.messages
18661882
body["messages"] = optimized_messages
18671883
if presend_event.tools is not None:
1868-
sorted_tools = self._sort_tools_deterministically(presend_event.tools)
1869-
if sorted_tools != presend_event.tools:
1870-
tools = sorted_tools
1871-
else:
1872-
tools = presend_event.tools
1884+
tools = self._tools_for_forwarding(
1885+
presend_event.tools,
1886+
preserve_order=preserve_tool_order,
1887+
)
18731888
if tools or body.get("tools") is not None:
18741889
if tools != body.get("tools"):
18751890
body["tools"] = tools
@@ -2897,10 +2912,6 @@ async def handle_anthropic_batch_create(
28972912
params = batch_req.get("params", {})
28982913
canonical_params = dict(params)
28992914
original_tools = canonical_params.get("tools")
2900-
if original_tools is not None:
2901-
sorted_tools = self._sort_tools_deterministically(original_tools)
2902-
if sorted_tools != original_tools:
2903-
canonical_params["tools"] = sorted_tools
29042915
messages = params.get("messages", [])
29052916
original_messages = copy.deepcopy(messages)
29062917
model = params.get("model", "unknown")
@@ -2915,6 +2926,11 @@ async def handle_anthropic_batch_create(
29152926
)
29162927
continue
29172928

2929+
if original_tools is not None:
2930+
sorted_tools = self._sort_tools_deterministically(original_tools)
2931+
if sorted_tools != original_tools:
2932+
canonical_params["tools"] = sorted_tools
2933+
29182934
# Apply optimization
29192935
original_tokens = 0 # Initialize before try to prevent UnboundLocalError
29202936
try:

tests/test_anthropic_stage_timings.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,42 @@ def test_anthropic_http_happy_path_emits_stage_timings(stage_log_capture):
245245
assert "total_pre_upstream" in emitted
246246

247247

248+
def test_anthropic_no_optimize_preserves_client_tool_order():
249+
tools = [
250+
{
251+
"name": "Read",
252+
"description": "Read a file",
253+
"input_schema": {"type": "object", "properties": {}},
254+
},
255+
{
256+
"name": "Bash",
257+
"description": "Run a shell command",
258+
"input_schema": {"type": "object", "properties": {}},
259+
},
260+
]
261+
request = _build_request(
262+
{
263+
"model": "claude-3-5-sonnet-latest",
264+
"messages": [{"role": "user", "content": "use a tool"}],
265+
"tools": tools,
266+
},
267+
{"authorization": "Bearer sk-ant-api-test"},
268+
)
269+
handler = _DummyAnthropicHandler()
270+
271+
import headroom.tokenizers as _tk
272+
273+
orig_get = _tk.get_tokenizer
274+
_tk.get_tokenizer = lambda model: _DummyTokenizer()
275+
try:
276+
anyio.run(handler.handle_anthropic_messages, request)
277+
finally:
278+
_tk.get_tokenizer = orig_get
279+
280+
_, _, _, forwarded_body = handler.captured
281+
assert [tool["name"] for tool in forwarded_body["tools"]] == ["Read", "Bash"]
282+
283+
248284
def test_anthropic_http_invalid_body_still_emits_stage_timings(stage_log_capture):
249285
async def receive():
250286
# Invalid JSON — produces ``ValueError`` from ``_read_request_json``.

tests/test_proxy_anthropic_cache_stability.py

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,32 @@ def _make_proxy_client() -> TestClient:
8686
return TestClient(app)
8787

8888

89-
def test_anthropic_tools_sorted_deterministically_before_forward() -> None:
89+
@pytest.mark.parametrize(
90+
("optimize", "expected_names"),
91+
[
92+
(False, ["zeta", "alpha", "mu"]),
93+
(True, ["alpha", "mu", "zeta"]),
94+
],
95+
)
96+
def test_anthropic_tools_forwarding_order_matches_optimization_mode(
97+
optimize: bool,
98+
expected_names: list[str],
99+
) -> None:
90100
captured = {}
91101
with _make_proxy_client() as client:
92102
proxy = client.app.state.proxy
103+
proxy.config.optimize = optimize
104+
proxy.config.mode = "token"
105+
106+
if optimize:
107+
proxy.anthropic_pipeline.apply = lambda **kwargs: SimpleNamespace(
108+
messages=kwargs["messages"],
109+
transforms_applied=[],
110+
timing={},
111+
tokens_before=100,
112+
tokens_after=100,
113+
waste_signals=None,
114+
)
93115

94116
async def _fake_retry(method, url, headers, body, stream=False, **kwargs): # noqa: ANN001
95117
captured["body"] = body
@@ -128,7 +150,7 @@ async def _fake_retry(method, url, headers, body, stream=False, **kwargs): # no
128150

129151
assert response.status_code == 200
130152
sent_tools = captured["body"]["tools"]
131-
assert [t["name"] for t in sent_tools] == ["alpha", "mu", "zeta"]
153+
assert [t["name"] for t in sent_tools] == expected_names
132154

133155

134156
def test_image_compression_only_applies_to_latest_non_frozen_user_turn() -> None:
@@ -186,10 +208,20 @@ def test_image_compression_does_not_touch_previous_turns_if_last_message_not_use
186208
assert result[0]["content"][0]["source"]["data"] == "OLD_IMAGE_BYTES"
187209

188210

189-
def test_anthropic_batch_tools_sorted_deterministically_before_forward() -> None:
211+
@pytest.mark.parametrize(
212+
("optimize", "expected_names"),
213+
[
214+
(False, ["zeta", "alpha", "mu"]),
215+
(True, ["alpha", "mu", "zeta"]),
216+
],
217+
)
218+
def test_anthropic_batch_tools_forwarding_order_matches_optimization_mode(
219+
optimize: bool,
220+
expected_names: list[str],
221+
) -> None:
190222
captured = {}
191223
config = ProxyConfig(
192-
optimize=False,
224+
optimize=optimize,
193225
cache_enabled=False,
194226
rate_limit_enabled=False,
195227
cost_tracking_enabled=False,
@@ -203,6 +235,17 @@ def test_anthropic_batch_tools_sorted_deterministically_before_forward() -> None
203235

204236
with TestClient(app) as client:
205237
proxy = client.app.state.proxy
238+
proxy.config.mode = "token"
239+
240+
if optimize:
241+
proxy.anthropic_pipeline.apply = lambda **kwargs: SimpleNamespace(
242+
messages=kwargs["messages"],
243+
transforms_applied=[],
244+
timing={},
245+
tokens_before=100,
246+
tokens_after=100,
247+
waste_signals=None,
248+
)
206249

207250
async def _fake_retry(method, url, headers, body, stream=False, **kwargs): # noqa: ANN001
208251
captured["body"] = body
@@ -259,7 +302,7 @@ async def _fake_retry(method, url, headers, body, stream=False, **kwargs): # no
259302

260303
assert response.status_code == 200
261304
sent_tools = captured["body"]["requests"][0]["params"]["tools"]
262-
assert [t["name"] for t in sent_tools] == ["alpha", "mu", "zeta"]
305+
assert [t["name"] for t in sent_tools] == expected_names
263306

264307

265308
def test_append_context_targets_latest_non_frozen_user_turn() -> None:

tests/test_proxy_byte_faithful_forwarding.py

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -308,10 +308,10 @@ def on_pipeline_event(self, event): # noqa: ANN001
308308
return None
309309

310310

311-
def _make_no_optimize_app() -> tuple[TestClient, _CapturingTransport]:
312-
"""Boot a proxy with all transforms disabled and a capturing transport."""
311+
def _make_anthropic_app(*, optimize: bool) -> tuple[TestClient, _CapturingTransport]:
312+
"""Boot an Anthropic proxy with a capturing transport."""
313313
config = ProxyConfig(
314-
optimize=False,
314+
optimize=optimize,
315315
cache_enabled=False,
316316
rate_limit_enabled=False,
317317
cost_tracking_enabled=False,
@@ -335,6 +335,11 @@ def _make_no_optimize_app() -> tuple[TestClient, _CapturingTransport]:
335335
return TestClient(app), transport
336336

337337

338+
def _make_no_optimize_app() -> tuple[TestClient, _CapturingTransport]:
339+
"""Boot a proxy with all transforms disabled and a capturing transport."""
340+
return _make_anthropic_app(optimize=False)
341+
342+
338343
def test_passthrough_no_mutation_byte_equal_sha256() -> None:
339344
"""No transform → upstream SHA-256 equals client-sent SHA-256."""
340345
client, transport = _make_no_optimize_app()
@@ -448,7 +453,7 @@ def test_anthropic_tools_canonical_order_preserves_byte_faithful_request() -> No
448453
)
449454

450455

451-
def test_anthropic_tools_unsorted_reordered_and_canonicalized() -> None:
456+
def test_anthropic_tools_unsorted_order_preserves_byte_faithful_request() -> None:
452457
client, transport = _make_no_optimize_app()
453458
inbound_dict = {
454459
"model": "claude-sonnet-4-6",
@@ -459,6 +464,49 @@ def test_anthropic_tools_unsorted_reordered_and_canonicalized() -> None:
459464
{"name": "alpha"},
460465
],
461466
}
467+
inbound_bytes = serialize_body_canonical(inbound_dict)
468+
469+
response = client.post(
470+
"/v1/messages",
471+
headers={
472+
"x-api-key": "test-key",
473+
"anthropic-version": "2023-06-01",
474+
"content-type": "application/json",
475+
},
476+
content=inbound_bytes,
477+
)
478+
assert response.status_code == 200, response.text
479+
upstream = transport.captured_body or b""
480+
assert upstream == inbound_bytes
481+
forwarded = json.loads(upstream.decode("utf-8"))
482+
assert [tool["name"] for tool in forwarded["tools"]] == ["zeta", "alpha"]
483+
484+
485+
def test_anthropic_tools_unsorted_reordered_and_canonicalized_when_optimized() -> None:
486+
client, transport = _make_anthropic_app(optimize=True)
487+
proxy = client.app.state.proxy
488+
proxy.config.mode = "token"
489+
490+
def _fake_apply(**kwargs):
491+
return SimpleNamespace(
492+
messages=kwargs["messages"],
493+
transforms_applied=[],
494+
timing={},
495+
tokens_before=100,
496+
tokens_after=100,
497+
waste_signals=None,
498+
)
499+
500+
proxy.anthropic_pipeline.apply = _fake_apply
501+
inbound_dict = {
502+
"model": "claude-sonnet-4-6",
503+
"max_tokens": 64,
504+
"messages": [{"role": "user", "content": "plan test"}],
505+
"tools": [
506+
{"name": "zeta", "description": "later"},
507+
{"name": "alpha"},
508+
],
509+
}
462510
expected_dict = {
463511
**inbound_dict,
464512
"tools": [

tests/test_proxy_handler_helpers.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,15 @@ def test_anthropic_tool_sort_and_context_append_helpers() -> None:
570570
"tool",
571571
]
572572
assert AnthropicHandlerMixin._sort_tools_deterministically(None) is None
573+
assert AnthropicHandlerMixin._tools_for_forwarding(tools, preserve_order=True) == tools
574+
assert [
575+
AnthropicHandlerMixin._tool_sort_key(tool)[0]
576+
for tool in AnthropicHandlerMixin._tools_for_forwarding(tools, preserve_order=False) or []
577+
] == [
578+
"alpha",
579+
"beta",
580+
"tool",
581+
]
573582
assert (
574583
AnthropicHandlerMixin._append_context_to_latest_non_frozen_user_turn(
575584
[], "ctx", frozen_message_count=0

0 commit comments

Comments
 (0)