Commit graph

2630 commits

Author SHA1 Message Date
chopratejas
00902b8fea fix: B7 — CCR hardening: persistent backends + always-on tool
P2-25, P2-26: CCR (Compress-Cache-Retrieve) used an in-memory store
that fragmented across uvicorn workers and was wiped on restart, and
the `headroom_retrieve` tool was registered/unregistered per-request
based on whether the latest body happened to contain compression
markers — every flip busted the prompt cache. Both are sticky
side-channels: once a session has done CCR, the tool list bytes and
the retrieval store must stay stable. This PR fixes both.

Rust:
* Split `ccr.rs` into `ccr/` with `backends/` submodule
  (`in_memory.rs`, `sqlite.rs`, `redis.rs` cfg-gated).
* `SqliteCcrStore` (production default): WAL mode, prepared upsert,
  lazy TTL purge on read, persistent across worker restarts and
  shareable across workers on the same host via SQLite file locking.
* `RedisCcrStore` (cfg-gated behind `feature = "redis"`): SETEX with
  startup PING smoke-test, no key-prefix collision risk, no sticky
  session required at the LB.
* `CcrBackendConfig::{InMemory, Sqlite, Redis}` + `from_config(...)`
  factory — every init failure surfaces (no silent fallback per
  `feedback_no_silent_fallbacks.md`).
* `ccr::compute_key` (BLAKE3 → first 24 hex chars) and
  `ccr::marker_for("HASH") -> "<<ccr:HASH>>"` centralize the hash +
  marker format; one definition for the live-zone dispatcher and the
  Python regex (`headroom/ccr/tool_injection.py:211`).
* `compress_anthropic_live_zone_with_ccr` accepts
  `Option<&dyn CcrStore>`. When wired, every accepted compression
  puts the original bytes into the backend and appends `<<ccr:HASH>>`
  to the compressed string. The token-validation gate runs on the
  marker-augmented string so the `compressed_tokens >=
  original_tokens` rejection stays honest.

Python:
* `SessionCcrTracker` + `apply_session_sticky_ccr_tool` mirror the
  PR-A7 `SessionToolTracker` / `apply_session_sticky_memory_tools`
  pattern: once a session has done CCR, every subsequent request
  injects the recorded golden tool-definition bytes. Tool list bytes
  are byte-stable across turns (snapshot test pins them).
* `headroom/ccr/tool_injection.py::inject_tool_definition` accepts a
  new `session_has_done_ccr` kwarg per the PR-B7 spec change at line
  302-328. The legacy per-request path stays intact for callers that
  don't yet thread a session id (e.g. Google handler).
* Anthropic + OpenAI handlers route their CCR tool-list updates
  through `apply_session_sticky_ccr_tool`, keyed off the existing
  `session_tracker_store.compute_session_id(...)` plumbing.

Backend selection model: `CcrBackendConfig::Sqlite { path }` is the
production default — single host, persistent, multi-worker safe with
sticky session. `CcrBackendConfig::Redis { url }` is the multi-host
scale-out option — no stickiness needed. `InMemory` is for tests
and single-worker dev only. RUST_DEV.md "Multi-worker deployment —
CCR fragmentation" rewritten around this matrix.

Tests:
* `crates/headroom-core/tests/ccr_backends.rs` — 7 tests covering
  SQLite round-trip, TTL purge, proxy-restart survival, cross-backend
  byte-equal keys, `from_config` paths, and the no-redis-feature
  loud-failure check (+ 2 redis tests gated behind the feature).
* `crates/headroom-core/tests/live_zone_ccr.rs` — confirms
  `<<ccr:HASH>>` marker injection, store population, and
  no-marker-when-no-store invariants end-to-end.
* `tests/test_ccr_tool_always_on.py` — 12 tests pinning the
  always-on behaviour, session/provider isolation, LRU bound, no-
  session-id fallback, and (per-acceptance-criterion) the byte-stable
  tool-definition snapshot.

Per-PR-B7 plan: REALIGNMENT/04-phase-B-live-zone.md.
2026-05-02 16:52:33 -07:00
chopratejas
2ee05774b9 fix: B6 — memory injection moves to live-zone user-tail
PR-A2 locked the system prompt and routed Anthropic memory injection to the
latest non-frozen user turn. PR-B6 finishes the job: every provider handler
that auto-injects memory context now does so via the live-zone tail, and a
new MemoryMode enum makes the routing explicit and configurable.

What changed
------------
* New `MemoryMode` enum in `headroom/proxy/memory_handler.py` with two
  values:
    - `AUTO_TAIL` (default) — retrieval results auto-append to the latest
      user message. The cache hot zone (system / instructions / frozen
      prefix) is never mutated.
    - `TOOL` — auto-injection is disabled entirely. The model must call
      `memory_search` to retrieve. Memory is opt-in and visible.
* `MemoryConfig.mode: MemoryMode = MemoryMode.AUTO_TAIL` propagates into
  `search_and_format_context`, which now short-circuits to `None` in `TOOL`
  mode. This is the single chokepoint that gates every provider — Anthropic
  /v1/messages, OpenAI /v1/chat/completions, OpenAI /v1/responses, and
  Gemini all funnel through it, so flipping a deployment to tool mode does
  not require auditing every handler.
* New `MemoryHandler._append_to_latest_user_tail(messages, context_text,
  provider=..., frozen_message_count=...)` static helper provides the unified
  tail-append entry point and dispatches to the existing provider-specific
  helpers (`AnthropicHandlerMixin._append_context_to_latest_non_frozen_user_turn`
  for Anthropic, `append_text_to_latest_user_chat_message` for OpenAI).
* Gemini handler swapped from auto-prepending memory as a system message
  (the old P2-24 cache-hot-zone mutation pattern) to using
  `_append_to_latest_user_tail(provider="openai")`.
* `ProxyConfig.memory_mode: Literal["auto_tail", "tool"] = "auto_tail"`
  surfaces the mode for deployment configuration. Server constructs the
  enum via `MemoryMode(config.memory_mode)` and raises loudly on unknown
  values (no silent fallback).
* OpenAI Chat Completions, OpenAI Responses, and Anthropic handlers were
  already routing to the live-zone tail via PR-A2/A3 — no code change
  needed beyond inheriting the `TOOL`-mode skip from the chokepoint.

Tests
-----
* `tests/test_memory_auto_tail.py` (6 tests):
    - `test_memory_appears_in_latest_user_message_tail` — Anthropic shape.
    - `test_memory_appears_in_latest_user_message_tail_openai_shape` —
      OpenAI string + list-content shapes.
    - `test_memory_does_not_modify_system_or_tools` — system prompt and
      tools list are never touched; frozen-prefix tail is a no-op.
    - `test_same_query_byte_identical_across_runs` — two independent runs
      with identical inputs produce byte-identical mutated message lists
      (determinism gate).
    - `test_default_mode_is_auto_tail` — fresh `MemoryConfig` defaults to
      `AUTO_TAIL`.
    - `test_unknown_provider_raises` — invalid provider strings raise
      loudly per the no-silent-fallback policy.
* `tests/test_memory_tool_mode.py` (4 tests):
    - `test_tool_mode_skips_auto_injection` — `search_and_format_context`
      returns `None` and the backend is never queried.
    - `test_tool_mode_skip_emits_structured_log` — skip emits the
      `event=memory_mode_skip` log line for routing-decision auditability.
    - `test_auto_tail_mode_does_query_backend` — inverse contrast pinning
      down that AUTO_TAIL still works end-to-end while TOOL skips.
    - `test_tool_mode_enum_value_is_stable` — string round-trip is pinned
      so deployment configs do not drift on rename.

