Commit graph

1312 commits

Author SHA1 Message Date
Tejas Chopra
2febf1bbbf
Merge pull request #475 from chopratejas/fix/wrap-e2e-startup-timeout
fix(tests): widen wrap-e2e openclaw startup timeout to 30s
2026-05-14 21:02:31 -07:00
Tejas Chopra
3762f6375c
Merge pull request #473 from chopratejas/refactor/request-outcome-funnel
refactor(proxy): introduce RequestOutcome funnel; collapse 3 streaming finalizers
2026-05-14 21:01:18 -07:00
chopratejas
2ae88a6874 fix(tests): widen wrap-e2e openclaw startup timeout from 5s to 30s
The wrap-e2e harness passed `--startup-timeout-ms 5000` to `headroom
wrap openclaw`, leaving zero slack for the openclaw plugin's auto-start
launcher to bring up the headroom proxy before the 5s health-check
deadline. On a busy CI runner, cold Python import of `headroom.cli` plus
pyo3 dlopen plus FastAPI app boot routinely lands in the 4–8s range, so
this was always a coin-flip.

Evidence: run 25897154424 failed on main with the exact code that
passed pre-merge on PR #474's docker-wrap-e2e check (run 25897085244).
Both logs show identical openclaw "Config warnings" output — that's
normal noise, not the cause. The differentiating line is
`[plugins] Headroom proxy started and reachable` (pass) vs
`[plugins] Headroom proxy unavailable: health check failed` (fail).

30s matches what other wrap-e2e callers already use as a working margin
for the headroom proxy boot path; the runtime default for `headroom
wrap openclaw --startup-timeout-ms` is 20s.
2026-05-14 20:00:29 -07:00
chopratejas
96a674f1b9 feat(proxy): add client (harness) identification — per-harness analytics across every handler
Cashes in the RequestOutcome refactor with a typed-field surface that
gives EVERY handler per-harness visibility — Codex / Claude Code /
aider / Cursor / Zed / opencode / DROID / antigravity / etc. — for one
field-add across the contract.

The one-field-add proof

Headroom went from "what fraction of OUR requests come from which
harness?" being unanswerable (handlers logged ad-hoc User-Agent strings
in heterogeneous tag dicts at 18 sites, with 9 of 18 not even
populating them) to one structured ``client: str | None`` value on
every observation flowing through the funnel. No new bookkeeping at
call sites; every handler picks it up via a single
``classify_client(headers)`` call at request entry.

Implementation

* New ``CLIENT_UA_MAP`` + ``classify_client()`` in
  ``headroom/proxy/auth_mode.py``. Substring match against
  User-Agent; ``X-Client`` header overrides UA. Returns ``str | None``
  so ``None`` is the loud "unidentified" signal rather than a silent
  empty bucket.
* New ``RequestOutcome.client: str | None = None`` field.
* Funnel updates (in ``outcome.py``):
  - Appends ``client=X`` to the PERF log line ONLY when set, so
    ``headroom perf --client X`` parsing stays clean for
    unidentified traffic (no bogus ``client=`` token).
  - Copies ``client`` into ``RequestLog.tags["client"]`` so the
    dashboard's existing tag-based filtering surfaces per-harness
    slicing with zero new columns.
* Every handler that constructs a RequestOutcome now passes
  ``client=client`` — wired across streaming.py (3 finalizers,
  with ``_finalize_stream_response`` gaining a new optional kwarg
  since it doesn't have direct access to headers), anthropic.py
  (6 sites), openai.py (8 sites including Codex WS), gemini.py
  (2 emitting sites), batch.py (5 sites).

Harnesses recognised

  Anthropic ecosystem:  claude-code, claude-cli, claude-vscode,
                        anthropic-cli
  OpenAI ecosystem:     codex-cli
  Editors:              cursor, zed
  AI coding harnesses:  aider, droid, opencode, github-copilot
  Other:                antigravity (Google experimental)

Adding a new client is a one-line edit to ``CLIENT_UA_MAP``.

Tests

* 8 new tests in ``test_request_outcome.py`` covering:
  - ``client`` field round-trips on the value type
  - ``classify_client`` against every recognised UA prefix
  - ``X-Client`` header override beats UA match
  - ``None`` for unknown traffic (the loud signal)
  - Funnel appends ``client=X`` to PERF when set
  - Funnel OMITS ``client=`` from PERF when None (no bogus empty)
  - Funnel stamps ``client`` into ``RequestLog.tags``
* All 228 existing tests still pass (full sweep across streaming,
  cache, Codex, Anthropic, OpenAI, Gemini, batch, auth-mode).
* ruff + ruff-format + mypy clean.

What's now true that wasn't before

Once this lands, the dashboard can answer:

* "Show me cache hit rate by harness"
  → ``GROUP BY tags.client FROM request_log``
* "Which harness contributes the most cache writes?"
  → same
* "Per-harness savings ratio"
  → same
* ``headroom perf --client codex`` / ``--client claude-code``
  → analyzer filters PERF log lines on ``client=X`` token

Zero new bookkeeping in handlers. Zero changes to Prometheus label
cardinality (kept the client dimension out of Prometheus on purpose —
the tags route is the right surface). The "what's our traffic split
by harness?" question is now answerable in three places (PERF log,
RequestLog tags, dashboard widgets that already filter on tags)
without any per-provider work.
2026-05-14 19:39:35 -07:00
chopratejas
299d995066 test: wire test dummies to RequestOutcome funnel for batch + copilot-auth paths
Full regression sweep found 7 failures in test dummies (out of 4242
tests) that didn't have the production handler interface my refactor
now requires. All same root cause: the dummies need
``_record_request_outcome`` to delegate to the funnel; the
copilot-auth passthrough dummy also needs ``_next_request_id``
because the migrated passthrough handler now allocates an ID at
record-time.

Failures:
  test_proxy_handlers_batch.py (6 sites — all DummyBatchHandler)
  test_proxy_copilot_auth_hooks.py (1 site — Dummy in passthrough test)

