Commit graph

1553 commits

Author SHA1 Message Date
chopratejas
74843d1d62 fix(tests): tomllib fallback to tomli on python 3.10
`tomllib` is a Python 3.11+ stdlib module. The project supports
3.10+ (per pyproject.toml `requires-python = ">=3.10"`) and the
test (3.10) matrix job correctly caught this:

  ModuleNotFoundError: No module named 'tomllib'
  at tests/test_release_workflows.py:1036

Apply the same try/except fallback pattern used in
headroom/release_version.py: tomllib on 3.11+, tomli as the
backport on 3.10.
2026-05-25 19:00:34 -07:00
chopratejas
c8e347f1ac ci(release): adopt release-please for gated publishes
Replace "every push to main = release" with release-please's
release-PR pattern: the bot watches main and maintains a single
"chore: release vX.Y.Z" PR aggregating conventional commits; merging
that PR creates the tag + GitHub Release, which fires the
release:published event that release.yml now triggers on.

Why
---
Per-merge releases burned PyPI's 10 GiB per-project storage quota
(one fresh wheel matrix ~= 200 MB per merged fix/feat PR).
publish-pypi has failed on every main merge since PR #482 with
"400 Project size too large". Consolidating many fixes into one
release cuts upload frequency ~5x.

What changed
------------
- .github/workflows/release-please.yml: bot watching main
- .release-please-config.json: python release-type + extra-files
  for sdk/typescript and plugins/openclaw package.json
- .release-please-manifest.json: tracks current 0.9.1
- .github/workflows/release.yml:
  * trigger: push to main -> release: published
  * detect-version: reads tag from github.event.release.tag_name
    (strips leading "v") so release_version.py does not re-bump
    past the bot's tag
  * create-release: when release already exists (typical
    release-please path), do not pass --notes-file -- that would
    clobber the bot's auto-generated changelog body

Tests
-----
Five new regression tests in test_release_workflows.py prevent
silent reversion to per-push triggering and assert the bot
workflow + config invariants.

Note
----
This commit does NOT fix the existing quota breach. Request a
PyPI quota increase, yank old releases, or shrink the wheel
matrix to free immediate space. This PR ensures the future
release cadence stops growing the problem.
2026-05-25 18:21:37 -07:00
Tejas Chopra
b36ad9fe1c
Merge pull request #494 from chopratejas/realign-G3-rtk-metrics-and-obs
fix(observability): RTK metrics + Rust observability (Phase H blocker)
2026-05-25 16:56:14 -07:00
Tejas Chopra
c7d1247a2b
Merge pull request #493 from chopratejas/realign-G2-tokens-saved-rtk
fix(subscription): wire tokens_saved_rtk data plane
2026-05-25 16:56:00 -07:00
chopratejas
c38dac301e fix(tests): mock logger.warning directly instead of relying on caplog
Two prior attempts to capture the structured warning via pytest's caplog
fixture both passed locally and failed in CI on all 4 Python versions:

  * commit 317dffe — caplog scoped to logger="headroom.proxy"
  * commit 9b6d637 — caplog set_level at root, no logger argument

Symptom in both cases was identical: caplog.records was empty even
though the helper's `except` branch was reached (the function returned
the synthetic-zero payload). Likely a logger-propagation or handler-
config difference in the CI test harness that isn't reproducible
locally.

Switch to mocking `_helpers.logger.warning` directly via MagicMock.
When the production code calls `logger.warning(...)` the mock
intercepts regardless of propagation, formatters, or handler order.
Also surfaces actual call args in the assertion failure message so
future CI debugging has signal.

Production code unchanged.
2026-05-25 16:32:31 -07:00
chopratejas
9b6d6374f1 fix(tests): drive RTK subprocess failure with real exec, not monkeypatched run
Previous attempt (commit 317dffe) patched `subprocess.run` via monkeypatch
+ scoped caplog to logger=headroom.proxy. Passed locally, still failed in
CI on all 4 Python versions — likely a logger-propagation difference in
the CI test runner.

Simpler approach: point `get_rtk_path` at a definitely-nonexistent absolute
path and let the REAL subprocess.run raise FileNotFoundError. That drives
the helper's `except Exception` branch (which logs the structured warning)
deterministically across all environments — no subprocess mock involved.
Also capture from the root logger so propagation config can't hide the
record.

Pure test-side fix; production code unchanged.
2026-05-25 16:04:59 -07:00
chopratejas
317dffe58f fix(tests): patch headroom.rtk.get_rtk_path, not the helpers alias
CI failed on `test_rtk_subprocess_failure_logs_structured_warning`
because the test patched `headroom.proxy.helpers.get_rtk_path` but
`_read_rtk_lifetime_stats` does a LOCAL import
(`from headroom.rtk import get_rtk_path`) inside the function body —
so the patched attribute on `helpers` was never read. In CI (no rtk
installed), the LOCAL import returned `None`, the function took the
early-return branch, and the structured warning the test expected
was never emitted.

Fix: patch `headroom.rtk.get_rtk_path` directly so the local import
returns the test's stub. The subprocess.run patch then takes effect,
the fake non-zero exit triggers the `event=rtk_stats_subprocess_failed`
warning, and the assertion holds.

Pure test-side fix; production code unchanged.
2026-05-25 15:14:27 -07:00
chopratejas
ea1976e37a fix(cli): G1 remediation — non-string clobber, per-model systemMessage, openhands gate
Addresses 1 High + 4 Medium findings from the PR-G1 code review.

H1: `_inject_continue_rtk_systemmessage` previously fell through to an
unconditional `data["systemMessage"] = RTK_INSTRUCTIONS_BLOCK` when the
existing value was non-string (dict / list / number), silently clobbering
user data despite a docstring promising otherwise. Extracted a small helper
`_apply_rtk_to_systemmessage_field` that returns `(changed, ok)` and refuses
loudly on non-string user data with guidance to clear the field before
re-running. The injecting helper reports `ok=False` on any refusal so the
caller surfaces it as a warning instead of pretending the injection
succeeded. Tests cover dict, list, and int values for both top-level and
per-model sites.

M2: Continue overrides top-level `systemMessage` with per-model
`systemMessage` when set, so users with per-model configs were silently
getting no RTK guidance. The helper now visits every `models[i]` dict in
addition to the top-level field, applying the same idempotency and non-
string-clobber rules at each site. Non-dict entries in `models[]` are
skipped.

M3: The openhands subcommand previously called `_ensure_rtk_binary()` and
ignored the result, then proceeded to inject `OPENHANDS_INSTRUCTIONS` even
when rtk install had failed. Mirrored the cline/continue/goose pattern —
if rtk install fails (and `--no-context-tool` was not passed), exit 1 with
a clear error explaining how to install rtk manually or skip rtk. No
silent fallback to env-only injection.

