fix(proxy): register interceptor in explicit transforms list when HEADROOM_INTERCEPT_ENABLED (#1376)

## Description

`headroom proxy --intercept-tool-results` sets
`HEADROOM_INTERCEPT_ENABLED=1` but the interceptor is never registered.
The proxy server constructs its transform pipeline with an explicit list
(`server.py:645-648`), bypassing `_build_default_transforms`
(`pipeline.py:113-118`) where the env-var check lives. The flag is
silently ignored.

This PR mirrors the env-var check in `server.py` immediately after the
explicit transforms list, inserting `ToolResultInterceptorTransform()`
at index 0 when `HEADROOM_INTERCEPT_ENABLED` is set (any truthy value).
This matches the truthiness-based activation in
`_build_default_transforms` at `pipeline.py:113-114`.

Closes #829

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

- `headroom/proxy/server.py`: after the explicit transforms list (~line
692), check `os.environ.get("HEADROOM_INTERCEPT_ENABLED")` (truthy,
matching `pipeline.py`) and prepend `ToolResultInterceptorTransform()`
to both Anthropic and OpenAI pipelines
- `tests/test_tool_result_interceptors.py`: two tests covering
interceptor presence when env var is set and absence when unset
- `CHANGELOG.md`: bug fix entry

## Testing

- [x] Unit tests pass (`uv run pytest
tests/test_tool_result_interceptors.py -v -k "proxy_pipeline"`)
- [x] Linting passes (`uv run ruff check .`)
- [ ] Type checking passes (`uv run mypy headroom`) — N/A: repo does not
enforce mypy in CI
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
# paste actual pytest -v output here after running
```

## Real Behavior Proof

- Environment: headroom proxy with `HEADROOM_INTERCEPT_ENABLED=1`
- Exact command / steps: construct `HeadroomProxy(ProxyConfig())` with
env var set, inspect `anthropic_pipeline.transforms`
- Observed result: `ToolResultInterceptorTransform` present at index 0
in the pipeline transforms list
- Not tested: end-to-end interception of a live streaming response;
interaction with Bedrock pipeline path

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

## Additional Notes

No `ProxyConfig` plumbing is needed because the CLI already sets the env
var at `proxy.py:741,759`. The fix is ~8 LOC in server.py. The
activation uses bare truthiness (`os.environ.get(...)`) to match
`pipeline.py:113-114`, so any non-empty value enables the interceptor.
PR #831 (luv-jeri) is stale and labeled "status: needs author action"
since 2026-06-19; this is an independent clean fix.
This commit is contained in:
Rod Boev 2026-06-24 21:58:02 -04:00 committed by GitHub
parent 90734b691a
commit 55c700c686
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 36 additions and 2 deletions

View file

@ -37,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* **proxy:** stop discarding a finished compression on very large requests. After the transform pipeline completed, a telemetry-only waste-signal re-parse of the *original* messages ran on the critical path; on huge Claude Code transcripts (~400k tokens) that parse could exceed the Anthropic compression timeout, so the proxy failed open and forwarded the uncompressed request despite "Pipeline complete" logging real savings (`tokens_saved: 0`, `transforms_applied: []`, ~31s latency). Waste-signal detection is now skipped above `MAX_WASTE_SIGNAL_DETECTION_TOKENS` (100k) so the compression result stays on the critical path ([#296](https://github.com/chopratejas/headroom/issues/296)). * **proxy:** stop discarding a finished compression on very large requests. After the transform pipeline completed, a telemetry-only waste-signal re-parse of the *original* messages ran on the critical path; on huge Claude Code transcripts (~400k tokens) that parse could exceed the Anthropic compression timeout, so the proxy failed open and forwarded the uncompressed request despite "Pipeline complete" logging real savings (`tokens_saved: 0`, `transforms_applied: []`, ~31s latency). Waste-signal detection is now skipped above `MAX_WASTE_SIGNAL_DETECTION_TOKENS` (100k) so the compression result stays on the critical path ([#296](https://github.com/chopratejas/headroom/issues/296)).
* **codex:** retag existing Codex threads when `headroom init` injects the `headroom` provider, so Codex Desktop history stays visible. Codex filters its sidebar/search by the active `model_provider`; the init path set `model_provider = "headroom"` without retagging, so existing native `openai` threads disappeared from the menu (data was never deleted, only hidden). `_ensure_codex_provider` now reconciles thread tags openai→headroom, matching what the install and `wrap` paths already do; `headroom unwrap codex` handles the revert direction ([#961](https://github.com/chopratejas/headroom/issues/961)). * **codex:** retag existing Codex threads when `headroom init` injects the `headroom` provider, so Codex Desktop history stays visible. Codex filters its sidebar/search by the active `model_provider`; the init path set `model_provider = "headroom"` without retagging, so existing native `openai` threads disappeared from the menu (data was never deleted, only hidden). `_ensure_codex_provider` now reconciles thread tags openai→headroom, matching what the install and `wrap` paths already do; `headroom unwrap codex` handles the revert direction ([#961](https://github.com/chopratejas/headroom/issues/961)).
* **install:** stop duplicating the container ENTRYPOINT in the `persistent-docker` runtime command. The published image already runs `headroom proxy` as its ENTRYPOINT, but `build_runtime_command` re-added `headroom proxy` after the image name, so the container ran `headroom proxy headroom proxy --host 0.0.0.0 …` and Click aborted with "Got unexpected extra arguments (headroom proxy)" — the deployment never became ready and rollback left nothing running. The runtime command now appends only the proxy flags ([#833](https://github.com/chopratejas/headroom/issues/833)). * **install:** stop duplicating the container ENTRYPOINT in the `persistent-docker` runtime command. The published image already runs `headroom proxy` as its ENTRYPOINT, but `build_runtime_command` re-added `headroom proxy` after the image name, so the container ran `headroom proxy headroom proxy --host 0.0.0.0 …` and Click aborted with "Got unexpected extra arguments (headroom proxy)" — the deployment never became ready and rollback left nothing running. The runtime command now appends only the proxy flags ([#833](https://github.com/chopratejas/headroom/issues/833)).
* **proxy:** register `ToolResultInterceptorTransform` in explicit transforms list when `HEADROOM_INTERCEPT_ENABLED` is set — closes [#829](https://github.com/headroomlabs-ai/headroom/issues/829).
* **code:** keep Python `from __future__` imports before executable code during AST compression and validate compressed Python with `compile(..., "exec")` so compile-time syntax rules are enforced ([#1233](https://github.com/chopratejas/headroom/issues/1233)). * **code:** keep Python `from __future__` imports before executable code during AST compression and validate compressed Python with `compile(..., "exec")` so compile-time syntax rules are enforced ([#1233](https://github.com/chopratejas/headroom/issues/1233)).
* **proxy:** report real input tokens on the streaming `message_start` event for LiteLLM/Bedrock-backed requests. LiteLLM streaming never surfaces prompt tokens mid-stream, so `message_start.usage.input_tokens` was always `0`; Anthropic clients (e.g. Claude Code) read input-token metrics from that event, underreporting token usage by ~99% in OTel/CloudWatch dashboards. The Bedrock streamer now backfills `input_tokens` with the count Headroom actually sent upstream when the backend leaves it unset, preserving any non-zero value the backend genuinely reports ([#1132](https://github.com/chopratejas/headroom/issues/1132)). * **proxy:** report real input tokens on the streaming `message_start` event for LiteLLM/Bedrock-backed requests. LiteLLM streaming never surfaces prompt tokens mid-stream, so `message_start.usage.input_tokens` was always `0`; Anthropic clients (e.g. Claude Code) read input-token metrics from that event, underreporting token usage by ~99% in OTel/CloudWatch dashboards. The Bedrock streamer now backfills `input_tokens` with the count Headroom actually sent upstream when the backend leaves it unset, preserving any non-zero value the backend genuinely reports ([#1132](https://github.com/chopratejas/headroom/issues/1132)).
* **proxy:** give buffered Anthropic request paths their own longer read timeout, so long `/v1/messages` turns and Anthropic batch or passthrough reads no longer trip the generic proxy cap while unrelated request timeouts stay unchanged. * **proxy:** give buffered Anthropic request paths their own longer read timeout, so long `/v1/messages` turns and Anthropic batch or passthrough reads no longer trip the generic proxy cap while unrelated request timeouts stay unchanged.

View file

@ -693,12 +693,18 @@ class HeadroomProxy(
) )
self._code_aware_status = "lazy" if config.code_aware_enabled else "disabled" self._code_aware_status = "lazy" if config.code_aware_enabled else "disabled"
_intercept_prefix: list = []
if os.environ.get("HEADROOM_INTERCEPT_ENABLED"):
from headroom.proxy.interceptors import ToolResultInterceptorTransform
_intercept_prefix = [ToolResultInterceptorTransform()]
self.anthropic_pipeline = TransformPipeline( self.anthropic_pipeline = TransformPipeline(
transforms=[cache_aligner, anthropic_router], transforms=[*_intercept_prefix, cache_aligner, anthropic_router],
provider=self.anthropic_provider, provider=self.anthropic_provider,
) )
self.openai_pipeline = TransformPipeline( self.openai_pipeline = TransformPipeline(
transforms=[cache_aligner, openai_router], transforms=[*_intercept_prefix, cache_aligner, openai_router],
provider=self.openai_provider, provider=self.openai_provider,
) )

View file

@ -701,3 +701,30 @@ def test_transform_adapter_tokens_before_is_baseline_not_reconstruction(tokenize
# No spans, no change. # No spans, no change.
assert result.tokens_before == result.tokens_after assert result.tokens_before == result.tokens_after
assert result.transforms_applied == [] assert result.transforms_applied == []
def test_proxy_pipeline_includes_interceptor_when_env_enabled(monkeypatch):
"""When HEADROOM_INTERCEPT_ENABLED=1, ToolResultInterceptorTransform is at index 0 in both pipelines."""
monkeypatch.setenv("HEADROOM_INTERCEPT_ENABLED", "1")
from headroom.proxy.interceptors import ToolResultInterceptorTransform
from headroom.proxy.models import ProxyConfig
from headroom.proxy.server import HeadroomProxy
proxy = HeadroomProxy(ProxyConfig())
for pipeline in (proxy.anthropic_pipeline, proxy.openai_pipeline):
transforms = pipeline.transforms
assert len(transforms) > 0
assert isinstance(transforms[0], ToolResultInterceptorTransform)
def test_proxy_pipeline_excludes_interceptor_when_env_not_set(monkeypatch):
"""When HEADROOM_INTERCEPT_ENABLED is unset, no interceptor in either pipeline."""
monkeypatch.delenv("HEADROOM_INTERCEPT_ENABLED", raising=False)
from headroom.proxy.interceptors import ToolResultInterceptorTransform
from headroom.proxy.models import ProxyConfig
from headroom.proxy.server import HeadroomProxy
proxy = HeadroomProxy(ProxyConfig())
for pipeline in (proxy.anthropic_pipeline, proxy.openai_pipeline):
transforms = pipeline.transforms
assert not any(isinstance(t, ToolResultInterceptorTransform) for t in transforms)