Determinism
-----------
Tests stub the backend with a fixed, ordered result set so the byte-identical
assertion isolates the tail-injection layer from upstream search non-
determinism. The vector-search layer itself (LocalBackend / HNSW) is
deterministic per-process for the same inputs but has thread-scheduling
variability across processes; per the realignment plan, request-time
determinism is guaranteed by the formatter and the tail-append helpers
(this PR's responsibility), and the backend layer's determinism stays
out-of-scope for B6.

Per-PR-B6 plan: REALIGNMENT/04-phase-B-live-zone.md.
2026-05-02 16:52:31 -07:00
chopratejas
6819b7e5e5 fix: B5 — TOIN observation-only refactor + per-tenant aggregation key
Retire the request-time hint API. PR-B5 splits TOIN into two phases:
  1. Observation: TOIN keeps recording compressions/retrievals at runtime,
     but `get_recommendation()` is deprecated and now returns None.
  2. Publish-then-load: the new `headroom.cli.toin_publish` CLI walks the
     on-disk store and emits `recommendations.toml`. The Rust proxy reads
     that file once at startup via `transforms::recommendations` and
     exposes `get(auth_mode, model, structure_hash) -> Option<&Rec>`.
     PR-F3 will wire the loader into the live-zone dispatcher.

Per-tenant aggregation: `_patterns` is now keyed by
`(auth_mode, model_family, sig_hash)` so PAYG/OAuth/subscription tenants
no longer share buckets. Callers that don't supply auth/model land in the
`("unknown", "unknown", sig_hash)` slot. Added `_make_pattern_key` helper
+ updated tests that previously indexed by raw `structure_hash`.

AuthMode is canonical in `transforms::live_zone`; `transforms::recommendations`
re-exports it (no duplicate enum). Live-zone enum gained `Unknown`,
`as_str()`, and `Hash` derive to serve recommendations callers without a
second source of truth.

Why: per-request hint calls coupled output to mutable TOIN state, breaking
prompt-cache stability across runs (P2-27, P5-56). Pulling advice into a
startup-published TOML keeps per-request output deterministic and lets the
deploy pipeline gate publication independently of proxy uptime.

Per-PR-B5 plan: REALIGNMENT/04-phase-B-live-zone.md.
2026-05-02 16:24:03 -07:00
chopratejas
b3b3feff6f fix: B4 — token validation gate + per-content-type byte thresholds
Eliminate P3-33 / P3-34. Wraps every per-block compression in
the live-zone dispatcher with two new gates:

1. Per-content-type byte thresholds — pinned as `const` at the top
   of `live_zone.rs` so the table is grep-able and reviewable in
   one place. No magic numbers anywhere in the dispatch logic; a
   `threshold_for(ContentType)` helper returns the value. Below
   threshold → no compressor invoked, recorded as
   `BlockAction::BelowByteThreshold { content_type, byte_count,
   threshold_bytes }`. Thresholds:

   - JSON-array tool_results:  1 KiB
   - Build / log output:       512 B
   - Search-result blocks:     1 KiB
   - Git-diff blocks:          1 KiB
   - Source code:              2 KiB (pinned for the future
                               Rust code-compressor port)
   - Plain text:               5 KiB (pinned for Kompress wiring)
   - HTML:                     5 KiB (no compressor today)

2. Tokenizer-validated rejection — the byte-length proxy
   (`compressed_bytes >= original_bytes`) is replaced with a
   token-count check using `headroom_core::tokenizer::get_tokenizer`.
   The dispatcher creates one tokenizer per request (model-aware
   via the new `model: &str` parameter to
   `compress_anthropic_live_zone`) and counts both the original
   and compressed text. When `compressed_tokens >= original_tokens`
   the candidate is rejected and the original bytes are kept.

   `BlockAction::Compressed` and `BlockAction::RejectedNotSmaller`
   gain `original_tokens` and `compressed_tokens` fields so the
   proxy can log token-savings (the currency that actually matters
   for prompt cache + provider billing) instead of bytes.

The proxy `live_zone_anthropic.rs` extracts `body["model"]` (or
falls back to `DEFAULT_MODEL = "claude-3-5-sonnet-20241022"` when
the field is missing — the chars-per-token estimator is calibrated
for the Claude family at 3.5 cpt) and threads it through. The
`Compressed` outcome now reports token counts from the manifest,
not byte counts, so the existing
`tokens_before / tokens_after` plumbing is suddenly accurate.

Tests added:

- `live_zone_thresholds.rs::below_threshold_no_compression_attempted`
  — 200 B JSON array → `BelowByteThreshold` and `NoChange`.
- `live_zone_thresholds.rs::above_threshold_compression_attempted`
  — 10 KB JSON array → byte-threshold gate clears and a compressor
  runs (either `Compressed` or `RejectedNotSmaller`).
- `live_zone_token_validation.rs::compressed_more_tokens_falls_back`
  — pathological input must not produce `Compressed` with
  `compressed_tokens >= original_tokens`.
- `live_zone_token_validation.rs::compressed_fewer_tokens_accepted`
  — well-formed JSON array of dicts → `Compressed` with strict
  token shrinkage.
- Property test `live_zone_compression_token_count_non_increasing`
  — for any well-formed body generated by `proptest`, the
  dispatcher's emitted body has token-count <= input's token-count.
  Pins the central PR-B4 invariant: the dispatcher never inflates
  tokens.

Existing 12 unit tests in `live_zone.rs` and 6 integration tests
in `tests/live_zone_dispatch.rs` updated for the new field shape
and the `model` parameter; all pass. The diff-routing test's
fixture grew to 1.3 KiB so it clears the new GitDiff threshold
gate, exercising the dispatch path rather than short-circuiting.

Per-PR-B4 plan: REALIGNMENT/04-phase-B-live-zone.md.
2026-05-02 14:11:15 -07:00
chopratejas
2b55050a65 fix: B3 — wire type-aware compressors into live-zone dispatcher
Phase B step 3: replace PR-B2's no-op dispatcher with real per-block
compression. SmartCrusher / LogCompressor / SearchCompressor /
DiffCompressor are wired behind content-type detection. SourceCode
and PlainText remain no-op for now (Rust code-compressor port and
Kompress prose compressor land in follow-up work; they're explicit
TODOs in `dispatch_compressor`).

# What's wired

For each block in the latest user message (live zone):

| Detected type | Compressor       | Strategy tag       |
|---------------|------------------|--------------------|
| `JsonArray`   | SmartCrusher     | `smart_crusher`    |
| `BuildOutput` | LogCompressor    | `log_compressor`   |
| `SearchResults` | SearchCompressor | `search_compressor` |
| `GitDiff`     | DiffCompressor   | `diff_compressor`  |
| `SourceCode`  | (no-op, Rust port pending) |       |
| `PlainText`   | (no-op, PR-B4 wires Kompress) |    |
| `Html`        | (no-op, no compressor)   |          |

Anthropic-specific block types (`tool_use`, `thinking`,
`redacted_thinking`, `compaction`) stay tagged `BlockAction::Excluded`
so they remain in the cache hot zone even when they appear in the
live-zone message.

# Cache-safety invariant — byte-range surgery

The PR replaces "deserialize → mutate → serialize" with byte-range
surgery: the dispatcher uses `serde_json::value::RawValue` borrowed
slices and pointer arithmetic to recover each block's exact byte
offset in the input buffer, then splices replacement bytes
in-place. Bytes outside any rewritten range are *literally copied*
from the input, never re-serialized.

The new integration test
`crates/headroom-core/tests/live_zone_dispatch.rs::byte_fidelity_outside_compressed_block`
pins this in CI: SHA-256 of `body[..block_start]` and
`body[block_end..]` must equal the input's, AND the block must
shrink by >2× on a 50 KB JSON-array tool_result.

# Provider scope (Phase B is Anthropic-only)

The entry point is renamed `compress_live_zone` →
`compress_anthropic_live_zone` to make scope explicit. OpenAI Chat
Completions, OpenAI Responses, and Google Gemini each need their
own dispatcher because the request shapes diverge: OpenAI puts
tool results in `role: "tool"` messages (not nested in user),
Responses uses `input` with `function_call_output` items, Gemini
uses `contents`/`parts`/`function_response`. Phase C
(`REALIGNMENT/05-phase-C-rust-proxy.md`) introduces those
dispatchers; they share `LiveZoneOutcome`, `BlockAction`,
`CompressionManifest` and the per-content-type compressor backend
from this module.

# BlockAction taxonomy (replacing PR-B2's `NoOpSkeleton`)

- `Compressed { strategy, original_bytes, compressed_bytes }` —
  compressor ran and produced strictly smaller output; spliced in.
- `RejectedNotSmaller { strategy, original_bytes, compressed_bytes }`
  — compressor ran but didn't shrink; original kept. PR-B4 swaps
  this byte-length proxy for a tokenizer-validated count.
- `CompressorError { strategy, error }` — compressor failed loudly.
  Per project memory `feedback_no_silent_fallbacks.md`, surfaced in
  the manifest; proxy logs warn-level and forwards original bytes
  for that block; other blocks in the same body still compress.
- `NoCompressionApplied { content_type }` — content type has no
  applicable compressor (PlainText, SourceCode, Html, Image,
  Unknown). Replaces PR-B2's `NoOpSkeleton` as the default.
- `Excluded { reason }` — block intentionally outside live zone
  (HotZoneBlockType, BelowFrozenFloor, AboveLiveZone).

# Sequential per-block dispatch (parallelism deferred)

Per-block compression is sequential in B3. Most requests have 1-3
blocks in the latest user message; the rayon/spawn_blocking
overhead approaches the savings below ~4 blocks. PR-B4 will add
async coordination per block (since token validation needs an
async hop anyway) — that's the natural place to add parallelism
guarded by a benchmark-driven threshold.

# Observability

The proxy log line gains the new fields when bytes are rewritten:

- `decision="compressed"`, `reason="live_zone_blocks_rewritten"`
- `body_bytes_in`, `body_bytes_out`, `bytes_freed`
- `live_zone_strategies` (Vec of unique strategy tags)
- `live_zone_block_original_bytes`, `live_zone_block_compressed_bytes`

The PR-B2 `decision="no_change"` arm is preserved with
`reason="no_block_compressed"`.

# Files

- `crates/headroom-core/src/transforms/live_zone.rs` (≈1100 LOC,
  +900 from B2): byte-range surgery; `dispatch_compressor` switch;
  `OnceLock` singletons for SmartCrusher / Log / Search / Diff;
  expanded `BlockAction` enum.
- `crates/headroom-proxy/src/compression/live_zone_anthropic.rs`:
  translates `LiveZoneOutcome::Modified` → `Outcome::Compressed`
  with aggregated manifest counters.
- `crates/headroom-core/tests/live_zone_dispatch.rs` (NEW):
  routing tests + 50 KB byte-fidelity invariant test.
- `crates/headroom-proxy/tests/integration_compression.rs`: log
  contract updated to `reason="no_block_compressed"`.

# Acceptance

- `cargo build --workspace` + `clippy` + `fmt` green.
- `cargo test --workspace --exclude headroom-py`: 881 passed.
- 6 new integration tests in `live_zone_dispatch.rs`:
  json/log/diff routing, source-code no-op, unknown no-op,
  byte-fidelity (50 KB → >2× reduction with byte-equal envelope).
- Existing 12 unit tests in `live_zone.rs` still pass.

Per-PR-B3 plan: REALIGNMENT/04-phase-B-live-zone.md.
2026-05-02 13:52:18 -07:00
chopratejas
e190544c77 fix: B2 — live-zone block dispatcher skeleton
Phase B step 2 of the live-zone-only realignment. Replaces PR-A1's
unconditional "passthrough" stub with a real dispatcher that
inspects the Anthropic /v1/messages body, identifies the live zone
(latest user message at index >= frozen_message_count), and routes
each block to a per-type compressor. PR-B2 wires every per-type
compressor to a no-op, so the dispatcher returns
LiveZoneOutcome::NoChange on every call — bytes-in == bytes-out.
PR-B3+ replaces the no-ops with SmartCrusher, Log, Search, Diff,
and Code compressors.

Adds:
- crates/headroom-core/src/transforms/live_zone.rs — public API:
  - `compress_live_zone(body, frozen_message_count, AuthMode)`
  - `LiveZoneOutcome::{NoChange, Modified}`
  - `CompressionManifest` with per-block outcomes (message_index,
    block_index, block_type, BlockAction).
  - `BlockAction::{NoOpSkeleton, Excluded { reason }}`. The
    HOT_ZONE_BLOCK_TYPES list (`tool_use`, `thinking`,
    `redacted_thinking`, `compaction`) excludes blocks even when
    they appear in the latest user message.
  - `AuthMode::{Payg, OAuth, Subscription}` — accepted but unused
    in B2; PR-F2 wires the auth-mode gate.
  - 12 unit tests pin: empty messages, no messages field, invalid
    JSON, latest user message selection, frozen_count respect,
    hot-zone block exclusion, string-shaped content, no user msg
    in live zone, AuthMode no-op, NoChange contract, manifest
    counters, frozen-count clamping.

- crates/headroom-proxy/src/compression/live_zone_anthropic.rs —
  new entry point. `compress_anthropic_request` parses the body,
  resolves frozen_count via `resolve_frozen_count` (PR-A4 helper),
  dispatches via `compress_live_zone`, and returns
  `Outcome::NoCompression` on PR-B2 success / `Outcome::Passthrough
  { reason: NotJson | NoMessages | ModeOff }` on body-shape /
  policy issues. Six unit tests pin: mode_off short-circuit, no
  messages field, invalid JSON, valid body NoCompression,
  empty body, cache_control disabled.

Modifies:
- compression/mod.rs — re-exports `compress_anthropic_request` from
  `live_zone_anthropic` instead of `anthropic`. The old anthropic
  module is reduced to the `resolve_frozen_count` helper only
  (not deleted, because its CacheControlAutoFrozen-policy gate is
  reused).
- proxy.rs — passes `state.config.cache_control_auto_frozen` into
  the dispatcher. Drops the obsolete "live_zone reserved for
  Phase B" warning that PR-A1 emitted on every request.
- compression/anthropic.rs — pruned to the resolve_frozen_count
  helper plus its tests. The PR-A1 passthrough stub
  `compress_anthropic_request` is gone (live_zone_anthropic owns
  the name now).
- config.rs — `compression_mode` doc updated to reflect the wired
  dispatcher (no longer "reserved for Phase B").
- tests/integration_compression.rs — `compression_decision_logged`
  pins the new log contract (`decision="no_change"`,
  `reason="no_op_skeleton_pr_b2"`, plus manifest fields
  `frozen_message_count`, `messages_total`, `live_zone_blocks`).
  Asserts the obsolete Phase A warning is NOT emitted.
- proxy.rs no longer imports CompressionMode (only used inside the
  retired warning).

Benchmark cleanup (B1 leftovers that surfaced now):
- benchmarks/proxy_mode_benchmark.py + claude_session_mode_benchmark.py:
  drop `intelligent_context=False` arg from ProxyConfig (the field
  was retired in B1; tests/test_proxy_mode_benchmark.py and
  tests/test_claude_session_mode_benchmark.py imported these
  factories and started failing).
- benchmarks/bench_transforms.py: delete TestRollingWindowBenchmarks
  class; rewire TestTransformPipelineBenchmarks fixture without
  RollingWindow.
- benchmarks/conftest.py: drop rolling_window_config fixture.
- benchmarks/run_benchmarks.py: drop the `window` suite + table
  rows referencing RollingWindow.

Cache-safety invariant:
- PR-B2 dispatcher never mutates body bytes (no-op skeleton). The
  proxy forwards the original buffered bytes byte-equal. Phase A's
  SHA-256 fixtures pin this.
- `passthrough_mode_live_zone_currently_passthrough_byte_equal_sha256`
  retitled comment to reflect the dispatcher being live but
  no-op.

Acceptance:
- cargo build --workspace + clippy + fmt: green.
- cargo test --workspace --exclude headroom-py: all green
  (777 + 12 new live_zone + 6 new live_zone_anthropic tests).
- pytest: 4678 passed, 240 skipped, 0 failed.
- Anthropic decision log includes manifest fields per the
  observability contract documented in
  REALIGNMENT/02-architecture.md.

Per-PR-B2 plan: REALIGNMENT/04-phase-B-live-zone.md.
2026-05-02 12:45:43 -07:00
chopratejas
967b0db439 fix: B1 — retire ICM, RollingWindow, scoring, relevance + dependents
Phase B step 1 of the live-zone-only realignment. Removes ~10K LOC of
"drop messages from history" machinery that became unreachable after
PR-A1 made `/v1/messages` a passthrough on the proxy. Live-zone-only
compression (PR-B2..B7) operates on content blocks within messages;
message-list mutation no longer happens in the pipeline.

Python deletes:
- headroom/transforms/intelligent_context.py (1077 LOC)
- headroom/transforms/rolling_window.py (395 LOC)
- headroom/transforms/progressive_summarizer.py (508 LOC)
- headroom/transforms/scoring.py (459 LOC)
- headroom/transforms/tool_crusher.py (338 LOC)
- 5 corresponding tests/test_transforms/* and tests/test_proxy_intelligent_context.py

Rust deletes:
- crates/headroom-core/src/context/* (manager, config, workspace,
  candidate, ccr_drop, strategy/, mod) + safety.rs replaced
- crates/headroom-core/src/scoring/* (mod, score, scorer, traits, weights)
- MessageScorerComparator from crates/headroom-parity (PR #338/#343
  becomes deletable; sunk cost stays sunk)
- 13 message_scorer fixtures + record_message_scorer.py

Rust adds (move + rewrite):
- crates/headroom-core/src/transforms/safety.rs — `tool_pair_indices`
  preserves the OpenAI/Anthropic tool_use ↔ tool_result pairing rule
  the live-zone dispatcher (PR-B2) needs. No IcmConfig dependency.

Surface refactors:
- HeadroomConfig: drop `tool_crusher`, `rolling_window`,
  `intelligent_context` fields; hoist `output_buffer_tokens` to top
  level (used by client.py).
- ProxyConfig: drop `intelligent_context*` fields.
- `headroom wrap` proxy server: retire IntelligentContextManager
  and RollingWindow imports + branch; pipeline is CacheAligner →
  ContentRouter (smart_routing) or CacheAligner → SmartCrusher
  (legacy).
- CLI: drop `--no-intelligent-context`, `--no-intelligent-scoring`,
  `--no-compress-first` flags.
- LangChain memory integration: rename `_apply_rolling_window` →
  `_apply_compression`, drop RollingWindowConfig dep. Threshold is
  now advisory — B6 will rework the contract.
- TransformPipeline.create_pipeline now takes only cache_aligner_config.
- headroom/__init__.py + headroom/transforms/__init__.py: strip
  exports of deleted symbols.

Bug fixes uncovered by full pytest sweep:
- providers/copilot/wrap.py: `environ or os.environ` collapsed
  empty-dict to falsy → callers passing `environ={}` accidentally
  pulled from os.environ. Use `environ if environ is not None else
  os.environ`.

Test correctness fixes:
- _DummyAnthropicHandler._retry_request gains **_kwargs to match
  the real handler signature post-A8.
- test_ws_http_fallback extracts JSON from `content=` (post-A3
  byte-faithful) rather than the obsolete `json=` kwarg.
- test_ccr_response_handler_extra fixture joins SSE events with
  `\n\n` per spec (post-A8 byte-buffer parser requirement).
- test_proxy_responses_phase_preservation: capture via direct
  handler attached to the named logger, so the assertion is
  order-independent (proxy `_setup_file_logging` flips
  `headroom.propagate=False` once any earlier test triggers it).
- conftest.py autouse fixture resets `headroom.propagate=True`
  before each test as a defensive measure for the same pollution.
- test_wrap_copilot_translated_backend_still_requires_byok:
  monkeypatch.delenv every provider key so the BYOK error
  actually fires.
- test_native_installers: skip when system bash < 4.3 (macOS ships 3.2).
- TestGeminiEmbedContent / TestGeminiBatchEmbedContents:
  pytest.mark.skip — proxy currently has no :embedContent route;
  feature gap, not regression.

Acceptance:
- cargo build --workspace + cargo clippy + cargo fmt --check: green.
- cargo test --workspace --exclude headroom-py: 777 passed.
- pytest: 4892 passed, 240 skipped, 0 failed.
- git grep returns only intentional comments referencing the deletion.

Per-PR-B1 plan: REALIGNMENT/04-phase-B-live-zone.md.
2026-05-02 12:23:17 -07:00
chopratejas
4073dcd231 fix(proxy): route Codex subscription /backend-api/* catchall to chatgpt.com
Codex CLI in subscription mode polls /backend-api/wham/usage, fetches
agent identity JWKS from /backend-api/wham/agent-identities/jwks, and
hits other auxiliary /backend-api/* endpoints during startup. The HTTP
catchall in _select_passthrough_base_url ignored ChatGPT auth and
routed all unmatched paths to api.openai.com, which 404s on every
backend-api path. Codex interprets that as "session invalid" and
refuses subscription auth.

Add a single branch at the top of _select_passthrough_base_url: when
_resolve_codex_routing_headers reports ChatGPT auth (explicit
ChatGPT-Account-Id header or JWT with chatgpt_account_id claim), return
https://chatgpt.com so the catchall forwards to the right host.

No-op for Anthropic (x-api-key, no JWT), Gemini (x-goog-api-key, no
JWT), OpenAI API key (sk- tokens fail JWT decode), and explicit-route
OpenAI passthroughs (/v1/embeddings, /v1/moderations, etc. don't go
through the catchall). Only behavior change is the targeted unblock for
subscription Codex.
2026-05-02 11:24:09 -07:00
chopratejas
341dcf03e9 fix(proxy): route Codex subscription /backend-api/* catchall to chatgpt.com
Codex CLI in subscription mode polls /backend-api/wham/usage, fetches
agent identity JWKS from /backend-api/wham/agent-identities/jwks, and
hits other auxiliary /backend-api/* endpoints during startup. The HTTP
catchall in _select_passthrough_base_url ignored ChatGPT auth and
routed all unmatched paths to api.openai.com, which 404s on every
backend-api path. Codex interprets that as "session invalid" and
refuses subscription auth.

Add a single branch at the top of _select_passthrough_base_url: when
_resolve_codex_routing_headers reports ChatGPT auth (explicit
ChatGPT-Account-Id header or JWT with chatgpt_account_id claim), return
https://chatgpt.com so the catchall forwards to the right host.

No-op for Anthropic (x-api-key, no JWT), Gemini (x-goog-api-key, no
JWT), OpenAI API key (sk- tokens fail JWT decode), and explicit-route
OpenAI passthroughs (/v1/embeddings, /v1/moderations, etc. don't go
through the catchall). Only behavior change is the targeted unblock for
subscription Codex.
2026-05-02 11:18:53 -07:00
chopratejas
148ded392a fix: A8 — SSE delta arms, UTF-8 buffer, phase preservation, request-id, 413
Eliminates the Python wire-format hotfix bugs gated on Phase A's
lockdown so the proxy is safe through Phase H's Python retirement.

Bugs retired:
  - P0-7 / P4-44: Codex `phase` field is now explicitly preserved
    through the Responses-API ↔ Chat-Completions round-trip; multi
    text-part rebuild collapses to a single text part (no more
    content doubling).
  - P1-8: Bytes-level SSE event splitter
    `parse_sse_events_from_byte_buffer`; emoji/CJK split across
    chunks survive intact. Buffer is `bytearray`; UTF-8 decode happens
    only AFTER the `\n\n` event terminator is located in bytes.
    Invalid UTF-8 in a *complete* event raises (operator-visible
    diagnostic, not silent corruption).
  - P1-9: `_parse_sse_to_response` handles all delta types per
    Anthropic guide §5.1: `thinking_delta`, `signature_delta`,
    `citations_delta`. Block map keyed by `index` so out-of-order
    events reconstruct correctly. `redacted_thinking.data` preserved.
  - P4-47: Unknown Responses-API item types now log a structured
    `unknown_responses_item_type` warning so operators see new
    Codex item types in flight before they break.
  - P5-57: Rust proxy captures upstream `request-id` (Anthropic) and
    `x-request-id` (OpenAI); surfaced as `headroom-upstream-request-id`
    on the response and as a tracing span field. Distinct from the
    proxy's own `x-request-id`.
  - P5-59: Body-too-large now returns 413 (was 400). Pre-checks
    `Content-Length` and rejects without consuming the body when
    present; chunked uploads still buffer-then-fail with 413.

Configurability (no hardcodes):
  - HEADROOM_SSE_BUFFER_MAX_BYTES (default 1 MiB) — per-event cap.
  - HEADROOM_PROXY_BODY_TOO_LARGE_STATUS (default 413) — operator
    override for body-too-large status.

A7 follow-up: `_DummyAnthropicHandler._retry_request` accepts the
A3 byte-faithful kwargs (`original_body_bytes`, `body_mutated`,
`mutation_reasons`, `request_id`, `forwarder_name`, `path_for_log`)
so the existing 20 backpressure tests stay green against the real
handler signature.

The project-wide grep
  git grep 'errors="ignore"\|errors="replace"' headroom/proxy/handlers/ headroom/ccr/
returns nothing; the single remaining lossy-decode site (response-
body diagnostics, not SSE) routes through `safe_decode_for_logging`
in `headroom/proxy/helpers.py`.

Tests:
  - tests/test_sse_thinking_blocks.py (4 tests)
  - tests/test_sse_utf8_split.py (3 tests)
  - tests/test_proxy_responses_phase_preservation.py (4 tests)
  - crates/headroom-proxy/tests/integration_request_id.rs (2 tests)
  - crates/headroom-proxy/tests/integration_body_size.rs (2 tests)
2026-05-02 10:35:11 -07:00
chopratejas
8dcd474aca fix: A7 — memory tool injection session-sticky for both Anthropic and OpenAI
Closes the second half of P0-6: once memory injects memory_save / memory_search
into body["tools"] for a session, every subsequent turn injects the byte-equal
same definitions — even if memory is disabled mid-session. Toggling tool list
mid-session busts Anthropic prefix cache per guide §6.3 #2.

Adds in headroom/proxy/helpers.py:

  * SessionToolTracker — bounded LRU keyed by (provider, session_id) storing
    GOLDEN tool-definition bytes from the first injection. Tracker is
    provider-aware so the same session_id under Anthropic and OpenAI keeps
    independent state. Reentrant lock for concurrent access; LRU eviction at
    HEADROOM_TOOL_TRACKER_MAX_SESSIONS (default 1000).
  * apply_session_sticky_memory_tools — single coordination point with three
    paths: first-time inject (record golden bytes), sticky replay (always
    inject golden bytes regardless of inject_this_turn), and skip. Honors
    HEADROOM_TOOL_INJECTION_STICKY=disabled as a loud operator opt-in for
    rollback (NOT a fallback).
  * serialize_tool_definition_canonical — deterministic byte serialization
    via the same separators=(",",":")/ensure_ascii=False rules as
    serialize_body_canonical.
  * log_tool_injection_decision — structured per-decision log line; never
    logs the tool definition contents.

Wires the helper into all four memory tool injection sites:
  * handlers/anthropic.py — /v1/messages
  * handlers/openai.py — /v1/chat/completions
  * handlers/openai.py — /v1/responses
  * handlers/openai.py — Codex WS path

memory_handler.MemoryHandler gains compute_memory_tool_definitions(provider) —
a pure builder that returns the tool definitions without mutating a tools
list, so the proxy can route through the sticky tracker. The legacy
inject_tools(...) is preserved for callers without a session_id.

Tests: tests/test_memory_tool_session_sticky.py — 29 unit + integration
cases covering: turn-1→turn-2 byte-equality (Anthropic + OpenAI), sticky
replay after memory disabled, golden-fixture pin, LRU eviction, provider
isolation under shared session_id, thread-safe concurrent access, env-var
contract, disabled-mode passthrough, dedupe with client tools.

Golden fixtures pin canonical bytes:
  * tests/fixtures/memory_tool_definitions/anthropic.json
  * tests/fixtures/memory_tool_definitions/openai.json

No regex. No hardcodes (env-configurable: HEADROOM_TOOL_INJECTION_STICKY,
HEADROOM_TOOL_TRACKER_MAX_SESSIONS). No silent fallbacks. Per-decision
structured logging. Realignment build constraints satisfied.
2026-05-02 10:11:27 -07:00
chopratejas
aec5ba3253 fix: A6 — anthropic-beta and openai-beta deterministic merge + session-sticky
PR-A6 of the Phase A cache-safety lockdown. Eliminates P5-50 and preps
P0-6 (memory tool injection toggling).

Two cache-killer patterns the merge + tracker defeat:

  1. Mid-session mutation: when memory was enabled the proxy did an
     ad-hoc concat of `context-management-2025-06-27` onto the client
     value (anthropic.py:1244-1248). The order varied with the client
     value, breaking byte-stable headers across turns.

  2. Token drop-out across turns: clients (Claude Code, Codex CLI) MAY
     drop a beta token between turn N and turn N+1 even when the proxy
     mutated turn N to add it. The cache hot zone is positional, so the
     next turn's prefix bytes hash differently and the prefix-cache
     read misses.

Changes
-------

`headroom/proxy/helpers.py`
  * `merge_anthropic_beta` / `merge_openai_beta`: pure, deterministic,
    order-preserving merge. Client tokens first (in their original
    order), then Headroom-required tokens (in the order passed). Dedupe
    is case-insensitive but preserves the original casing of the first
    occurrence. No regex.
  * `SessionBetaTracker`: bounded LRU keyed by (provider, session_id),
    unioning client tokens with previously-seen tokens. OrderedDict
    LRU; threading.RLock for thread safety (mirrors the
    CompressionCache pattern from compression_cache.py).
  * `get_session_beta_tracker` / `_reset_session_beta_tracker_for_test`
    process-wide singleton with test reset.
  * `log_beta_header_merge`: structured log per cache-affecting merge.
  * Env-var knobs (NO HARDCODES):
    - HEADROOM_BETA_HEADER_STICKY=enabled|disabled (default enabled).
    - HEADROOM_BETA_TRACKER_MAX_SESSIONS (default 1000).

`headroom/proxy/handlers/anthropic.py`
  * After `compute_session_id` (line ~744): record client
    `anthropic-beta` against the session tracker, write the sticky
    value back into `headers` if changed. Order matters: sticky-merge
    FIRST so memory-injection has the canonical baseline.
  * Memory-injection site (line ~1244): replace the ad-hoc concat with
    `merge_anthropic_beta(headers["anthropic-beta"], required_tokens)`.

`headroom/proxy/handlers/openai.py`
  * Chat-completions (line ~360): record/merge `openai-beta`.
  * /v1/responses HTTP (line ~1213): compute `_responses_session_id`
    and record/merge `openai-beta`.
  * /v1/responses WS (line ~1711): replace the ad-hoc absent-only
    inject with `merge_openai_beta(sticky, ["responses_websockets=
    2026-02-06"])`. Replaces any case-variants of the existing key.

Tests
-----

`tests/test_anthropic_beta_session_sticky.py` (26 tests):
  * Pure helper: empty inputs, only-client, only-headroom, ordering,
    dedupe casing, deterministic memory-injection order, no-double-
    inject when token already present.
  * Tracker: sticky-on across turns even when client drops, casing
    preservation, provider namespace independence, LRU eviction at
    max_sessions, env-var validation (loud failures), thread safety
    under 16-thread concurrent access, blank-input rejection.

`tests/test_openai_beta_session_sticky.py` (17 tests):
  * Mirror of the anthropic suite for `OpenAI-Beta`.
  * Plus WS-specific coverage: sticky-then-merge of
    `responses_websockets=2026-02-06` against client baseline.

`tests/test_openai_codex_routing.py`
  * Add `session_tracker_store` stub to `_DummyOpenAIHandler` so the
    routing tests still exercise the responses HTTP handler now that
    it computes a session_id for beta-merge.

Notes
-----

Build constraints honored:
  * Configurable: HEADROOM_BETA_HEADER_STICKY,
    HEADROOM_BETA_TRACKER_MAX_SESSIONS.
  * No regex, no hardcodes (env-var bounds), no fallbacks (disabled
    mode is operator opt-in for diagnostics, loud failures on invalid
    values).
  * Structured tracing log via `log_beta_header_merge`.

Acceptance:
  * 43 new tests pass.
  * `cargo test --workspace` green (no Rust changes).
  * `make ci-precheck` green.
2026-05-02 09:53:37 -07:00
chopratejas
2e874c5e3e fix: A5 — strip x-headroom-* from upstream-bound headers (P5-49)
Eliminate P5-49: every Python forwarder and the Rust transparent proxy
now drop internal `x-headroom-*` request headers (`x-headroom-bypass`,
`x-headroom-mode`, `x-headroom-user-id`, `x-headroom-stack`,
`x-headroom-base-url`) before the upstream call. Stops fingerprinting
of the proxy by subscription-revocation enforcers and prevents leakage
of internal user-id / stack / base-url internals to whichever vendor
terminates the request.

Python:
- `_strip_internal_headers(headers)` in `headroom/proxy/helpers.py`
  returns a NEW dict with `x-headroom-*` keys removed (case-insensitive
  prefix match, no regex). Pure function. Operator opt-in
  `HEADROOM_STRIP_INTERNAL_HEADERS=disabled` keeps internal headers in
  the upstream-bound dict for diagnostic shadow tracing — explicit, not
  a fallback.
- Strip applied at every handler entry capture in `anthropic.py`,
  `openai.py`, `batch.py`, `gemini.py` (chat completions, responses,
  WebSocket handshake, Copilot passthrough, batch passthroughs, Gemini
  generate / stream / countTokens / cloudcode-assist, Anthropic
  passthrough + batch results). Inbound reads of x-headroom (bypass
  gating, memory user-id) migrated to `request.headers.get(...)` so
  they continue working off the original dict.
- `log_outbound_headers` emits `event=outbound_headers forwarder=...
  stripped_count=N request_id=...` per call. Never logs header values.

Rust (crates/headroom-proxy):
- `strip_internal_headers(&mut HeaderMap)` and `is_internal_header`
  helpers in `src/headers.rs`. `build_forward_request_headers` accepts
  a `strip_internal: bool` so the same path serves HTTP and WebSocket.
- `Config::strip_internal_headers: StripInternalHeaders` driven by CLI
  flag `--strip-internal-headers` and env var
  `HEADROOM_PROXY_STRIP_INTERNAL_HEADERS` (default `enabled`).
- `proxy.rs` and `websocket.rs` call `build_forward_request_headers`
  with the resolved policy; structured `tracing::info!` /
  `tracing::warn!` line per request describes the strip decision.

Tests: 24 Python (`tests/test_header_isolation.py`) + 4 Rust
integration (`crates/headroom-proxy/tests/integration_headers.rs`) +
4 Rust unit tests in `headers.rs`. Covers every named header
(`bypass`, `mode`, `user-id`, `stack`, `base-url`), case-insensitive
prefix matching, legitimate-headers passthrough, the `disabled`
operator-opt-in mode, and that the inbound bypass-gating read path
is unaffected by the strip.

Acceptance: targeted `pytest -x` suite green (87 tests across
test_header_isolation, test_proxy_byte_faithful_forwarding,
test_proxy_anthropic_cache_stability, test_proxy_system_prompt_immutable,
test_proxy_openai_cache_stability, test_proxy_pipeline_lifecycle).
`cargo test -p headroom-proxy` green (23 tests across all integrations
plus 7 lib unit tests). `cargo clippy -p headroom-proxy -- -D warnings`
clean. `cargo fmt --all -- --check` clean. `cargo test --workspace`
green (~900 tests total).

Per realignment build constraints: configurable (env + CLI), no
hardcodes, no regex (pure `.lower().starts_with()` match), no silent
fallbacks (`disabled` is loud operator opt-in), structured logs
(`event=outbound_headers`).

Remaining `x-headroom-` references in `headroom/proxy/handlers/` are
inbound-read sites only: `request.headers.get("x-headroom-bypass")` /
`x-headroom-mode` for behavior gating, `request.headers.get
("x-headroom-user-id")` for memory user-id resolution, and `ws_headers
.get(...)` on the WebSocket inbound path. Response-side `X-Headroom-*`
injection (e.g. `x-headroom-tokens-saved`) is unrelated to upstream
forwarding and untouched.
2026-05-02 09:35:27 -07:00
chopratejas
2e9f52fda2 fix(providers): register claude-opus-4-7 + [1m] tier with 1M context
claude-opus-4-7[1m] (the model name Claude Code sends to select Anthropic's
1M tier) was unmapped in ANTHROPIC_CONTEXT_LIMITS and unmapped in LiteLLM's
bundled JSON, so get_context_limit fell through to _PATTERN_DEFAULTS["opus"]
= 200K. The under-budget made IntelligentContextManager.should_apply skip
compression on typical ~50K Claude Code sessions, surfacing as "0 savings"
on Opus 4.7 traffic. Register both the bare and [1m]-suffixed forms at 1M
with Opus-tier pricing.
2026-05-02 09:30:43 -07:00
chopratejas
f0dcc02775 fix: A3 — byte-faithful Python forwarders; serialize canonical only when mutated
Eliminates P0-2 universally. Every Python forwarder (server.py
`_retry_request`, handlers/streaming.py `_stream_response`,
handlers/openai.py `_ws_http_fallback`, handlers/batch.py `_batch_passthrough`
+ batch-create + Google batch passthrough, handlers/anthropic.py CCR
continuation + batch endpoint) now switches from `httpx ... json=body` to
`httpx ... content=raw_bytes`. The default httpx JSON encoder was
re-serializing every request with `, `/`: ` separators and `\\uXXXX` ASCII
escapes — collapsing Anthropic prompt-cache hit-rate.

Forwarder strategy:
  - unmutated body → forward `await request.body()` verbatim;
  - mutated body  → re-serialize once via the new
    `serialize_body_canonical(body) -> bytes` helper (compact separators,
    `ensure_ascii=False`, dict insertion order preserved).

`HEADROOM_PROXY_PYTHON_FORWARDER_MODE` env var configures the mode:
  - `byte_faithful` (default) — the new behavior;
  - `legacy_json_kwarg` — explicit operator opt-in for emergency rollback.
Documented in `docs/content/docs/configuration.mdx`. NOT a fallback —
unknown values raise loudly per build constraint #4.

`BodyMutationTracker` accompanies each request through the handler so
transform sites mark the tracker (`memory_injection`,
`image_compression`, `compression_*`, `batch_compression`,
`ccr_continuation`, etc.). At forwarder dispatch we additionally compare
the final body dict against the parsed original bytes as a structural
safety net — any silent mutation we missed still triggers canonical
re-serialization.

A2 follow-up: `handlers/openai.py:534-540` (Chat Completions memory
injection) was prepending a system message; replaced with
`append_text_to_latest_user_chat_message`, the OpenAI Chat Completions
analog of `_append_context_to_latest_non_frozen_user_turn`. The cache
hot zone (system messages) is now sacrosanct on /v1/chat/completions
too. Honors `HEADROOM_MEMORY_INJECTION_MODE=disabled`.

Structured logging: every forwarder emits an `event=outbound_request`
log line with `forwarder`, `path`, `body_bytes`, `body_mutated`,
`mutation_reasons`, `source` (passthrough|canonical|legacy),
`request_id`. Never logs Authorization or full body.

`_read_request_json` factored to share `_read_request_body_bytes` with
new `read_request_json_with_bytes` so the anthropic handler can capture
both the parsed dict and the original (decompressed) bytes.

Tests:
  - `tests/test_proxy_byte_faithful_forwarding.py` (28 tests):
    SHA-256 byte-equality on /v1/messages and streaming, unicode
    preservation, numeric precision, mutation-tracker invariants,
    canonical-serializer properties, legacy-mode rollback, OpenAI
    Chat memory routing.
  - Existing test mocks updated to accept the new `**kwargs` on
    `_retry_request` (no behavior change).
  - `tests/test_proxy_handlers_batch.py` updated to read the captured
    `content=` bytes (formerly `json=`).
  - One A2 test corrected (`test_anthropic_tool_sort_and_context_append_helpers`)
    to match the live-zone-tail semantics introduced by A2.

Constraints satisfied: configurable env var; no new regex / hardcodes;
no silent fallback (`legacy_json_kwarg` is operator opt-in);
performant (`prepare_outbound_body_bytes` is O(1) for passthrough);
elegant single-responsibility helpers; structured tracing logs.
2026-05-02 09:02:10 -07:00
chopratejas
704fb2f19d fix: A2 — system prompt immutable; memory routes to live-zone tail; cache_aligner detector-only
P0-1: Delete `_inject_system_context` from `proxy/server.py`. Memory
context now routes exclusively to the first text block of the latest
non-frozen user message via `_append_context_to_latest_non_frozen_user_turn`
(promoted to the canonical default in handlers/anthropic.py). Mirror
applied to OpenAI Responses API at handlers/openai.py: `body["instructions"]`
is no longer mutated; memory context appends to the latest user item in
`body["input"]`.

P2-23: Replace `headroom/transforms/cache_aligner.py` with a detector-only
implementation. The legacy rewrite path (~400 LOC) is removed. The volatile-
content detector uses no regex — UUIDs via `uuid.UUID`, ISO 8601 via
`datetime.fromisoformat`, JWT shape via base64url segment-count check, hex
hashes via length + `int(token, 16)` validation. Volatile findings surface
through `cache_metrics`/`warnings`/`logger.warning`; the prompt is never
mutated.

Configurability: new env var `HEADROOM_MEMORY_INJECTION_MODE` with values
`live_zone_tail` (default) and `disabled`. No `system_prompt` value — that
path is permanently retired.

Structured logs: every memory injection emits `event=memory_injection`
with `decision`, `bytes_injected`, `query_hash` (BLAKE2b, never raw query),
`session_id`, `request_id`. Auth is never logged.

Tests:
- Add `tests/test_proxy_system_prompt_immutable.py` (7 tests).
- Add `tests/test_cache_aligner_detector_only.py` (20 tests).
- Replace `tests/test_transforms/test_cache_aligner.py` (rewrite-path
  tests, 58 cases) with detector-only behavior.
- Update `tests/test_acceptance.py::TestDateTrap` to pin the new
  detector-only contract.

Acceptance:
- `git grep -n "_inject_system_context\|_inject_to_system_or_instructions" headroom/`
  returns nothing.
- `git grep -n "import re\|from re import" headroom/transforms/cache_aligner.py`
  returns nothing.
- Targeted suite (`test_proxy_system_prompt_immutable.py`,
  `test_cache_aligner_detector_only.py`, `test_proxy_anthropic_cache_stability.py`,
  `test_acceptance.py::TestDateTrap`, `test_memory*.py`, `test_cli/`) green.
2026-05-02 08:34:34 -07:00
chopratejas
3f99128236 fix(rust): A4 — honor cache_control markers; serde_json arbitrary_precision + raw_value
PR-A4 of the Realignment Phase A lockdown
(REALIGNMENT/03-phase-A-lockdown.md). Eliminates P0-3 (Rust proxy
ignores customer cache_control markers) and P0-5 (numeric precision
lost via serde_json::Value round-trip) at the library level; Phase B
PR-B2 wires the helper into the live-zone block dispatcher.

Cargo.toml — add `arbitrary_precision` and `raw_value` to
`serde_json` workspace features. `arbitrary_precision` keeps `1.0`
from collapsing to `1` and preserves >2^53 integers; `raw_value`
exposes `&RawValue` so PR-B2 can forward unmodified `messages[*]`
entries as exact byte copies.

crates/headroom-core/src/cache_control.rs (new) — `compute_frozen_count`
walks `messages[i].content[*].cache_control` via serde_json
accessors only (no regex) and returns the smallest N such that
`messages[i]` is frozen for every i < N. Markers in `system` or
`tools[*]` log at debug! but never bump the floor (those fields are
unconditionally cache-hot per invariant I2). TTL ordering violations
(5m before 1h, guide §2.19) emit `tracing::warn!` but the function
computes the correct count regardless — the customer's request, not
ours to reject.

crates/headroom-core/src/lib.rs — re-export `compute_frozen_count` at
crate root so the proxy crate has a stable import path.

crates/headroom-proxy/src/compression/anthropic.rs — add
`resolve_frozen_count` thin wrapper that consults the
`cache_control_auto_frozen` config flag. When `disabled`, returns 0
regardless of body content (operator opt-out for benchmarking).

crates/headroom-proxy/src/config.rs — add `CacheControlAutoFrozen`
enum and the matching CLI flag `--cache-control-auto-frozen` /
env var `HEADROOM_PROXY_CACHE_CONTROL_AUTO_FROZEN`. Default is
`enabled`. Documented in the doc comments.

Tests
- crates/headroom-core/src/cache_control.rs (inline): 11 unit tests
  covering marker detection, system/tools negative cases, ordering
  state machine, defensive (missing fields, non-array messages,
  non-object content blocks).
- crates/headroom-core/tests/cache_control.rs: 11 unit + 3 property
  tests (monotonic non-decrease as markers are added; system/tools
  markers don't change count; empty messages → 0).
- crates/headroom-proxy/tests/integration_cache_control.rs: 8 tests
  exercising the proxy wrapper (configurability gate; tracing
  capture for the 5m-before-1h warn path).

Acceptance gates: `cargo build --workspace`, `cargo test --workspace`
(33 new tests green), `cargo clippy --workspace -- -D warnings`,
`cargo fmt --all --check` all clean. No new `regex::` imports;
`git grep -n 'regex::' crates/{headroom-core/src/cache_control.rs,
headroom-core/tests/cache_control.rs, headroom-proxy/tests/
integration_cache_control.rs}` empty.

Honors the realignment build constraints: configurable (CLI + env),
no hardcodes (TTL strings live as const), no regex (serde_json
accessor walk), no fallbacks (one impl), structured logging
(debug!/warn! with field/index/ttl/rule context), tests
comprehensive (unit + property + integration + tracing capture).
2026-05-02 08:22:10 -07:00
chopratejas
a974bb153a fix(rust): PR-A1 — make /v1/messages compression a passthrough
Stop calling IntelligentContextManager from the Rust proxy on
/v1/messages. The proxy is now a byte-faithful passthrough on this
endpoint. Eliminates the C1+C2+C3+C4 cache-killer cluster (P0-3,
P0-4, P0-5, P1-13) by not running ICM with `frozen_message_count: 0`
hardcoded — Phase B PR-B2 brings live-zone-only compression back.

Per REALIGNMENT/03-phase-A-lockdown.md.

Changes:
- Add `--compression-mode {off,live_zone}` flag and
  `HEADROOM_PROXY_COMPRESSION_MODE` env var. Default `off`. Both
  modes passthrough in PR-A1; `live_zone` warns loudly because
  Phase B isn't implemented yet (no silent fallback).
- Replace `compress_anthropic_request` body with a passthrough
  stub that emits a structured `tracing::info!` decision log line
  (request_id, path, method, compression_mode, decision,
  reason="phase_a_lockdown", body_bytes) and returns
  `Outcome::NoCompression`. Function signature preserved so
  Phase B PR-B2 is a pure body swap.
- Delete `compression/icm.rs` (per the realignment plan: ICM
  modules in headroom-core are deleted in PR-B1).
- Drop the `Arc<IntelligentContextManager>` field from `AppState`
  — no longer used.
- Add request-entry `tracing::debug!` with auth_mode_placeholder
  ("unknown" until Phase F PR-F1 wires the auth-mode classifier).
- Add `debug_assert!` on the NoCompression branch that the
  buffered bytes length is stable, locking in Phase A's
  cache-safety invariant at the call site.
- Tighten existing tests from `len()` equality to SHA-256 byte
  equality. Rename `compression_on_oversized_body_trims_messages`
  → `compression_on_long_body_passes_through_in_phase_a` and
  flip the assertion to byte-equal.
- Add new tests: passthrough_mode_off_byte_equal_sha256,
  passthrough_mode_live_zone_currently_passthrough_byte_equal_sha256,
  passthrough_preserves_numeric_precision (literal-byte body so
  serde_json's f64 quantization can't mask a regression),
  passthrough_preserves_cache_control_markers,
  passthrough_preserves_thinking_signature,
  passthrough_preserves_redacted_thinking_data,
  passthrough_recorded_fixture_byte_equal_sha256,
  tracing_capture::compression_decision_logged.
- Add fixture
  `crates/headroom-proxy/tests/fixtures/anthropic_messages_request_real.json`
  with system block list + cache_control markers, tools with
  nested JSON Schema, messages containing text + thinking +
  signature + tool_use + tool_result + image, non-ASCII content,
  large numbers. Used as the canonical SHA-256 round-trip gate.

Constraints honored: configurable (compression_mode is the only
new knob), no hardcoded thresholds, no regex usage, no silent
fallbacks (live_zone-not-implemented warns), structured tracing
on every cache-affecting decision, comprehensive tests.

Acceptance criteria from PR-A1 spec:
- `cargo build --workspace` clean
- `cargo test --workspace` green (886 tests pass)
- `cargo clippy --workspace -- -D warnings` clean
- `cargo fmt --all --check` clean
- `make ci-precheck` green
- New SHA-256 byte-equality tests pass against the recorded fixture
- `tracing::info!` decision-log line is observable
- `--compression-mode` CLI + env var work
- No regex import added
2026-05-01 23:58:20 -07:00
chopratejas
0ce2243dfb docs: add Realignment plan (40 PRs, 9 phases)
Comprehensive PR-by-PR plan to realign Headroom around live-zone-only
compression with prefix-cache safety as a non-negotiable invariant.
Drafted from a 10-agent deep audit against the LLM-proxy compression
guide.

- 14 documents under REALIGNMENT/
- 72 ranked bugs (P0 cache-killers through P6 test-infra)
- 40 feature PRs + 10 test-infra PRs across 9 phases
- ~25K LOC retirement (ICM + scoring + relevance + rolling-window
  + summarizer + tool-crusher + LiteLLM-fake-Bedrock)
- Preserves TOIN, CCR, Kompress-base per user direction
- Auth-mode policy gates (PAYG / OAuth / subscription)
- Phase 3 cache stabilization surface (tool-sort, schema-sort,
  cache_control auto-place, prompt_cache_key)
- Native Bedrock SigV4 + Vertex ADC handlers
- Test infrastructure: SHA-256 byte-faithful gate, SSE corner cases,
  property tests, real-traffic shadow
2026-05-01 23:34:46 -07:00
Tejas Chopra
51eeaf6662
Merge pull request #344 from chopratejas/audit-followup-c1-c2-c3
fix(proxy): cache concurrency lock, multi-worker docs, bounded compre…
2026-05-01 17:12:43 -07:00
Tejas Chopra
4c52766860
Merge pull request #345 from chopratejas/rust-proxy-wire-compressors
fix(rust): wire ICM compressor into Rust proxy on /v1/messages
2026-05-01 16:57:17 -07:00
chopratejas
456a6b33af fix(test): stub _run_compression_in_executor on _DummyOpenAIHandler
The bounded compression executor introduced in this PR moved every
handler's compression call from `asyncio.wait_for(asyncio.to_thread(...))`
to `self._run_compression_in_executor(...)`, which lives on
`HeadroomProxy` (server.py) and is inherited by handler mixins at
runtime.

The test's `_DummyOpenAIHandler` only inherits `OpenAIHandlerMixin`,
not `HeadroomProxy`, so it lacks the method. The Responses API
compression path caught the AttributeError and silently fell back —
which made `test_handle_openai_responses_stream_keeps_compression`
fail with `apply.call_count == 0`.

Add a synchronous stub that just invokes the callable; tests don't
need real thread-pool semantics.
2026-05-01 16:53:21 -07:00
chopratejas
fa5fbfabf4 fix(rust): wire ICM compressor into Rust proxy on /v1/messages
Adds an opt-in compression interceptor that buffers Anthropic
/v1/messages requests, runs IntelligentContextManager over the
messages array, and forwards the (possibly trimmed) body upstream.
All other paths, methods, and content-types stay on the original
streaming passthrough — so existing operators see zero change.

Behaviour gates ALL must be true to buffer + compress:
  - --compression flag (or HEADROOM_PROXY_COMPRESSION=1)
  - method == POST
  - path == /v1/messages
  - Content-Type: application/json
  - ICM constructed successfully at startup

Falls through to streaming on any failure: parse, missing fields,
unknown model, body-too-large. Compression must never break a
request — that's the safety contract.

Model context windows come from a vendored LiteLLM snapshot at
crates/headroom-proxy/data/model_prices_and_context_window.json
parsed once into an OnceLock<HashMap>. Refresh via
scripts/refresh_model_limits.sh. Rationale documented inline:
hardcoded tables silently rot; LiteLLM is the canonical source
the entire LLM-tooling ecosystem relies on.

New tests:
  - 16 unit tests across compression::{anthropic, icm, model_limits}
  - 5 integration tests: off-passthrough, on-short-passthrough,
    on-oversized-trim, on-non-json-skip, on-non-llm-path-skip

Verification:
  - cargo test --workspace -> 884 passed, 0 failed
  - cargo clippy --workspace -- -D warnings -> clean
  - cargo fmt --check -> clean
2026-05-01 16:44:44 -07:00
Tejas Chopra
f726d0e280
Merge pull request #343 from chopratejas/rust-message-scorer-port
Rust message scorer port
2026-05-01 16:38:14 -07:00
chopratejas
ea78cf6252 fix(proxy): cache concurrency lock, multi-worker docs, bounded compression executor
Three audit follow-ups from issue #327's deep-dive review.

C1 — CompressionCache concurrency lock
======================================

`CompressionCache` instances are shared per `session_id` and accessed from
async-dispatched threadpool workers. Pre-fix, concurrent requests for the
same session raced on `_cache`, `_stable_hashes`, `_first_seen`, and
`_total_tokens_saved` with no synchronization. Observable failures:

* Lost-update on `_total_tokens_saved` (read-modify-write).
* `RuntimeError: OrderedDict mutated during iteration` from `apply_cached`
  when a concurrent `store_compressed` evicts during the walk.
* Lost stable-hash records — next-turn compute_frozen_count reads
  inconsistent state.

May also explain part of SvenMeyer's `_cache: 0 entries / 1003 misses`
observation: the cache was being clobbered concurrently.

Added `threading.RLock` guarding all mutating methods. `RLock` (not `Lock`)
so future code can call locked methods from inside another locked method
without self-deadlock. Also locked `HeadroomProxy._compression_caches`
dict-of-caches access via a separate `_compression_caches_lock` so two
concurrent calls for the same session_id can't each create distinct
CompressionCache objects (which would split the cache state between them).
The `/stats` endpoint snapshots the cache list under the dict lock before
iterating to avoid eviction-during-iteration.

C2 — Multi-worker CCR fragmentation: documented + startup warning
=================================================================

The in-memory `InMemoryCcrStore` (Rust), `_compression_caches` (Python),
`session_tracker_store` (Python), and TOIN learner state are ALL
per-process. Multi-worker uvicorn round-robins requests across workers,
so a session whose turn-1 lands on worker A may have turn-2 land on
worker B. Worker B has zero knowledge of A's CCR markers, replay cache,
or prefix-cache state. Result: `Retrieve original: hash=X` markers stay
in-context as opaque directives, every fresh tool_result is recompressed
from scratch, and `frozen_message_count=0` causes Anthropic prefix-cache
busts on every cross-worker turn.

Added a "Multi-worker deployment — CCR fragmentation" section in
`RUST_DEV.md` documenting the failure modes, the supported configuration
(`--workers 1`), and the sticky-session workaround for horizontal scale.
The proxy emits a `WARNING`-level log line on startup if `workers > 1` is
detected, pointing at the doc section.

C3 — Bounded compression executor with cancel-aware metrics
===========================================================

`asyncio.wait_for(asyncio.to_thread(pipeline.apply), timeout=...)`
cancellation does NOT propagate into the threadpool worker that's running
Rust code. Once the worker has picked up the task,
`concurrent.futures.Future.cancel()` returns False and the thread runs to
completion. Stuck threads accumulated invisibly on asyncio's default
executor, contending with unrelated `to_thread` callers (file IO, etc.).

Replaced all 7 `asyncio.to_thread` call sites for `pipeline.apply()`
across `proxy/handlers/anthropic.py` (3) and `proxy/handlers/openai.py` (4)
with a new `HeadroomProxy._run_compression_in_executor(fn, *, timeout)`
helper that:

  1. Submits to a dedicated bounded `ThreadPoolExecutor` named
     `headroom-compress` (configurable via
     `ProxyConfig.compression_max_workers`; defaults to
     `min(32, (cpu_count or 1) * 4)`).
  2. Increments `_compression_in_flight` (gauge) when work starts and
     decrements when work completes; tracks `_compression_in_flight_max`
     as a high-water mark.
  3. Detects "leaked threads" by comparing wall-clock elapsed against the
     timeout in the worker's `finally` block. Increments
     `_compression_leaked_threads` when a worker finishes after its
     asyncio future was cancelled. Operators can see the leaked-thread
     rate climbing in `/stats runtime.compression_executor` BEFORE the
     pool fills up.

Tests
=====

* `TestCompressionCacheConcurrency` (3 tests) — many threads
  store_compressed / apply_cached / update_from_result on a single
  CompressionCache; assert no exceptions, no lost updates, no partial
  state.
* `test_get_compression_cache_returns_same_instance_under_contention` —
  32 concurrent `_get_compression_cache(same_id)` calls return the
  identical instance (would split pre-lock).
* `test_proxy_compression_executor.py` (8 tests) — pool size respects
  config, in-flight gauge tracks running compressions, high-water mark
  is monotonic, timeout propagates to awaiter, leaked-thread counter
  increments on post-deadline completion, `/stats` surfaces all three
  gauges.

Verification
============

* All 123 targeted regression tests pass.
* `make ci-precheck` clean.
* No `Co-Authored-By` trailer; conventional `fix:` prefix; no
  `--no-verify`.
2026-05-01 15:25:18 -07:00
Tejas Chopra
21784f4288
Merge pull request #340 from chopratejas/rust-icm-core
fix(rust): IntelligentContextManager port (simplified, OSS) — PR-B
2026-05-01 14:48:24 -07:00
chopratejas
013344f6fd fix(rust): IntelligentContextManager port (simplified, OSS) — PR-B
Direct port of headroom/transforms/intelligent_context.py (1077 LOC)
with a deliberately simpler architecture:

- One built-in strategy: DropByScoreStrategy. Multi-factor scoring +
  safety rails + CCR-on-drop persistence. Outclasses every gateway
  competitor's rolling-window behaviour.
- Minimal ContextStrategy trait so Enterprise plugs additional
  strategies (compress-first, summarize, memory tiers) into the same
  orchestrator without modifying it.
- 6 config fields instead of 12+. Cuts: compress_threshold,
  summarize_threshold, summarization_*, memory_tiers_*, warm/cold_*.

Module layout (parallel to scoring/, signals/, transforms/):

  context/
  ├── config.rs                — IcmConfig (6 fields)
  ├── workspace.rs             — ContextWorkspace + StrategyOutcome
  ├── safety.rs                — system / last-N-turns / tool-pair
  │                              atomicity / frozen prefix
  ├── candidate.rs             — turn / tool-unit / single candidates
  ├── ccr_drop.rs              — serialize dropped messages, store
  │                              under content-hash, emit marker
  ├── manager.rs               — cascade orchestrator
  └── strategy/
      ├── mod.rs               — ContextStrategy trait
      └── drop_by_score.rs     — THE OSS strategy

Quirks preserved from Python:
- Tool-call/response atomicity (OpenAI + Anthropic + Strands shapes)
- frozen_message_count protects prompt-cache prefix
- Cascade: each strategy returns tokens_freed + fully_resolved
- should_apply strict gate (no tokenization on under-budget requests)
- CCR-on-drop default ON (the OSS-defining behaviour)

44 new unit tests (50 total context tests), 811 total in headroom-core.
NO PyO3 yet (PR-C). NO Python deletion yet (PR-D).
2026-05-01 14:40:47 -07:00
Tejas Chopra
05f91d9adc
Merge pull request #338 from chopratejas/rust-message-scorer-port
fix(rust): port MessageScorer to Rust + parity harness (PR-A)
2026-05-01 14:15:38 -07:00
Tejas Chopra
9129188d4a
Merge pull request #339 from chopratejas/fix-327-walker-removal
fix(proxy): remove content-keyed TTL walker that conflated content wi…
2026-05-01 14:15:28 -07:00
chopratejas
bcef763720 ci: pin dtolnay/rust-toolchain@stable, set toolchain via input
The @1.95.0 git ref of dtolnay/rust-toolchain shipped action code
that errors on ubuntu-latest with:

  failed to install component: 'clippy-preview-x86_64-unknown-linux-gnu',
  detected conflict: 'bin/cargo-clippy'

The runner's pre-installed Rust ships cargo-clippy at $HOME/.cargo/bin,
and the older action code's rustup invocation hits a path conflict
when adding the clippy-preview component for 1.95.0.

The @stable ref of the action has the fix; pass toolchain: 1.95.0 as
input so the version stays pinned. rust-toolchain.toml continues to
be the source of truth for the version (used by cargo's
auto-detection); this keeps the action's install in sync.
2026-05-01 13:58:25 -07:00
chopratejas
521fbbeabd style: apply ruff format to test_proxy_anthropic_cache_stability lambdas 2026-05-01 13:52:46 -07:00
chopratejas
35eaf8de7f fix(proxy): remove content-keyed TTL walker that conflated content with positional cache (#327)
The Anthropic token-mode handler walked past prefix_tracker.frozen_message_count
whenever an upcoming tool_result's content-hash matched comp_cache._stable_hashes
or should_defer_compression returned True. That conflated content equality with
positional cache membership.

Anthropic's prefix cache is POSITIONAL: bytes 0..K cached, anything past K is
fresh. _stable_hashes is content-keyed and grows unbounded. In long Claude Code
sessions where tool_result content rhymes across turns (repeated system prompts,
repeated file reads, repeated tool descriptions), the walker advanced
frozen_message_count to len(messages) on every turn and the pipeline produced
transforms_applied=[] on 73% of requests in user SvenMeyer's reported session
(headroom-stats-2026-05-01.json: 74 of 101 eligible requests "prefix_frozen") —
even after the prior fix in 44944fb. The 15 requests that did compress averaged
21%, proving compression itself works when reached.

Fix: delete the walker. The freeze boundary is now

    frozen_message_count = min(
        prefix_tracker.frozen_message_count,    # positional ground truth
        comp_cache.compute_frozen_count(messages),  # local cache lower bound
    )

compute_frozen_count's use of _stable_hashes can only LOWER the freeze via the
min clamp, never raise it past prefix_tracker's value. For any position in the
gap [compute_frozen_count, prefix_tracker.frozen_count], recompressing produces
byte-stable output (compression is deterministic on input content), so
Anthropic's prefix cache stays valid.

Cross-handler verification:
* OpenAI handler (proxy/handlers/openai.py:358-382) does not have this walker
  — uses only compute_frozen_count. Codex routes through OpenAI handler. Both
  unaffected.
* Streaming and non-streaming both invoke anthropic_pipeline.apply() before the
  upstream call. One fix covers both paths.
* Cache mode (is_cache_mode) takes the _extract_cache_stable_delta path and is
  independent of the walker. Unaffected.

Tests: six new regression tests lock down the post-fix invariants — clamp to
min(prefix_tracker, compute_frozen_count); fresh tool_result whose hash matches
old _stable_hashes entry is not frozen; frozen prefix byte-stable across the
pipeline; 10-turn session produces non-empty compression suffix every turn;
streaming and non-streaming compute identical frozen_message_count; OpenAI
handler never calls the walker functions. Plus scripts/smoke_issue_327.py
(gated by RUN_LIVE_API=1) drives a 10-turn conversation against
api.anthropic.com in both shapes (string + list-of-blocks) and both modes
(streaming + non-streaming).

ci-precheck clean. 191 tests pass.

Follow-ups (separate PRs):
* Fix _cache perpetually empty (anthropic.py result.messages != working_messages
  comparison rarely fires in token mode).
* Cap _stable_hashes with bounded LRU + 1h TTL — hygiene only after the freeze
  gate is removed.
* List-shape tool_result content gates at content_router.py:1975 and
  intelligent_context.py:657 (cluster A from the audit).
2026-05-01 12:04:28 -07:00
chopratejas
c4989b1844 Merge remote-tracking branch 'origin/main' into rust-message-scorer-port
# Conflicts:
#	crates/headroom-core/src/transforms/smart_crusher/crusher.rs
2026-05-01 10:16:59 -07:00
chopratejas
21989e3640 fix(rust): port MessageScorer to Rust + parity harness (PR-A)
Direct port of `headroom.transforms.scoring.MessageScorer` (459 LOC).
Foundation piece for the IntelligentContext port (PR-B onward).

What's wired:
- Deterministic factors fully ported: recency (exp-decay), forward
  references (tool_call_id graph), token density (unique/total).
- External-dep factors gated behind traits: `EmbeddingProvider` and
  `ToinProvider`. No concrete impls yet — both default to neutral
  values matching Python's `embedding_provider=None` / `toin=None`.
  PR-A1 wires fastembed; PR-A2 plugs in a PyO3 ToinProvider.
- ScoringWeights + MessageScore with serde + BTreeMap-ordered
  breakdown for stable JSON.

Parity:
- 13 fixtures recorded from Python, byte-equal under the comparator.
- Floats rounded to 5 decimals on both sides — absorbs f32-vs-f64
  drift in the weighted sum without masking real bugs.

Drive-by: re-fix three pre-existing clippy errors in
smart_crusher/crusher.rs that re-emerged with new test additions
(field_reassign_with_default + dead hash_array_for_ccr).
2026-05-01 10:06:56 -07:00
Tejas Chopra
764b7a8021
Merge pull request #336 from chopratejas/rust-audit-cleanup
fix(rust): audit cleanup — DiffCompressor CCR leak, CCR TOCTOU race, …
2026-04-30 21:31:17 -07:00
chopratejas
378d8a0f05 fix(rust): audit cleanup — DiffCompressor CCR leak, CCR TOCTOU race, clippy debt, dep dedup
Closes findings from the post-Phase-3g audit. Five surgical fixes
plus telemetry-discoverability docs. PyO3 0.22 → 0.24 security
upgrade is its own PR (issue #335).

1. DiffCompressor cache_key persistence (production bug)
---------------------------------------------------------
Pre-fix: `RustDiffCompressor.compress()` minted a `cache_key`,
embedded `[... hash=abc123]` in the wire marker, and returned
without storing the original anywhere. Python ContentRouter then
returned the compressed text with a dangling marker — every
retrieval tool call from the LLM 404'd.

Sibling compressors (LogCompressor, SearchCompressor) already had
the right pattern: Rust mints the key, Python's
`_persist_to_python_ccr` writes the original to the production
`CompressionStore`. DiffCompressor was the asymmetric one.

Fix:
- Rust: add `DiffCompressor::compress_with_store(content, context,
  Option<&dyn CcrStore>)` mirroring siblings. Calls `store.put`
  when a key is minted; legacy `compress()` and
  `compress_with_stats()` delegate with `None` for parity.
- Python: add `_persist_to_python_ccr` helper to
  `headroom/transforms/diff_compressor.py.compress()` mirroring
  `log_compressor.py` and `search_compressor.py`.
- Pipeline `DiffOffload`: switch to `compress_with_store(Some(store))`
  and drop the post-hoc double-store hack that papered over this
  bug at the orchestrator boundary.

2. CCR store TOCTOU race in `get()`
-----------------------------------
`InMemoryCcrStore::get()` checked TTL under a read lock, dropped
the lock, then called `remove()`. Between drop and remove a
concurrent `put()` of the same hash with fresh data could land —
and our `remove` would then wipe that fresh entry. Under
multi-worker proxy load this manifested as "I just stored it; why
is it gone?"

Fix: use `DashMap::remove_if`. Predicate runs under the shard
write lock so check-and-remove is atomic. New regression test
exercises a tight contention loop between writer and reader on
the same key.

3. Pre-existing clippy debt in smart_crusher
--------------------------------------------
- 3× `field_reassign_with_default` in `crusher.rs` test setup —
  switch to struct-update syntax `Config { field: x, ..Default }`.
- `hash_array_for_ccr` was `#[cfg(test)]` but unused; deleted with
  a comment so a future test can reintroduce it as a one-liner.

`cargo clippy --workspace --all-targets -- -D warnings` is now
clean across the whole workspace; previous CI patches that allowed
these warnings can be removed in a follow-up.

4. Tokenizers dependency dedup
------------------------------
`tokenizers 0.21` (direct dep) + `tokenizers 0.22` (transitive via
fastembed) compiled twice into the binary. Bumped direct dep to
`0.22` to align; API is compatible (verified by full tokenizer
test suite). Saves compile time + binary bloat.

5. Telemetry-discoverability doc (no new code)
----------------------------------------------
The audit recommended a per-transform invocation counter to
inform the next Python → Rust port. Discovered the infrastructure
already exists at `/stats`:
- `compressions_by_strategy` — invocation count per strategy
- `pipeline_timing` — count + avg/max ms per transform name
- `tokens_saved_by_strategy` — savings attribution

Added a section to `RUST_DEV.md` showing the `curl + jq` recipes
to read this data, with example output highlighting how to spot
zero-invocation deferral candidates (e.g. `code_compressor`).

Verification: workspace tests 734 + 14 + 5 + 4 + 6 + 5 + 2 + 2 +
3 + 4 + 2 + 2 + 1 = all green; cargo fmt clean; cargo clippy
--all-targets clean; Python tests 185 pass; commitlint clean.
2026-04-30 20:54:22 -07:00
Tejas Chopra
276e92e05c
Merge pull request #333 from smartwatermelon/fix/proxy-numpy-import
fix(memory): make numpy import optional for proxy boot path
2026-04-30 14:31:20 -07:00
Claude Code Bot
d236b4befd fix(memory): make numpy import optional for proxy boot path
The proxy startup chain eagerly loads `headroom.memory.adapters`, which
imports `ports.py` and `sqlite.py`. Both files declared `import numpy as np`
at module top even though numpy is not in base dependencies (it is only
under `[all]`, `[dev]`, `[evals]`, and `[relevance]` extras). As a result,
`pipx install 'headroom-ai[mcp]' && headroom proxy` fails with
`ModuleNotFoundError: No module named 'numpy'` even when memory features
are disabled (the default).

Both files already use `from __future__ import annotations`, so the
`np.ndarray` type hints are strings at runtime - numpy is only needed
for static type checking. Move the module-level `import numpy as np`
under a `TYPE_CHECKING` guard. For the only runtime users
(`SQLiteMemoryStore._serialize_embedding` and `_deserialize_embedding`),
add a local `import numpy as np` so they raise a clear `ImportError`
only when embeddings are actually persisted.

Net effect: the proxy boots without numpy installed; memory features
that actually use numpy still work when its optional deps are present.

Fixes #332
2026-04-30 14:09:46 -07:00
Tejas Chopra
33190301af
Merge pull request #331 from chopratejas/rust-stage-3g-pr2-smartcrusher-offload
Rust stage 3g pr2 smartcrusher offload
2026-04-30 14:02:48 -07:00
Tejas Chopra
a1d62d81b0
Merge pull request #330 from chopratejas/rust-stage-3g-reformat-offload-rework
fix(rust): reformat/offload pipeline + log templates + diff noise (Ph…
2026-04-30 14:02:36 -07:00
chopratejas
b6137aa15d test(proxy): align hooks regression test with Bug 3 recount semantics
test_anthropic_hooks_do_not_break_extract_user_query_lookup mocks
pipeline.apply to return tokens_after=40 and a tiny compressed
message. The pre-Bug-3 proxy trusted the mock's tokens_after and
emitted x-headroom-tokens-after: 40. After issue #327 Bug 3 the
proxy recounts optimized_tokens from result.messages with its
own tokenizer (the mocked "compressed" string counts to 11), so the
header asserted against the wrong tokenizer's number.

Compute the expected value from the same tokenizer the proxy uses
(get_tokenizer("claude-sonnet-4-6")) and assert the recounted
header matches that. Add a tokens_before > tokens_after invariant
so the spirit of the test (compression actually reduced bytes) is
preserved without coupling to a specific tokenizer's calibration.
2026-04-30 13:26:53 -07:00
chopratejas
44944fb3fe fix(proxy): restore Anthropic compression on token mode (issue #327)
Three bugs combined to drive end-to-end compression on the Anthropic
backend to ~0% in token mode (the default). User report #327 saw a
~9× drop in dashboard savings from one day to the next on Claude
Code traffic; the dashboard headline was technically correct but the
underlying compression genuinely was not running. After this change
the same Claude Code-shape multi-turn conversation goes from
14987 → 14371 tokens at the request boundary on turn 1 and only
recompresses the freshest tool_result on subsequent turns, with the
prior turns frozen byte-identical to preserve the upstream prefix
cache.

Bug 1 — IntelligentContextManager inner ContentRouter has no observer

PR #302 (commit cf979958, 2026-04-28) wired CompressionObserver onto
the outer ContentRouter in proxy/server.py and onto SmartCrusher.
The inner ContentRouter constructed lazily inside
IntelligentContextManager._get_content_router (added Jan 18, 2026
in 57b2de5 alongside the COMPRESS_FIRST strategy) was missed. That
inner router handles the bulk of Claude Code's tool_result-block
compression, so per-strategy counters surfaced by PR #314 in v0.15.0
showed compressions_by_strategy={"text": 6} while
summary.compression.total_tokens_removed=1.3M — math-impossible.

Fix: add observer= parameter to IntelligentContextManager.__init__,
forward it to the inner ContentRouter at intelligent_context.py:525,
and pass observer=self.metrics from proxy/server.py.

Bug 2 — TTL deferral marks every fresh tool_result as stable

should_defer_compression in compression_cache.py returned True on
first-sight (added 2026-04-07 in commit 22dad13 with the intent of
batching first-time compressions near the 5-min cache TTL boundary
to trade many small busts for one). The token-mode walker at
anthropic.py:766-787 walks every message past frozen_message_count,
calls should_defer_compression on each fresh tool_result, gets True,
and advances ttl_frozen += 1 — every iteration. Result:
frozen_message_count grows to len(messages), the pipeline freezes
the entire request, and nothing reaches a real compressor.

The defer-first-sight rationale assumes recurring content within
TTL. Real Claude Code traffic produces unique content per turn, so
"defer until next sight" defers forever. Compressing fresh content
on first sight does not bust any prefix cache because Anthropic has
not cached that byte position yet — it's a cache write either way.

Fix: should_defer_compression returns False on first-sight (record
the timestamp; compress now). Subsequent sightings within TTL still
defer (batch window preserved for genuinely repeating content).
Updated tests in test_compression_cache.py to assert the corrected
semantics and verify _first_seen is recorded on first call.

Bug 3 — cross-tokenizer comparison in token-mode inflation guard

anthropic.py:634 sets original_tokens = tokenizer.count_messages(...)
using the proxy-side EstimatingTokenCounter. The token-mode branch
at line 816 set optimized_tokens = result.tokens_after from
pipeline, which uses the provider-side AnthropicProvider tiktoken
estimator. The two tokenizers disagree by ~25% on the same payload.

The inflation guard at line 901
(if optimized_tokens > original_tokens: revert to originals) treats
those two numbers as comparable. After a real 12% compression the
provider-tokenizer figure was still higher than the proxy-tokenizer
baseline, so the guard fired, optimized_messages was reset to the
original input, transforms_applied was emptied, and tokens_saved
went to 0. The dashboard showed no compression even when the
pipeline successfully compressed.

Fix: recount optimized_tokens with the proxy tokenizer right after
the pipeline returns, so the guard compares apples-to-apples. The
recount cost is a few ms on a 50K-token request and is dwarfed by
upstream call latency.

Verification

* 80 targeted tests across test_compression_cache,
  test_compression_observability, test_proxy_anthropic_cache_stability,
  test_proxy_intelligent_context pass.
* make ci-precheck clean.
* End-to-end real-API run against api.anthropic.com via local proxy:
  - Turn 1 fresh: 14987 → 14371 (4.1%) on a 3-tool-round payload;
    smart_crusher and diff strategies fired with non-zero savings.
  - Turn 2 (turn 1 history + 1 new tool_result): 23161 → 21928 (5.3%);
    only the new tool_result compressed; older turns marked
    router:protected:user_message; Anthropic returned
    cache_creation_input_tokens > 0 confirming the prefix was not
    busted.

Two new regression tests in test_compression_observability lock down
the inner ContentRouter observer wiring so a future copy of Bug 1
fails the suite the day it lands.
2026-04-30 12:59:19 -07:00
chopratejas
2a0582dee1 fix(rust): wire SmartCrusher as JsonOffload in the pipeline (Phase 3g PR2)
Stacked on rust-stage-3g-reformat-offload-rework. Adds the JSON
offload — the last big content shape that was deferred from PR1 —
by wrapping the existing SmartCrusher subsystem in the
OffloadTransform contract.

Why this is a thin wrapper, not a refactor: SmartCrusher is a
3000+ line subsystem (tabular IR, cell classifier, document
compactor, formatters, parity fixtures, PyO3 bridge) that's already
shadow-validated against Python. Reimplementing it under the trait
shape would risk parity. Instead JsonOffload composes:

- estimate_bloat: cheap byte scan that spots array-of-objects shape
  and counts row separators (`},{`, `}, {`, `},\n`). No JSON parse —
  reserved for apply.
- apply: delegates to SmartCrusher::crush(content, ctx.query, 0.0).
  On modification, hashes the WHOLE input and stashes it through
  the orchestrator-supplied CCR store under that hash, appends a
  `[json_offload CCR: hash=...]` marker. Honors the trait contract:
  cache_key resolves in the store the orchestrator passed.

SmartCrusher's per-array CCR markers in the compressed body remain
informational. The wrapper-level outer hash is what the LLM
retrieves to recover the full original payload — single
authoritative recovery path for the orchestrator pipeline.

Config goes in [offload.json] of pipeline.toml:
- min_array_rows = 5  (estimator floor)
- saturation_rows = 50 (score saturates to 1.0 here)

12 new JsonOffload tests + 2 orchestrator E2E tests; full
headroom-core suite (730) green; cargo fmt clean; no regex per
project convention.
2026-04-30 11:23:51 -07:00
chopratejas
01a423a316 fix(rust): reformat/offload pipeline + log templates + diff noise (Phase 3g rework)
Replaces PR1's lossless/lossy split with ReformatTransform (pack
denser, no info lost) and OffloadTransform (drop bytes, CCR-stash
original via required cache_key). With CCR every transform is
information-preserving end-to-end, so the lossless/lossy distinction
misnamed the architecture.

OffloadTransform carries a cheap, structural estimate_bloat() method
scoped to its domain — generic byte-redundancy heuristics miss domain
semantics. The orchestrator runs reformat phase + per-offload bloat
estimation in parallel via rayon::join + par_iter, then runs offload
iff bloat clears threshold OR reformat underwhelmed.

Transforms shipped:

REFORMATS (lossless):
- JsonMinifier: serde_json round-trip whitespace stripping.
- LogTemplate: Drain-inspired order-preserving template miner.
  Collapses consecutive runs of same-template lines into
  [Template Tn: ...] (Nx) + variant table. Win comes from emitting
  the constant-token prefix once instead of N times. Lossless: every
  original line reconstructible from template + variants.

OFFLOADS (drop bytes, stash original via CCR):
- LogOffload: wraps existing LogCompressor; bloat = repetition x
  uniqueness_weight + dilution x priority_dilution_weight.
- DiffOffload: wraps existing DiffCompressor; bloat = context-to-
  change ratio. Bug-fix-on-port — persists original under the
  cache_key the parity-bound DiffCompressor mints (closes a leak).
- DiffNoise: drops lockfile hunks (Cargo.lock, package-lock.json,
  yarn.lock, etc., suffix list configurable in TOML) and
  whitespace-only hunks. Stashes original via CCR for retrieval.

Search offload exists but is not in default re-exports — modern
agents (Claude Code, Codex) use scoped rg/grep, the marginal value
didn't justify default registration. Reach via the explicit module
path if opting in.

JSON Offload is intentionally absent from this PR — already lives at
SmartCrusher; Phase 3g PR3 wraps it in the OffloadTransform contract.

Thresholds and weights live in config/pipeline.toml, embedded via
include_str!; PipelineConfig::from_toml_str loads runtime overrides.

98 new pipeline tests; full headroom-core suite (714) and workspace
tests green; cargo fmt clean. No regex, per project convention.
2026-04-30 11:10:24 -07:00
Tejas Chopra
dd287a8257
Merge pull request #326 from gglucass/fix/traffic-learner-min-evidence
fix(traffic-learner): block bogus error_recovery pairs at the source
2026-04-30 09:49:28 -07:00
Tejas Chopra
c89182f6cb
Merge pull request #324 from chopratejas/rust-stage-3e-4-tag-protector
feat(rust): port tag_protector to Rust + 5 bug fixes (Phase 3e.4)
2026-04-30 09:48:47 -07:00
Garm
4512a0626e test(traffic-learner): cover helper edge cases + apply ruff format
CI flagged two issues on the rebased branch:
1. ruff format --check failed on server.py and test_traffic_learner.py
   after the rebase; line-collapse / trailing-whitespace nits.
2. Codecov reported 80% patch coverage with 20 lines missing in the
   matcher helpers — mostly branches not exercised by the high-level
   tests (empty Levenshtein inputs, source-prefix Bash parsing, env-var
   skip, equal-string short-circuit in binary match, the substantive-
   token path that beats the edit-distance gate, error_recovery patterns
   with non-canonical content in _drop_contradictions).

Adds 16 targeted unit tests for those branches and applies ruff format.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:05:54 +09:00
Garm
bb9f1ffe22 chore(traffic-learner): apply ruff format + add CHANGELOG entry
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:47:10 +09:00
Garm
606131451b fix(traffic-learner): tighten matchers and drop contradictions
The recovery matchers paired any failed and successful tool call within
a 5-call window with no semantic check that the pair was actually a
retry. This produced confidently-wrong rules like:

  File `state.rs` does not exist. The correct path is `lib.rs`.

…where the user simply read two unrelated files in the same directory.
Across sessions the same user can also typo in opposite directions,
producing directly contradictory rules side by side.

This commit adds three structural checks:

1. Read recovery: require the failed and successful basenames to be
   identical or close in Levenshtein distance. Rejects the "same dir,
   different file" case that was the most common noise source.

2. Bash recovery: require both commands to share a binary (allowing
   path-prefixed variants and short prefix-versions like
   `python` ↔ `python3`) AND either have low normalized edit distance
   or share a substantive non-flag token. Rejects pairs that share only
   the binary name but differ in every meaningful argument.

3. Contradiction filter on flush: detect A→B and B→A pairs in
   error_recovery patterns and drop both. They almost always indicate
   opposite-direction typos in different sessions, not stable advice.

Also: stash failed_path in metadata so the contradiction filter and
downstream consumers can reason about pairs without parsing content.

Tests: 13 new tests covering the heuristics directly. Existing tests
exercising legitimate recoveries (`python`→`python3`, `ruff`→`.venv/bin/ruff`,
`pip install`→success) continue to pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:45:45 +09:00
Garm
a8ebf9ac5e test(traffic-learner): regression test for shutdown evidence gate
Asserts that stop()'s final flush_to_file does not bypass the evidence
threshold. Earlier behavior collapsed the gate to 1 at shutdown,
persisting every singleton pattern. This guards against that change
sneaking back in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:44:22 +09:00