Fix is the same pattern used in the earlier dummy fixes
(test_anthropic_pre_upstream_backpressure, test_openai_codex_routing,
test_openai_codex_ws_lifecycle):

    async def _record_request_outcome(self, outcome):
        from headroom.proxy.outcome import emit_request_outcome
        await emit_request_outcome(self, outcome)

After the fix: 22 / 22 in the previously-failing tests; full
regression sweep 4242 / 4242 with zero failures (179 skipped, all
opt-in real-API).

Unrelated env issues observed in the same sweep but skipped:
* tests/test_memory/* — huggingface-hub<2.0 / transformers version
  drift in local venv. Pre-existing, not caused by this refactor.
* tests/integrations/* — same env class.
* tests/test_realignment_live_multi_turn.py — opt-in live tests
  needing API keys.
2026-05-14 19:39:15 -07:00
chopratejas
993c9076f9 refactor(proxy): migrate Codex WS + OpenAI HTTP + batch handlers; delete Databricks
Completes the migration of every ``metrics.record_request`` call site
in ``headroom/proxy/handlers/`` onto the canonical funnel. After this
commit, **zero ad-hoc record_request calls remain** across the entire
handler subtree. Every request — regardless of provider, harness, or
transport — flows through ``emit_request_outcome``.

Migrated sites (this commit):

* **handle_openai_responses_ws** (Codex WS) — 2 sites:
  - per-turn record (per ``response.completed``)
  - session-end residual (leftover tokens not captured per-turn)
  Pre-refactor these sites emitted only metrics + cost_tracker — no
  RequestLog, no PERF — so Codex traffic was invisible to
  ``headroom perf`` and the recent-requests feed. Funnel restores all
  four effects uniformly per turn. (Closes the visibility half of
  what #471's sibling PR addressed for the scheduler half.)
  The explicit session-summary RequestLog at session-end stays as a
  separate explicit log entry — it's a session-cumulative summary,
  distinct from per-turn observations.

* **handle_openai_chat** — 3 sites:
  - response-cache hit (uses ``from_response_cache=True``)
  - backend-routed (LiteLLM/AnyLLM) non-streaming success
  - direct OpenAI non-streaming success

* **handle_openai_responses** HTTP (Codex HTTP transport) — 1 site

* **handle_passthrough** (OpenAI passthrough endpoints) — 1 site

* **batch.py** handlers — 5 sites:
  - handle_google_batch_create
  - handle_google_batch_passthrough (Files API forward)
  - handle_google_batch_passthrough (list/get/cancel)
  - handle_google_batch_results (CCR-processed)
  - handle_batch_create (OpenAI batches)
  All converge on the funnel. Several gain request_id allocation
  they didn't have before (passthrough sites previously emitted
  ``request_id=None`` in logs).

**Deleted: handle_databricks_invocations + its route + test cases.**

Databricks was a 57-line thin wrapper at openai.py that parsed JSON,
injected the model from URL into body, and delegated to
``handle_openai_chat``. It enabled
``databricks serving-endpoints query <model> --profile HEADROOM``
direct CLI use. No evidence of active users (no docs, no issues, no
mentions). Databricks-hosted models still work via the standard
``/v1/chat/completions`` surface; LiteLLM has its own Databricks
support too. If a user complains, this PR is a 30-minute revert.

Architectural note: also updated 2 more test dummies
(``_DummyOpenAIHandler`` in routing + WS lifecycle tests) to bind
``_record_request_outcome`` via the free function
``emit_request_outcome`` — same pattern as ``_run_compression_in_executor``.

Final migration tally (from P0 audit + extensions):

* **18 audit sites** + **5 batch.py sites discovered during migration** = 23 sites migrated
* **1 site deleted** (Databricks)
* **0 sites remaining** anywhere under ``handlers/``

Surface impact (this commit):

* openai.py: −168 LOC (315 deletions − 147 insertions)
* batch.py: +35 LOC (124 ins − 89 del; mostly comments)
* proxy_routes.py: −4 LOC (Databricks route gone)
* tests: +11 LOC (dummy `_record_request_outcome` bindings, 2 sites)
* Net: ~−126 LOC in production handler code

Tests
* All 157 existing streaming/cache/Codex/anthropic/openai/backpressure/
  routes tests pass with zero regressions.
* ruff + ruff-format + mypy clean.

This brings the cumulative refactor delta (across all 3 commits on
this branch) to:

  contract introduced (outcome.py + funnel):      ~+200 LOC fixed cost
  handler migrations (streaming + anthropic +
    gemini + openai + batch + WS):                 ~−700 LOC
  Databricks deletion:                              −57 LOC
  ──────────────────────────────────────────────  ─────────
  Net production code delta:                       ~−557 LOC

  Plus +474 LOC of test coverage (RequestOutcome unit tests +
  funnel contract assertions).

  And every handler now emits identical observable outputs per
  request: same metrics shape, same cost_tracker shape, same
  RequestLog shape, same PERF format. The wire is uniform.
2026-05-14 19:39:15 -07:00
chopratejas
7aca00445e refactor(proxy): migrate Gemini + Anthropic non-streaming onto outcome funnel
Builds on the RequestOutcome contract introduced in the previous commit.
This commit collapses **8 more record_request sites** across two
providers, demonstrating that the contract works across the
provider-shape diversity it was designed for:

* `handle_gemini_generate_content` (1 site) — read-only cache, no
  write counter, no TTL splits. The funnel's optional fields default
  to 0 for everything Gemini doesn't have; no special-casing needed.
* `handle_gemini_count_tokens` (1 site) — sizing helper, no output
  tokens, no cache. Funnel handles the "minimal observation" shape
  with zero ceremony.
* `handle_anthropic_messages` — **6 sites collapse to 1 funnel call
  per site**, including the response-cache-hit path, the
  Bedrock/Vertex non-streaming backend path, the main native
  Anthropic non-streaming path, and three batch handlers
  (create / passthrough / CCR-processed results).

Bug fixes that fall out of the migration:

* The non-streaming Anthropic main site was missing
  `attempted_input_tokens=` (one of the 7-of-18 sites flagged in the
  P0 audit). Dashboards showing 0% active-savings on non-streaming
  Anthropic traffic will now show the correct ratio (= #454/#455
  silently retired for this surface).
* Bedrock/Vertex non-streaming site was missing cache args entirely,
  hardcoding `cache_hit=False` on RequestLog. Now `cache_hit` is
  derived from the outcome correctly. Cache extraction itself is
  still a follow-up — but the wire shape is now uniform.
* Three batch handlers (create / passthrough / CCR-processed) were
  emitting only `record_request` — no RequestLog, no PERF log. They
  now flow through the canonical funnel so batch traffic appears in
  `headroom perf` and the recent-requests feed for the first time.

Architectural changes:

* **Extracted the funnel from `HeadroomProxy._record_request_outcome`
  into a free function `emit_request_outcome(handler, outcome)`** in
  `outcome.py`. The proxy method becomes a thin two-line wrapper.
  Reason: test dummies (e.g. `_DummyAnthropicHandler` in
  `test_anthropic_pre_upstream_backpressure.py`) need to call the
  funnel from their mixin tests without inheriting from
  `HeadroomProxy`. A free function with structurally-typed `handler`
  arg satisfies both production and test paths without a typing.Protocol
  ceremony.
* **Added `from_response_cache: bool = False` to `RequestOutcome`**
  to model Headroom's semantic-cache hits separately from
  upstream-prompt-cache hits. Both still collapse to the unified
  `cache_hit` derived property for downstream consumers, but
  dashboards can split them. Previously the cache-hit path
  hardcoded `cached=True` to `record_request`; now it's a typed,
  explicit signal.
* **Two batch handlers (`handle_anthropic_batch_passthrough`,
  `handle_anthropic_batch_results`) now allocate a `request_id`** at
  entry. They didn't have one before (they logged
  `request_id=None`), but the funnel requires it. Minor logging
  improvement.

Tests
* `tests/test_anthropic_pre_upstream_backpressure.py::_DummyAnthropicHandler`
  gets a 5-line `_record_request_outcome` that delegates to
  `emit_request_outcome`. Same pattern the dummy uses for
  `_run_compression_in_executor` / `_next_request_id`.
* All 140 streaming/cache/Codex/anthropic/backpressure tests pass:
  - test_request_outcome.py (14)
  - test_backend_streaming_cache_metrics.py (4)
  - test_proxy_streaming_request_logger.py (8)
  - test_proxy_streaming_resilience.py (24)
  - test_proxy_anthropic_cache_stability.py (22)
  - test_anthropic_pre_upstream_backpressure.py (20)
  - test_openai_codex_routing.py (11)
  - test_openai_codex_ws_lifecycle.py (10)
  - test_responses_ws_pyo3_compression.py (27)
* ruff + mypy clean.

Surface impact
* `anthropic.py`: 6 record_request sites → 0 (all go through funnel).
  Net 315 insertions, 273 deletions, but **the insertions are mostly
  comments explaining the migration** — actual code change is closer
  to a net wash. The wins compound in next migrations.
* `gemini.py`: 2 sites → 0. Net +30 LOC (mostly comments).
* `server.py`: −90 LOC (funnel extracted to free function).
* `outcome.py`: +110 LOC (free function + comments).

Remaining migrations from P0 audit §6 (still pending):
* handle_openai_responses_ws (Codex WS, 2 sites)
* handle_openai_chat non-streaming
* handle_openai_responses HTTP
* handle_gemini_stream_generate_content + handle_google_cloudcode_stream
* handle_databricks_invocations
2026-05-14 19:38:54 -07:00
chopratejas
e898f68b89 refactor(proxy): introduce RequestOutcome funnel; collapse 3 streaming finalizers
P0 audit (docs/superpowers/specs/P0-proxy-pipeline-audit.md) catalogued
**18 metrics.record_request call sites** across 4 handler files with **4
distinct argument shapes**: 9 of 18 omitted `cached=`, 7 of 18 omitted
`attempted_input_tokens=` (= bug #454/#455's "headline 0%"), only 4 sites
emitted a `PERF` log line (= bug #327's "msgs=0" sibling — Codex traffic
invisible to `headroom perf`), and `cache_hit` was hardcoded `False` at
9 of 18 RequestLog sites.

The cause was structural, not tactical: every site was independently
deciding what "record this completed request" meant. This PR puts a
single value type + a single function between the handlers and the
metrics layer.

Two new files:

* `headroom/proxy/outcome.py` — `RequestOutcome` frozen dataclass.
  Captures everything we ever need to record about one completed
  request: identity, tokens, cache stats (per-TTL splits + inferred
  flag for OpenAI), timing, transforms, diagnostics. Provider-specific
  fields default to neutral values so non-Anthropic handlers don't have
  to know about 5m/1h splits, non-OpenAI handlers don't have to know
  about inferred writes, etc. Computed properties (`cache_hit`,
  `cache_hit_pct`, `savings_pct`) make "forgot to compute it" mistakes
  structurally impossible.

* `HeadroomProxy._record_request_outcome` in `server.py` — the single
  funnel. Owns the four downstream effects in canonical order:
    1. `metrics.record_request(...)` with the FULL kwarg set
    2. `cost_tracker.record_tokens(...)` with `(model, tokens_saved,
       optimized_tokens)` positional + all cache kwargs
    3. `logger.log(RequestLog(...))` with `cache_hit` correctly derived
    4. structured `PERF` log line in the canonical key=value shape

Migrated three streaming finalizers in this PR:

* `_finalize_stream_response` (Anthropic native + OpenAI HTTP streaming)
* `_stream_response_bedrock` (Bedrock-native Anthropic streaming)
* `_stream_openai_via_backend` (OpenAI/Azure backend via LiteLLM/AnyLLM)

All three previously had inline, drifted versions of the four-call
sequence. Each is now ~70 fewer lines: build a `RequestOutcome` from
local context, call `self._record_request_outcome(outcome)`. The
prefix-tracker mutation (Anthropic-specific) stays outside the funnel —
different concern.

Six more migrations queued for follow-up PRs (handle_anthropic_messages
6 sites, handle_openai_chat, handle_openai_responses, handle_openai_
responses_ws 2 sites, handle_gemini_*, handle_databricks_invocations).
Each is mechanical now.

Tests
* New: `tests/test_request_outcome.py` — 14 tests covering value-type
  contract (frozen, derived properties, neutral defaults) + funnel
  contract (full record_request kwargs, canonical record_tokens shape,
  derived cache_hit in RequestLog, PERF log key=value format,
  optional cost_tracker/logger). Bind the real production method via
  descriptor binding so the test exercises the real implementation, not
  a fork.
* All 135 existing streaming/cache/Codex tests pass with zero
  regressions (`tests/test_backend_streaming_cache_metrics.py`,
  `test_proxy_streaming_request_logger.py`, `test_proxy_streaming_resilience.py`,
  `test_proxy_anthropic_cache_stability.py`, `test_openai_codex_*`,
  `test_responses_ws_pyo3_compression.py`, `test_anthropic_pre_upstream_backpressure.py`).
* `mypy headroom/proxy/{outcome,server,handlers/streaming}.py` clean.
* `ruff check` clean.

Surface impact
* −238 lines from `handlers/streaming.py` (deduplication).
* +92 lines in `server.py` (the funnel — counted ONCE, not 18×).
* +130 lines in new `outcome.py` (frozen dataclass + docstrings).
* Net production code: ~−16 lines today, ~−500 lines after the
  remaining six migrations land.

Forward design constraints (per
docs/superpowers/specs/P0-proxy-pipeline-audit.md §7)
* KISS: one value type, one function, no factory hierarchies.
* No regex in routing — handlers stay provider-specific in their
  upstream contract. Output unification only.
* No silent fallbacks — `cache_hit` is computed, not defaulted.
  `cache_inferred=True` is the loud signal when OpenAI write count
  came from `_infer_openai_cache_write_tokens`.
* PERF format frozen so `headroom/perf/analyzer.py` keeps parsing
  cleanly; P3 follow-up replaces the free-text shape with a
  structured event.
2026-05-14 19:38:54 -07:00
Tejas Chopra
ec8e2080f3
Merge pull request #474 from chopratejas/fix/pypi-wheel-size-cargo-strip
fix(build): shrink Rust extension wheels (strip + thin-LTO + single codegen unit)
2026-05-14 19:35:00 -07:00
chopratejas
d73cbd6b0a fix(build): shrink Rust extension wheels — strip + thin-LTO + single codegen unit
PyPI rejected the v0.21.37 release publish with:

    HTTPError: 400 Bad Request from https://upload.pypi.org/legacy/
    Project size too large. Limit for project 'headroom-ai' total size is 10 GB.

PyPI inventory check confirmed: **191 versions × ~213 MB/release =
10.00 GB exactly** — at the cumulative project storage ceiling. Each
recent release ships 12 wheels × ~16-18 MB each.

Post-mortem inspection of a production wheel
(``headroom_ai-0.21.36-cp311-cp311-manylinux_2_28_x86_64.whl``)
showed the binary was ``not stripped``:

    .text             18.3 MB  (code)
    .rodata           11.4 MB  (Magika model + ONNX runtime data)
    .strtab            4.9 MB  (debug strings — strippable)
    .eh_frame          1.9 MB  (unwind tables)
    .symtab            1.5 MB  (debug symbols — strippable)
    .gcc_except_table  1.2 MB

This commit adds a release profile:

    [profile.release]
    strip      = "symbols"
    lto        = "thin"
    codegen-units = 1

That:
* Strips ``.symtab`` + ``.strtab`` (~6.4 MB direct savings per wheel)
* Enables thin link-time optimization for cross-crate dead-code
  elimination (~5-10% ``.text`` savings)
* Single codegen unit for better inlining + DCE at the cost of
  ~30-50% slower release builds (acceptable for CI)

Deliberately NOT setting ``panic = "abort"``:
* The proxy is a long-lived async process. A panic on one bad
  request triggering process abort would disconnect every concurrent
  client. Accept the smaller savings; keep unwind behaviour.

Estimated impact
* Per wheel: ~16-18 MB → ~10-11 MB (40% smaller)
* Per release (12 wheels): ~213 MB → ~130 MB
* PyPI capacity: ~30+ more releases before hitting 10 GB again

Verification
* Local build of ``headroom._core`` with new profile:
  ``.so`` size 29 MB on macOS arm64 (was ~45 MB pre-fix; final wheel
  compressed will be smaller on Linux which also benefits from the
  ``strip`` directive).
* 77 Rust-parity tests pass — extension still functional.
* Single-codegen-unit slows build by ~30-50% but maturin/cibuildwheel
  build time was never the bottleneck.

Forward strategy (separate work)
* Submit a PyPI project-size-limit-increase request to unblock the
  immediate release.
* Adopt a release-deprecation policy: yank versions older than N
  patches per minor; consider dropping Python 3.10 wheels (EOL'd
  October 2026) and manylinux_2_28_aarch64 wheels (niche audience,
  largest at 18.75 MB).
* Investigate runtime-download for Magika model (~10 MB further
  savings) — same pattern Kompress already uses.
2026-05-14 19:31:23 -07:00
Tejas Chopra
6cf8f7f44a
Merge pull request #471 from chopratejas/fix/codex-compression-scheduler
fix(proxy): unblock Codex WS compression — delete inner-pool + global semaphore
2026-05-14 16:32:33 -07:00
chopratejas
c22342e1c7 fix(tests): make stress test CI-robust — uniform frame sizes, tighter ratio
Second CI failure on the same stress test, this time with the ratio
threshold:

  AssertionError: p99/p50 ratio is 7.4× (p50=28406ms, p99=210468ms).
  Expected < 5× — wall=651s.

Root cause: previous iteration used MIXED frame sizes (200 B → 16 KB)
across 30 concurrent sessions on a 2-vCPU CI runner. The p99/p50
ratio captured TWO things:

1. The contention-tail signature we want to catch (≈27× pre-fix).
2. Size-variance compute spread (≈3–8× depending on hardware).

On dev hardware the (2) component was small relative to the
contention signal. On CI it dominated, masking the (1) detection.

The fix is to remove (2) from the measurement entirely:

* All 60 frames are now identical 4 KB plain-text payloads.
* Concurrency dropped from 30 to 12 — still > the deleted 10-slot
  semaphore (so the bug pattern, if reintroduced, surfaces), but
  doesn't oversaturate the 2-vCPU CI runner with OS-scheduler
  noise.
* Frames per session dropped from 12 to 5 → 60 total samples,
  still enough to compute a meaningful p99, with bounded runtime.
* Threshold tightened from 5× to 4×. On uniform workload the only
  legitimate source of p99/p50 spread is OS-level scheduling
  noise (≈2–3×). 4× sits comfortably between that and the bug
  signature (≈27×).

Local re-run: 60 frames, 0.59s wall, p50=108ms p99=198ms ratio=1.83×
— well under the 4× ceiling, captures the bug shape unambiguously.

Test design note added to docstring explaining the why so future
CI hardware changes don't trip the threshold again.
2026-05-14 15:20:37 -07:00
chopratejas
f82d61b163 fix(tests): make codex scheduler stress test machine-independent
CI failure on first attempt at the stress test:
  p99 per-frame elapsed_ms = 214020; expected < 1000

GitHub Actions runners (2 vCPU, shared) are 5–50× slower in absolute
terms than the 12-CPU dev box this PR's baseline numbers were taken on.
The absolute thresholds (p99<1000ms, wall<5s) intentionally caught the
bug on dev hardware but force CI either to skip the test or to use
thresholds so loose they stop catching the regression.

The bug being guarded against creates a *bimodal* latency distribution
(most fast, some catastrophic) via the deleted
``_CODEX_WS_UNIT_ROUTER_SEMAPHORE``. Pre-fix on dev: p50=91ms,
p99=2433ms → ratio=27×. The contention *pattern* is invariant — if the
semaphore tail comes back, the ratio explodes regardless of CPU speed.

This commit:
* Removes the machine-dependent absolute thresholds (p99<1000ms,
  wall<5s).
* Keeps the p99/p50 ratio test (now strictly < 5×, no special floor).
* Adds a `print()` of the full distribution so CI logs always show
  numbers — useful both for diagnosing failures and tracking drift.

Local re-run: p50=264ms p99=492ms ratio=1.87× — well under the 5×
ceiling and the test still proves the contention tail is gone.
2026-05-14 14:23:36 -07:00
chopratejas
e53276302e fix(tests): ship scripts/replay_codex_ws_load.py so CI can import it
CI ran the Codex compression scheduler stress test on python 3.10/3.11/
3.12/3.13 and all four versions failed with:

    ModuleNotFoundError: No module named 'scripts.replay_codex_ws_load'

The test imports ``boot_proxy``, ``warmup``, ``replay_session``, ``Frame``,
and ``Scenario`` from the replay tool to drive 30 concurrent compression
calls and assert no p99 contention tail (the very regression this PR
fixes). The tool was untracked because ``scripts/*`` is gitignored by
allowlist — local-only tools work fine for measurement but CI cannot
import them.

Add the replay tool to the allowlist so it ships. Same pattern as
``scripts/smoke_issue_327.py`` and other single-purpose scripts already
on the allowlist. The tool is genuinely useful beyond this PR: it lets
any contributor reproduce the Codex slowness baseline numbers and
measure their own fix against the same workload shape.

Local re-run with the tool committed: 3 passed, 1 skipped — identical
to the pre-fix branch run.
2026-05-14 13:44:41 -07:00
chopratejas
a167f5cc29 fix(proxy): unblock Codex WS compression — delete inner-pool + global semaphore
Production proxy logs (2026-05-14) showed 305 `TimeoutError: forwarding
original frame` warnings and 12,905 `slow compression unit elapsed_ms>1s`
log entries, with p99 unit elapsed_ms = 587 SECONDS, max = 1987 seconds,
and WS session p90 duration = 48 minutes. The cause was a two-layer
concurrency bug in `_compress_openai_responses_payload`:

* `_CODEX_WS_UNIT_ROUTER_SEMAPHORE = threading.BoundedSemaphore(10)` — a
  process-global gate over every compression unit in every frame across
  every concurrent session. At ~3+ active Codex users it saturates;
  subsequent units block on acquisition. The 30s parent timeout fires;
  uncompressed frames forward but the user already waited 30s.
* `time.perf_counter()` started BEFORE semaphore acquisition, so
  `elapsed_ms` conflated wait time with compute. A `strategy=passthrough`
  unit on 148 bytes (a no-op) showed `elapsed_ms=60917` in the log — 60
  seconds of "compression" that was actually 60 seconds of queueing.
* `concurrent.futures.ThreadPoolExecutor(max_workers=worker_count)` was
  created and torn down per frame, layered on top of the
  `self._compression_executor` proxy-wide pool. Pool-on-pool plus the
  global semaphore made the bug self-amplifying.

Fix: delete all three. Process routed units serially within the frame-
level worker thread. Frame-level parallelism is already provided by the
existing `self._compression_executor` (32 workers, sized `min(32,
cpu*4)`, instrumented). Bonus: add a structured PERF log emit from
`handle_openai_responses_ws` so Codex traffic is no longer invisible to
`headroom perf` — same visibility bug class as #327, fixed for Codex.

Tier 3 replay against `scripts/replay_codex_ws_load.py` (30 concurrent
sessions × 30 frames = 900 frames, 4.6MB) — same machine, before vs
after:

| metric              | pre-fix (main)  | post-fix       | Δ          |
|---------------------|-----------------|----------------|------------|
| p50 per-frame       |    91 ms        |   258 ms       | +183 %     |
| p99 per-frame       |  2 434 ms       |   275 ms       | −89 %      |
| max per-frame       |  2 681 ms       |   368 ms       | −86 %      |
| p99 / p50 ratio     |    27 ×         |   1.06 ×       | tail gone  |
| wall time           |  7.54 s         |  7.09 s        | −6 %       |
| errors              |     0           |     0          |   —        |

The median rises modestly at high load (the cost of KISS: serial units
instead of intra-frame parallelism, documented in EC2 of the design).
That trade is right: the catastrophic p99 contention tail is what users
felt, and it collapses 9×. At low load (10c × 20f) the fix is strictly
equal-or-better on every metric — the trade is invisible until the
semaphore was actually the binding constraint.

Tests
* tests/test_codex_ws_compression_scheduler.py — three regression
  guards: source-level assertions that `_CODEX_WS_UNIT_ROUTER_SEMAPHORE`
  and `concurrent.futures.ThreadPoolExecutor` cannot reappear in
  handlers/openai.py, plus a concurrency stress test asserting p99 <
  1000ms and p99/p50 < 5× at 30 concurrent sessions.
* All 95 existing Codex/streaming/cache tests pass with zero
  regressions.

Removed surface
* Deleted `_CODEX_WS_UNIT_ROUTER_MAX_WORKERS`,
  `_CODEX_WS_UNIT_ROUTER_SEMAPHORE`, `_codex_ws_unit_worker_count`,
  and the `HEADROOM_CODEX_WS_UNIT_WORKERS` env knob. Net −13 module-
  level lines + one undocumented env var gone from the public surface.
2026-05-14 13:30:44 -07:00
Tejas Chopra
7ddc0cef1e
Merge pull request #469 from chopratejas/fix/cache-writes-backend-streaming
fix(proxy): record cache reads/writes on backend-routed streaming (#327)
2026-05-14 11:19:32 -07:00
chopratejas
3e97a0cb73 fix(proxy): record cache reads/writes on backend-routed streaming (#327)
Two regressions surfaced as "Cache write: 0" in `headroom perf` and the
dashboard for every backend-routed streaming request (e.g. SvenMeyer's
DROID CLI > headroom > Azure GPT-5.5 setup):

* `_stream_openai_via_backend` parsed only `completion_tokens` and never
  read `prompt_tokens_details.cached_tokens` from the upstream usage
  frame. It also emitted no PERF log line at all, so `headroom perf`
  couldn't even count the request to report numbers. Now buffers SSE
  bytes, drains via `_parse_sse_usage_from_buffer(provider="openai")`,
  infers writes via `_infer_openai_cache_write_tokens` (only when the
  upstream actually reported usage — mirrors `_extract_responses_usage`),
  threads cache values into `record_request`, `cost_tracker.record_tokens`,
  the RequestLog, and a real PERF log line.

* `_stream_response_bedrock` hardcoded `cache_read=0 cache_write=0
  cache_hit_pct=0` in its PERF line regardless of what `message_start.usage`
  reported. Extended `stream_state` with `cache_read_input_tokens` and
  `cache_creation_input_tokens` (plus 5m/1h TTL buckets), captures them
  from `message_start`, threads through `record_request(cached=...)`,
  `cost_tracker.record_tokens(...)`, and `RequestLog(cache_hit=...)`.

Tests: four new tests in `test_backend_streaming_cache_metrics.py` cover
both paths plus a source-level regression guard against the hardcoded
zero string reappearing.
2026-05-14 11:01:57 -07:00
Tejas Chopra
bcf5517259
Merge pull request #467 from chopratejas/docs-ai-agent-discoverability
docs: improve discoverability for AI agents and search crawlers
2026-05-13 17:38:15 -07:00
chopratejas
c1d2eec588 docs: improve discoverability for AI agents and search crawlers
Several signals AI agents and search engines use to discover and
install a project were misaligned or missing:

* ``docs/app/layout.tsx`` set ``metadataBase`` to
  ``https://chopratejas.github.io/headroom/`` while the live docs run
  on Vercel — every page's ``og:url`` and ``twitter:url`` resolved to
  a URL that returns 404 for ``/llms.txt``. Now points at the live
  Vercel host (overridable via ``NEXT_PUBLIC_SITE_URL`` for a future
  custom domain). Adds explicit ``openGraph`` and ``twitter`` metadata
  so social shares render a card with the project's pitch.
* No ``llms.txt`` at the GitHub repo root. AI agents crawling
  ``github.com/chopratejas/headroom/`` saw only the README. The new
  ``llms.txt`` follows the llmstxt.org convention: 1-line pitch,
  canonical docs links, copy-paste install commands (pip / npm /
  Docker / proxy / ``headroom wrap``), and entry points for the
  library, proxy, MCP server, and SDK integrations. Points at the
  Fumadocs-generated ``/llms.txt`` and ``/llms-full.txt`` for the
  full picture.
* ``pyproject.toml`` ``Documentation`` URL pointed at the GitHub
  README anchor. Updated to point at the docs site so PyPI visitors
  land on searchable docs, and adds an ``AI / LLM Index`` URL
  pointing at the Fumadocs ``/llms.txt``.
* No explicit AI-bot allow list. Added ``docs/app/robots.ts`` (Next
  13+ App Router convention) with explicit allows for GPTBot,
  ClaudeBot, PerplexityBot, Google-Extended, OAI-SearchBot,
  ChatGPT-User, Cohere-AI, CCBot, and Applebot-Extended. Wildcard
  allow as the catch-all. Advertises the sitemap.
* No ``sitemap.xml`` route. Added ``docs/app/sitemap.ts`` that pulls
  every Fumadocs page out of ``source`` (same source backing
  ``/llms.txt``, search, and OG images) so search and AI crawlers
  can enumerate doc pages without scraping HTML.
* README didn't tell AI agents where to look. Added a 2-line
  pointer near the top nav row: read ``/llms.txt`` here, or fetch
  the live index / full docs blob.

Also tightened the GitHub repo description and added five topics
(``claude-code``, ``cursor``, ``tokens``, ``prompt-engineering``,
``typescript``) via ``gh repo edit`` — that's already live on the
repo, not part of this commit.

No Python or Rust code changes; ``make ci-precheck`` was run to
confirm the test slice still passes.
2026-05-13 17:36:06 -07:00
Tejas Chopra
1139b21f2a
Merge pull request #466 from chopratejas/fix-464-traffic-learner-system-reminder-noise
fix(memory): drop regex-based pref extraction; filter system-reminder noise (refs #464)
2026-05-13 16:49:12 -07:00
Tejas Chopra
f8ffdbfe73
Merge pull request #465 from chopratejas/fix-462-per-project-memory-scope
fix(memory): per-project storage so projects can no longer bleed memories (refs #462)
2026-05-13 16:19:25 -07:00
chopratejas
0be0eede9e fix(memory): traffic_learner indexes system-reminder fragments as user preferences (refs #464)
`TrafficLearner._extract_preferences` ran three regex patterns over raw
user-message text and saved any match as a `User preference: <captured>`
memory. Two compounding bugs made ~10% of the reporter's saved memories
(187 of 1796) garbage:

1. **System-reminder content was matched.** Claude Code injects
   `<system-reminder>…</system-reminder>` blocks into user-role
   messages — scaffolding ("don't mention this reminder", "use colgrep
   instead of Grep", "never bypass signing") that hits every correction
   trigger. The learner happily persisted scaffolding as authoritative
   user preferences.
2. **Capture groups were fixed-length windows.** `(.{10,100})` grabbed
   the next 10–100 chars with no boundary awareness, producing
   mid-word truncations like `User preference: of Grep, Glob. When
   spawning agents, mention colgrep features a`.

This change rewrites `_extract_preferences` to be **regex-free** and
adds two layered defences:

- `_strip_system_reminders` (literal `str.find` scan, no regex)
  removes `<system-reminder>…</system-reminder>` blocks from user
  text before any pattern matching. Unclosed reminders drop to
  end-of-string. Case-insensitive on the tag name only. ~95% of the
  reporter's noise sample comes from this single layer.
- A token-based correction scanner replaces the three `re.compile`
  patterns. It tokenises on whitespace (lowercasing once, up front),
  matches trigger sequences as ordered token lists (`don't`, `do not`,
  `stop`, `never`, `avoid`, `no use`, `no try`, `no do`, `instead`),
  and captures the trailing content until a sentence terminator
  (`.!?\n`) or end-of-input. Captures shorter than 10 chars are
  rejected (stray triggers), and captures that hit the 78/98-char cap
  without finding a terminator are rejected (rambling fragments). The
  former noise — `colgrep instead of Grep, Glob. When spawning…` —
  fails this gate; short complete user utterances
  (`don't use git push, I'll push manually`) still pass because
  end-of-input counts as a boundary.

Net regex count in this file: -3, +0.

`_hydrate_persisted_state` already runs in `start()` and seeds
`_saved_hashes`/`_persisted_ids` from prior rows, so cross-restart
dedup is already wired up — the reporter's "doesn't survive restarts"
note was partially outdated. The narrow remaining edge (in-process
`_dedup_window=100` eviction within a single very-long-running
process) self-heals on next restart and is left as a separate
follow-up.

Tests: 17 new across `TestStripSystemReminders`,
`TestExtractPreferencesSystemReminderFiltering`,
`TestExtractPreferencesRealCorrections`, and
`TestExtractPreferencesSentenceBoundary`. Full traffic_learner suite:
139 passing. ci-precheck green.
2026-05-13 16:13:04 -07:00
chopratejas
7694f050fe fix: per-project memory storage so projects can no longer bleed memories (GH #462)
Memory retrieval was partitioned only by `x-headroom-user-id`. Claude
Code never sets that header, so every project a user worked on landed
in one global `default` bucket; the proxy then injected semantically
similar memories from that mixed bucket into every `/v1/messages`
request, regardless of which repo the session was actually about. The
injected `## Relevant Memories` block reads like a prompt-injection
payload and Claude has been seen to refuse to act on it, defeating the
feature.

This change makes leakage structurally impossible by giving each
resolved workspace its own SQLite database file. The wrong DB is
simply not open during a request.

- `headroom/memory/storage_router.py` (new) — `MemoryStorageMode`
  (project/user/global), `ProjectResolver` (x-headroom-project-id →
  x-headroom-cwd → --memory-project-root CLI override → env-block
  parse: `Primary working directory:` / `Working directory:` / `cwd:`,
  no regex), and `BackendRouter` with an LRU of open `LocalBackend`s
  keyed by db_path.
- `proxy/memory_handler.py` — `MemoryConfig.storage_mode` defaults to
  `PROJECT`. Provider handlers build a `RequestContext` once and pass
  it through; `search_and_format_context`, `handle_memory_tool_calls`,
  and the `_execute_*` methods route save/search/update/delete on the
  per-project backend. Qdrant-neo4j gets a composite
  `user::project_key` partition so external Mem0-style deployments
  also isolate per project without a parallel collection.
- Fix C — injected block carries provenance:
  `## Relevant Memories (workspace: <basename>, scope: project)`.
  CCR proactive-expansion block gets a matching workspace tag.
- `memory/factory.py` — process-wide embedder cache so opening N
  project DBs doesn't load the embedder N times. OpenAI key
  validation runs ahead of the cache.
- CLI — `--memory-storage={project,user,global}` (default `project`),
  `--memory-project-root` override, rewritten `--memory` help text,
  banner reports storage mode.
- Migration UX — if the legacy single-file DB has content while
  project mode is active, an INFO log points users at
  `--memory-storage=global`. Bridge currently only syncs the legacy
  DB; a WARN fires when bridge + project mode are combined.

Backward-compatible: legacy `~/.headroom/memory.db` untouched and
reachable via `--memory-storage=global`. `request_context` is
keyword-only on entry points so existing tests/mocks keep working.

Tests: 24 new (resolver tiers, LRU eviction, two-cwd isolation,
user-mode partition, legacy fallback, provenance headers); full
suite 5260 passing, ci-precheck green.
2026-05-13 15:27:41 -07:00
chopratejas
a8a1ba426e chore: add cache TTL cost analysis script 2026-05-13 10:49:17 -07:00
chopratejas
05fe6c0102 fix: narrow compressed type for mypy 1.14 in ContentRouter.compress 2026-05-13 10:49:09 -07:00
chopratejas
07bcda2796 chore: sync plugin versions to 0.21.33 2026-05-13 10:49:03 -07:00
Tejas Chopra
294df2b894
Merge pull request #403 from chopratejas/realign-F2_2-policy-tuning
fix(proxy): F2.2 — per-mode CompressionPolicy tuning fields
2026-05-12 18:13:21 -07:00
Tejas Chopra
fc0cba7b48 fix: format Kompress tests for ruff 2026-05-12 16:47:11 -07:00
Tejas Chopra
98f73e3502 fix: speed up Kompress inference 2026-05-12 16:40:12 -07:00
Tejas Chopra
b946a44c44 fix: batch and guard Kompress inference 2026-05-12 16:39:36 -07:00
Tejas Chopra
a2ea9648a4 fix: add Kompress backend and thread controls 2026-05-12 15:32:57 -07:00
Tejas Chopra
4e146b0b5a
Merge pull request #459 from chopratejas/fix/codex-compression-observability
fix: expose compression latency bottlenecks
2026-05-12 15:05:37 -07:00
Tejas Chopra
03d12fc140 fix: format Codex compression changes 2026-05-12 14:35:47 -07:00
Tejas Chopra
e9cae0131b fix: expose compression latency bottlenecks
Add Codex WS unit-level timing and bounded parallel compression, clarify context-tool session savings, and avoid costly diff/log fallbacks to Kompress.
2026-05-12 13:34:08 -07:00
chopratejas
0f6df1fef0 docs(readme): redesign with lean-ctx-style crispness
- ASCII block logo replaces plain # heading
- Power-stats line + nav links above the fold
- Time-boxed section headings (30s / 60s)
- What-it-does bullets pruned to one clause each
- Agent table notes trimmed to ≤5 words with ● markers
- Pipeline internals + provider slices moved to collapsed <details>
- New When-to-use / When-to-skip section
- GIFs centered via HTML with captions
- Integrations and What's-inside remain collapsed <details>
2026-05-11 22:33:03 -07:00
chopratejas
3432ee3a96 docs: add README redesign spec (lean-ctx parity + Headroom-first blend) 2026-05-11 22:22:34 -07:00
Tejas Chopra
71348f407f
Merge pull request #458 from chopratejas/fix-litellm-azure-dashboard-compression-issues-454-455
fix(proxy,dashboard): correct savings reporting
2026-05-11 18:44:23 -07:00
Tejas Chopra
6643266ec0 fix(proxy,dashboard): correct savings reporting for backend-routed traffic and drop broken Compression Quality widget
Fixes #454, #455.

Streaming record_request paths were calling metrics without
attempted_input_tokens, so attempted_input_tokens_total stayed at 0 for
backend-routed traffic (litellm-azure, bedrock, anthropic streaming).
active_savings_percent then divided by zero and the dashboard headline
showed 0% even while compression was working. The three streaming sites
now pass the pre-compression request size as the attempted denominator,
matching the non-streaming sibling in openai.py.

The dashboard headline also falls back to proxy_savings_percent when
attempted is missing so historical 0% values self-heal.

The "Compression Quality" widget computed totalWaste / saved as a
percentage and could exceed 100. The metric is conceptually broken (the
two values measure different things across different surfaces — a perfect
semantic compressor surfaces zero waste signals and scores "low quality"
by this formula), not merely unbounded, so capping it just hides the
underlying confusion. Dropped the widget and the matching Quality column
on Recent Requests; removed the dead confidence getters.

For #454's diagnostic gap, added two visibility levers:
- --compress-user-messages CLI flag (+ HEADROOM_COMPRESS_USER_MESSAGES
  env) flips the router's skip_user_messages default off for workloads
  where the bulk of input lives in user messages (OpenAI/Azure chat with
  pasted code/RAG context).
- /stats now includes router.route_counts aggregating the router's
  protection categories (user_msg, system_msg, recent_code,
  excluded_tool, …) so operators can see why compression is low without
  local patching.

Analysis of the issue reporter's attached proxy_savings logs showed
day-on-day savings ranging 0.6%–76% based on workload shape (pasted user
content vs tool-output rounds), not a version regression — the dashboard
0% headline made workload variance look like a regression.
2026-05-11 18:07:30 -07:00
Tejas Chopra
f34748df7a
Merge pull request #457 from JerrettDavis/main
ci: correct live checks when credentials are missing
2026-05-11 16:09:48 -07:00
Tejas Chopra
5f96b836d2
Merge pull request #451 from cowwoc/feature/issue-265-lean-ctx
[codex] Add lean-ctx context tool support
2026-05-11 16:09:38 -07:00
Tejas Chopra
5cc3fd4852
Merge pull request #450 from chopratejas/fix/windows-ort-docker-signing
fix: Windows ORT builds and Docker signing retries
2026-05-11 16:09:29 -07:00
Gili Tzabari
4b061792b2 feat: add lean-ctx context tool support 2026-05-11 17:54:17 -04:00
JerrettDavis
7057227c84 test: fix websocket lifecycle import order 2026-05-11 14:51:18 -05:00
Tejas Chopra
d49eddacc9
Merge pull request #453 from cowwoc/fix/codex-ws-dashboard-performance
[codex] Populate Codex WS dashboard performance metrics
2026-05-11 12:12:36 -07:00
JD Davis
e6a9604217
Merge branch 'chopratejas:main' into main 2026-05-11 13:59:07 -05:00
Gili Tzabari
28a0e19deb Merge origin/main into fix/codex-ws-dashboard-performance 2026-05-11 14:45:30 -04:00
JerrettDavis
2fd48260f6 ci: skip live evals without credentials 2026-05-11 13:35:24 -05:00
Tejas Chopra
9a156105fa
Merge pull request #452 from cowwoc/fix/codex-ws-compression-executor
[codex] Bound Codex Responses compression work
2026-05-11 11:25:27 -07:00
Gili Tzabari
4279a7f353 fix: populate Codex WS dashboard performance metrics 2026-05-11 03:26:59 -04:00
Gili Tzabari
160989c43e fix(proxy): bound Codex Responses compression work 2026-05-11 03:02:58 -04:00