mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
90734b691a
commit
55c700c686
3 changed files with 36 additions and 2 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue