Commit graph

2630 commits

Author SHA1 Message Date
Tejas Chopra
2b331e297a fix: add Codex wire debug and WS usage metrics 2026-05-08 13:53:52 -07:00
chopratejas
5dd2ac5e51 fix(cli): resolve duplicate --code-aware flag breaking proxy import
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.
2026-05-08 11:43:59 -07:00
Tejas Chopra
2f982ade0e
Merge pull request #411 from manorit2001/wip
export code-aware flag in proxy
2026-05-08 10:24:22 -07:00
Tejas Chopra
c6ecdc4299
Merge pull request #427 from mbachaud/fix/vertex-dead-code-and-body-limit
fix(vertex,bedrock): remove dead handle_raw_predict, honour X-Forwarded-Proto, cap Bedrock body size
2026-05-08 10:24:12 -07:00
Tejas Chopra
399cb32fa4
Merge pull request #426 from mbachaud/fix/bedrock-utf8-slice-panic
fix(bedrock): use floor_char_boundary to avoid UTF-8 slice panic in header preview
2026-05-08 10:24:02 -07:00
SwiftWing21
b784f400c1 fix(bedrock): use is_char_boundary loop instead of floor_char_boundary (MSRV 1.80)
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.
2026-05-07 21:24:41 -07:00
SwiftWing21
28b2bacdf6 fix(vertex,bedrock): remove dead handle_raw_predict, honour X-Forwarded-Proto, cap Bedrock body size
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
2026-05-07 21:22:57 -07:00
SwiftWing21
42ff8afa90 fix(bedrock): apply rustfmt to header_value_preview tests 2026-05-07 21:20:18 -07:00
SwiftWing21
82468e4e99 fix(bedrock): use floor_char_boundary to avoid UTF-8 slice panic in header preview
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
2026-05-07 21:13:00 -07:00
Tejas Chopra
cd89a8297a
Merge pull request #423 from chopratejas/hotfix-sdist-test-followup
fix(ci): update sdist license-packaging invariant test to match new shape
2026-05-07 17:19:44 -07:00
chopratejas
37c0df23b6 fix(ci): update sdist license-packaging invariant test to match new shape
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
2026-05-07 17:16:34 -07:00
Tejas Chopra
9ff28e9803
Merge pull request #422 from chopratejas/fix-user-experience-and-feature-clarity
fix(cli): proxy/perf/wrap UX cleanup + perf --hours correctness
2026-05-07 16:46:55 -07:00
chopratejas
265554d4ad fix(cli): proxy/perf/wrap UX cleanup + perf --hours correctness
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.
2026-05-07 16:43:35 -07:00
Tejas Chopra
fc6b0a0917
Merge pull request #421 from chopratejas/hotfix-sdist-notice
fix(ci): include NOTICE in sdist + assert License-File metadata matches tarball
2026-05-07 16:40:55 -07:00
chopratejas
183d51c8a8 fix(ci): include NOTICE in sdist + assert License-File metadata matches tarball
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
2026-05-07 16:29:05 -07:00
Tejas Chopra
7aaa4ac48f
Merge pull request #420 from chopratejas/fix-codex-responses-pyo3-and-frozen-count
fix: re-enable Codex /v1/responses compression + fix prose-format over-freeze
2026-05-07 15:36:31 -07:00
chopratejas
89f7b6c2dd fix: complete /v1/responses compression telemetry, multi-frame WS, frozen-count cap
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.
2026-05-07 14:50:03 -07:00
chopratejas
be5d9f7baa fix(tests): update CacheAligner detector-only tests for F2.2 5-field CompressionPolicy
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.
2026-05-06 14:39:39 -07:00
chopratejas
5b38cbf8a7 fix(transforms): F2.2 c2/3 — wire toin_read_only gate + extend policy_selected log
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)
2026-05-06 14:37:33 -07:00
chopratejas
797dc63da7 fix(core): F2.2 c1/3 — extend CompressionPolicy with three per-mode tuning fields
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)
2026-05-06 14:37:33 -07:00
chopratejas
665c0823d9 fix(proxy): F4 — trust X-Forwarded-* only behind allow-listed gateway
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.
2026-05-06 14:37:09 -07:00
Tejas Chopra
f94a9d22ba
Merge pull request #412 from chopratejas/fix-pypi-skip-existing
fix(ci): pypi publish skip-existing to unblock idempotent re-runs
2026-05-06 14:22:29 -07:00
chopratejas
6a191b405b fix(ci): pypi publish skip-existing to unblock idempotent re-runs
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
2026-05-06 14:04:00 -07:00
Tejas Chopra
1050e00241
Merge pull request #410 from chopratejas/hotfix-ws-responses-compression
fix(proxy): inline WebSocket /v1/responses compression via PyO3
2026-05-06 13:51:46 -07:00
Manorit Chawdhry
c07c72de3c docs: surface code-aware proxy flags
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>
2026-05-07 00:18:50 +05:30
Manorit Chawdhry
7d99a71285 fix: expose code-aware flag
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>
2026-05-07 00:18:50 +05:30
chopratejas
4a50313548 fix(proxy): inline WebSocket /v1/responses compression via PyO3
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)
2026-05-06 11:10:24 -07:00
Tejas Chopra
1a3098a48f
Merge pull request #406 from chopratejas/hotfix-codex-responses-pyo3
fix(proxy): hot-fix Codex /v1/responses compression via PyO3 inline call
2026-05-06 10:56:04 -07:00
Tejas Chopra
6c4ddc824e
Merge pull request #409 from JerrettDavis/fix/codex-config-bug-3-strip-auth-injection
fix(codex): bug 3 — strip requires_openai_auth, restore consistent openai_base_url injection
2026-05-06 10:55:49 -07:00
JerrettDavis
4f654212d5 style(ci): apply ruff format to bug-3 fix files
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>
2026-05-06 12:23:10 -05:00
JerrettDavis
4071d57134 fix(ci): update tests to assert absence of requires_openai_auth (bug 3, #406)
- 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>
2026-05-06 12:18:47 -05:00
JerrettDavis
32f499cbba fix(codex): drop env_key from provider blocks to preserve subscription auth
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.
2026-05-06 11:36:03 -05:00
JerrettDavis
bf1e31b27c fix(codex): inject openai_base_url in init and persistent-install paths
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>
2026-05-06 11:32:12 -05:00
JerrettDavis
d54c5b6a58 fix(codex): restore openai_base_url top-level injection for subscription routing
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>
2026-05-06 11:32:12 -05:00
JerrettDavis
1c6ae45603 test(codex): add regression tests for bug 3 config injection
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>
2026-05-06 11:32:12 -05:00
JerrettDavis
09b851001b fix(codex): strip requires_openai_auth and openai_base_url injection (bug 3, #406)
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>
2026-05-06 11:32:12 -05:00
Tejas Chopra
a7e1f931a5
Merge pull request #407 from danmunoz/fix/pipx-python314-install-message-docs
fix: harden release gating and clarify pipx Python compatibility
2026-05-06 08:04:03 -07:00
Daniel Munoz
9492386398 fix: harden release gating and clarify pipx compatibility 2026-05-06 12:41:17 +02:00
chopratejas
c0baaf052f fix(proxy): wire PyO3 compression into /v1/responses handler (hot-fix c2/2)
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.
2026-05-05 23:10:59 -07:00
chopratejas
c48735d029 fix(core): expose compress_openai_responses_live_zone via PyO3 (hot-fix c1/2)
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.
2026-05-05 23:10:22 -07:00
Tejas Chopra
7e29a60b6f
Merge pull request #401 from JerrettDavis/fix/issue71-regression
fix: preserve Codex OAuth provider config
2026-05-05 21:37:45 -07:00
JerrettDavis
06428d20fd fix: preserve Codex OAuth proxy delivery
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>
2026-05-05 21:03:42 -05:00
Tejas Chopra
3b1ee2e4af
Merge pull request #400 from chopratejas/realign-F2_1-policy-from-auth-mode
fix(proxy): F2.1 — per-auth-mode CompressionPolicy gates
2026-05-05 18:14:15 -07:00
chopratejas
a281de6bb0 fix(ci): skip smoke OpenAI eval cleanly when OPENAI_API_KEY secret unset
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)
2026-05-05 18:07:15 -07:00
chopratejas
708b87a19f fix(proxy): hoist compression_policy outside is_token_mode branch (F2.1 c5 followup)
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
2026-05-05 17:22:12 -07:00
chopratejas
0546795547 fix(proxy): rustfmt drift in live_zone_anthropic imports (F2.1 c2 followup)
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
2026-05-05 17:22:12 -07:00
chopratejas
0fef428f1f fix(proxy): wire CompressionPolicy through handlers + flip default enabled (F2.1 c5/5)
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
2026-05-05 17:22:08 -07:00
chopratejas
de8e245990 fix(transforms): Python parity port of CompressionPolicy + cache_aligner gate (F2.1 c4/5)
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.
2026-05-05 16:49:50 -07:00
chopratejas
027b203c23 fix(proxy): add auth_mode_policy_enforcement feature flag (F2.1 c3/6)
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.
2026-05-05 16:42:22 -07:00
chopratejas
948c8f2069 fix(proxy): plumb CompressionPolicy through proxy + dispatchers (F2.1 c2/6)
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).
2026-05-05 16:39:34 -07:00