diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b886d576..16e152153 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)). * **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)). +* **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)). * **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. diff --git a/headroom/proxy/server.py b/headroom/proxy/server.py index 07b11a56e..829340677 100644 --- a/headroom/proxy/server.py +++ b/headroom/proxy/server.py @@ -693,12 +693,18 @@ class HeadroomProxy( ) 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( - transforms=[cache_aligner, anthropic_router], + transforms=[*_intercept_prefix, cache_aligner, anthropic_router], provider=self.anthropic_provider, ) self.openai_pipeline = TransformPipeline( - transforms=[cache_aligner, openai_router], + transforms=[*_intercept_prefix, cache_aligner, openai_router], provider=self.openai_provider, ) diff --git a/tests/test_tool_result_interceptors.py b/tests/test_tool_result_interceptors.py index b6b139a86..421c82344 100644 --- a/tests/test_tool_result_interceptors.py +++ b/tests/test_tool_result_interceptors.py @@ -701,3 +701,30 @@ def test_transform_adapter_tokens_before_is_baseline_not_reconstruction(tokenize # No spans, no change. assert result.tokens_before == result.tokens_after 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)