PR #411 reintroduced an older `--code-aware` is_flag option and a
duplicate `code_aware_enabled=` kwarg in the ProxyConfig call, which
collided with the canonical tristate `--code-aware/--no-code-aware`
introduced in #260. The result: every CLI entry point (`headroom proxy`,
`headroom wrap codex`, `headroom wrap claude`, etc.) raised at import
time:
File ".../headroom/cli/proxy.py", line 575
code_aware_enabled=code_aware or _get_env_bool(...)
SyntaxError: keyword argument repeated: code_aware_enabled
Removes:
- The legacy `@click.option("--code-aware", is_flag=True, ...)`
- The legacy `code_aware: bool` function parameter
- The duplicate `code_aware_enabled=` kwarg
Keeps the tristate `--code-aware/--no-code-aware` > env-var >
default-off resolver. Behavior is unchanged for all flag combinations
covered by tests/test_cli_proxy_env.py.
Test mocks for `run_server` updated to accept `**kwargs` to match
the real signature (config plus run-time options like print_banner).
Without this the four code-aware tests added in #411 raised
TypeError on each invocation.
Plugin marketplace/manifest version bump 0.21.5 → 0.21.7 carried in
this commit by the sync-plugin-versions pre-commit hook.
floor_char_boundary was stabilised in Rust 1.91; headroom's MSRV is 1.80.
Walk back from byte 64 manually — UTF-8 codepoints are at most 4 bytes
so this loop runs at most 3 times in the worst case.
Three related proxy hygiene fixes:
#417 — delete handle_raw_predict from vertex/raw_predict.rs
The dispatcher (handle_vertex_predict_dispatch in vertex/mod.rs) calls
forward_vertex_request directly. handle_raw_predict was never wired into
the router and is unreachable code. Deleting it removes 80 lines of dead
logic and eliminates confusion for new contributors.
#418 — honour X-Forwarded-Proto in forward_vertex_request
build_forward_request_headers received a hardcoded literal 'http' for the
forwarded protocol. Proxies deployed behind a TLS load balancer would emit
X-Forwarded-Proto: http even for HTTPS upstream connections. Now reads the
incoming X-Forwarded-Proto header and falls back to 'http' only when the
header is absent.
#416 — apply DefaultBodyLimit to Bedrock routes in proxy.rs
Bedrock handlers use axum's Bytes extractor, which respects
DefaultBodyLimit (default 2 MiB). All other routes buffer the body
manually and apply config.max_body_bytes (default 100 MiB). Adding
.layer(DefaultBodyLimit::max(state.config.max_body_bytes)) to the Bedrock
router aligns the cap across providers.
Closes#416, #417, #418
header_value_preview in eventstream_to_sse.rs used a raw byte-slice
(&s[..64]) to truncate long header strings for log output. If byte
index 64 landed inside a multi-byte codepoint (e.g. 63 ASCII chars
followed by é or an emoji), Rust panics at runtime.
Replace with floor_char_boundary(64) which returns the largest valid
char boundary ≤ 64 without scanning the whole string.
Two regression tests added:
- truncates_at_char_boundary: 63 ASCII + é → must not panic, must end with …
- exact_boundary_not_truncated: 64-byte ASCII string is returned unchanged
Fixes#415
The structural-invariant test in test_release_workflows.py pinned
literal strings from the previous single-LICENSE shape:
- 'include = [{ path = "LICENSE", format = "sdist" }]' (single line)
- 'name: Verify sdist includes top-level LICENSE'
- 'license_path = f"{root}/LICENSE"' (verifier line)
The previous commit broke all three intentionally — sdist include is
now multi-line and lists NOTICE alongside LICENSE; the workflow step
was renamed to "Verify sdist license-file metadata matches tarball
contents"; the verifier no longer hardcodes a single license_path,
it parses every License-File entry from PKG-INFO.
Update the test to:
- Pin substrings (not full lines) for both LICENSE and NOTICE
entries so future formatting changes won't break the test
- Pin the new step name
- Pin two distinctive verifier signatures (the License-File parse
line and the missing-files error message) so a refactor that
silently drops the cross-check fails loudly
- Add a docstring explaining the regression history so a future
"let me clean up this test" pass can't accidentally re-loosen
the invariant
Address user-reported UX gaps across the CLI surface:
- code-aware: add --code-aware/--no-code-aware (+ HEADROOM_CODE_AWARE_ENABLED env)
to the Click CLI. PR #411 had added these only to the orphaned argparse main;
the user-facing CLI couldn't reach the flag. Banner status text "remove
--no-code-aware to enable" referenced a flag that didn't exist — fix to point
at the actual flag/env. Surface code-aware in the click banner and add
print_banner=False plumbing to run_server so the click path doesn't print
two banners back-to-back.
- --mode: hide alias clutter via metavar=[token|cache] and rewrite help to
lead with the two real modes. Legacy aliases (token_mode/token_savings/...)
still validate.
- perf --hours: was documented but ignored. Records are now actually filtered,
the report shows the actual time-range covered, and the count of records
filtered out (so users can tell when raising --hours helps).
- perf TOIN: replace the hash-keyed pattern dump with a strategy-distribution
view + recommendation-eligibility from the live store — actionable signal
rather than opaque rows.
- code-graph: clarify in --help that it indexes cwd / project root.
- wrap: spell out supported tools, wrap-vs-proxy distinction, and that
`headroom wrap opencode` isn't a thing (use `proxy` directly for opencode;
openclaw is not opencode).
- mcp: note that mcp__headroom__headroom_retrieve is correct MCP namespacing,
not a doubled-prefix bug. Renaming would break the proxy's tool injection.
- LLMLingua cleanup: remove [llmlingua] extra from pyproject (no live code
uses it). Delete wiki/llmlingua.md and clean retired flag/class references
in 6 other wiki pages. Point at [ml] (Kompress) where ML compression is
documented.
- init -g openclaw: strip mcpServers from existing plugin entries before
re-writing — newer openclaw schemas reject it, leaving stale entries from
older installs unhealable. Pinned with regression test.
Tests: mock_run_server signatures in two existing tests accept **kwargs
(needed for the new print_banner plumbing). New test for the openclaw
mcpServers strip. Full suite: 4847 passed, 262 skipped, 0 failed.
Every release since v0.20.16 has uploaded 12 wheels but no sdist. The
underlying failure is a 400 from PyPI:
400 License-File NOTICE does not exist in distribution file
headroom_ai-X.Y.Z.tar.gz at headroom_ai-X.Y.Z/NOTICE
Two-part regression:
1. The hatch -> maturin migration in 2a91cbb (single-wheel maturin build
backend, May 4) replaced `[tool.hatch.build.targets.sdist].include`,
which listed both `LICENSE` and `NOTICE`, with maturin's own include
directive that only carried `LICENSE` over. Maturin's PEP 639 license
auto-discovery still emits `License-File: NOTICE` into the sdist's
PKG-INFO (because NOTICE exists at the project root and matches the
default glob), so the sdist tarball declares a license file it
doesn't physically contain. PyPI's PEP 639 validator rejects with
400. Wheels were unaffected because maturin auto-injects both files
into `*.dist-info/licenses/`.
2. CI showed "publish-pypi" green for ~22 releases despite this break
because twine was bailing earlier with `400 File already exists` on
the wheels (the version detector kept computing the same v0.21.5).
PR #412 added `skip-existing: true` (May 6) to make wheel re-uploads
idempotent. With wheels now silently skipping, twine proceeded to
upload the sdist for the first time in three weeks - and the
dormant License-File error surfaced as a hard 400.
Fix:
- Add `NOTICE` alongside `LICENSE` in `[tool.maturin].include` for the
`sdist` format. Both files now ship in the tarball, matching what
PEP 639 already declares in PKG-INFO.
- Replace the existing "verify sdist contains LICENSE" check with a
generic "every License-File entry in PKG-INFO resolves to a real
tarball member" check. This catches the same bug class for any
future addition (COPYING, AUTHORS, etc.) without another bespoke
literal.
Verified locally:
$ maturin sdist --out dist
Including license file `LICENSE`
Including license file `NOTICE`
Including files matching "LICENSE"
Including files matching "NOTICE"
Built source distribution to dist/headroom_ai-0.9.1.tar.gz
$ tar -tzf dist/headroom_ai-0.9.1.tar.gz | grep -E '(LICENSE|NOTICE)$'
headroom_ai-0.9.1/LICENSE
headroom_ai-0.9.1/NOTICE
$ twine check dist/headroom_ai-0.9.1.tar.gz
Checking dist/headroom_ai-0.9.1.tar.gz: PASSED
Builds on PR #406 (HTTP /v1/responses PyO3) and PR #410 (WS first-frame
PyO3) which landed the binding for `compress_openai_responses_live_zone`.
This change closes the remaining gaps so every (provider × endpoint ×
auth-mode × streaming) combination compresses AND surfaces in the
dashboard.
Telemetry: extend the PyO3 binding return tuple from `(bytes, modified)`
to `(bytes, modified, tokens_saved, transforms_applied)` by adding
`CompressionManifest::tokens_saved()` and `transforms_applied()`
accessors on the existing manifest. The Python proxy populates
request-log telemetry from the binding output instead of recounting
tokens. Updates the existing 2-tuple call sites in HTTP and WS
first-frame, plus the unpacks in tests.
WebSocket multi-frame compression: subscription Codex users keep a
long-lived WS open and send multiple `response.create` events per
session. PR #410 only compressed the first frame; subsequent frames
went raw. Added `_maybe_compress_response_create_frame` closure inside
`_client_to_upstream` that runs the same Rust dispatcher on every
client→upstream `response.create` text frame, passes other event
types (response.cancel, session.update, etc.) through unchanged, and
accumulates `tokens_saved` / `transforms_applied` /
`ws_frames_compressed` counters across the session.
Pre-existing dashboard gap: `streaming.py` and `anthropic.py` write
`RequestLog` entries; the non-streaming OpenAI HTTP and WS handlers
did not. Result: /transformations/feed was invisible for every Codex
turn and every Cline / OpenClaude / Aider turn. Added the same wiring
in `handle_openai_chat` (non-streaming), `handle_openai_responses`
(non-streaming HTTP), and `handle_openai_responses_ws` (session-end).
All three populate `auth_mode` + `endpoint` tags so the dashboard can
break compression activity down by client class (PAYG / OAuth /
Subscription) and surface (`chat_completions` / `responses_http` /
`responses_ws`). The WS metric record is now unconditional — was
previously gated on `tokens_saved > 0`, so first-frame no-changes
never registered.
compute_frozen_count over-freeze for prose-format clients:
`compute_frozen_count` walked until it found an unstable
`tool_result` / `role: "tool"` block. Cline / OpenClaude / Aider —
clients that embed tool calls as XML inside plain text — never
produce such a boundary, so the function returned `len(messages)` and
the pipeline froze 100% of messages including the brand-new user
turn. Live zone empty → `Transform content_router: 16414 → 16414
tokens (saved 0)`. Reported on Discord 2026-05-07 with Cline+DeepSeek.
Fix: cap at `max(0, len(messages) - 1)`. Updates 3 existing test
assertions whose expected values encoded the old over-freeze. Adds 6
new prose-format invariant tests.
CodeQL "clear-text logging of sensitive information" fix:
`tests/e2e_real_compression.py` previously stored API keys in local
variables in the same scope as diagnostic prints, which CodeQL flagged
via data-flow analysis. Refactored to read keys from `os.environ`
inside the request helper — the credentials never enter the runner's
main scope, so the taint flow never reaches the print.
End-to-end verification with real keys (.env):
/v1/messages (PAYG, non-stream) tok 14109 → 969 saved 13140
/v1/messages (PAYG, stream) tok 14109 → 969 saved 13140
/v1/chat/completions (PAYG, non-stream) tok 18460 → 1374 saved 17086
/v1/chat/completions (PAYG, stream) tok 18460 → 1374 saved 17086 (cache_hit=100%)
/v1/responses HTTP (PAYG, non-stream) bytes 50138 → 597 saved 18391
/v1/responses WS (frame 1) bytes 46429 → 488 saved 16791
/v1/responses WS (frame 2 multi) bytes 46429 → 488 saved 16791
/v1/responses WS (response.cancel) passthrough untouched
Tests: cargo workspace + pytest (4846 pass, 0 fail), make ci-precheck
passed, two E2E scripts (multi-turn HTTP, WS fake-upstream) all green.
The two F2.1 fixtures constructed CompressionPolicy with only 2
fields; F2.2 added three required tuning fields (volatile_token_threshold,
max_lossy_ratio, toin_read_only). Updated subscription-disabled fixture to
use Subscription defaults (32 / 0.25 / True) and the PAYG-enabled fixture
to use PAYG defaults (128 / 0.45 / False), mirroring policy_for_mode().
Prefer the fixture update over default-valued fields in the dataclass so
the F2.2 parity-test invariant (every CompressionPolicy is fully specified
per-mode) stays load-bearing.
Wires the F2.2 ``toin_read_only`` field through the only consumer where
it's load-bearing (TOIN write surface) and extends the proxy's
structured ``policy_selected`` log event with all three F2.2 fields so
the bake dashboard has per-mode observability.
Wiring (gates only TOIN writes — compression itself still runs):
- headroom/transforms/smart_crusher.py: capture
kwargs["compression_policy"] onto self._runtime_compression_policy
at the start of apply(). _record_to_toin returns early when the
policy says toin_read_only=True. Direct crush() / crush_array_json()
callers don't go through apply() and keep pre-F2.2 write-enabled
behaviour (no auth context for non-proxy callers).
- headroom/transforms/content_router.py: same one-liner in apply(),
same gate in _record_to_toin. Mirrors the existing
_runtime_target_ratio / _runtime_kompress_model pattern.
Telemetry:
- crates/headroom-proxy/src/proxy.rs: extend the policy_selected
structured log with volatile_token_threshold, max_lossy_ratio, and
toin_read_only. F2.2 bake telemetry can now observe all five fields
on every request — load-bearing for the F2.2-followup tune decision
since volatile_token_threshold and max_lossy_ratio are plumbed-but-
unconsumed today and the log is the only signal that the values are
flowing correctly.
Plumbed-but-unconsumed (deliberate; flagged in PR body):
- volatile_token_threshold — the volatile detector in cache_aligner.py
is shape-based, not token-count-based; wiring it forces a detector
refactor outside F2.2 scope.
- max_lossy_ratio — distinct from the caller-driven target_ratio kwarg
in content_router.py; gating lossy paths on a policy cap is F2.2-
followup once telemetry decides whether to gate or just observe.
Tests (tests/test_compression_policy_toin_gate.py):
- 7 tests covering the gate. SmartCrusher tests skip when the
headroom._core Rust wheel isn't installed (matches the existing
test_smart_crusher_rust_parity.py pattern); the 3 ContentRouter
tests exercise the gate without the Rust dependency. CI's
ci-precheck-python target runs scripts/build_rust_extension.sh
before pytest so all 7 will run in the gate.
Refs: F2.1 (#400)
Adds three per-mode tuning fields to the F2.1 CompressionPolicy struct
on both sides of the parity bridge:
- volatile_token_threshold (u32 / int) — per-mode threshold below which
content is treated as cache-stable. PAYG=128 (relaxed), Subscription=32
(strict). Plumbed but unconsumed in F2.2 — the volatile detector in
cache_aligner.py is shape-based; wiring it is a follow-up.
- max_lossy_ratio (f32 / float, [0.0, 1.0]) — per-mode upper bound on
lossy compression aggressiveness. PAYG=0.45, Subscription=0.25.
Plumbed but unconsumed in F2.2 — distinct from the caller-driven
target_ratio kwarg in ContentRouter.
- toin_read_only (bool) — TOIN learning gate. True = serve cached
patterns but never write new observations from this request.
PAYG/OAuth=false (network effect feeds on aggressive traffic),
Subscription=true (consistency over learning).
OAuth stays identical to PAYG across all five fields; the canary parity
test (oauth_matches_payg_today) covers the full struct so a future
divergence on any field trips the assertion just as loudly as a flag flip.
Per-mode defaults are CONSERVATIVE pending F2.1 bake telemetry; F2.2-
followup will tune. Per the realignment build constraints, the
configuration IS the per-mode default — no separate env var per field.
What's NOT in this commit:
- TOIN gate wiring (next commit, c2/3)
- policy_selected log extension (next commit, c2/3)
Tests:
- Rust: 6 unit tests in compression_policy::tests (3 new). OAuth=PAYG
canary now compares ALL fields.
- Python: 10 tests in tests/test_compression_policy.py. Hard-coded
expected_fields set in TestRustParityFieldMap extended.
Refs: F2.1 (#400)
The proxy previously had no spoofing defence on `X-Forwarded-*`
headers. Any client could forge `X-Forwarded-For`, `-Proto`, or
`-Host` and rotate rate-limit buckets / poison logs / mis-attribute
auth. F4 adds an explicit gateway-allowlist gate.
* New `headroom/proxy/forwarded_headers.py` module:
- `resolve_client_ip(request)` — IP for log/auth/rate-limit.
- `trusted_forwarded_headers(request)` — sanitised `{for,proto,host}`
dict (empty strings when the gate fails).
- Configured by `HEADROOM_PROXY_TRUSTED_GATEWAY_CIDRS`
(comma-separated CIDRs). Empty/unset = strict-secure default.
- Malformed CIDR raises `ValueError` (loud, no silent skip).
- Every spoof rejection emits `forwarded_headers_rejected` log
event with `peer_ip` + the three header values.
- Result cached on `request.state.{client_ip,forwarded}` so the
helpers run once per request.
* Anthropic handler now keys the rate-limit bucket on the gated IP.
* 36 tests cover: default-strict / allow-listed peer / non-allow-listed
peer (with log assertion) / IPv6 / IPv4-mapped IPv6 / multi-CIDR /
whitespace tolerance / malformed CIDR loud-fail / empty headers /
caching / FastAPI integration.
Every push to main since v0.21.5 was first published has failed the
publish-pypi job with `400 File already exists`. The workflow's
detect-version step has been computing v0.21.5 repeatedly (the
canonical+commit-height algorithm hasn't bumped past it for the
recent fix-only commits), so each run rebuilds the same wheels with
the same version and twine rejects the duplicates.
Failed runs:
- 25443521479 (PR #406 merge, 15:04 UTC)
- 25452026402 (PR #409 merge, 17:55 UTC)
- 25452038283 (next push, 17:56 UTC)
PyPA's recommended pattern for this scenario is `skip-existing: true`
on the publish action — duplicate uploads become no-ops, fresh
versions still publish normally. Idempotent.
This unblocks main without touching the version-detection algorithm.
A follow-up audit of `headroom/release_version.py` is the right
deeper fix (so a series of `fix:` commits between releases produces
a sequence of patch bumps), but that's a deeper investigation; this
patch just stops the publish job from going red on every push.
Effect after this lands:
- Push to main → wheels rebuilt with whatever version detect-version
computes
- If that version's wheels are already on PyPI → twine skips them,
exit 0, downstream jobs (publish-npm, publish-docker, create-release)
run normally
- If detect-version computes a NEW version not on PyPI → wheels
publish as before, no behaviour change
Document the proxy-side code-aware flags so the CLI reference matches the current wrapper and server behavior.
The wrapper now exposes the positive flag, and the server docs should show both enable/disable forms with the shared env var defaulting behavior.
Assisted-by: Sisyphus:openai/gpt-5.4-mini
Signed-off-by: Manorit Chawdhry <m-chawdhry@ti.com>
headroom proxy now accepts --code-aware so callers do not need to drop to the lower-level server entrypoint.
Keep the existing env fallback so HEADROOM_CODE_AWARE_ENABLED still works when the flag is omitted.
Assisted-by: Sisyphus gpt-5.4-mini
Signed-off-by: Manorit Chawdhry <m-chawdhry@ti.com>
PR-C5 retired Python compression on the WebSocket /v1/responses path
expecting the standalone Rust proxy binary to take over. That binary
isn't deployed by the CLI today (`headroom proxy` runs only the Python
proxy via uvicorn). PR #406 closed the equivalent gap on the HTTP path;
this commit closes the gap on WebSocket.
Subscription users matter most here. The PR #409 reviewer confirmed
empirically that ChatGPT-subscription Codex CLI defaults to WebSocket
transport for /v1/responses. After PR #409 the routing reaches Headroom;
before this commit, every byte was forwarded uncompressed.
# What this does
In handle_openai_responses_ws, after memory injection finalises the
first-frame body, we re-parse first_msg_raw, detect the wrap shape
(Codex sends either {"type": "response.create", "response": {...}} or
the payload directly), call the PyO3 binding from PR #406 on the
inner payload, and re-wrap on modified return.
The compression engine runs in Rust — the binding exposes it inline
so the Python WS handler can call it without a process chain.
# Failure mode
Wrapped in try/except — passthrough on any unexpected condition.
# Subsequent client→upstream frames
Out of scope. Multi-frame compression is a separate follow-up.
# Tests
26 new tests in tests/test_responses_ws_pyo3_compression.py pinning
the body-shape contract: wrapped + unwrapped envelopes, garbage shapes
(no exception leak), every F1 AuthMode accepted. Existing WS
lifecycle + timings tests still pass.
Refs: PR #406 (HTTP path), PR-C5 (the retirement that needs closing)
Three files modified in the previous commit (4071d57) needed ruff
format reformatting per CI's `ruff format --check .` step.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Restore build_provider_section() to headroom/providers/codex/install.py
without requires_openai_auth (was removed entirely; pre-existing test
test_provider_codex_install.py imports it and would fail to collect)
- Flip test_codex_provider_section_preserves_openai_oauth to assert
requires_openai_auth is ABSENT, not present (old behavior was wrong)
- Fix test_provider_codex_runtime.py:337 same way — init config must
NOT contain requires_openai_auth
- Fix Ruff B023 lint error in test_providers.py:492 — capture loop
variable config_path in lambda default arg (_p=config_path)
- Fix e2e/init/run.py _verify_codex_local and _verify_codex_global to
assert requires_openai_auth is absent, not present
- Fix e2e/wrap/run.py verify_codex_wrap same way
All unit tests pass locally (82 affected tests green).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per issue #393, env_key = "OPENAI_API_KEY" breaks ChatGPT subscription
users who don't have OPENAI_API_KEY set. Remove it from wrap, init, and
persistent install entry points. The openai_base_url top-level injection
handles subscription routing without requiring env_key.
Bug 3 fix is now consistent across all three Codex entry points.
Subscription (ChatGPT plan) users will always have their traffic routed
through headroom regardless of whether they reached Codex config via
`headroom wrap codex`, `headroom init codex`, or the persistent-install
provider scope — all three now write `openai_base_url` at the TOML
top-level (outside any `[model_providers.*]` block) so Codex's built-in
openai provider is intercepted even when subscription auth bypasses the
`model_provider = "headroom"` selection.
Changes:
- headroom/cli/init.py: add `openai_base_url` line to `_ensure_codex_provider`
block; add `_strip_codex_init_block` helper with orphan-key cleanup
(mirrors `_strip_codex_headroom_blocks` in wrap.py)
- headroom/providers/codex/install.py: add `openai_base_url` line to
`apply_provider_scope` section; add orphan-cleanup regexes and apply
them in `revert_provider_scope` to handle crash-recovery scenarios
- tests/test_install/test_providers.py: add
`test_apply_provider_scope_writes_openai_base_url`,
`test_persistent_install_strip_removes_openai_base_url`
- tests/test_cli/test_init_cli.py: add
`test_init_codex_writes_openai_base_url`,
`test_init_codex_strip_removes_openai_base_url`
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug 3 (#406) has two halves:
1. Strip requires_openai_auth from all three headroom provider block
emission sites — done in 3ca48d3. This prevented custom-provider traffic
from triggering OpenAI OAuth login prompts.
2. Inject openai_base_url at the top level of ~/.codex/config.toml — this
commit. Without this key, Codex subscription (ChatGPT plan) users bypass
headroom entirely: Codex detects subscription auth and routes through the
built-in openai provider using chatgpt.com/backend-api/codex as the base
URL, ignoring both OPENAI_BASE_URL env var and model_provider = "headroom".
Setting openai_base_url in config.toml overrides that default so both
API-key and subscription traffic flow through the proxy.
Changes:
- headroom/cli/wrap.py: add openai_base_url = "http://127.0.0.1:{port}/v1"
to the top-level marker block in _inject_codex_provider_config; add orphan
cleanup regex for openai_base_url in _strip_codex_headroom_blocks (handles
crash/migration residue).
- tests/test_install/test_providers.py: invert
test_inject_codex_provider_config_does_not_write_openai_base_url →
test_inject_codex_provider_config_writes_openai_base_url (asserts exact
value "http://127.0.0.1:8787/v1" so port drift causes failure); add
test_unwrap_removes_top_level_openai_base_url covering both the
backup-restore path and the _strip_codex_headroom_blocks orphan-cleanup path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add two deterministic, no-network regression tests to prevent bug 3 from
silently re-appearing:
- test_headroom_provider_block_never_sets_requires_openai_auth: calls
apply_provider_scope() directly with multiple ports and asserts the
rendered TOML never contains requires_openai_auth anywhere in the
headroom provider block.
- test_inject_codex_provider_config_does_not_write_openai_base_url:
calls _inject_codex_provider_config(8787) against a tmp_path-based
home dir (via monkeypatched HOME/USERPROFILE) and asserts openai_base_url
is absent at the top level, and requires_openai_auth is absent in the
injected provider block.
Both tests fail loudly with descriptive messages if either field
re-appears after a future change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Remove `requires_openai_auth = true` from all three sites that emit the
`[model_providers.headroom]` block: `headroom/providers/codex/install.py`
(persistent install), `headroom/cli/wrap.py` (_inject_codex_provider_config),
and `headroom/cli/init.py` (_ensure_codex_provider).
The field belongs only on the built-in `openai` provider where codex
hardcodes it. Setting it on a custom local-proxy provider forces codex
to demand OpenAI OAuth login for every headroom-routed request.
Top-level `openai_base_url` injection was audited — it was never written
to config.toml by the current codebase, only referenced in comments and
env-var routing logic. No change needed there.
Update test_apply_codex_provider_scope_replaces_existing_managed_block to
assert the replacement block no longer carries requires_openai_auth.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes Bug 1 by calling the new c1 PyO3 binding from
`handle_openai_responses` right after the upstream URL is resolved.
Policy gating already happened at request entry (F1 classify_auth_mode
populated request.state.auth_mode; F2.1 resolve_policy applied).
The call serialises the (memory-injected) body to JSON bytes, hands
them to the Rust dispatcher, and on modified return parses the result
back into the dict so the rest of the handler (streaming branch,
_retry_request POST, error paths) sees the compressed body.
On any exception, logs a warning and forwards the original body —
passthrough-on-failure is the right default for a hot-fix that must
not regress in any edge case. transforms_applied gains
"openai_responses_live_zone" on success.
# Streaming + WebSocket scope
Streaming SSE is covered: the call site runs BEFORE the if-stream
branch so SSE requests get the same body compression as non-streaming.
WebSocket /v1/responses (handle_openai_responses_ws) is a separate
path, retired by PR-C5 with "WS-side compression is a follow-up via
Rust if ever needed". Out of scope here.
# Tests
Existing handler tests pass (test_proxy_openai_cache_stability,
test_proxy_openai_responses_integration, test_compression_policy).
The c1 commit's test_responses_pyo3_compression covers the binding
contract directly. Build+import smoke test verified.
PR-C5 (May 3) retired the Python `/v1/responses` compression pipeline
with the comment "Rust handles item-aware compression natively" — but
the standalone `crates/headroom-proxy` binary that was supposed to do
that compression is not deployed by the CLI today (`headroom proxy`
and `headroom wrap codex` both run only the Python proxy via uvicorn).
Result: every `/v1/responses` request since v0.20.16 has been
forwarded uncompressed. Codex CLI is the flagship consumer of this
endpoint; this is the regression users have been reporting.
Closes Bug 1 of the Codex regression by exposing the existing
`headroom_core::transforms::compress_openai_responses_live_zone` as
a PyO3 binding so the Python proxy can call the live-zone dispatcher
in-process. The `headroom._core` extension is already loaded at
proxy startup (PR-A0 verifies), so adding one more callable is
mechanical.
Why PyO3 inline (Layer 1) vs originally-intended two-process chain
(Layer 2): the inline call requires zero deployment changes — the
wheel already ships `headroom._core`. Layer 2 (build + ship the
standalone `headroom-proxy` binary, teach CLI to spawn both
processes) is the right long-term move; Layer 1 restores v0.5.21
functional behaviour today.
# Returns
`(body, modified)`. On change → `(new_body_bytes, True)`; on
passthrough → `(input_bytes, False)`.
# Failure mode
Never raises. The dispatcher's `LiveZoneError` cases (body not JSON,
no input array) are passthrough conditions matching the Rust proxy's
`compress_openai_responses_request` contract.
# Tests
14 new tests in `tests/test_responses_pyo3_compression.py`:
binding exposed, passthrough cases, every F1 AuthMode variant,
empty-model default, no-raise on garbage bytes.
Preserve Codex OAuth-safe provider config across init, wrap, and
persistent install paths, and strengthen coverage so Codex requests
are proven to reach Headroom and the mock upstream.
The wrap e2e now sends a real chat-completions probe and checks
Headroom /stats. Runtime tests cover temporary launch env, install
env, init config, provider-scope config delivery, and the Python
3.11 ws bootstrap path.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The smoke-test job in .github/workflows/eval.yml has been silently
broken on every PR that touches headroom/transforms/**, evals/**, or
compress.py. Root cause: the public chopratejas/headroom repo has zero
Actions secrets configured (`gh api repos/chopratejas/headroom/actions/secrets`
returns `{"total_count":0,"secrets":[]}`), so OPENAI_API_KEY resolves
to the empty string and `openai.OpenAI()` raises OpenAIError at client
init before any compression code runs.
Fix: gate the live OpenAI eval step on a non-empty key and emit a
GitHub `:⚠️:` annotation when skipped, so the skip is loud in
the run summary (per the project's "no silent fallbacks" rule). The
CCR round-trip step above remains the mandatory gate — it tests real
compression logic with zero external dependencies.
Operators who DO wire OPENAI_API_KEY as a repo secret get the live
eval as before.
Test plan:
- yaml syntax validated locally
- Will re-run on PR #400 push; expect smoke-test to pass with the
:⚠️: annotation visible in the run summary.
Refs: F2.1 (unblocks merge of #400)
c5 (865475b) introduced compression_policy inside the is_token_mode
branch in headroom/proxy/handlers/openai.py, but the else branch
referenced it at the pipeline.apply call site → NameError on every
non-token-mode request the moment optimize=True. The single failing
test in tests/test_proxy_openai_cache_stability.py
(test_openai_cache_mode_freezes_previous_turns) flagged it.
Fix: hoist `compression_policy = resolve_policy(...)` to before the
is_token_mode check in both handlers, and pass it to ALL three
anthropic_pipeline.apply call sites (token / non-cache / cache-delta)
so F2.1 gating applies uniformly. The token-mode call site already
had the kwarg; the other two were leaking through ungated.
Tests:
- tests/test_proxy_openai_cache_stability.py: 3 passed
- tests/test_proxy_anthropic_cache_stability.py: 22 passed
- tests/test_proxy_anthropic_compression_diagnostics.py: 2 passed
- tests/test_compression_policy.py: 6 passed
- tests/test_cache_aligner_detector_only.py: 22 passed
Refs: F2.1
c2 (948c8f2) added an `AuthMode` import that pushed the import group
over the rustfmt single-line threshold, producing two-line output that
rustfmt then re-collapses on `cargo fmt --check`. Pre-commit only runs
ruff on Rust files, so the drift slipped through.
Pure formatting — no behavior change.
Refs: F2.1
Final commit of F2.1. Flips the CliArgs default for the
auth-mode policy enforcement flag from Disabled to Enabled (matching
the Rust + Python defaults), and wires resolve_policy() through the
Python Anthropic and OpenAI chat handlers so the live TransformPipeline
sees the correct CompressionPolicy on every request.
Changes:
- crates/headroom-proxy/src/config.rs: CliArgs default flips to
AuthModePolicyEnforcement::Enabled. Config::for_test stays Disabled
so the existing test corpus is unaffected.
- headroom/proxy/handlers/anthropic.py: resolve_policy() called once
per request just before pipeline.apply(); compression_policy=
passed through to TransformPipeline.
- headroom/proxy/handlers/openai.py: same pattern in both branches
of the chat completion path.
- headroom/transforms/compression_policy.py: added is_enforcement_enabled()
reading HEADROOM_PROXY_AUTH_MODE_POLICY_ENFORCEMENT (matching Rust)
and resolve_policy() — the single public entry point handlers call.
Subscription-classified requests now skip CacheAligner, addressing
the cache-instability complaints in #327 / #388. PAYG and OAuth
remain on the aggressive path until F2.2 telemetry says otherwise.
Refs: F2.1
Phase F2.1, commit 4 of 5 (consolidated from the 6-commit plan after
finding the Rust dispatcher gate is a no-op in F2.1 — Subscription
still gets live-zone compression by design, so the only behaviour
change is on the Python detector side).
What lands:
- New `headroom/transforms/compression_policy.py` — hand-mirror of
`headroom_core::compression_policy::CompressionPolicy`. Two fields,
`policy_for_mode(AuthMode)` constructor, `policy_default_payg()`
helper for the enforcement-flag-off path. Source of truth is the
Rust crate; a parity test guards against silent drift.
- `CacheAligner.should_apply` now reads `kwargs["compression_policy"]`
and returns `False` when `policy.cache_aligner_enabled is False`.
This is THE behaviour change for F2.1: subscription requests stop
triggering volatility warnings and the per-pipeline-instance
`_previous_prefix_hash` is no longer updated by them.
Hidden state caveat: the hash field is per-pipeline-instance, not
per-request. Clearing it on skip would race with concurrent PAYG
requests on the same pipeline, so we don't. The behaviour is "skip
silently" — exactly what cache-stability-sensitive callers want.
- 6 new tests in `tests/test_compression_policy.py`:
- per-mode field assertions (mirror Rust unit tests)
- `oauth_matches_payg_today` canary for F2.2 divergence
- immutability check (`@dataclass(frozen=True)`)
- field-set parity guard against Rust + Python drift
- 2 new tests in `tests/test_cache_aligner_detector_only.py`:
- subscription policy short-circuits should_apply
- PAYG policy does NOT short-circuit (sanity)
Note: this commit does NOT yet plumb the policy from the proxy
handlers into `pipeline.apply(...)` kwargs. That happens in c5/5
alongside the flag default flip. Until c5/5 lands, the gate is
reachable but unfired — `kwargs["compression_policy"]` is absent
from every production call site, so `policy.get(...)` returns None
and current behaviour is preserved bit-for-bit.
Verified: 28/28 affected Python tests pass.
Phase F2.1, commit 3 of 6. Behind a default-disabled gate, no
behaviour change until commit 6 flips the default.
What lands:
- New `AuthModePolicyEnforcement` clap-friendly enum
(`Enabled`/`Disabled`) in `config.rs`. Pattern follows
`CacheControlAutoFrozen` and `StripInternalHeaders`: ValueEnum
derive, snake_case rename, env var
`HEADROOM_PROXY_AUTH_MODE_POLICY_ENFORCEMENT`.
- New `Config::auth_mode_policy_enforcement` field, wired through
`from_cli` and `for_test`. `for_test` defaults to `Disabled` so
every existing test stays green without per-test opt-out (F2.1's
own integration tests opt-IN per case).
- Proxy entry in `proxy.rs::proxy_request` reads the flag and gates
the policy derivation: `Enabled` -> `CompressionPolicy::for_mode(auth_mode)`
(the real per-mode value); `Disabled` -> forces
`CompressionPolicy::for_mode(AuthMode::Payg)`. Either way the
policy is stored in extensions; c4/6's dispatcher gate reads from
there without re-checking the flag.
- The `policy_selected` debug log now also emits the `enforcement`
field so dashboards can split "policy is PAYG because mode is
PAYG" from "policy is PAYG because the flag is off."
Why a flag rather than landing the behaviour change directly:
F2.1 ships in 6 commits. c1-c5 are wiring + Python parity; c6 is
the single commit that flips behaviour for default users. Reviewers
can land c1-c5 freely without worrying about subscription users
seeing the new path before we have telemetry to validate it. An
operator in a dogfood env can opt in early via the env var to
generate that telemetry.
Rollback story: flip env var back to `disabled` (instant if hot-
reload available; else redeploy). c6/6 is the only commit that
needs `git revert` to roll back if the default flip surfaces a
regression.
Note on conventional-commit prefix: this is a Rust-migration
internal-phase commit; using `fix:` rather than `feat:` so
semantic-release does not bump the package minor version on the
phase plumbing. The user-visible behaviour change in c6/6 is the
appropriate place for `feat:` if anywhere — and even there,
arguably still `fix(proxy):` because the change addresses an
existing user complaint (#327/#388 cache instability) rather than
adding a brand-new capability.
Verified: `cargo check -p headroom-proxy` clean. No tests modified
because the default-disabled path is identical to current main
behaviour - every existing test continues to assert what it asserted
before. c4/6 adds the integration tests that exercise the
enforcement-on path.
Phase F2.1, commit 2 of 6. No behaviour change — wiring only.
Three things land:
1. **Proxy entry derives the policy alongside auth_mode** (proxy.rs).
Both go into `req.extensions_mut()` so downstream stages can read
either without re-classifying. New structured log event
`policy_selected` fires once per request with auth_mode +
live_zone_only + cache_aligner_enabled — gives F2.2 the bake-time
data it'll need to tune. Pure addition; no existing log was
removed.
2. **OpenAI live-zone dispatchers stop hard-coding `AuthMode::Payg`.**
`compress_openai_chat_request` and `compress_openai_responses_request`
already received `auth_mode` from the proxy caller (F1's plumbing),
but the dispatcher invocation hard-coded `AuthMode::Payg` and
ignored the parameter — c.f. the `_auth_mode` underscore-prefixed
identifier in `compress_openai_chat_live_zone` upstream. Now the
classified mode is forwarded via `auth_mode.into()` (uses the
`From<>` impl added in c1/6 to bridge the two `AuthMode` enums).
3. **Anthropic live-zone dispatcher gets the same fix.**
`compress_anthropic_request` was the closest to right — it threaded
auth_mode all the way to `compress_anthropic_live_zone` already —
but the inner call still had `AuthMode::Payg` hard-coded. Now it
uses `auth_mode.into()` for symmetry and so the F2.1 dispatcher-
level gate (added in c4/6) reads the real mode.
The dispatcher is unchanged in F2.1: it still runs the same
compression for every mode. The point of c2/6 is that *if* a future
commit (c4/6 in this PR, or anything post-F2.1) gates on the
mode-aware policy, the wiring is already in place. PAYG behaviour is
byte-for-byte identical because every mode currently dispatches
identically.
Two tiny cleanups: drop the `AuthMode` import from the three live-zone
dispatcher files now that the value flows in via `.into()` (the
`RequestAuthMode` alias remains, since it's still in the function
signatures).
Behaviour-change risk in c2/6: zero. Verified by running the full
`cargo test -p headroom-proxy` suite; existing tests pass without
modification because they're already shape-compatible (tests pass
`AuthMode::Payg` directly into the inner dispatcher; this commit
only changes how the *outer* compress_* fns invoke that dispatcher).