Derive source-tree versions from release history so headroom --version no longer reports stale project metadata.
Restart stale idle proxies after an upgrade, but leave active sessions running to avoid interrupting ongoing conversations.
Remove _version.py from version-sync ownership and make version verification catch package/plugin manifest drift.
compression_units.py:
- Replace dict-unpacking pattern with dataclasses.replace() so mypy can
type-check fields. The `**base` form forced mypy to infer
`dict[str, object]`, which doesn't satisfy the per-field types of
UnitCompressionResult (46 arg-type errors).
- Use `isinstance(candidates, Iterable)` for the transform-iteration
guard. The previous `iter()` call had a `# type: ignore[arg-type]`
that was misclassified — mypy actually emits `call-overload` here.
live_zone_thresholds.rs:
- Update the JsonArray threshold assertion from 1024 to 512 to match
the new constant. eaf5980 lowered THRESHOLD_JSON_ARRAY from 1024 → 512
in live_zone.rs but missed this integration test.
d9d8972 wired auto-MCP registration into ``init`` so ``[Retrieve
more: hash=…]`` markers stay live for users who never ran
``headroom mcp install`` separately, but the ``seq_claude_local``
e2e assertion was still pinned to the pre-MCP two-command sequence
and failed in docker-native-e2e on main.
The ``-e HEADROOM_PROXY_URL=…`` arg is only emitted when the proxy
port differs from the 8787 default; this case sets ``--port 9011``,
so the env arg is included in the expected argv.
The per-chunk SSE parser only flushes events terminated by `\n\n`.
When upstream truncates mid-event (client disconnect, network drop,
RemoteProtocolError), the message_start (cache_read /
cache_creation) or message_delta (output_tokens) usage events sit
in the residual sse_buffer and never get parsed — the finalizer
then logs cache_read=cache_write=0, which the freeze heuristic on
the next request reads as "no provider cache, reprocess
everything," producing a different prefix and a real cache miss
on the *next* turn.
Append `\n\n` to the residual buffer at end-of-stream so the
existing parser drains the partial event. Only fills None / 0
slots so a real cache_read=0 from earlier in the stream isn't
clobbered.
The proxy compresses tool_result payloads and emits [Retrieve more: hash=…]
markers, but Claude Code / Codex had no headroom_retrieve tool to call on
those markers unless the user separately ran 'headroom mcp install'. The
markers were dead pointers — silent quality loss.
Adds a per-agent MCP registrar abstraction (mcp_registry/) and wires it
into wrap and init so MCP install happens automatically alongside rtk:
- mcp_registry/base.py — MCPRegistrar ABC, ServerSpec, RegisterResult,
RegisterStatus enum.
- mcp_registry/claude.py — Claude Code registrar (claude mcp add CLI
with .claude.json / mcp.json file fallback).
- mcp_registry/codex.py — OpenAI Codex registrar (marker-delimited TOML
block edits to ~/.codex/config.toml; preserves user's other config).
- mcp_registry/install.py — install_everywhere() orchestrator with
detect-then-register semantics.
- mcp_registry/display.py — shared format_result()/format_results() for
consistent CLI output across wrap, init, and 'headroom mcp install'.
Adding a new agent (Cursor, Continue, Cline, Windsurf, Goose) is now a
single new file plus one entry in get_all_registrars(); call sites and
display logic don't change.
Test seam is constructor injection (home_dir, claude_cli) — zero patches
in 66 new tests across the registry. Removed 13 brittle CLI integration
tests in test_mcp.py that were patching module-level globals; equivalent
coverage now lives at the registrar/orchestrator layer.
wrap codex: snapshot ~/.codex/config.toml at the top of the command so
the existing wrap→unwrap round-trip captures the true pre-wrap state
even though MCP install now writes to the same file mid-flow.
220 tests pass (66 new + 154 existing CLI + integration). ruff and mypy
clean on touched files.
PR #431 (merged) added text-block compression to support DeepSeek + Cline,
but the gate ("skip user/system") leaves assistant text blocks compressible
by default. Assistant content is echoed back by the client in subsequent
turns and becomes part of the upstream provider's prefix cache (Anthropic
explicit cache_control, DeepSeek/OpenAI auto-prefix). Compressing it
silently changes the bytes the next turn must match for a cache hit —
turning a 90% read discount into a 25% write penalty on Anthropic, or a
full prefill on DeepSeek/OpenAI when the in-process result cache evicts
or differs across restarts.
Re-aligns the design around prefix-cache safety:
* Block-level cache_control protection (defense in depth). Any block
carrying cache_control is the client's explicit cache breakpoint;
never modified, regardless of role or block type. Closes the gap
that frozen_message_count alone leaves — that count is a coarse
message-level approximation; this is the per-block guarantee.
Applies to both tool_result and text paths.
* compress_assistant_text_blocks defaults to False (off). Assistant
text blocks are skipped by default, restoring pre-#431 cache safety
for Anthropic flows. Per-request opt-in via kwargs (or via
ContentRouterConfig.compress_assistant_text_blocks for deployment-
wide enable) preserves the Cline + DeepSeek goal — only enable
when the backend doesn't honor cache_control AND compression is
deterministic enough that the auto-prefix cache still hits across
eviction/restart.
* Unknown roles default-skip too (was: compressed). developer/judge/
custom roles are safer to leave untouched than to compress
aggressively without thinking through their cache semantics.
* Online streaming usage parser. Replaces the per-stream
list[bytes] buffer with a single last_completion_tokens int updated
per chunk via a module-level _parse_completion_tokens_from_sse_chunk
helper. Streaming memory is now O(1) regardless of stream length —
important for 200K-output reasoning models and DeepSeek V4 Pro's
384K max output.
* Renames the unused min_tokens parameter to min_chars (the threshold
has always been chars, not tokens, in both the tool_result and text
paths). Now also wired through ContentRouterConfig
.min_chars_for_block_compression so the threshold is configurable
per Realignment build constraints.
Tests:
* 17 new tests in tests/test_transforms_content_router.py covering
the role matrix (user / system / assistant / tool / unknown),
cache_control protection on both paths, opt-in semantics, the
min_chars threshold, and idempotent pinning detection.
* 9 new tests in tests/test_streaming_usage_parser.py covering the
online parser's success and edge cases (usage frame, [DONE],
invalid JSON, multi-frame chunks, zero tokens, non-dict payloads,
invalid UTF-8).
Trade-off: deployments pointed at non-cache-aware backends (DeepSeek
direct, OpenAI direct) lose blanket assistant-text compression by
default — they opt in via config. Anthropic flows go back to being
prefix-cache-safe out of the box.
ContentRouter._process_content_blocks previously only compressed tool_result
blocks. Anthropic-format requests routed through the OpenAI/DeepSeek backend
arrive with text blocks in their content lists; those were passing through
unchanged, so DeepSeek + Cline saw zero compression. Adds text-block
handling with role-based protection (user/system text blocks are skipped so
the user's actual prompt is never silently mutated) and reuses the existing
two-tier compression cache.
Streaming OpenAI-via-backend path now buffers chunks to parse the final
SSE usage frame for completion_tokens, forwards waste_signals through
metrics + RequestLog, and emits a RequestLog entry so the dashboard's
recent-requests feed and "What Headroom Removed" stop being empty for
this code path. Same RequestLog wiring added to the non-streaming
backend path, which previously logged nothing at all.
DeepSeek V4 entries added to the model registry and OpenAIProvider
context limits with values verified against api-docs.deepseek.com
(1M context / 384K max output) and LiteLLM model_cost (deprecated
deepseek-chat / deepseek-reasoner aliases at 131K). LiteLLM lookup
remains the first source; these are the manual fallback that suppresses
the unknown-model warning.
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.
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)