Commit graph

1252 commits

Author SHA1 Message Date
Tejas Chopra
44231f68cd
Merge pull request #447 from chopratejas/serena-mcp-wrap-default
fix: register serena mcp during wrap
2026-05-09 23:20:10 -07:00
Tejas Chopra
a7160f7eab fix: register serena mcp during wrap 2026-05-09 23:05:59 -07:00
Tejas Chopra
9577ef811b
Merge pull request #444 from chopratejas/fix/learn-cross-platform-paths
fix: harden learn path handling across platforms
2026-05-09 22:34:26 -07:00
Tejas Chopra
22e750c78f Fix learn CLI worker expectation in CI 2026-05-09 22:11:42 -07:00
Tejas Chopra
2732ae7d82
Merge pull request #445 from chopratejas/fix/version-aware-proxy-upgrade
fix: make proxy upgrades version-aware
2026-05-09 18:26:16 -07:00
Tejas Chopra
155c069176 fix: stabilize learn write-failure worker test
Pin os.cpu_count in the write-failure regression test so the expected default worker cap is independent of the GitHub runner CPU count.
2026-05-09 16:00:10 -07:00
Tejas Chopra
ea1f608e79 fix: make proxy upgrades version-aware
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.
2026-05-09 15:58:27 -07:00
Tejas Chopra
5ceca13c65 fix: harden learn path handling across platforms 2026-05-09 15:45:26 -07:00
Tejas Chopra
4a88c698ad
Merge pull request #437 from chopratejas/fix/codex-wrap-proxy-mcp-sync
Sync Codex MCP proxy config during wrap
2026-05-09 15:19:29 -07:00
Tejas Chopra
17ffae0cd8 fix: clear CI mypy + rust test failures introduced in eaf5980
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.
2026-05-09 15:05:29 -07:00
Tejas Chopra
c7af8307d1 Merge branch 'main' into fix/codex-wrap-proxy-mcp-sync
Resolve conflict in tests/test_proxy_streaming_request_logger.py by
keeping both new tests:
- test_finalize_openai_responses_stream_uses_provider_usage_for_dashboard
  (this branch — OpenAI Responses stream dashboard usage)
- test_finalize_stream_response_recovers_usage_from_truncated_buffer
  (main — SSE buffer recovery on truncated streams)

streaming.py auto-merged cleanly (additive only).
2026-05-09 14:17:40 -07:00
Tejas Chopra
359e8c9a5b fix: align docs and prune dead compatibility surfaces 2026-05-09 14:07:59 -07:00
Tejas Chopra
eaf5980b4a fix: stabilize codex compression, stats, and proxy lifecycle 2026-05-09 13:47:53 -07:00
Tejas Chopra
ac1d11c9a2 fix: sync Codex MCP proxy config during wrap 2026-05-08 19:14:07 -07:00
Tejas Chopra
b166bd3d6a
Merge pull request #436 from chopratejas/fix/sse-usage-recovery-on-truncated-stream
fix(proxy): recover SSE usage from truncated final event
2026-05-08 18:40:19 -07:00
Tejas Chopra
dcc004234c
Merge pull request #435 from chopratejas/fix/init-e2e-claude-local-mcp-add-expectation
test(init-e2e): expect mcp add in seq_claude_local install sequence
2026-05-08 18:03:39 -07:00
Tejas Chopra
8714c64a20 test(init-e2e): expect mcp add in seq_claude_local install sequence
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.
2026-05-08 17:38:34 -07:00
Tejas Chopra
d91254a0f2 fix(proxy): recover SSE usage from truncated final event
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.
2026-05-08 17:23:10 -07:00
Tejas Chopra
d9d8972ac4 fix(mcp): auto-register headroom MCP server in wrap claude/codex and init -g
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.
2026-05-08 17:18:32 -07:00
Tejas Chopra
7afb30186a
Merge pull request #433 from chopratejas/rtk-dashboard-compression-savings
Combine rtk and compression savings in dashboard
2026-05-08 15:36:50 -07:00
Tejas Chopra
ecb3e00451
Merge pull request #434 from chopratejas/fix/cache-safe-text-block-compression
fix(content-router,proxy): cache-safe text-block compression and online streaming usage
2026-05-08 15:36:33 -07:00
chopratejas
79baee082f fix(content-router,proxy): cache-safe text-block compression and online streaming usage
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.
2026-05-08 15:20:54 -07:00
Tejas Chopra
d322e6df1f
Merge pull request #431 from chopratejas/fix/deepseek-text-block-compression-and-streaming-metrics
fix(content-router,proxy): compress text blocks + close DeepSeek metrics gaps
2026-05-08 14:48:20 -07:00
Tejas Chopra
6eec38767b Combine rtk and compression savings in dashboard 2026-05-08 14:46:54 -07:00
Tejas Chopra
7c94e72841
Merge pull request #430 from chopratejas/codex-wire-debug-ws-metrics
fix: add Codex wire debug and WS usage metrics
2026-05-08 14:34:54 -07:00
Tejas Chopra
d955abb1d6 fix(content-router,proxy): compress text blocks + close DeepSeek metrics gaps
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.
2026-05-08 14:33:50 -07:00
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
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