M4: Wrapped the marker-injection + rtk-setup prelude of all four new
subcommands (cline, continue, goose, openhands) in a try/except for
KeyboardInterrupt. On Ctrl-C between marker injection and proxy startup,
we emit a clear "wrap was interrupted; marker file at <path> is on disk;
rerun to retry — it's idempotent" message and exit 130. Pre-compute the
marker path so the message can name it even if the interrupt fires before
`_inject_rtk_instructions` returns. Introduces a small `_emit_wrap_
interrupted` helper.

M1 + M5: Documented the uninstall procedure (hand-remove the
`<!-- headroom:rtk-instructions -->` block) and the lean-ctx agent-name
caveat in each of the four new subcommand docstrings. We chose docstring
guidance over `unwrap cline|continue|goose|openhands` subcommands to keep
the PR scoped. Also documented Continue's modern YAML-first config in the
`continue` docstring so users on the YAML schema know this command only
handles the JSON variant.

Tests: +9 new tests across the 4 wrap test files exercising H1 refusal
(dict/list/int parametrized × top-level + per-model), M2 per-model
injection + idempotency + non-dict-entry skip, M3 rtk install failure
abort + `--no-context-tool` bypass, and M4 KeyboardInterrupt-during-
prelude flows for all four agents.

Cosmetic: Removed the misleading "re-invocation in the same shell session"
comment from openhands; the marker guard is for pre-existing env vars.
2026-05-25 11:54:06 -07:00
chopratejas
2a717a993e fix(observability): G3 remediation — bound cardinality + wire dead metrics
Phase G PR-G3 review identified 5 Critical + 4 High + 5 Medium
findings. This commit lands all 14 fixes plus the optional nits.

CRITICAL

* C1 (cardinality DoS): `service_tier` was read from inbound JSON
  and used verbatim as a metric label. A malicious client could
  blow up the metric vector unboundedly. Added bounded vocabulary
  in `metric_names.rs::service_tier` ({auto, default, flex,
  on_demand, priority, scale, other-sentinel}) + a `validate()`
  helper. Both request-side (`handlers/responses.rs`) and
  response-side (`proxy.rs` Responses arm) gate raw values through
  it.

* C2 (dead metric): `proxy_passthrough_bytes_modified_total` had
  no production emit site. Wired it in `proxy.rs` to fire when a
  dispatcher arm returning `NoCompression`/`Passthrough` produces
  a body of a different byte length (a true cache-poisoning
  regression detector). The check runs BEFORE the PR-E4
  prompt_cache_key injector so legitimate injector mutations do
  not trip the alarm.

* C3 (Python/Rust boundary): `proxy_image_generation_call_log_redacted_total`
  was a dead Rust counter — the redaction happens entirely in the
  Python proxy's request_logger. Removed the Rust counter; moved
  the metric to the Python proxy's `/metrics` exporter via the
  existing `redactions_total()` module-level counter.

* C4 (Python/Rust boundary): `wrap_rtk_invocations_total` was a
  dead Rust counter with no wrap-side bridge. Removed the Rust
  counter; added new `headroom/cli/wrap_rtk_metrics.py` with
  `record_rtk_invocation(tool, delta)` + `rtk_invocation_counts()`
  primitives and surfaced them via the Python proxy's `/metrics`
  exporter.

* C5 (dead metric): `proxy_compression_rejected_by_token_check_total`
  had no production caller. Wired it in
  `live_zone_anthropic.rs`, `live_zone_openai.rs`, and
  `live_zone_responses.rs` to increment on every
  `BlockAction::RejectedNotSmaller` block in the manifest. The
  metric now reflects real "compressor ran but kept original"
  cases.

HIGH

* H1 (per-strategy ratio garbage): `proxy_compression_ratio_by_strategy`
  emitted the same aggregate ratio for every strategy in
  `strategies_applied` when multiple strategies ran on one body.
  Added `per_strategy_tokens: Vec<PerStrategyTokens>` to
  `Outcome::Compressed`; per-strategy `(before, after)` is
  accumulated from the manifest at the wrapper sites and emitted
  one sample per strategy in `proxy.rs`. Empty vec → fallback to
  one aggregate-labelled sample with a debug log (Phase E
  normalization paths that don't track per-strategy tokens).

* H2 (aborted stream): cache_hit_rate observed on client
  disconnects mid-stream. Added a gate: Anthropic only fires when
  `state.status == MessageStop`, OpenAI Responses only when
  `terminal_status().is_some()`. Extracted the gate into the
  pure function `compute_anthropic_session_hit_rate(state)` so
  the H2 contract is unit-testable independent of the shared
  global registry.

* H3 (docs lie + alarm contract): docs claimed HELP/TYPE is
  reachable on fresh boot, then contradicted itself. Force-zero
  every counter / gauge MetricVec with an `__init__` sentinel
  label on each scrape so HELP/TYPE + a zero row are visible from
  boot. Histograms are NOT force-zeroed (a synthetic observe(0.0)
  would pollute percentiles). PromQL queries in docs filter
  `{... != "__init__"}` so the sentinel rows are excluded from
  aggregations.

* H4 (crate-version dependency): pinned `prometheus = "=0.13.4"`
  exactly (no caret) so a future minor bump cannot silently break
  the H3 force-zero contract that relies on this crate's gather()
  semantics. Added a clear "retest the alarm contract on bump"
  paragraph in docs.

MEDIUM

* M1 (saturate on cached > input): OpenAI Chat + Responses cache-
  hit-rate computed `non_cached = input.saturating_sub(cached)`,
  silently clamping to 0 if `cached > input`. Per "no silent
  fallbacks", log + skip the emit on this wire-format pathology.

* M2 (over-fire on non-image base64): Python redactor's "density
  heuristic" over-fired on encrypted blobs / signed tokens /
  minified JSON / tool outputs. Tightened: only redact strings
  inside known image-bearing JSON paths (`data`, `url`,
  `image_url`, `image`) OR strings starting with `data:image/`.

* M3 (NaN clamp): cache_hit_rate::observe used `f64::clamp(0,1)`
  which returns NaN for NaN input; the `debug_assert!` was
  compiled out in release. Added `is_finite()` guard with a
  loud-log + skip before observe.

* M4 (PromQL median-only): added p95, p99, mean (sum/count), and
  Phase H canary-gate query section to docs. Canary fails if ANY
  of {p50, p95, p99, mean} regresses below the Python baseline.

* M5 (label byte vs char): the `<image:base64-redacted bytes=N>`
  placeholder reported character count, not UTF-8 byte count.
  Switched to `.encode('utf-8').__len__()` so the label is
  honest for non-ASCII payloads (ASCII base64 still has byte ==
  char so existing scrapes are unchanged).

OPTIONAL

* Removed dead `debug_assert_eq!(buffered.len(), buffered.len(),
  ...)` no-op in proxy.rs.
* Normalised `record_response_status` log level from `info` to
  `debug` to match peer metric helpers.

Tests:

* Rust: 11 integration_metrics tests (was 6) + 9 cache_hit_rate
  unit tests (was 4) + 2 compression_ratio (unchanged). New
  coverage: service_tier known/unknown bucketing, C2 alarm wire,
  H1 per-strategy ratio, H2 abort gate, M3 NaN/inf skip.
* Python: 27 tests (was 13). New coverage: M2 path-gated
  redaction, M5 byte vs char label, wrap_rtk_metrics primitive
  thread safety and validation.

`cargo fmt --check`, `cargo clippy --workspace -- -D warnings`,
`cargo test -p headroom-proxy --lib` (221 passed) and the
integration_metrics + integration_compression +
integration_volatile_detector + integration_cache_control +
integration_cache_drift + integration_responses +
integration_bedrock_metrics test files all green. Full
`cargo test --workspace` deferred — disk pressure during the
agent session left insufficient space for the linker to write
the full integration test artifacts; runs that did fit all
passed. `make ci-precheck` deferred for the same reason.

ruff check + ruff format + mypy headroom/proxy/request_logger.py
+ headroom/cli/wrap_rtk_metrics.py + headroom/proxy/prometheus_metrics.py
green.
2026-05-24 10:41:56 -07:00
chopratejas
f68090c5b4 fix(subscription): address G2 review findings — phantom delta, multi-worker race, silent fallbacks
Remediates 3 Critical, 3 High and 5 Medium findings from review of the
G2 ``tokens_saved_rtk`` wiring.

Critical
  - C1: read SESSION-incremental ``session.tokens_saved`` from the RTK
    helper instead of the raw ``lifetime_tokens_saved`` counter. The
    helper de-baselines per proxy session, so the first poll after
    process startup correctly reads 0 rather than emitting the entire
    pre-Headroom RTK history (months of saves) as one phantom delta.
  - C2: dissolved by C1 — the helper rebaselines session counters at
    every proxy startup, so a post-restart first poll is naturally
    bounded by what happened since restart. No need to persist
    ``_last_rtk_tokens_saved`` across restarts. Verified with a
    persist+load round-trip test.
  - C3: gate the RTK poll behind a non-blocking fcntl.flock owner
    election (mirrors the beacon-lock pattern in proxy/server.py). Only
    the lock-holder worker polls; non-owners return 0 from
    ``_poll_rtk_delta``. Lock path is configurable via
    ``HEADROOM_RTK_POLL_LOCK``.

High
  - H1: validate ``HEADROOM_RTK_WIRING`` eagerly in
    ``configure_subscription_tracker`` so a typo crashes the proxy at
    startup instead of being silently swallowed at every
    ``update_contribution`` call. Runtime path elevated from WARNING to
    ERROR with the ``event=subscription_rtk_invalid_env`` field.
  - H2: structured-log every synthetic-zero exit path in
    ``_read_rtk_lifetime_stats`` (subprocess non-zero exit + exception)
    via ``event=rtk_stats_subprocess_failed``. Downstream consumers
    can now distinguish a broken RTK from a healthy zero.
  - H3: every failure-path test uses ``caplog`` to assert the expected
    structured log line is emitted, satisfying the no-silent-fallback
    constraint at test level.

Medium
  - M1: documented the new ``tokens_saved_cli_filtering`` default
    semantic in the ``update_contribution`` docstring.
  - M2: legacy state file load migrates pre-G2 ``rtk`` (aliased to
    cli_filtering) into ``rtk_raw`` so accumulated history isn't
    silently zeroed. Emits ``event=subscription_state_legacy_load``.
  - M3: legacy-format load test added.
  - M4: garbage-env-value test added.
  - M5: ``cli_filtering = tokens_saved_cli_filtering or 0`` replaced
    with explicit ``None``-guard for symmetry with the rtk sentinel.

Test count: 7 → 16 (+9). All passing.
2026-05-22 15:01:32 -07:00
chopratejas
44c605fbb0 fix(subscription): wire tokens_saved_rtk from RTK stats endpoint
PR-G2 (Realignment) — retire the dead `tokens_saved_rtk` data plane.

Previously, `SubscriptionTracker.update_contribution` silently mirrored
`tokens_saved_cli_filtering` into `tokens_saved_rtk`, making the two
counters identical at all times and hiding wrap-side RTK savings from
the dashboard.

Wiring:
- New `_last_rtk_tokens_saved` state on the tracker (init to 0).
- `update_contribution` now polls `_get_rtk_stats()` when the caller
  omits an explicit `tokens_saved_rtk`, computes the delta against the
  last lifetime total, and writes only the positive delta. State
  advances monotonically; a counter regression re-baselines without
  emitting a negative delta.
- `cli_filtering` and `rtk` are no longer aliased anywhere in the
  hot path.
- Persistence: `to_dict()` exposes raw `cli_filtering_raw` and
  `rtk_raw` keys (legacy dashboard `cli_filtering` / `rtk` still report
  `max(cli, rtk)` for back-compat). `_load_persisted_state` reads the
  raw keys when present and defaults to 0 otherwise so legacy state
  cannot silently re-inflate `tokens_saved_rtk` by mirroring
  `cli_filtering`.

Build constraints honoured:
- No silent fallback — transient `_get_rtk_stats()` exceptions are
  caught, structured-logged (`event=subscription_rtk_stats_fetch_failed`
  / `event=subscription_rtk_stats_unavailable`), and yield zero delta.
- Configurable — `HEADROOM_RTK_WIRING={enabled,disabled}` opts the
  polling out without disturbing tool selection. Unknown values raise
  loudly via `_rtk_wiring_mode`.
- Comprehensive tests — 9 new unit tests in
  `tests/test_subscription_tracker_rtk_wired.py` pin the wiring
  (baseline, delta across two/three polls, None payload, monotonic
  advance, counter regression, exception, env-var opt-out, explicit
  override, decoupling from cli_filtering). Existing tracker tests
  updated to reflect the no-mirror behaviour.

Refs: REALIGNMENT/09-phase-G-rtk-observability.md (PR-G2)
2026-05-22 13:48:42 -07:00
chopratejas
5f264a5329 fix(observability): wire Phase G PR-G3 RTK + proxy metrics (H-blocker)
Phase H ("retire the Python proxy") needs cache-hit-rate parity
between the Rust and Python proxies during canary. This PR lands
the per-invocation RTK metrics and the proxy-side observability
surface that the canary gate depends on.

Rust observability:
- `proxy_cache_hit_rate_per_session{provider}` — histogram, emitted
  per session at SSE state-machine close (Anthropic message_delta,
  OpenAI Chat final usage chunk, OpenAI Responses response.completed).
  The Phase H canary gate metric.
- `proxy_compression_ratio_by_strategy{strategy, content_type}` —
  histogram; one sample per shrunk block.
- `proxy_compression_rejected_by_token_check_total{strategy}` —
  counter for tokenizer-validated rejections.
- `proxy_passthrough_bytes_modified_total{path}` — counter (must
  stay 0 outside compression hot path; alarmable via PromQL rate).
- `proxy_rate_limit_remaining_{requests,tokens,input_tokens,output_tokens}{provider}` —
  gauges populated from anthropic-ratelimit-* / x-ratelimit-* headers.
- `proxy_service_tier_count_total{tier}` and
  `proxy_response_status_count_total{status}` — counters for
  Responses-API outcome telemetry.
- `proxy_image_generation_call_log_redacted_total` — counter.
- `wrap_rtk_invocations_total{tool}` and
  `wrap_rtk_tokens_saved_per_session` — RTK metrics exposed via
  the proxy's /metrics scrape so wrap-side tail can increment
  through one observability surface.

All metric names and label keys live in a single
`observability/metric_names.rs` constants module per realignment
build-constraint "configurable". Bounded label vocabularies
(service_tier, response_status, provider) are defined alongside.

Python (P4-45):
- `headroom/proxy/request_logger.py` — base64-image payloads in
  request/response logs over 1024 bytes are replaced with
  `<image:base64-redacted bytes=N>` placeholders. Walks Anthropic
  source.data and OpenAI data URLs. No regexes — substring +
  density heuristic.

Tests:
- `crates/headroom-proxy/tests/integration_metrics.rs` — 6 tests
  covering cache-hit-rate, compression-ratio, passthrough-bytes,
  service-tier, response-status, and rate-limit-snapshot.
- `tests/test_image_log_redaction.py` — 13 tests for the Python
  redaction helper.
- Existing tests: 1100+ Rust + 76 Python regression checks green.

Docs:
- `docs/observability.md` — metric catalogue + PromQL queries.
- `docs/rtk-architecture.md` — locks the wrap-CLI-only decision so
  future contributors don't relitigate proxy-side RTK.

No silent fallbacks: zero-denominator cache-hit-rate logs and
skips rather than synthesising 0.0. Unparseable rate-limit headers
stay None rather than coerced to 0. Missing upstream JSON fields
log + skip emit rather than fabricating data.
2026-05-22 13:18:42 -07:00
chopratejas
c375fa156d fix(cli): wrap subcommands for cline, continue, goose, openhands
Adds four new `headroom wrap <agent>` subcommands so the proxy can
front-end Cline (VS Code), Continue (VS Code/JetBrains), Goose (Block CLI),
and OpenHands (CLI), extending the existing claude/codex/aider/copilot/
cursor pattern. Phase G PR-G1 of the realignment work.

Architectural decision: extended the existing `headroom/cli/wrap.py`
module in-place rather than splitting it into a `headroom/cli/wrap/`
package. The spec at REALIGNMENT/09-phase-G-rtk-observability.md
mentions per-agent files under a package, but the existing five wrap
subcommands all live in the single module and the extension is small
relative to the file. Keeping the file together preserves the simple
import surface used by tests (`from headroom.cli import wrap as wrap_mod`).

Per-agent wiring:
- cline → injects RTK block into `.clinerules` at project root
  (Cline is a VS Code extension; API base URL is configured in the UI,
  so the command prints config instructions and blocks on the proxy).
- continue → injects RTK guidance into the `systemMessage` field of
  `.continue/config.json` (idempotent; refuses malformed JSON or
  non-object roots; supports `--config` for custom paths).
- goose → injects RTK block into `.goosehints` at project root and
  launches the goose CLI with OPENAI_BASE_URL / OPENAI_API_BASE /
  ANTHROPIC_BASE_URL env vars pointed at the proxy.
- openhands → injects RTK guidance via the `OPENHANDS_INSTRUCTIONS`
  env var at launch (no on-disk artifact) and sets OPENAI_BASE_URL /
  ANTHROPIC_BASE_URL / LLM_BASE_URL. Preserves any pre-existing
  OPENHANDS_INSTRUCTIONS content.

Tests:
- tests/test_cli/test_wrap_cline.py: 4 tests covering prepare-only
  injection, idempotence, --no-context-tool, and existing content
  preservation.
- tests/test_cli/test_wrap_continue.py: 8 tests covering the new
  `_inject_continue_rtk_systemmessage` helper (new-file, existing
  keys, idempotence, malformed JSON, non-object roots) and the click
  command surface (default path, custom --config).
- tests/test_cli/test_wrap_goose.py: 5 tests covering env-var wiring,
  `.goosehints` injection, idempotence, missing-binary error, and
  --no-context-tool.
- tests/test_cli/test_wrap_openhands.py: 6 tests covering env-var
  wiring, OPENHANDS_INSTRUCTIONS injection, preservation of existing
  instructions, idempotence, missing-binary error, and
  --no-context-tool.

E2E: extended `e2e/wrap/run.py` with `--prepare-only` smoke tests for
all four new wrappers (full launches require agent CLIs not present in
the e2e image; unit tests cover the env-var wiring).
2026-05-21 21:04:50 -07:00
chopratejas
20dc1f28f3 fix(proxy): Strands MCP bundle + backend path fixes + Codex fail-closed protection
Three logically-related sets of proxy changes ship in this branch:

1. Strands integration on the Bedrock path (HeadroomBundle + 4 OpenAI
   handler fixes + LiteLLM cache stats + dep pin)
2. /stats MCP aggregation (cross-process events log → proxy summary)
3. Codex compression-failure fail-closed (WS + HTTP /v1/responses)

== 1. Strands integration on the Bedrock path ==

* HeadroomBundle (headroom/integrations/strands/bundle.py): single-helper
  MCP wiring for a Strands Agent — Headroom MCP server (headroom_compress
  / headroom_retrieve / headroom_stats) plus optional Serena MCP and
  optional in-process compression hook. Constructor builds unstarted
  MCPClient instances per server; Strands' Agent owns the subprocess
  lifecycle. Default config: MCP enabled, Serena enabled, hook OFF
  (proxy is the single source of truth for compression). User-side
  integration is two lines in any Strands app.

* headroom/proxy/handlers/openai.py — backend path now:
  - calls PrefixCacheTracker.update_from_response (was direct-OpenAI only)
  - intercepts CCR headroom_retrieve tool_calls server-side, mirroring
    the Anthropic handler pattern; NO silent fallback, re-raises on
    CCR errors (per feedback_no_silent_fallbacks)
  - works for both non-streaming and streaming paths

* headroom/proxy/handlers/streaming.py: _stream_openai_via_backend now
  accepts prefix_tracker + optimized_messages, parses cache stats from
  the SSE final-usage frame (cache_creation_input_tokens added to the
  state machine), records CCR retrieve feedback via a new
  _record_ccr_feedback_from_openai_sse helper. Streaming CCR intercept
  is intentionally out of scope (mirrors Anthropic streaming behaviour).

* headroom/backends/litellm.py: send_openai_message response usage block
  now carries cache_read_input_tokens / cache_creation_input_tokens
  (Anthropic/Bedrock dialect) and prompt_tokens_details.cached_tokens
  (OpenAI dialect). Backwards-compatible — cold-start callers see the
  same 3-key shape; cache keys appear only when the underlying provider
  returns them. Pinned by test_no_cache_fields_means_no_cache_keys.

* headroom/proxy/auth_mode.py: ("strands-agents/", "strands") added to
  CLIENT_UA_MAP. Production callers should also set X-Client: strands
  since the default openai-python UA carries no Strands signal.

* pyproject.toml: huggingface-hub>=1.5.0,<2.0 pinned in [ml] so a sibling
  install (e.g. strands-agents) can't drag the version below the floor
  transformers 5.x requires (otherwise Kompress silently goes
  "unavailable").

== 2. /stats MCP aggregation ==

* headroom/proxy/cost.py: _aggregate_mcp_events() reads the cross-process
  shared events file the Headroom MCP server already writes to and
  surfaces summary.mcp with three new keys:
    - compressions       (count of headroom_compress invocations)
    - tokens_removed     (sum of input - output across those)
    - retrievals         (count of headroom_retrieve — the load-bearing
                          over-compression alarm; if it grows linearly
                          with turn count, lossy compressors are
                          dropping info the model actually needs)
  Defensive on every axis — missing MCP SDK, missing file, malformed
  events, read errors — never blocks /stats.

* examples/strands_bundle_demo.py: stats panel prints the new fields so
  the demo shows the full proxy-HTTP + MCP-tool story in one view.

== 3. Codex compression-failure fail-closed protection ==

Reported by Camille (2026-05-21): Codex threads were locking with
"ran out of room in the model's context window" after Headroom's
compression timed out on an oversized response.create frame and
forwarded the original ~1.7 MB frame to the upstream, which then
rejected it. Codex's auto-compact heuristic gates on the upstream-
reported total_usage_tokens (which Headroom had been shrinking on
earlier turns), so its compaction never fired and the thread locked.

Validated against open Codex issues (CLI + Desktop share codex-rs/core):
* #16068 — confirms compaction gates on total_usage_tokens,
  estimated_token_count is computed but only logged
* #19806 — confirms image token estimator unbounded, contributes to
  the same ContextManager.get_total_token_usage → auto-compaction chain

* headroom/proxy/helpers.py: decide_compression_failure_action() with a
  unit-tested decision matrix:
    - asyncio.TimeoutError                              → refuse, always
    - non-timeout failure + frame > 256 KiB (configurable) → refuse
    - non-timeout failure + small frame                 → forward (legacy)
  Operator escape hatches:
    - HEADROOM_WS_FAIL_OPEN_ON_COMPRESSION_FAILURE=1 restores legacy
    - HEADROOM_WS_COMPRESSION_FAIL_THRESHOLD_BYTES tunes the threshold

* headroom/proxy/handlers/openai.py (WS /v1/responses): consults the
  helper after compression failure. On refuse: close client websocket
  code 1009 with "headroom: compression <reason> — please compact
  context and retry" reason; set termination_cause for the outer
  lifecycle finally; return.

* headroom/proxy/handlers/openai.py (HTTP /v1/responses): same helper.
  On refuse: raise HTTPException(413) with a structured error body so
  FastAPI's HTTPException handler emits a clean 413. The existing
  `except HTTPException: raise` guard in this handler already ensures
  the 413 propagates without being swallowed by the 502 catch-all.

Anthropic /v1/messages NOT changed in this branch: no equivalent bug
report on Anthropic-protocol clients, Claude Code (Anthropic-owned)
handles context overflow via its own cache_control/ephemeral
primitives, and Cursor/Aider don't maintain the local-Y estimate the
Codex bug requires. Deferred until a real report lands; the patch is
a one-liner reusing the same helper.

== Tests + verification ==

* tests/test_backends/test_litellm_cache_stats.py — 3 tests pinning
  cache-stat surfacing across Anthropic/OpenAI dialects + backwards-
  compat for no-cache responses.
* tests/test_proxy/test_openai_backend_path.py — 5 tests (Bedrock cache
  fields, OpenAI fallback shape, CCR intercept with provider="openai",
  CCR re-raise on exception, streaming signature contract).
* tests/test_proxy/test_mcp_stats_aggregation.py — 5 tests pinning the
  aggregator across compress+retrieve mixes, empty events, unknown event
  types, missing token fields, and read failures.
* tests/test_proxy/test_compression_failure_action.py — 12 tests pinning
  the fail-closed decision matrix (timeout always refuses, small
  transient passes through, oversize refuses, env override variants,
  custom threshold, invalid threshold falls back, 0/negative ignored).

* examples/strands_bedrock_demo.py — model_id bumped from deprecated
  Claude 3 Haiku to Sonnet 4.5 (the deprecated model now errors on
  account access).
* examples/strands_via_proxy_demo.py — proxy + Bedrock cache + streaming
  smoke test.
* examples/strands_mcp_dispatch_test.py — pure MCP round-trip probe.
* examples/strands_bundle_demo.py — full Strands + HeadroomBundle E2E
  demo (this is the shape a real Strands user copies into their app).

Full pytest: 5327 passed, 178 skipped. The previously-failing
test_core_operations.py::TestAddBatch::test_add_batch_basic passes now
that the huggingface-hub pin in pyproject.toml unblocks transformers
imports.

E2E verified live against AWS Bedrock (Sonnet 4.5):
* cache_write=10,438 on turn A → cache_read=10,438 on turn B
* streaming SSE final usage frame carries cache_read_input_tokens
* 78.7% reduction on a 50 KB JSON tool_result via SmartCrusher (
  dispatched per-content-type by ContentRouter)
* Strands Agent + HeadroomBundle: model autonomously called
  headroom_compress + headroom_retrieve via MCP; CompressionStore
  round-trip succeeded; final answer correct.
2026-05-21 11:00:14 -07:00
chopratejas
2208121e12 test(memory): live tests for delete-via-[id] + dedup-hint mechanism
Three live tests now cover the model-as-judge memory loop end-to-end
against the real Anthropic API:

1. memory_update via [id] (already existed) — model extracts ID from
   the auto-tail block, calls memory_update with the exact seeded ID.

2. memory_delete via [id] (new) — same handle, destructive verb.
   Proves the [id] prefix is verb-agnostic. Prompt explicitly tells
   the model to skip memory_search / memory_list so the test targets
   the direct-from-tail path.

3. memory_save → dedup-hint mechanism (new) — when the model fires
   memory_save on near-duplicate content, the proxy's _execute_save
   must return a "Similar memory exists" note carrying the existing
   memory's exact ID. Without this hint, parallel duplicates would
   silently accumulate, polluting the cache prefix.

The dedup test asserts the MECHANISM (hint contains seeded ID),
not the model's downstream behaviour. The hint text intentionally
ends with "or ignore if these are distinct facts", so the model is
free to decline consolidation. Whether it consolidates depends on a
judgement call about whether two phrasings name the same fact —
intentionally outside this test's contract.

Refactor: extract _seed_memory and _install_tool_call_recorder
module-level helpers so all three live tests read as their intent.
Recorder also captures the tool result now (needed to inspect the
JSON-encoded dedup hint).
2026-05-19 22:18:15 -05:00
chopratejas
002ed97c54 Merge remote-tracking branch 'origin/main' into refactor/memory-tool-handles
# Conflicts:
#	headroom/proxy/memory_handler.py
2026-05-19 22:17:20 -05:00
chopratejas
c62d45eea8 fix(memory): expose memory IDs in auto-tail + memory_list tool + ID-usage guidance
Pre-this-PR the auto-injected memory block rendered rows as `1. <content>`
with no addressable handle. To UPDATE or DELETE a row the model first had
to call memory_search to discover its ID — two round trips, against the
model-as-judge architecture.

This PR adds three tightly-coupled affordances so the model can act on
memory directly:

1. Auto-tail rows now carry the memory ID:
     `1. [mem_alpha_001] User prefers Python`
   The bracketed token is the canonical ID — same identifier accepted by
   memory_update and memory_delete.

2. New `memory_list` tool — chronological browse (vs `memory_search`'s
   semantic lookup). Returns recent memories with their IDs. Backend
   dispatches to `Backend.list_memories` if available, else falls back
   to an empty-query `search_memories`. Caps at 100 entries.

3. ID-usage guidance text appended to the auto-tail block. Tells the
   model that bracketed IDs can go straight to memory_update /
   memory_delete with no intervening search. The guidance lives in the
   user-message tail (never system) — preserves cache-prefix byte
   stability (invariant I2).

`memory_update` and `memory_delete` tool descriptions also point at the
[id] block as a valid ID source — keeps tool docs consistent with the
new affordance.

Verification:
- 10/10 tests pass in tests/test_memory_auto_tail.py (incl. 2 new
  guidance tests + 2 new ID-format tests)
- 31/31 tests pass in tests/test_memory_handler_native_ops.py (incl. 4
  new memory_list dispatch tests + existing assertions updated for the
  [id] format change)
- Golden fixtures regenerated for the tool-description copy changes
  (tests/fixtures/memory_tool_definitions/{anthropic,openai}.json)
- Live end-to-end test against real Anthropic API
  (tests/test_proxy_memory_integration.py::TestMemoryIdAutoTailAndUpdate):
  seeded memory → auto-tail → Claude → memory_update with exact ID.
  PASSED.
2026-05-19 22:10:42 -05:00
chopratejas
7a0f80d0f4 Merge remote-tracking branch 'origin/main' into refactor/memory-ranker-and-image-decision
# Conflicts:
#	headroom/proxy/memory_handler.py
2026-05-19 20:37:50 -05:00
chopratejas
a7b197c6ec refactor(proxy): MemoryRanker + ImageCompressionDecision + branch-aware version-sync
Three independent contract-pattern follow-ons bundled into one PR.
Same frozen-dataclass + factory + apply_to_tags + Rust-portable
shape that PR #473 / #477 / #483 established.

## (1) MemoryRanker + RecencyBoostRanker

Pre-this-PR Headroom ranked memory candidates by pure cosine
similarity. Every other memory system we surveyed (Letta, Mem0,
Cognee, Supermemory) re-ranks beyond cosine.

* ``MemoryRanker`` Protocol — pluggable re-ranker; future PRs add
  source-weight + access-count rankers behind the same interface.
* ``RecencyBoostRanker`` — first concrete impl. Final score is
  ``cosine × exp(-age_days / decay_days)``. Default decay 30 days
  (half-life ~21 days; 60-day-old factor 0.135, 90-day-old 0.050).
* ``MemoryCandidate`` — backend-agnostic frozen value type that
  flows through the ranker. ``MemoryCandidate.from_backend_result``
  adapter converts the existing ``MemoryResult`` shape (with nested
  ``memory.created_at``) into the ranker's flatter form.
* Wired into ``memory_handler.search_and_format_context`` as an
  optional ``ranker=`` kwarg — backwards-compat: ``None`` (default)
  preserves the pure-cosine path identically.

Defensive:
* ``created_at=None`` → factor 1.0 (recency-neutral, back-compat with
  legacy rows / migrating backends)
* Negative age (clock skew) → clamped to factor 1.0 (a future-dated
  row can't outrank a real fresh memory)
* Sort is stable on ties — same input → same output every turn, so
  consecutive turns inject memories in the same order (prefix-cache
  friendly)

Performance: O(N) over candidates where N=top_k≈10. One ``math.exp``
per candidate. Sub-microsecond. Zero new I/O.

## (2) ImageCompressionDecision

Mirror of :class:`CompressionDecision` for image compression. Two
sites today (``openai.py:1203``, ``anthropic.py:868``) gate inline;
both already respect bypass (no Gemini-class drift bug like text
compression had), but consolidating into a value type:

* Locks bypass-respect via AST contract test — future sites can't
  drift on it
* Surfaces ``image_skip_reason`` in ``RequestOutcome.tags`` for
  dashboard slicing (same observability surface as
  ``passthrough_reason`` and ``memory_skip_reason``)
* Same Rust-port shape as the other decision types

Precedence: ``bypass_header`` > ``image_optimize_disabled`` >
``no_messages`` > ``should_compress=True``.

Anthropic's extra ``is_cache_mode`` check stays inline because it's
Anthropic-specific (openai/gemini don't have it). Documented in a
code comment.

## (3) Branch-aware sync-plugin-versions hook

Pre-this-fix the pre-commit ``sync-plugin-versions`` hook ran on
every commit and bumped manifests to the predicted-next-release
version. Every PR ended up carrying the prediction as collateral
("Why are we bumping ``.claude-plugin/marketplace.json`` — we
should not, right??" - user, on PR #483).

Fix: the hook is now a NO-OP unless EITHER:
* We're on the ``main`` branch, OR
* ``HEADROOM_SYNC_VERSIONS=1`` is set explicitly (release workflow)

On feature branches the hook prints a single line explaining the
skip and exits cleanly. The release workflow opts in via the env
var; behaviour on main / at release time is unchanged.

## Test coverage

* 16 new tests on ``MemoryRanker`` / ``RecencyBoostRanker``
  (frozen, equal cosine wins by recency, decay configurable, NULL
  timestamp neutral, no-mutation contract, Rust-port shape)
* 17 new tests on ``ImageCompressionDecision`` (frozen, all 3
  skip reasons, precedence, observability fields, apply_to_tags)
* 1 new AST invariant test (extends
  ``test_handler_outcome_tag_invariant.py``) — locks "no raw
  ``if self.config.image_optimize and messages and not _bypass:``
  conjunction in any handler"

All existing memory + cache-stability + handler tests pass (203 ✓).
``make ci-precheck`` clean.

## Rust portability

All three new value types port cleanly to frozen Rust structs +
pure functions. Same migration pattern as ``CompressionDecision``
(already locked in for the SmartCrusher Rust port).

## Zero-regression contract

* Default ``ranker=None`` → memory_handler behaves identically to
  pre-this-PR (pure cosine; no perf change)
* Image decision migration is identity at the bypass/optimize/messages
  gate — no behaviour change, just contract consolidation
* Hook fix is no-op on feature branches (less churn) and unchanged
  on main (release flow preserved)
2026-05-19 12:13:09 -05:00
chopratejas
67620522f9 test(memory): update _extract_user_query test to lock no-truncation
PR-this removed the 500-char query truncation in
``memory_handler._extract_user_query``. The pre-existing assertion at
``test_memory_handler_native_ops.py:845`` was testing the old buggy
behaviour (output truncated at 500) and CI caught it. Updated to
assert the full-fidelity return.
2026-05-19 11:26:21 -05:00
chopratejas
71d5a7b545 fix(proxy): MemoryDecision contract + 3 bypass bugs + drop 500-char query cap
Three bug classes fixed plus three architectural extension points,
together making the memory subsystem uniform across all five sites
and ready for future Mem0/Letta/Cognee backend integration.

## Bug fixes

* **3 sites silently ignored `x-headroom-bypass: true`** —
  ``anthropic.py:1303``, ``openai.py:1620`` (chat), ``gemini.py:382``
  injected memory under bypass, mutating request bytes when the user
  explicitly asked for byte-faithful passthrough. Now gated on
  ``MemoryDecision.decide(...)`` which honours bypass uniformly.

* **500-char query truncation** — ``memory_handler._extract_user_query``
  capped at 500 chars, silently throwing away signal. None of Letta /
  Mem0 / Cognee / Supermemory truncate. Removed; the embedding model
  handles its own window.

* **Gemini had no timeout** on ``search_and_format_context`` — the
  only chat handler without one. A slow backend could stall requests.
  Added ``asyncio.wait_for`` matching Anthropic + OpenAI Chat +
  Responses.

* **WS injected into ``body["instructions"]``** — the system /
  cache-hot-zone field, violating invariant I2 (all other handlers
  inject at user-message tail). Switched to ``ws_response_body["input"]``
  for string-shaped input; list-shaped input deferred to the Rust
  handler with a clear log.

## New value types (extension points)

* ``MemoryDecision`` — frozen dataclass + factory. Five-way skip
  reason enum (``bypass_header`` / ``no_handler`` / ``no_user_id`` /
  ``mode_disabled`` / ``mode_tool``). ``apply_to_tags()`` surfaces
  the skip reason in ``RequestOutcome.tags["memory_skip_reason"]``
  — dashboards can now slice memory-blind traffic by cause.

* ``MemoryQuery`` — multi-source retrieval query. ``from_messages()``
  walks the conversation and extracts latest user text + recent tool
  outputs + recent assistant turns at FULL fidelity (no truncation).
  Handles both OpenAI-shape ``role: tool`` and Anthropic-shape
  ``tool_result`` content blocks. ``to_embedding_input()`` produces
  a delimited concatenation the embedder sees as structured context.

* ``MemoryInjectionBudget`` — uniform token / entry / similarity
  bound on the formatted injection block. Pre-this-PR no cap (~4000
  tokens could land per request). Default 1024 tokens / 10 entries /
  0.3 similarity floor. ``apply_to_text()`` truncates at line
  boundaries so dashboard renders intact bullet points.

## Migration scope — all 5 sites uniform at the GATE level

| Site | Handler | Pre-PR gate | Post-PR gate |
|---|---|---|---|
| 1 | anthropic.py | `memory_handler and memory_user_id` | `memory_decision.inject` |
| 2 | gemini.py | `memory_handler and memory_user_id` | `memory_decision.inject` |
| 3 | openai.py chat | `memory_handler and memory_user_id` | `memory_decision.inject` |
| 4 | openai.py Responses | `memory_handler and memory_user_id and not _bypass` | `responses_memory_decision.inject` |
| 6 | openai.py WS | `memory_handler and body and not _ws_bypass` | `ws_memory_decision.inject` |

Site 5 (Responses bypass-elif log-only branch) is preserved verbatim.

## Deliberately deferred (separate PRs)

* **Memory injection order inversion** — sites 4 and 6 inject
  BEFORE compression; sites 1/2/3 inject AFTER. Moving 4 + 6 to
  post-compression needs its own focused cache-stability testing.
* **Importance scoring** — recency × source × access-count.
* **Per-memory atomize-and-split** — Mem0/Supermemory pattern.
* **AST-aware code chunking for tool outputs** — Supermemory's
  code-chunk approach.

The contracts shipped here (``MemoryQuery`` + ``MemoryInjectionBudget``)
are the extension points those will plug into.

## Test coverage

* 20 new tests on ``MemoryDecision``
* 14 new tests on ``MemoryQuery`` (full-fidelity, multi-source)
* 10 new tests on ``MemoryInjectionBudget``
* 3 new AST contract tests (no raw gate; no system writes; every
  search call passes ``query=``)
* All existing memory + cache-stability tests still pass (222 passed)

## Rust portability

Every new value type ports cleanly to a frozen Rust struct. Pure
functions, no I/O, no global state. Same Python ↔ Rust parity-test
pattern that ``CompressionDecision`` already uses.

## Zero-regression contract

Existing chat/completion harnesses (Claude Code, Codex, Cursor,
Continue, Aider) see ZERO wire-byte changes when bypass is NOT set.
When bypass IS set, the 3 chat handlers now correctly skip memory
injection — that's the bug fix, not a regression.
2026-05-19 11:13:52 -05:00
chopratejas
3ec549288a fix(proxy): thread tags into 13 outcome sites + synth /v1/models + free-fn _extract_tags
Three fixes bundled; all in admin / cache-hit paths where tests didn't
catch the regression.

## (A) 13 RequestOutcome sites missing tags=

An AST audit found that 13 of 21 ``RequestOutcome(...)`` construction
sites across the four handler files emitted outcomes without threading
``tags=``. Affected paths:

* ``handle_anthropic_messages`` — the ``from_response_cache=True``
  early-return outcome (Claude Code cache-hit turns dashboard-blind)
* ``handle_openai_chat`` — same cache-hit early-return (Codex +
  Cursor + Continue cache-hit turns dashboard-blind)
* ``handle_openai_responses_ws`` — the per-turn outcome inside the
  Codex WS session. The stale comment that said "ws_session_tags is
  not yet bound" was wrong — ``ws_tags`` was already extracted at
  handler entry
* ``handle_anthropic_batch_create / batch_passthrough / batch_results``
* ``handle_passthrough`` (OpenAI Models / Files / List-Batches)
* ``handle_google_batch_create / batch_passthrough / batch_results``
* ``_google_batch_passthrough`` (internal helper)
* ``handle_batch_create`` (OpenAI batch entry)
* ``handle_gemini_count_tokens`` (also fixed in #479; identical)

Pattern of the fix is uniform: pull tags from headers and thread
them into the ``RequestOutcome`` construction.

New contract test ``test_handler_outcome_tag_invariant.py`` walks each
handler file's AST and asserts every ``RequestOutcome`` site inside any
``handle_*`` or ``*_passthrough`` method passes both ``tags=`` and
``client=``. Future handlers get a clear test failure with file +
line + method name if they regress.

## (B) Issue #478 — /v1/models 403 under Codex ChatGPT auth

Codex Desktop with ChatGPT-subscription OAuth polls ``/v1/models`` to
populate its model picker. Forwarding to ``chatgpt.com/backend-api/
models`` returned 403 to OAuth tokens. Fix: synthesize an OpenAI-
compatible payload locally from a known-supported model set
(``gpt-5.5`` through ``gpt-5``). All other ChatGPT-auth paths still
forward as before — only model-metadata gets the local response.

## (C) Move _extract_tags to free function (mixin-isolation test compat)

Handlers called ``self._extract_tags(headers)``. That worked in
production where ``HeadroomProxy`` composes every mixin and defines
the method, but broke tests that instantiate a single mixin via
``object.__new__(OpenAIHandlerMixin)``. The free-function form
removes that coupling — handlers import ``extract_tags`` from
``headroom.proxy.helpers`` and call directly. ``HeadroomProxy.
_extract_tags`` is kept as a thin wrapper for any external caller
still using the method form. 17 call sites migrated.

## Zero behavior change for existing users

Claude Code, Codex, Cursor, Continue, Aider, Gemini-routed harnesses
all hit handlers that already extracted tags. Their wire bytes to
upstream LLMs are byte-identical. Only the dashboard view gains tags
on previously-blind paths.

Closes #478.
2026-05-15 19:15:48 -07:00
chopratejas
e4e28b65f4 fix(proxy): surface CompressionDecision.passthrough_reason in tags
Adds ``CompressionDecision.apply_to_tags(tags)`` — a one-liner mutator
that stamps the passthrough reason into a tags dict for downstream
observability. Each migrated handler now calls
``_decision.apply_to_tags(tags)`` immediately after
``CompressionDecision.decide(...)``. The tags dict flows unchanged
into every downstream ``RequestOutcome(tags=tags, ...)`` construction,
which the funnel surfaces in ``RequestLog.tags`` — same mechanism the
funnel already uses for ``client``.

Dashboards can now slice passthrough traffic by cause:

  * tags["passthrough_reason"] == "bypass_header"
  * tags["passthrough_reason"] == "compression_disabled"
  * tags["passthrough_reason"] == "no_messages"
  * tags["passthrough_reason"] == "license_denied"

No-op when ``should_compress=True`` — compressing requests don't
carry the tag, so absence vs presence is itself the signal.

Bonus fix: ``handle_gemini_count_tokens`` was the one Gemini handler
that never pulled tags out of headers, so its emitted
``RequestOutcome`` reached the dashboard without any of the per-
request slicing keys. Added the missing ``tags = self._extract_tags
(request.headers)`` and threaded ``tags=tags`` into its outcome.

Closes the observability loop opened by PR #477: the four Gemini-
bypass-bug fixes are now visible in the request-log feed the moment
they fire.
2026-05-15 14:40:00 -07:00
chopratejas
2935cd9c89 refactor(proxy): introduce CompressionDecision; fix 4 missing-gate bugs
Pre-this-PR, four handler files computed "should this request be
compressed?" inline at five sites with subtle drift. Three Gemini
sites silently ignored ``x-headroom-bypass: true``; one of those
also ignored the license gate. Anthropic and OpenAI got the full
conjunction right, but encoded it inline.

``CompressionDecision.decide(headers, config, usage_reporter,
messages)`` is the single canonical factory. Precedence:

  1. ``bypass_header``       — user's explicit opt-out (highest)
  2. ``compression_disabled`` — operator config.optimize=False
  3. ``no_messages``         — nothing to compress
  4. ``license_denied``      — commercial gate

The factory exposes every constituent boolean
(``bypass_header_set``, ``config_optimize_enabled``,
``license_allows``, ``has_messages``) so debug tooling can answer
"what did the decision see?" without re-running it.

Each migrated site now logs ``Compression skipped: reason=<X>`` on
passthrough — new structured observability.

Bug fixes that came with the consolidation:

* ``handlers/gemini.py:handle_gemini_generate_content`` — now
  respects ``x-headroom-bypass``
* ``handlers/gemini.py:handle_google_cloudcode_stream`` — now
  respects ``x-headroom-bypass``
* ``handlers/gemini.py:handle_gemini_count_tokens`` — now respects
  ``x-headroom-bypass`` AND the license gate (was missing both)
2026-05-15 10:16:23 -07:00
chopratejas
694589fec4 refactor(proxy): collapse 3 stream finalizers onto RequestOutcome.from_stream
Three streaming finalizers — ``_finalize_stream_response``,
``_stream_response_bedrock``, ``_stream_openai_via_backend`` — each
duplicated the same set of body- and config-derived fields when
constructing a ``RequestOutcome``:

  * ``attempted_input_tokens = optimized_tokens + tokens_saved``
  * ``num_messages = len(body.get("messages", []))``
  * ``request_messages`` conditional on ``config.log_full_messages``
  * ``transforms_applied`` list → tuple (frozen-dataclass contract)
  * ``tags or {}`` normalization
  * ``turn_id`` via ``compute_turn_id``

The last one was a real bug. Only the Bedrock site computed
``turn_id`` — sites 1 and 3 silently dropped it, breaking the
dashboard's multi-turn-session grouping for every Anthropic-SSE and
OpenAI-via-backend request. The new ``RequestOutcome.from_stream``
classmethod computes it uniformly so the three finalizers cannot
drift apart on derivation logic again.

Each call site now hands ``from_stream`` the body + provider-specific
cache/timing fields and gets a fully-constructed outcome back. The
funnel call after it stays identical (``await
self._record_request_outcome(outcome)``).
2026-05-14 22:34:23 -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
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
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
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
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
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
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
a2ea9648a4 fix: add Kompress backend and thread controls 2026-05-12 15:32:57 -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
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
Gili Tzabari
28a0e19deb Merge origin/main into fix/codex-ws-dashboard-performance 2026-05-11 14:45:30 -04: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
Tejas Chopra
c83687798b Fix Windows ORT builds and Docker signing retries 2026-05-10 20:59:28 -07:00