mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
2232 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6469fcd018
|
feat(transforms): language-aware stack-trace collapse for Go/Rust/.NET/Java/Node (#1791)
## Description
Stack-trace handling covered only Python tracebacks and a generic ` at
symbol(` pattern. Go panics and Rust panics flowed to prose compression;
.NET traces were unrecognized; Java chained exceptions split into
separate traces at every `Caused by:` (so later chain heads fell off the
`max_stack_traces` cliff); and oversized traces were blindly
head-truncated — keeping runtime scheduler noise while dropping the app
frames and chain heads an agent actually needs.
This PR adds language-aware trace flavors (Go, Rust, .NET, Java chains,
Node async) to the Rust core and both Python mirrors, and replaces blind
truncation with a runtime-frame collapse: message lines, chain heads,
the trace head, and app-code frames survive; contiguous runtime/stdlib
frames fold into `[... N frames collapsed]` markers. A 147-line Go panic
dump compresses to 19 lines with the panic message, signal line, and app
frame intact.
Note one intentional behavior change: now that Go/Rust panics are
*detected*, panics ≤8KB in tool outputs gain the existing error-output
protection (`protect_error_outputs`) they previously missed — small
panics stay verbatim, exactly like small Python tracebacks already do.
## Type of Change
- [x] New feature (non-breaking change which adds functionality)
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] Breaking change (fix or feature that would cause existing
functionality to not work as expected)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring
## Changes Made
- `crates/headroom-core/src/transforms/log_compressor.rs`:
- New `TraceFlavor::GoPanic` (`panic:` / `fatal error:` / `goroutine N
[state]:` openers, tab-indented `.go:` frame lines, `created by` /
call-line continuation, blank-separated goroutine blocks) and
`TraceFlavor::DotNet` (`Unhandled exception.`, `at … ) in file:line N`
frames — checked before Java since those frames also satisfy the Java
shape — plus `--->` inner-exception heads and `--- End of` separators).
- Renamed the misnamed `Go` flavor to `RustBacktrace` (its `is_go_frame`
matched `N: 0x<hex>` — the Rust backtrace shape) and gave it real
openers (`thread '…' panicked at`, `stack backtrace:`); the free-text
panic-message line after the opener stays in the trace (`terminates` now
receives `lines_so_far`).
- Java: continues across `Caused by:` / `Suppressed:` / `... N more`,
and `is_java_at_frame` admits `/` so JPMS module frames (`at
java.base/…`) pass the opener re-check — without this, modern JDK traces
fragmented at the parse cap into ≤20-line groups.
- Frame collapse (`collapse_trace_frames`): for traces over
`stack_trace_max_lines`, keeps message/chain-head lines, first
`trace_head_frames` frames, up to `trace_app_frames` app frames; runtime
frames (prefix + path marker tables per language) fold into `[... N
frames collapsed]` markers that occupy the run's first line slot and
carry score 0.8 so the global cap doesn't drop them first. Collapsed
frame indices are excluded from the context-line pass (otherwise ±3
context re-added them), and the parse cap re-opens on continuation lines
so selection sees one contiguous trace. New config:
`collapse_runtime_frames=true`, `trace_head_frames=3`,
`trace_app_frames=5`; new sidecar stat `runtime_frames_collapsed`.
- `crates/headroom-py/src/lib.rs`: the three new knobs on the
`LogCompressorConfig` PyO3 signature.
- `headroom/transforms/log_compressor.py`: dataclass fields +
constructor pass-through; `_parse_lines` opener patterns mirrored per
the documented contract.
- `headroom/transforms/content_detector.py`: `_LOG_PATTERNS` additions
(Go panic/goroutine/frame lines, Rust panic/backtrace/numbered frames,
.NET, Java chain heads, Node `at async`); JS/Java `at` pattern admits
JPMS module paths.
- `tests/test_transforms_stack_traces.py` (new, 10 tests) + 7 new Rust
unit tests (flavor open/continue/terminate, chain grouping, collapse
keeps chain heads/app frames, collapse-off comparison, small traces
untouched).
## Testing
- [x] Added new tests for the changes
- [x] All existing tests pass
### Test Output
```
$ cargo test -p headroom-core
928 passed; 3 ignored
$ python -m pytest tests/test_transforms_stack_traces.py tests/test_log_compressor.py \
tests/test_transforms_log_compressor.py tests/test_transforms_content_detection.py \
tests/test_transforms_content_router.py tests/test_transforms/test_content_router.py \
tests/test_lossless_mode.py tests/test_compression_fidelity_regression.py -q
191 passed
```
## Real Behavior Proof
- Environment: macOS arm64, Python 3.13, repo main @
|
||
|
|
737b332129
|
feat(config): fold TOML array-of-tables to csv-schema via SmartCrusher (#1799)
## Description Adds a schema-fold tier to the structured-config compressor (introduced in #1784). TOML files containing an `[[array-of-tables]]` are parsed with the stdlib `tomllib` reference parser and bridged to SmartCrusher's lossless `csv-schema` renderer, which folds the repeated per-record keys into a single schema over the rows. On lockfiles and override-lists — where the repeated keys dominate the byte count — this is a large win. **Stacked on #1784 — review only the top commit** (`feat(config): fold TOML array-of-tables to csv-schema via SmartCrusher`). The base commit is #1784's config-compressor PR; this PR will collapse to the single new commit once #1784 merges. Faithfulness is guaranteed by construction, not by a heuristic: - `tomllib` is the reference TOML parser, so the extracted records are ground-truth. - `csv-schema` is a lossless JSON renderer (`smart_crusher.py` documents it as such), so the model reads a faithful, reformatted view of the exact parsed data. - Byte-exact recovery rides the existing CCR path — the original is persisted to the `CompressionStore` and a `Retrieve original: hash=…` marker is emitted. The fold is only emitted when that store write succeeds, so nothing is ever unrecoverable. Scope is deliberately **TOML-only**: `tomllib` is stdlib, whereas PyYAML is only a *transitive* dependency (not declared in `pyproject.toml`), and INI record-sections would need a bespoke dict-of-dicts→records transform. Those flavors can follow in a separate PR with an explicit dependency decision. ## Type of Change - [x] New feature (non-breaking change which adds functionality) ## Changes Made - `headroom/transforms/config_compressor.py`: added Tier 3 (`_schema_fold`) — TOML `[[array-of-tables]]` → `tomllib` → JSON → `SmartCrusher(csv-schema)`. New `enable_schema_fold` config flag (default on; auto-off in lossless mode since it rides `enable_ccr`). The fold competes with the reversible text tiers and is adopted only when strictly smaller. Added `_load_toml` (stdlib parser with tomli backport) and `_json_default` (TOML date/time → ISO; bail on any other non-serializable value). - Recovery reuses the existing `CompressionStore` + `Retrieve original: hash=` marker; no new CCR plumbing. - `tests/test_transforms_config_compressor.py`: 14 new tests covering the fold, big-win assertion, byte-exact CCR round-trip, lossless-mode disable, flag-off, non-TOML skip, no-array skip, small-array `passthrough` decline, store-failure fallback, savings-floor rejection, unparseable/non-serializable bails, `_load_toml`/`_json_default` units, and a datetime-valued fold. ## Testing - [x] New and existing unit tests pass locally - [x] New tests added for the new behavior ### Test Output ``` tests/test_transforms_config_compressor.py ............................. [ 59%] headroom/transforms/config_compressor.py 127 0 36 0 100% ============================== 49 passed in 0.61s ============================== ``` Must-stay-green suites (`test_lossless_mode`, `test_lossless_excluded_compaction`, `test_transforms_content_detection`, `test_compression_fidelity_regression`) — 48 passed. Router/tabular/smart_crusher regression — 73 + 82 passed. `mypy --strict` clean on the changed module. ## Real Behavior Proof - Environment: local, Python 3.11.0, macOS (darwin), `HF_HUB_OFFLINE=1` - Exact command / steps: parsed a 25-record `[[tool.mypy.overrides]]` TOML through `ConfigCompressor(ConfigCompressorConfig(enable_ccr=True)).compress()`, then retrieved the CCR hash from the `CompressionStore`. - Observed result: `strategy=config_schema_fold`, 2765 → 840 chars (30% of original); the marker hash resolved to the byte-exact original (`recovered == original` True); with `enable_ccr=False` (lossless mode) the fold did not run and no marker was emitted; a 3-record long-valued `[[package]]` array correctly declined (SmartCrusher `passthrough`). - Not tested: the live proxy end-to-end path and non-TOML flavors (YAML/INI schema folding is intentionally out of scope for this PR). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
12a9710665
|
feat(stats): per-bucket output-shaping savings in /stats-history (#1819)
## Description Adds per-bucket **output-shaping savings** to `/stats-history`. Today output-shaping savings exist only as a single global aggregate (`savings.by_layer.output_shaping`), so downstream consumers can't chart them over time. This threads a per-request output-savings estimate into the existing rollup so every `series` bucket carries `output_tokens_saved_delta` + `output_savings_usd_delta`, symmetric with the existing `compression_savings_usd_delta`. Motivation: on Claude Code subscription traffic, input is ~99% cache-discounted (the compressible live zone is a fraction of a percent), while output shaping is a ~36% reduction on full-price output tokens — so it's the dominant, honestly-attributable saving, and currently the only one a dashboard can't render per day. Closes #1816 ## Type of Change - [x] New feature (non-breaking change that adds functionality) ## Changes Made - `output_savings.py`: new read-only `SavingsRecorder.estimate_request_savings(labels, output_tokens)` → per-request synthetic-control estimate `max(0, baseline_mean(stratum) - output_tokens)` for treatment requests; 0 for control / unknown stratum / no label. Does **not** mutate the ledger, so it composes with `record_from_labels` without double-counting. `record_from_labels`'s `bool` contract is unchanged. - `outcome.py`: in the funnel, capture that estimate and pass it to `record_request(output_tokens_saved=...)`. - `savings_tracker.py`: `record_request` gains `output_tokens_saved`; accumulates lifetime cumulative `output_tokens_saved` / `output_savings_usd` (priced via new `_estimate_output_savings_usd`, output-rate), writes them into each checkpoint, and now checkpoints when **either** compression **or** output savings occurred (so output-only requests aren't dropped). `_build_rollup` diffs the cumulative into `output_tokens_saved_delta` / `output_savings_usd_delta` per bucket; `_normalize_history_entry` and the CSV export carry the fields. - Additive + backward-compatible: checkpoints predating the feature default the new fields to 0. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run --extra dev pytest tests/test_output_shaping_rollup.py tests/test_output_savings.py \ tests/test_output_savings_cli.py tests/test_proxy_savings_history.py tests/test_request_outcome.py -q ... 103 passed $ uv run --extra dev ruff check headroom/proxy/savings_tracker.py headroom/proxy/output_savings.py \ headroom/proxy/prometheus_metrics.py headroom/proxy/outcome.py tests/test_output_shaping_rollup.py All checks passed! $ uv run --extra dev mypy headroom/proxy/savings_tracker.py headroom/proxy/output_savings.py Success: no issues found in 2 source files ``` New tests (`tests/test_output_shaping_rollup.py`): output savings bucket into the daily series; an output-only request (no compression) still checkpoints; pre-feature requests default to 0; `estimate_request_savings` returns the baseline-relative saving for treatment and 0 for control / unknown / over-baseline. ## Real Behavior Proof - Environment: macOS, CPython 3.10.18, this branch (rebased on latest `main`), litellm pricing available. - Exact command / steps: seed a baseline (as `learn --verbosity` would), then drive 3 requests through the real, unmocked chain `SavingsRecorder.estimate_request_savings` → `SavingsTracker.record_request` → `history_response()`, and print `series.daily`. Full script + raw output: ```text $ uv run python proof.py # seeds baseline ~1000 out-tok; 3 treatment requests (out=600/550/700), one with no compression [ { "timestamp": "2026-07-05T00:00:00Z", "tokens_saved": 120, "compression_savings_usd_delta": 0.0006, "output_tokens_saved_delta": 850, "output_savings_usd_delta": 0.02125 }, { "timestamp": "2026-07-06T00:00:00Z", "tokens_saved": 80, "compression_savings_usd_delta": 0.0004, "output_tokens_saved_delta": 300, "output_savings_usd_delta": 0.0075 } ] ``` - Observed result: output-shaping savings appear per day and independent of the compression axis. 2026-07-05 = 850 (400+450 saved by two treatment requests vs the ~1000-token baseline, including one request with zero compression — proving the output-only checkpoint path), 2026-07-06 = 300, each priced at the model's output rate. Matches expectations. - Not tested: the full live proxy over HTTP with a real learned baseline and organic traffic — I exercised the same code path minus the HTTP/streaming layer. The measured-vs-estimated `method` gating is unchanged by this PR. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A — backend-only change (no UI surface in this repo). The runtime effect is the `/stats-history` `series.daily` JSON with the new `output_tokens_saved_delta` / `output_savings_usd_delta` fields, shown under **Real Behavior Proof** above. The downstream chart that renders them lives in the separate Headroom desktop app. ## Additional Notes - Per CONTRIBUTING's issue-first policy for features, I opened #1816 first with the spec; happy to adjust the API surface (field names / gating) to whatever you prefer. A downstream consumer (Headroom desktop chart) is already implemented against this exact contract and stacks the segment only when `output_reduction.method == "measured"`. - Docs checkbox left unchecked: I didn't find a `/stats-history` schema doc to update; point me at one if it exists. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
dec60de976
|
fix(codex): preserve wrapped sessions and recover state (#2160)
## Description Closes #2159. Codex wrappers currently launch against a disposable `CODEX_HOME`, so session state created during a wrapped run can disappear when that temporary directory is removed. This change launches Codex against its durable home, keeps proxy routing process-local, and adds recovery for retained temporary homes and pinned recovery sources. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Launch Codex against its durable `CODEX_HOME` and apply routing through process-local config overrides after the actual proxy port is resolved. - Preserve custom provider identity and reject providers that cannot be redirected safely. - Detect dangling temporary Codex homes before interactive wraps and offer recovery. - Add `headroom recover codex` with automatic discovery, repeatable `--source`, preview, confirmation, retained backups, and rollback on failure. - Search Python's temp root, `$TMPDIR`, `/tmp`, `/private/tmp`, and macOS `/private/var/folders/*/*/T` for retained `headroom-codex-home-*` directories. - Reuse `source-pinned/` copies left by interrupted or failed recovery attempts after the original temporary home has disappeared. - Report deleted temporary homes still referenced by SQLite rollout paths without treating paths pasted into prompts or errors as filesystem evidence. - Audit the durable thread index, rollout files, and history when no source remains, including indexed chat counts and history-only orphan records. - Normalize legacy localhost `headroom` providers in both SQLite thread rows and rollout `session_meta`, including retries after an earlier broken recovery, while preserving user-defined remote providers named `headroom`. - Merge compatible config, JSONL, rollout, SQLite, credential, and regular-file state without propagating deletions or runtime artifacts. - Rewrite recovered thread rollout paths to the durable home and restore legacy Headroom thread providers to the active provider. - Validate SQLite schemas, SQLx migration checksums, integrity, and foreign keys, and quarantine malformed JSONL. - Preserve failed targets with an atomic rename before rollback, avoiding recursive-deletion races with live SQLite runtime files. - Document discovery, migration, retained backups, rollback behavior, and the limits of deleted-source recovery. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] Type checking passes (`mypy`) - [x] New tests added for new functionality - [x] Manual testing performed in isolated Docker containers ### Test Output ```text $ uv run pytest tests/test_cli/test_wrap_codex.py tests/test_cli/test_recover_codex.py -q 122 passed $ uv run ruff check headroom/cli/recover.py headroom/providers/codex/recovery.py tests/test_cli/test_recover_codex.py All checks passed! $ uv run ruff format --check headroom/cli/recover.py headroom/providers/codex/recovery.py tests/test_cli/test_recover_codex.py 3 files already formatted $ uv run mypy headroom/cli/recover.py headroom/providers/codex/recovery.py Success: no issues found in 2 source files ``` All validation ran in `ghcr.io/astral-sh/uv:python3.12-bookworm` against a writable disposable copy of a read-only source mount. Codex was not installed or launched, and no real user Codex state was read or modified. The tests cover multi-root discovery, deleted-reference reporting, retained pinned-source recovery, durable SQLite path relocation, SQLite and rollout provider normalization, idempotent repair after an earlier broken recovery, remote provider preservation, unrelated dangling target rows, backup retention, atomic rollback, malformed-state quarantine, SQLite validation, and Windows-safe handle closure. The repository shim E2E was not launched locally because this recovery work intentionally avoids launching Codex. Upstream CI exercises wrapper E2E in isolated environments. ## Real Behavior Proof - Environment: `ghcr.io/astral-sh/uv:python3.12-bookworm`, Python 3.12, a writable disposable checkout copied from a read-only source mount, at head ` |
||
|
|
57e8dcb425
|
feat(proxy): add opt-in cost-aware model router (#1706) (#2205)
## Description Adds an optional, configuration driven model router (closes #1706). With `HEADROOM_MODEL_ROUTER_ENABLED` set, ordered rules in `HEADROOM_MODEL_ROUTES` rewrite the upstream model by estimated input size and tool presence, complementary to content compression, for example sending small, tool-free requests to a cheaper model. First matching rule wins and every decision is logged with a reason. Off by default so behavior is unchanged, skipped under `x-headroom-bypass`/passthrough, and wired on the Anthropic `/v1/messages` path. Malformed rules fail open, so a bad rule is skipped rather than silently widened. Closes #1706 ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/proxy/model_router.py`: new `ModelRouter` component (ordered rules, first-match decision with reason, fail-open env parsing, tokenizer-free input estimate). - `headroom/proxy/models.py` + `headroom/proxy/server.py`: `ProxyConfig.model_router` field, env loader (`HEADROOM_MODEL_ROUTER_ENABLED` / `HEADROOM_MODEL_ROUTES`), and proxy wiring. - `headroom/proxy/handlers/anthropic.py`: apply routing on `/v1/messages` after the bypass gate, tracked as a body mutation. - Tests, docs (`configuration.mdx`), and a CHANGELOG entry. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ pytest -q tests/test_proxy/test_model_router.py tests/test_proxy/test_model_router_wiring.py 36 passed, 1 warning $ ruff check . All checks passed! $ mypy headroom --ignore-missing-imports Success: no issues found in 477 source files ``` ## Real Behavior Proof - Environment: local, macOS, Python 3.12, headroom `.venv`, upstream mocked (no live provider call). - Exact command / steps: enable the router via `ProxyConfig(model_router=...)`, POST `/v1/messages` through `TestClient` with a rule routing low-risk requests to a cheaper model; repeat with header `x-headroom-bypass: true`. - Observed result: the forwarded upstream body model is rewritten from `claude-sonnet-4-6` to `claude-haiku-4-5` when the router is enabled, and is left unchanged under bypass (see `tests/test_proxy/test_model_router_wiring.py`). - Not tested: the OpenAI and Gemini handler paths (this PR wires the Anthropic path only); no live provider request (upstream is mocked). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Additional Notes Happy to adjust the interface or scope (for example OpenAI and Gemini parity) if you'd prefer a different shape. --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com> Co-authored-by: reneleonhardt <65483435+reneleonhardt@users.noreply.github.com> |
||
|
|
aa4515cf7a
|
fix(memory): filter inactive graph-expanded results (#2210)
## DescriptionKeep graph-expanded local-memory results consistent with the current-only contract already applied by vector search.Closes #2209## Type of Change- [x] Bug fix (non-breaking change that fixes an issue)- [ ] New feature (non-breaking change that adds functionality)- [ ] Breaking change (fix or feature that would cause existing functionality to change)- [ ] Documentation update- [ ] Performance improvement- [ ] Code refactoring (no functional changes)## Changes Made- Reject graph-expanded memories whose `valid_until` is set.- Reject graph-expanded memories whose `superseded_by` is set.- Add focused coverage for active, expired, and superseded related memories.## Testing- [x] Unit tests pass (`pytest`)- [x] Linting passes (`ruff check .`)- [ ] Type checking passes (`mypy headroom`)- [x] New tests added for new functionality- [ ] Manual testing performed### Test Output```text$ uv run --with pytest --with pytest-asyncio --with numpy pytest tests/test_memory/test_local_backend_search.py -q3 passed$ uv run --with ruff ruff check headroom/memory/backends/local.py tests/test_memory/test_local_backend_search.pyAll checks passed!$ uv run --with ruff ruff format --check headroom/memory/backends/local.py tests/test_memory/test_local_backend_search.py2 files already formatted```## Real Behavior Proof- Environment: Python 3.13, synthetic in-memory test doubles- Exact command / steps: run the focused test file above- Observed result: active graph-linked memory is returned; records with `valid_until` or `superseded_by` are excluded- Not tested: full repository suite, external vector/graph implementations## Review Readiness- [x] I have performed a self-review- [x] This PR is ready for human review## Checklist- [x] My code follows the project's style guidelines- [x] I have performed a self-review of my code- [ ] I have commented my code, particularly in hard-to-understand areas- [ ] I have made corresponding changes to the documentation- [ ] My changes generate no new warnings- [x] I have added tests that prove my fix is effective or that my feature works- [x] New and existing unit tests pass locally with my changes- [ ] I have updated the CHANGELOG.md if applicable## Screenshots (if applicable)N/A## Additional NotesDocumentation and changelog changes are not needed for this narrow internal behavior fix. The existing temporal-history APIs remain unchanged. Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
8537e2cf60
|
fix(wrap): self-heal a stale ANTHROPIC_BASE_URL left by a dead proxy (#2223)
## Description `headroom wrap claude` persists `ANTHROPIC_BASE_URL=<proxy>` into project-local `.claude/settings.local.json`. This is required: Claude Code's cc-daemon spawn-forks conversation workers that read settings fresh rather than inherit env, so the URL cannot just live in the child process env. When the proxy then dies via a **hard reboot / SIGKILL**, no signal/atexit cleanup fires, so the stale URL lingers and bricks a later **bare `claude`** with ConnectionRefused (#2221). #1768's mitigations (SIGHUP, next-`wrap` self-heal, doctor WARN) don't cover "reboot → bare `claude`", and — the key gap — `wrap` installed no hook of its own, so for a user who only ever ran `wrap claude` (never `init claude`) there was nothing to clean it up. `wrap claude` now installs a **SessionStart-only** self-heal hook (removed again on `unwrap`) that clears the persisted base URL **iff the recorded proxy port fails a retry-hardened liveness probe**. A responding proxy is never cleared, and the retry (3 attempts ~250 ms apart, alive on first success) keeps a transient blip from clearing a live session mid-run. Because workers read settings fresh per conversation, clearing at session start unblocks the current session too, not only the next. ## Design note / assumption (for maintainer confirmation) This relies on **the SessionStart hook completing before the first cc-daemon conversation worker reads `settings.local.json`**. That ordering lives in Claude Code, not this repo; it is grounded in the documented spawn-fresh-read model (the same reason the URL must be persisted at all). Raised on the issue for confirmation. The truly launcher-agnostic fix would be upstream — Claude Code falling back to the real upstream when its configured base URL is unreachable — which would make any stale local URL harmless. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `headroom/cli/wrap.py`: - `_wrap_proxy_alive(port, attempts=3, delay=0.25)` — retry-hardened liveness (alive on first success, dead only if all fail). - `_check_and_clear_dead_wrap_marker` — port is authoritative (survives PID reuse after reboot); a single probe decides; a responding proxy is never cleared; falls back to PID staleness only for port-less markers. - `_ensure_claude_wrap_selfheal_hook` / `_remove_claude_wrap_selfheal_hook` — install on `wrap claude`, remove on `unwrap`; **SessionStart-only** (never PreToolUse), idempotent, preserves the `env` block and unrelated/user hooks. - hidden `wrap selfheal` command the hook invokes. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff`) - [x] Type checking passes (`mypy`) - [x] New tests added for new functionality ### Test Output ```text $ pytest tests/test_cli/test_wrap_dead_marker_selfheal.py 22 passed $ pytest <related wrap/unwrap suites> 59 passed, 1 failed # the 1 failure (test_wrap_marker_is_stale_when_pid_reused) # is PRE-EXISTING + unrelated — fails identically on clean main # (macOS _proc_identity returns None); this PR touches neither # _wrap_marker_is_stale nor _identity_mismatch. $ ruff check / mypy headroom/cli/wrap.py # clean ``` ## Real Behavior Proof - Environment: macOS (Darwin), Python in a uv venv, branch `feat/wrap-stale-url-selfheal` off `main`. - Exact command / steps: `pytest tests/test_cli/test_wrap_dead_marker_selfheal.py` exercises: `wrap claude` writes a SessionStart-only self-heal hook into `settings.local.json` (idempotent, not on PreToolUse); `unwrap` removes it (keeping unrelated hooks); the `wrap selfheal` command clears a dead-port marker's base URL; and — bound to a REAL listening socket — a live proxy's marker is never cleared, including when a single probe transiently fails but the retry succeeds. - Observed result: dead-proxy marker → base URL restored to its prior value; live-proxy marker (real socket) → preserved; no marker / no settings file / port-less marker → no-op, no exception. All 22 pass. - Not tested: the actual Claude Code hook-vs-worker execution ordering (upstream, not in this repo) — see the Design note; the fix is correct given that documented model. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation — N/A (internal wrap behavior) - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md — happy to add an entry if preferred. ## Additional Notes - Scoped to the `wrap claude` project-local path (the reported scenario). The opt-in cc-switch reconciler writes `ANTHROPIC_BASE_URL` into the *global* `~/.claude/settings.json` with no restore today — a separate, lower-frequency gap I can follow up on if wanted. --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
ea0115cbdb
|
fix(backend/bedrock): preserve system-prompt cache_control breakpoint (list form) (#2225)
## Description The LiteLLM backend flattened the Anthropic top-level `system` field to a joined string whenever it arrived as a **list of content blocks**, discarding each block's `cache_control`. LiteLLM's Bedrock Converse transform (`AmazonConverseConfig._transform_system_message`) only emits a `cachePoint` for content blocks that carry `cache_control`, never for a plain string. So on any `--backend bedrock` deployment the **system prefix was never cached**: every turn re-sent the full system prompt (typically 5k-25k tokens with Claude Code) at full input price. #1390 fixed the analogous case for `tool_result` blocks in `_convert_messages_for_litellm`, but the top-level `system` field handling in `send_message` / `stream_message` was out of scope there and still flattened. The cache hits observed on live Bedrock traffic came only from the tool-result / message-tail breakpoint, masking that the largest, most stable block was uncached. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/backends/litellm.py`: factor the top-level `system` field conversion into a single `_system_field_to_message` helper. `str` stays string-content (unchanged behavior); a `list` maps to text blocks retaining each block's `cache_control`; non-dict entries coerce to a plain text block. Both call sites (`send_message` non-streaming, `stream_message` streaming) now call the helper, so they stay byte-identical. - `tests/test_bedrock_tool_result_cache_and_streaming_stats.py`: add `TestSystemFieldCacheControl` — list-with-`cache_control` retains it, plain-string is unchanged, list-without-`cache_control` produces list content with no marker, plus two end-to-end checks that drive the emitted message through `AmazonConverseConfig._transform_system_message` and assert a `cachePoint` is present for the cache_control case and absent otherwise. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run pytest tests/test_bedrock_tool_result_cache_and_streaming_stats.py -q collected 13 items tests/test_bedrock_tool_result_cache_and_streaming_stats.py ............. [100%] 13 passed in 1.18s $ uv run ruff check headroom/backends/litellm.py tests/test_bedrock_tool_result_cache_and_streaming_stats.py All checks passed! ``` ## Real Behavior Proof - Environment: personal fork deployed as a real proxy (macOS launchd service, `headroom install apply`) with `--backend bedrock --mode cache`, fronting a live Claude Code session. Model `global.anthropic.claude-sonnet-5`, region eu-west-1. - Exact command / steps: ran a purpose-built probe that POSTs Anthropic-shape `/v1/messages` to the running proxy with a 7,692-token STABLE system prompt carrying a single `cache_control: {type: ephemeral}` breakpoint (and no other cache_control anywhere), a pinned `x-headroom-session-id`, across 5 sequential turns, reading the raw response `usage` each turn. - Observed result: **before the fix**, the response `usage` had no cache fields at all — `cache_creation_input_tokens` and `cache_read_input_tokens` both absent, nothing cached. **After the fix**, turn 1 shows `cache_creation_input_tokens=10164` (write) and turns 2-5 each show `cache_read_input_tokens=10164` (read) with `cache_creation=0` — write-once, then read the system prefix from Bedrock's cache on every subsequent turn. The proxy's `/stats` `prefix_cache` tracker registered all four later turns as hits (`hit_requests += 1` per turn, `bust_count = 0`). - Not tested: no change to the tool_result / message-tail breakpoint path (already handled by #1390 / #2144); this fix is scoped to the top-level `system` field only. The in-`messages` text-block flatten in `_convert_messages_for_litellm` is intentionally left untouched. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - No linked issue number: found via independent investigation of a personal `--backend bedrock` deployment. - Companion to #2196 (`fix(proxy/bedrock): wire PrefixCacheTracker updates into Bedrock backend paths`) from the same investigation. #2196 wires the tracker; this fixes the system-prompt breakpoint that #1390 left flattened on the top-level `system` field. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
842d7e1ad1
|
fix(ccr): lowercase a retrieved hash so an uppercase echo still hits the store (#2236)
## Description
A CCR retrieval fails whenever the model echoes the content hash in
uppercase, even though the content is present in the store.
`parse_tool_call` extracts and validates the hash from a
`headroom_retrieve` tool call:
```python
# Validate hex characters only
if not all(c in "0123456789abcdef" for c in hash_key.lower()):
return None
return hash_key
```
The hex check is deliberately case-insensitive (`hash_key.lower()`), so
an uppercase hash passes validation — but the value is then returned
**verbatim**. The compression store, however, keys every entry by a
lowercase hash: writes use either a sha256 hexdigest
(`hashlib.sha256(...).hexdigest()[:24]`, always lowercase) or
`explicit_hash.lower()`, and `retrieve` / `get_entry_status` look the
key up as-is with no normalization.
So when a model reproduces the marker hash in uppercase (LLMs routinely
normalize hex casing when they copy tokens), the retrieve endpoint
validates it, calls `store.retrieve("ABC…")` against a store that only
holds `"abc…"`, and reports a miss — the original content is unreachable
even though it is right there. The case-insensitive validation shows the
intent was to accept either casing; only the return value was left
un-normalized.
## Fix
Return the canonical lowercase form so the whole pipeline is
consistently lowercase:
```python
return hash_key.lower()
```
Closes #
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `headroom/ccr/tool_injection.py`: `parse_tool_call` returns
`hash_key.lower()`.
- `tests/test_ccr_tool_injection.py`: new test asserting an uppercase
hash is normalized to lowercase.
- `CHANGELOG.md`: Bug Fixes entry.
## Testing
- [ ] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ uvx ruff@0.15.17 check headroom/ccr/tool_injection.py tests/test_ccr_tool_injection.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/ccr/tool_injection.py
Success: no issues found in 1 source file
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOM-kills this box (ML stack import), so I
reproduced the validate/return + a lowercase-keyed store with a
dependency-free script and left the full pytest to CI.
- Exact command / steps: put `"abc123def456abc123def456" -> content` in
a store, then looked it up with the uppercase echo
`"ABC123DEF456ABC123DEF456"` through the OLD (return verbatim) and NEW
(return `.lower()`) paths.
- Observed result: OLD returns the uppercase hash → store miss; NEW
returns the lowercase hash → store hit (original content recovered). A
lowercase hash resolves under both.
- Not tested: a live model round-trip that uppercases the marker; full
local `pytest` deferred to CI (OOM).
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable
## Additional Notes
The "unit tests pass locally" box is unchecked because the full suite
imports the ML stack, which I can't run here. The new test lives
alongside the existing `parse_tool_call` tests in
`tests/test_ccr_tool_injection.py`, so it runs under the normal CI
pytest job; behaviour is additionally verified by the standalone proof
above.
---------
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
|
||
|
|
17d60dce1f
|
docs(troubleshooting): document Windows Defender ast-grep-cli false positive + workarounds (#2200) (#2237)
## Description On Windows, `uv tool install "headroom-ai[all]"` (and `pip install`) fails while installing the `ast-grep-cli` wheel because Windows Defender quarantines the bundled `sg.exe` as `Trojan:Win64/Lazy!MTB` (`os error 225`). This is a **known upstream false positive** in the `ast-grep-cli` wheel ([ast-grep/ast-grep#2799](https://github.com/ast-grep/ast-grep/issues/2799)), not a Headroom-introduced problem — but because `ast-grep-cli` is a base dependency, the local install path is blocked on affected Windows machines. The issue (#2200) explicitly asks: "At minimum, please document a supported workaround." This adds a troubleshooting entry with safest-first workarounds. `ast-grep` is used only for optional AST-based Read-output outlining and Headroom degrades gracefully without it, so the impact is purely the install-time quarantine. Closes #2200 ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made `docs/content/docs/troubleshooting.mdx` only — a new `### Windows: Defender blocks ast-grep-cli (sg.exe) during install` subsection under the existing `## Installation Issues` section, following the file's `**Symptom**` / `**Cause**` / workarounds pattern: - **Symptom** — the exact `uv tool install` failure text (`os error 225`, `Trojan:Win64/Lazy!MTB`, `sg.exe`) so users match it by search. - **Cause** — known upstream `ast-grep-cli` wheel false positive (linked); base dependency so it hits `[proxy]` too; `ast-grep` is optional at runtime and Headroom runs without it. - **Workarounds, safest first:** (1) run the proxy in Docker (no local wheel → no AV trigger); (2) restore `sg.exe` from Defender quarantine and retry (no persistent change); (3) a temporary, *scoped* Defender exclusion for `uv tool dir` during install, framed as a known false positive with a caution not to disable Defender wholesale; (4) report the false positive to Microsoft for a durable signature fix. Explicitly out of scope: making `ast-grep-cli` optional (a dependency-policy change requiring maintainer justification per CONTRIBUTING). No code change. ## Testing - [ ] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [ ] New tests added for new functionality - [x] Manual testing performed ### Test Output Docs-only; verification is fact cross-check + MDX sanity: ```text $ grep -n "ast-grep-cli>=" pyproject.toml 60: "ast-grep-cli>=0.30.0", # AST-aware code slicing (CodeCompressor); binary wheel # → confirms ast-grep-cli is a base dependency (affects [proxy] too) $ sed -n '6,7p' headroom/proxy/interceptors/astgrep.py followed by an elided body marker. Falls back to the original text if ast-grep isn't available, the extension isn't supported, or there are fewer # → confirms graceful degradation: Headroom runs without a working sg.exe $ uv tool dir C:\Users\<user>\AppData\Roaming\uv\tools # → the directory the scoped-exclusion workaround targets (via `uv tool dir`, not a hardcoded path) # MDX sanity: balanced code fences (even count), well-formed headings, links close. ``` ## Real Behavior Proof - **Environment:** Windows 11 (the affected platform), the docs source inspected against the current `main` base. - **Exact command / steps:** Issue #2200 contains a complete, exact reproduction (command `uv tool install "headroom-ai[all]"`, the `os error 225` / `Trojan:Win64/Lazy!MTB` failure on `sg.exe`, `ast-grep-cli 0.44.1`, `uv 0.11.16`, Windows 11). The documented facts are verified against the tree: base-dependency declaration (`pyproject.toml:60`) and graceful degradation (`headroom/proxy/interceptors/astgrep.py:6-7`). The `uv tool dir` command used in the exclusion workaround resolves correctly on this machine. - **Observed result:** The troubleshooting note accurately describes the failure and gives valid Windows/Defender workarounds, ordered safest-first. - **Not tested:** I deliberately did **not** run `uv tool install "headroom-ai[all]"` to force a live Defender quarantine — doing so is disruptive (it can quarantine real files and pulls the full dependency set) and machine-specific. The reproduction in the issue is complete and corroborated by the upstream ast-grep report. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [ ] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A (troubleshooting prose addition). ## Additional Notes - Test/tests-added and CHANGELOG checklist items are N/A — documentation-only change (kept to a single file, matching the merged #2031 precedent). - The durable fix for the underlying false positive belongs upstream (ast-grep) and/or with Microsoft's signature update; this PR documents supported workarounds in the meantime, as the issue requested. - Making `ast-grep-cli` an optional dependency would remove the install blocker at the source, but that's a dependency-policy change for maintainers to weigh (the interceptor already tolerates its absence) — intentionally not attempted here. |
||
|
|
09c66ac212
|
fix(proxy): batch small Codex Responses tool outputs (#2239)
## Description Batches small Codex/OpenAI Responses tool-output units through the existing ContentRouter instead of skipping each unit individually below the 512-byte floor. This fixes sessions where many small tool outputs are collectively worth compressing, but no single output clears the per-unit threshold. The change keeps larger units on the existing independent compression path, preserves CCR retrieval markers and protected tags across the batch envelope, rejects structurally invalid batch output, and leaves under-floor tails as size-floor passthroughs. Fixes #2234 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Added `headroom/transforms/compression_batches.py` for bounded compatible-unit batching, batch envelope parsing, tag/CCR marker preservation, and per-entry result splitting. - Updated the OpenAI Responses compression adapter to batch small tool-output text slots while keeping larger units on the existing cached per-unit path. - Switched the unit size floor to UTF-8 bytes so CJK and other multibyte text are measured consistently with the byte threshold. - Added regression coverage for batching, CJK byte floors, CCR marker preservation, malformed batch rejection, array output parts, and under-floor tails. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] Type checking passes (`mypy`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ uv run --with pytest --with fastapi --with httpx --with anyio --with uvicorn --with h2 pytest tests/test_compression_batches.py tests/test_compression_units.py tests/test_openai_responses_compression_units.py -q 47 passed, 1 warning $ uvx ruff==0.15.17 check headroom/proxy/handlers/openai.py headroom/transforms/compression_batches.py headroom/transforms/compression_units.py tests/test_compression_batches.py tests/test_compression_units.py tests/test_openai_responses_compression_units.py --output-format concise All checks passed! $ uvx ruff==0.15.17 format --check headroom/proxy/handlers/openai.py headroom/transforms/compression_batches.py headroom/transforms/compression_units.py tests/test_compression_batches.py tests/test_compression_units.py tests/test_openai_responses_compression_units.py 6 files already formatted $ uv run --with mypy mypy headroom/transforms/compression_batches.py Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.13.3, local checkout of this PR branch. - Exact command / steps: ran the focused batching/unit/OpenAI Responses test suites above, including cases where four individually-small tool outputs collectively exceed the shared floor and where output arrays contain multiple text parts plus non-text parts. - Observed result: small outputs are sent through one router call and applied back to their original slots; under-floor tails remain unmodified; non-text parts are preserved; CCR markers are retained or the entire batch is rejected if moved/corrupted. - Not tested: a live Codex Responses proxy session against an upstream model; full-suite collection was not run locally. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
3f241e472b
|
fix(ccr): skip compact summaries for proactive expansion (#2242)
## Description Fixes #2186. Claude Code `/compact` continuation summaries are already session context. When Headroom tracks those summaries for CCR proactive expansion, later fresh sessions can receive stale compacted history again inside `<headroom_proactive_expansion>` blocks, increasing token usage and busting cache stability. This PR keeps CCR storage/retrieval intact but excludes probable Claude Code compact-summary payloads from the proactive-expansion tracker. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Added a narrow Claude Code compact-summary detector to the CCR context tracker. - Skipped tracking compact summaries when feeding Anthropic CCR metadata into proactive expansion. - Added an original-content preview to CCR metadata so the Anthropic feed point can classify compact summaries even when compressed text loses the distinctive header. - Added regression coverage proving compact summaries are not tracked and ordinary summaries are still eligible. ## Testing - [x] Unit tests pass - [x] Linting passes - [x] Formatting check passes - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ pytest tests/test_ccr_context_tracker.py -q 37 passed $ uvx ruff==0.15.17 check headroom/ccr/context_tracker.py headroom/cache/compression_store.py headroom/proxy/handlers/anthropic.py tests/test_ccr_context_tracker.py --output-format concise All checks passed! $ uvx ruff==0.15.17 format --check headroom/ccr/context_tracker.py headroom/cache/compression_store.py headroom/proxy/handlers/anthropic.py tests/test_ccr_context_tracker.py 4 files already formatted ``` ## Real Behavior Proof - Environment: local checkout on macOS, Python test environment used by the repository. - Exact command / steps: ran the focused CCR context tracker suite after adding compact-summary detection and tracker-feed filtering. - Observed result: compact-summary payloads are not tracked for proactive expansion, ordinary summary-like tool output is still eligible, and the existing tracker behavior remains covered by the full focused suite. - Not tested: live Claude Code `/compact` session through a running proxy. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review - [x] I have added tests that prove the fix is effective - [ ] I have updated the CHANGELOG.md if applicable |
||
|
|
fcf455a7eb
|
feat(wrap): add omp target (Oh My Pi) with models.yml override and unwrap (#1811)
## Description Adds `headroom wrap omp` / `headroom unwrap omp` — a one-command wrap for [Oh My Pi](https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent) (`omp`), the pi-mono-lineage coding agent, as proposed in #1149. One honest correction to the issue: #1149 proposed reusing the `ANTHROPIC_BASE_URL` redirect from `wrap claude`. During implementation I probed that empirically and it turned out to be wrong — omp only reads `ANTHROPIC_BASE_URL` in its web-search helper; its **chat** endpoint comes from the model registry (`providers.anthropic.baseUrl` in `~/.omp/agent/models.yml`). With the env var pointed at a local probe server, omp's chat traffic still went straight to the real endpoint (0 probe hits); with a `models.yml` same-ID override, every request arrived at the probe (9/9 hits on `/v1/messages`). A same-ID override keeps omp's bundled Anthropic model catalog and stored credentials (both keyed by provider id `anthropic`), so only the endpoint moves. The wrap therefore injects a marker-fenced `providers.anthropic.baseUrl` override into `models.yml`, snapshotting the pre-wrap file **byte-for-byte** first, and `headroom unwrap omp` restores it exactly (or removes the file when the wrap created it) — the same durable-wrap + backup + unwrap contract `wrap codex` uses for `config.toml`. Closes #1149 ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/providers/omp/` (new provider slice): `models_yml_path()` (honors `PI_CODING_AGENT_DIR`), `inject_models_override()` (yaml-merge preserving user providers; pristine byte-for-byte backup, never re-snapshotted while managed), `restore_models_override()` (`restored` / `removed` / `noop`; never touches an unmanaged file), `build_launch_env()` - `headroom/cli/wrap.py`: `wrap omp` (mirrors the aider/vibe `_launch_tool` shape; rtk instructions into the project's `AGENTS.md`, which omp reads natively) and `unwrap omp` (restore models.yml + scrub rtk block + stop proxy) - `headroom/telemetry/context.py`: `omp` added to `_KNOWN_WRAP_AGENTS` so the stack slug reports `wrap_omp` instead of `unknown` - `README.md` (agent matrix row + unwrap list), `llms.txt`, `CHANGELOG.md` - `tests/test_cli/test_wrap_omp.py`: 16 tests (injection fresh/merge/re-inject, restore statuses incl. unmanaged-file safety, env passthrough, CLI wiring, unwrap flows) ## Testing - [ ] Unit tests pass (`pytest`) — all new + `test_cli` tests pass; the full suite carries **3 pre-existing failures** that reproduce identically on unmodified `origin/main` (same set, same asserts — see Test Output and the rebase-validation comment) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run pytest -q # post-rebase, base |
||
|
|
996c1174a8
|
feat(proxy): show real upstream provider on dashboard for OpenAI-compatible endpoints (#1594)
## Description When the proxy runs against a custom OpenAI-compatible endpoint via `--openai-api-url` (OpenRouter, Groq, Together, Azure OpenAI, …), the dashboard always showed the provider as **OpenAI**, because the OpenAI handler records every request with `provider="openai"`. This detects well-known upstreams from the `--openai-api-url` host and adds a `--provider-name` override that takes precedence (the issue's option 3). The label is resolved only where the dashboard/stats payload is built — the internal provider key stays `openai`, so pricing and request formatting are unaffected. | Upstream URL | Provider shown | |--------------|----------------| | `https://api.openai.com/v1` | OpenAI | | `https://openrouter.ai/api/v1` | OpenRouter | | `https://api.groq.com/openai/v1` | Groq | | `https://api.together.xyz/v1` | Together AI | | `https://<resource>.openai.azure.com/` | Azure OpenAI | Unknown hosts keep the `openai` label unless `--provider-name` is set. Closes #1533 ## Type of Change - [x] New feature (non-breaking change that adds functionality) ## Changes Made - `helpers.py`: `classify_openai_upstream()` (host → display name) + `resolve_display_provider()` (precedence: `--provider-name` > host detection > raw provider; only relabels `openai`). - `models.py`: `ProxyConfig.provider_name`. - `cli/proxy.py`: `--provider-name` flag, threaded into `ProxyConfig`. - `server.py`: relabel at the four dashboard/stats display sites (recent requests, transformations feed, `requests.by_provider`, agent-usage breakdown) via the resolver / `_remap_provider_counts`. Stored logs and metrics keys are untouched. - `docs/content/docs/proxy.mdx`: document `--provider-name`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] New tests added ### Test Output ```text $ pytest tests/test_provider_display_classification.py tests/test_dashboard_agent_usage.py -q 16 passed 13 passed $ ruff check headroom/proxy/helpers.py headroom/proxy/server.py headroom/proxy/models.py headroom/cli/proxy.py All checks passed! ``` ## Real Behavior Proof - Environment: repo branch `feat/1533-upstream-provider-classify` @ HEAD, local `.venv` (Python 3) - Exact command / steps: ran the helpers directly from the venv — `python -c "from headroom.proxy.helpers import classify_openai_upstream, resolve_display_provider; print(classify_openai_upstream('https://openrouter.ai/api/v1')); print(resolve_display_provider('openai', openai_api_url='https://openrouter.ai/api/v1')); print(resolve_display_provider('openai', openai_api_url='https://openrouter.ai/api/v1', provider_name='Groq')); print(resolve_display_provider('anthropic'))"` - Observed result: host detection relabels `openai` → `OpenRouter`, `--provider-name` overrides detection (`Groq`), and the `anthropic` label (plus the `openai` pricing key) is unchanged. Full output below: ```text classify openrouter -> OpenRouter resolve openai+openrouter url -> OpenRouter override provider-name -> Groq anthropic untouched -> anthropic ``` - Not tested: live dashboard render against a real OpenRouter key (the payload-builder logic is covered by the unit tests above). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
7a5d8a7ace
|
fix(mcp): reap orphaned mcp serve on client death (#2226)
## Description `headroom mcp serve` processes survive after the launching MCP client (e.g. Claude Code) exits, get reparented to init/launchd (`ppid == 1`), and never terminate — piling up one pinned Python interpreter + tree-sitter grammars per dead session (observed 3+ simultaneously). An MCP stdio server is supposed to shut down on stdin EOF, but an abrupt client `SIGKILL` leaves the MCP SDK's blocking stdin-reader thread wedged, so `await self.server.run(...)` in `run_stdio()` never returns and the process orphans. Refs #2185 (its secondary "orphaned `mcp serve` pileup", left out of #2204's `Refs`-only Perl fix), #1761 (same symptom: "orphaned `headroom mcp serve` processes accumulate … even after quitting"). ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made `headroom/ccr/mcp_server.py`: - Added `PARENT_DEATH_POLL_INTERVAL = 5.0` module constant. - Added `HeadroomMCPServer._await_parent_death(interval)`: captures the launch ppid and resolves once it changes. Watching for a *change* (not a hard `== 1`) is portable to Linux PID subreapers, which adopt the orphan with their own pid. - Reworked `run_stdio()` to run that watchdog concurrently with `server.run()`. On parent death it `os._exit(0)`s **from inside** the `stdio_server()` context manager — the wedged stdin reader would also hang the context-manager teardown and a cooperative `server.run` cancel, so a hard exit is the only reliable reaper. The normal stdin-EOF path is unchanged: `server.run` wins the race, the watchdog is cancelled, and the context manager unwinds cleanly. `tests/test_ccr_mcp_server.py`: 3 regression tests (below). `CHANGELOG.md`: entry under Unreleased → Fixed. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_ccr_mcp_server.py -q`) - [x] Linting passes (`uv run ruff check headroom/ccr/mcp_server.py tests/test_ccr_mcp_server.py`) - [x] Type checking passes (`uv run mypy headroom/ccr/mcp_server.py`) - [x] New tests added for new functionality - [x] Manual testing performed (see Real Behavior Proof) New tests: - `test_parent_death_watchdog_fires_when_reparented` — ppid change resolves the watchdog. - `test_parent_death_watchdog_stays_quiet_with_live_parent` — a stable ppid never trips it. - `test_run_stdio_reaps_process_on_parent_death` — on reparent, `run_stdio` cleans up and hits `os._exit(0)` even though the (stubbed) `server.run` never returns. ### Test Output ```text $ uv run pytest tests/test_ccr_mcp_server.py -q collected 21 items tests/test_ccr_mcp_server.py ..................... [100%] ============================== 21 passed in 0.57s ============================== $ uv run ruff check headroom/ccr/mcp_server.py tests/test_ccr_mcp_server.py All checks passed! $ uv run mypy headroom/ccr/mcp_server.py Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: macOS 26.5 arm64, Python 3.14.6, headroom built from this branch via `uv sync --all-extras` (Rust extension compiled). No provider call. - Exact command / steps: launch a real `HeadroomMCPServer.run_stdio()` as a child of a throwaway parent, with stdin wired to a FIFO whose write end is held open by a separate process (so stdin **never** reaches EOF — this isolates the watchdog as the only possible reaper). Then `kill -9` the parent to reparent the server to `pid 1`, and watch. The watchdog poll interval is passed via `run_stdio(parent_death_poll_interval=…)` to A/B the exact same shipped code path: ```text ### interval=9999s (watchdog effectively OFF — reproduces the bug) ### ppid(pre-kill)=43438 -> STILL ALIVE after 8s (orphan lingers) ### interval=0.5s (watchdog ON — the fix) ### ppid(pre-kill)=43461 -> REAPED at ~2s ``` And with the default flow (`headroom mcp serve`, default 5s interval), the watchdog logs before the process exits: ```text headroom.ccr.mcp - INFO - Headroom MCP Server starting (proxy: http://127.0.0.1:8787) headroom.ccr.mcp - WARNING - parent process gone (ppid 41956 -> 1); shutting down MCP server ``` - Observed result: with the watchdog disabled the orphaned server lingers indefinitely (reproduces the reported pileup); with it enabled the orphan is reaped within one poll interval of the parent dying. - Not tested: Linux/systemd and Windows spawn paths (the change is POSIX-portable via ppid-change detection, but I only exercised macOS); the reporters' desktop-app menu-bar quit path. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md ## Additional Notes - Deliberately `os._exit(0)`, not a cooperative shutdown: the failure mode is a wedged native stdin-reader thread, so both `server.run` cancellation and the `stdio_server` context-manager exit can block forever. Exiting from inside the context manager is the only path that reliably reaps the orphan; the normal EOF path never reaches it. - A Linux-only `prctl(PR_SET_PDEATHSIG)` fast-path could cut reap latency to ~0, but it is racy (must re-check `getppid()` after arming) and non-portable, so the portable poll is the primary mechanism. Happy to add prctl as a follow-up optimization if wanted. - Watchdog latency is bounded by `PARENT_DEATH_POLL_INTERVAL` (5s default); trivial to make env-configurable if a tighter bound is preferred. --- 🤖 This PR was created with [Claude Code](https://claude.com/claude-code) but checked by the author Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
cb388f6af2
|
feat(wrap): add first-class Grok CLI support (#1823)
## Description
Adds first-class Grok CLI integration so Headroom can wrap, compress,
and learn from Grok sessions the same way it does for Claude Code and
Codex.
Grok routes inference through `GROK_CLI_CHAT_PROXY_BASE_URL`; Headroom
sets that to the local proxy so chat traffic is compressed before
forwarding to xAI. MCP retrieval and session learning follow the same
patterns as Codex.
Closes #
## Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [x] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [x] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- Add `headroom/providers/grok/` provider slice (`runtime.py`,
`install.py`) with `GROK_CLI_CHAT_PROXY_BASE_URL` routing and project
attribution prefix
- Add `headroom wrap grok` and `headroom unwrap grok` CLI commands (MCP
registration, RTK/context-tool setup, proxy launch)
- Add `GrokRegistrar` for marker-delimited `[mcp_servers.headroom]`
injection in `~/.grok/config.toml`
- Add `headroom learn --agent grok` plugin parsing
`~/.grok/sessions/*/updates.jsonl` and `GrokWriter` targeting `GROK.md`
- Register Grok in install planner, install registry, MCP install list,
and agent savings tracking
- Update README agent compatibility matrix and unwrap support list
- Add unit tests for provider, wrap CLI, MCP registrar, and learn plugin
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ uv run pytest tests/test_provider_grok.py tests/test_cli/test_wrap_grok.py tests/test_mcp_registry_grok.py tests/test_learn_grok_plugin.py -q
============================== 10 passed in 0.15s ==============================
$ uv run ruff check headroom/providers/grok headroom/mcp_registry/grok.py headroom/learn/plugins/grok.py headroom/cli/wrap.py headroom/learn/writer.py
All checks passed!
$ uv run mypy headroom/providers/grok headroom/mcp_registry/grok.py headroom/learn/plugins/grok.py
Success: no issues found in 5 source files
```
## Real Behavior Proof
- Environment: macOS (darwin), Python 3.13.14 via `uv`, repo at
`/Users/s/dev/headroom`, Grok CLI at `/Users/s/.grok/bin/grok`
- Exact command / steps: `cd /Users/s/dev/headroom && uv run pytest
tests/test_provider_grok.py tests/test_cli/test_wrap_grok.py
tests/test_mcp_registry_grok.py tests/test_learn_grok_plugin.py -q`; `uv
run ruff check headroom/providers/grok headroom/mcp_registry/grok.py
headroom/learn/plugins/grok.py`; `uv run python -c "from
headroom.providers.grok import build_launch_env; env, display =
build_launch_env(8787, environ={}); print(display[0])"`; `uv run python
-c "from pathlib import Path; import tempfile; from
headroom.mcp_registry.grok import GrokRegistrar; from
headroom.mcp_registry.install import build_headroom_spec;
td=tempfile.mkdtemp(); reg=GrokRegistrar(home_dir=Path(td));
print(reg.register_server(build_headroom_spec('http://127.0.0.1:8787'),
force=True).status.value)"`
- Observed result: pytest reported `10 passed`; ruff reported `All
checks passed!`; `build_launch_env` printed
`GROK_CLI_CHAT_PROXY_BASE_URL=http://127.0.0.1:8787/v1`; MCP registrar
returned `registered` and wrote the Headroom marker block to
`config.toml`
- Not tested: live `headroom wrap grok` session with authenticated Grok
API traffic through a running Headroom proxy (requires maintainer
environment with active `grok login`)
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable
## Screenshots (if applicable)
N/A — CLI/integration change only.
## Additional Notes
- Follows the provider-slice pattern from `
|
||
|
|
7bfb1d7f38
|
fix(cache): stable session identity and per-conversation prefix trackers under agentic clients (#2193)
## Description Running headroom as the proxy for Claude Code destroys Anthropic prompt-cache reuse (#2085: ~4.4x cache-creation inflation, 2.5–3x net cost). Tracing live Claude Code traffic through the proxy shows **two independent session-identity defects**, both of which orphan or thrash the frozen-prefix state; this PR fixes both. ### Defect 1: `<system-reminder>` turns rotate the fallback session id mid-conversation Claude Code interleaves reminder turns into the history as actual `role:"system"` messages (hook output, skills lists, file-truncation notices). `compute_session_id` hashed **every** system message, so the id rotated each time a reminder landed. Live trace (subagent reading two 80KB files; sid changes exactly when the truncation reminder appears, and the tracker restarts at turn 0): ``` REQ#2 sid=68d4ee666990 nmsg=3 [0]SYSTEM<<top-level system>> [1]user [2]SYSTEM<<skills reminder>> REQ#3 sid=6944948c9fb2 nmsg=6 ... [5]SYSTEM<<Truncated: PARTIAL view ...>> <- id rotated ``` Everything keyed on the session id is orphaned at that moment: the prefix tracker (freeze never survives past a reminder-bearing turn), beta-header stickiness, the CCR and memory-tool registries, and the compression cache. **Fix:** hash only the **leading run** of system messages (everything before the first non-system turn) — the top-level system prompt on the Anthropic path (folded in as the synthetic first message), the conventional leading system message(s) on the OpenAI path. Stable for the life of a conversation; mid-history system turns are content, not identity. ### Defect 2: conversations sharing a (now stable) id thrash one tracker With ids stable, the fallback tuple `model + system prompt` is identical across every same-type parallel subagent (and any sessions reusing one system prompt) — all of them collapse onto one `PrefixCacheTracker`, and their interleaved histories cross-contaminate the freeze state: the forwarded prefix is byte-unstable on nearly every turn and the provider cache is re-written instead of read. Reproduced against the real code paths (script below): ``` 1) fallback session ids: A=3dc639aaf4f48fa1 B=3dc639aaf4f48fa1 -> COLLIDE=True 2) single conversation, legacy : stable prefix on 4/4 later turns, trackers=1 2) interleaved (subagents), legacy : stable prefix on 0/9 later turns, trackers=1 2) interleaved, lineage resolution : stable prefix on 8/8 later turns, trackers=2 ``` **Fix:** `SessionTrackerStore.resolve_tracker` — within a session id, reuse the tracker whose previous request messages are a prefix of the incoming history (client histories are append-only, so a conversation's next request always extends its previous one); a diverging or rewritten history (client-side compaction) starts a fresh lineage. Matching uses the repo's existing canonical cross-turn equivalence (`_canonicalize_for_prefix_compare`, the same one the cache-stable delta path uses) on the **original client bytes**, so moved cache breakpoints, string<->block sugar, transport annotations, or a tail-mutating `pre_compress` hook never read as a rewrite. Byte-identical histories (templated fan-outs before they diverge) intentionally share a tracker — their provider cache line is identical too. ### Both fixes together, on live Claude Code traffic (sonnet, 2 parallel Explore agents) ``` main conversation: sid=5b7e245a... one tracker, turns 0->4, id stable across reminders agents (collide): sid=2bdffc9e... -> lineage bare (alpha) turns 0->1->2 -> lineage "~1" (beta) turns 0->1->2 ``` Before: the agents' ids rotated per reminder (every tracker stuck at turn 0), and whenever they did share an id they thrashed one tracker (`0/9` stable prefixes in the repro). ### Why not key the session id on conversation content? Draft #1912 folds the first user turn into the fallback id; this change composes with it, but identity-level keying alone can't close #2085: identical first turns (templated fan-outs) still collide, and everything keyed on the session id rotates with it when the client rewrites history. The "session" (client/workspace grouping) and the "conversation" (positional cache lineage) are different identities; only the tracker holds positional per-turn state that thrashes under collision — beta stickiness is a monotone union and the compression cache is content-addressed — so lineage resolution lives one level below the session id and leaves the id semantics (and every other consumer) untouched. ## Changes Made - `headroom/cache/prefix_tracker.py`: - `compute_session_id`: harvest only the leading system run (defect 1). - `SessionTrackerStore.resolve_tracker`: conversation-lineage resolution (defect 2). First lineage lives under the bare session id — single-conversation sessions behave byte-identically to before; degrades to `get_or_create` when messages are absent or prefix freeze is disabled. - Lineages are capped per session id (`PrefixFreezeConfig.max_lineages_per_session`, default 32). **Over-cap conversations share one overflow tracker instead of evicting an established lineage** — any eviction policy degrades every conversation once the working set exceeds the cap (under round-robin the victim is always the conversation about to arrive), while overflow sharing degrades only the over-cap tail, to exactly the pre-lineage shared behavior; `0` disables lineage splitting. Chains are stored as structural snapshots that normalize `NaN` (`json.loads` accepts bare NaN, and `NaN != NaN` would read a byte-identical resend as a rewrite). Synthetic lineage keys use a `\x00` separator, which cannot appear in an HTTP header value, so they can never collide with a client-supplied `x-headroom-session-id`. - `headroom/proxy/handlers/anthropic.py`, `openai.py`: the session id and the lineage both derive from the **same original client bytes** (a turn-dependent hook rewrite can no longer rotate one without the other); anthropic folds in its synthetic system message so explicit-header clients with different system prompts stay separate. Plus a docstring correction in `streaming.py` that falsely claimed its coarse mid-turn key "mirrors" `compute_session_id`. - `tests/test_cache/test_prefix_tracker.py`: 24 new test cases — reminder-rotation regression; interleaved isolation + per-conversation turn state; identical-first-turn share-then-split; cache_control movement (3 cases); representation churn (string<->block sugar / streaming `index` / Bedrock cachePoint); rewritten history → fresh lineage (compacted / middle-edited / truncated); legacy no-messages / freeze-disabled / empty-canonical fallbacks; NaN-in-tool-payload stability; overflow sharing, established-lineages-survive-cap, and a cap+1 round-robin no-cliff guard; TTL cleanup; session-id-not-rotated-by-lineage guard. One existing test renamed (`uses_all_system_messages` → `distinguishes_leading_system_run`) to match the new contract. - Three SimpleNamespace stub stores in existing tests gained a `resolve_tracker` field (handlers call it unconditionally — a silent `hasattr` fallback would degrade to the pre-fix behavior with no signal). One of them is the cold-start fast-pass suite (#2073), which landed while this branch was in review. - `CHANGELOG.md` entry. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Testing - [x] Unit tests pass (`pytest`) — 11 failed, 8652 passed, 528 skipped in 4:37 (the 11 are pre-existing on unmodified `main` — verified by rerunning the same node ids on a clean checkout: gh-CLI/onnx/PID-reuse/deadline flakes and order-dependent cases, none touching session/cache/proxy paths) - [x] Linting passes (`ruff check .`) — All checks passed (ruff 0.15.17, CI-pinned; `ruff format --check .` clean) - [x] Type checking passes (`mypy headroom`) — Success: no issues found in 471 source files - [x] New tests added for new functionality — 24 test cases; the rotation/isolation/no-cliff ones fail on `main` - [x] Manual testing performed — live Claude Code end-to-end, below ### Test Output ```text $ python -m pytest tests/ -q 11 failed, 8652 passed, 528 skipped, 5857 warnings in 276.68s (0:04:36) # same 11 fail on unmodified main (env/order-dependent: test_wrap_claude_base_url pid-reuse, # copilot_auth gh-cli fallback, image_compression onnx, content_router deadline, rtk/output-shaper/dedup order flakes) $ python -m pytest tests/test_cache/test_prefix_tracker.py -q 63 passed $ uvx ruff@0.15.17 check . && uvx ruff@0.15.17 format --check . All checks passed! / 1208 files already formatted $ mypy headroom Success: no issues found in 471 source files $ python repro_2085.py 1) fallback session ids: A=3dc639aaf4f48fa1 B=3dc639aaf4f48fa1 -> COLLIDE=True 2) single conversation, legacy : stable prefix on 4/4 later turns, trackers=1 2) interleaved (subagents), legacy : stable prefix on 0/9 later turns, trackers=1 2) interleaved, lineage resolution : stable prefix on 8/8 later turns, trackers=2 ``` ## Real Behavior Proof - Environment: macOS arm64, Python 3.13, `uv sync --extra dev --extra proxy`; real Claude Code CLI pointed at the proxy via `ANTHROPIC_BASE_URL=http://127.0.0.1:8790`, real Anthropic backend. - Exact command / steps: ran Claude Code sessions that launch 2–3 parallel Explore subagents (each reading multi-KB JSON files, several tool-loop turns each), with an observability wrapper printing each request's resolved session id, tracker identity, and turn counter inside the proxy. - Observed result: on `main`, subagent session ids rotate on reminder-bearing turns (trackers permanently stuck at turn 0); when conversations do share an id they share one tracker whose turn counter interleaves all of them. On this branch: ids stable for the life of each conversation; colliding subagents resolve to separate lineages (`bare`, `~1`) with clean per-conversation turn progressions (trace above). Unit-level repro shows forwarded-prefix stability going 0/9 → 8/8 for the interleaved shape. - Not tested: reporter-scale cache-economics (his 4.4x needs his long-session workload against a paid backend); happy to coordinate with @RomanAlexanderW on a before/after — the number to watch is the cache-read ratio in Claude Code transcripts recovering toward ~96%. <details> <summary>repro_2085.py</summary> ```python """Repro for #2085: concurrent conversations sharing a fallback session id (same model + system prompt — e.g. a Claude Code session and its parallel subagents) collapse onto one PrefixCacheTracker and thrash its frozen-prefix state -> byte-unstable forwarded prefixes -> the provider prompt cache is re-written on nearly every call. Uses headroom's real code paths. Run from the repo root: python ../repro_2085.py """ from headroom.cache.prefix_tracker import PrefixFreezeConfig, SessionTrackerStore MODEL = "claude-sonnet-5" # Claude Code system prompt: long, static, identical across the main session # and every parallel subagent of the same type. SYSTEM = ("You are Claude Code, Anthropic's official CLI for Claude. " * 40)[:2000] def convo(name: str, turns: int) -> list[dict]: msgs = [{"role": "system", "content": SYSTEM}] for t in range(turns): msgs.append({"role": "user", "content": f"[{name}] user turn {t}: " + ("x" * 800)}) msgs.append( {"role": "assistant", "content": f"[{name}] tool_result {t}: " + ('{"data": 1}' * 200)} ) return msgs class _Req: # request stub: no x-headroom-session-id header headers: dict = {} # --- Part 1: identity collision (real derivation) ---------------------------- store = SessionTrackerStore(PrefixFreezeConfig()) id_a = store.compute_session_id(_Req(), MODEL, convo("A", 3)) id_b = store.compute_session_id(_Req(), MODEL, convo("B", 5)) print(f"1) fallback session ids: A={id_a} B={id_b} -> COLLIDE={id_a == id_b}") # --- Part 2: interleaved conversations thrash the freeze state --------------- def run(interleave: bool, lineage_resolution: bool) -> tuple[int, int, int]: store = SessionTrackerStore(PrefixFreezeConfig()) stable_turns = 0 later_turns = 0 seq = [] for t in range(1, 6): seq.append(("A", convo("A", t))) if interleave: seq.append(("B", convo("B", t))) for _name, msgs in seq: sid = store.compute_session_id(_Req(), MODEL, msgs) if lineage_resolution: tracker = store.resolve_tracker(sid, "anthropic", messages=msgs) else: tracker = store.get_or_create(sid, "anthropic") if tracker._turn_number > 0: later_turns += 1 if tracker._forwarded_prefix_stable(msgs): stable_turns += 1 tracker.update_from_response( cache_read_tokens=5000 * len(msgs), cache_write_tokens=2000, messages=msgs, ) return stable_turns, later_turns, store.active_sessions for label, interleave, fixed in ( ("single conversation, legacy ", False, False), ("interleaved (subagents), legacy ", True, False), ("interleaved, lineage resolution ", True, True), ): stable, later, sessions = run(interleave, fixed) print(f"2) {label}: stable prefix on {stable}/{later} later turns, trackers={sessions}") ``` </details> ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation (CHANGELOG only — no docs describe the tracker store) - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Additional Notes - Addresses the session-identity mechanisms of #2085; intentionally does not `Closes` it — the reporter should confirm the cache-read ratio recovers on live traffic first. - Composes with draft #1912 (first-user-turn fallback id). - Known bounded tradeoffs (all strictly milder than the per-turn thrash this fixes): a fork-style branch that resends a parent's full history adopts the parent's lineage, costing the parent one cold restart at its next turn; a request that aborts before the response and is retried with different bytes starts a fresh lineage; history truncation/tail-edit starts a fresh lineage even though the shorter provider prefix may still be warm. - Hot-path cost, measured on a 199-message/2.1MB agentic history: canonical projection 0.21ms + structural snapshot 0.92ms + match loop 0.06ms with 32 candidate lineages (2.27ms absolute worst case) ≈ **1.3ms per request** — same order as the handler's existing request deepcopy (0.80ms) and below one `json.dumps` of the body (2.9ms). Chain memory is structure-only (~180-330KB per lineage; message strings are shared with state the tracker already retains). - Known semantic shift to flag: hashing only the leading system run means conversations distinguished ONLY by mid-list system messages (e.g. clients injecting a per-conversation system context late in the list) now share a fallback id. The tracker is protected by lineage resolution; the residual sharing concentrates in the CCR sticky-tool registry and the monotone beta union — the same pre-existing class as same-system-prompt conversations today. Happy to file the CCR-stickiness scoping as a follow-up. - Out of scope, observed while tracing: `SessionCcrTracker.has_done_ccr` mildly cross-contaminates conversations sharing an id (monotone, no thrash) — can file separately if useful. --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
560ffae103
|
feat(deploy): Add turnkey deploy command (#1404)
## Description Adds `headroom deploy` as the turnkey, zero-config local deployment entrypoint. The command chooses the most capable deployment path it can verify on the current host, configures detected tools through the existing persistent-install machinery, starts the proxy, and preserves the existing rollback behavior if an update fails. The selection order favors performance first: NVIDIA Docker GPU passthrough when `nvidia-smi` and Docker's NVIDIA runtime are available, then plain Docker, then native scheduled recovery, then a detached Python runtime fallback. ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [x] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made - Added the top-level `headroom deploy` command and reused the existing install manifest/apply/start/rollback path. - Added conservative runtime selection for GPU Docker, plain Docker, native schedulers, and detached Python fallback. - Added Docker runtime support for manifest-driven `--gpus all` passthrough. - Added tests for Docker selection, GPU Docker selection, detached fallback, GPU command rendering, and subprocess wrapper compliance. - Updated README and persistent-install docs to present the turnkey deployment flow and performance-first GPU behavior. - Allowed documented `opencode` targets through `headroom install apply --target`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] Formatting passes (`ruff format --check`) - [x] Type checking passes in local pre-commit and CI - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text uv run --with pytest --with pytest-asyncio python -m pytest tests/test_cli/test_subprocess_utf8_encoding.py tests/test_cli/test_install_cli.py tests/test_install/test_runtime.py::test_build_runtime_command_for_docker_includes_gpu_passthrough tests/test_install/test_planner.py -q 47 passed in 1.57s uvx --from ruff==0.15.17 ruff check headroom/cli/install.py headroom/install/runtime.py tests/test_cli/test_install_cli.py tests/test_install/test_runtime.py --output-format concise All checks passed! uvx --from ruff==0.15.17 ruff format --check headroom/cli/install.py headroom/install/runtime.py tests/test_cli/test_install_cli.py tests/test_install/test_runtime.py 4 files already formatted ``` GitHub checks are green on the current head. ## Real Behavior Proof - Environment: Windows local worktree `C:\git\headroom`, Python 3.13 via `uv`; GitHub Actions Ubuntu/macOS/Windows runners for full PR CI. - Exact command / steps: Ran the focused deploy/install tests above, checked the touched Python files with the CI-pinned Ruff version, and confirmed the current PR head is mergeable with green GitHub checks. - Observed result: The deploy command, runtime selection, Docker GPU command rendering, install CLI behavior, and subprocess encoding coverage all pass locally; the branch is no longer conflicted. - Not tested: Actual RTX 4090 hardware passthrough on a physical NVIDIA workstation; the PR tests conservative detection and Docker command rendering without requiring GPU hardware in CI. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A - CLI/runtime behavior only. ## Additional Notes CHANGELOG update is not included because this is an unreleased feature PR and the repository's release tooling owns release notes from conventional commits. |
||
|
|
ad6ab48cbb
|
refactor(proxy): extract tool definition serialization (#1998)
## Description Extracts canonical memory-tool definition byte serialization from `headroom.proxy.helpers` into a focused pure module. The existing helper function remains as a compatibility wrapper for sticky memory tool and CCR replay code. ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made - Added `headroom.proxy.tool_definition_serialization` for deterministic compact UTF-8 tool definition serialization. - Kept `helpers.serialize_tool_definition_canonical()` as a compatibility wrapper. - Added direct unit tests for compact separators, Unicode preservation, insertion-order byte stability, and parity with the existing body canonicalizer. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] Formatting passes (`ruff format --check`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text uv run pytest tests/test_tool_definition_serialization.py tests/test_ccr_tool_always_on.py tests/test_memory_tool_session_sticky.py tests/test_proxy_byte_faithful_forwarding.py -q 85 passed, 1 warning in 2.47s uvx --from ruff==0.15.17 ruff check headroom/proxy/helpers.py headroom/proxy/tool_definition_serialization.py tests/test_tool_definition_serialization.py --output-format concise All checks passed! uvx --from ruff==0.15.17 ruff format --check headroom/proxy/helpers.py headroom/proxy/tool_definition_serialization.py tests/test_tool_definition_serialization.py 3 files already formatted ``` ## Real Behavior Proof - Environment: Windows, Python 3.13.x - Exact command / steps: Ran direct serializer tests plus CCR always-on, sticky memory tool, and proxy byte-faithful forwarding regression coverage; then checked the touched files with the CI-pinned Ruff version. - Observed result: Serializer byte contract remains directly covered while existing sticky replay and byte-faithful proxy behavior stay green. - Not tested: Full repository pytest suite locally; GitHub CI is green for the current head. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes Documentation and changelog updates are not applicable for this internal refactor. The current head is mergeable and GitHub checks are green. --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> |
||
|
|
96bc4cd128
|
feat(dashboard): add settings dashboard for proxy configuration (#2101)
## Description Adds a loopback-only dashboard settings panel at `/dashboard/settings` for a curated, safe subset of Headroom runtime knobs. Settings persist to `settings.json`, are applied before Click resolves `envvar=` options, and keep precedence predictable: explicit shell export > stored settings > code default. The panel also adds an Endpoints group for custom Anthropic/OpenAI upstream base URLs and optional extra forwarded headers for gateway deployments. Mutating routes are loopback-gated and same-origin guarded; secret header values are masked and admin audit records only changed key names. ## Changes Made - Added `headroom/settings_store.py` with validation, masking, atomic save, partial-update merge semantics, and env application. - Added `/settings/schema`, `/settings`, `/settings/apply`, and `/dashboard/settings` routes. - Added same-origin protection for mutating local settings routes. - Added custom Anthropic/OpenAI endpoint and extra-header plumbing through CLI, provider registry, proxy config, and handlers. - Added deployment-aware apply/restart behavior for service/docker/foreground modes. - Added docs for the settings GUI and endpoint/header configuration. - Merged current `main`, added missing retry-delay settings registry entries, fixed the UI so no-op saves do not persist every default, and removed unrelated dependency/Cargo churn from the PR diff. ## Testing ```text uvx ruff@0.15.17 check headroom/settings_store.py headroom/proxy/server.py headroom/proxy/loopback_guard.py headroom/providers/registry.py headroom/proxy/helpers.py headroom/cli/main.py headroom/cli/proxy.py tests/test_proxy/test_settings_store.py tests/test_proxy_settings_endpoints.py tests/test_provider_registry.py tests/test_cli_proxy_env.py tests/test_header_isolation.py tests/test_install/test_runtime.py tests/test_proxy/test_settings_fresh_process_precedence.py All checks passed! uvx ruff@0.15.17 format --check headroom/settings_store.py headroom/proxy/server.py headroom/proxy/loopback_guard.py headroom/providers/registry.py headroom/proxy/helpers.py headroom/cli/main.py headroom/cli/proxy.py tests/test_proxy/test_settings_store.py tests/test_proxy_settings_endpoints.py tests/test_provider_registry.py tests/test_cli_proxy_env.py tests/test_header_isolation.py tests/test_install/test_runtime.py tests/test_proxy/test_settings_fresh_process_precedence.py 14 files already formatted uv run --extra dev python -m pytest tests/test_proxy/test_settings_store.py tests/test_provider_registry.py tests/test_cli_proxy_env.py tests/test_proxy_settings_endpoints.py tests/test_header_isolation.py tests/test_install/test_runtime.py tests/test_proxy/test_settings_fresh_process_precedence.py -q 192 passed, 1 skipped, 1 warning git diff --check headroomlabs/main...HEAD # no output ``` The pushed cleanup commits also passed local pre-commit hooks. ## Review Readiness - [x] Ready for review - [x] Regression tests added - [x] Documentation updated --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
cdba2eccdd
|
feat(core): gate ONNX transforms behind a default-on ml feature (static/lexical builds) (#2165)
## Description `TextCrusher` and the BM25 relevance path can run without the ONNX-backed ML stack, but `headroom-core` previously compiled `ort`, `fastembed`, and `magika` unconditionally. This made lexical-only downstream consumers carry the ONNX Runtime dependency even when they never used embedding relevance or Magika detection. This PR makes those ML crates optional behind a new default-on `ml` Cargo feature. Default builds keep the existing ML-backed behavior. Consumers that only need lexical compression can opt out with `default-features = false`; in that mode the ML modules are compiled out and the relevance path falls back to BM25. ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `crates/headroom-core/Cargo.toml`: marks `ort`, `fastembed`, and `magika` optional; adds default-on `ml = ["dep:ort", "dep:fastembed", "dep:magika"]`. - `crates/headroom-core/src/lib.rs`: gates the shared ONNX CPU helper behind `ml`. - `crates/headroom-core/src/relevance/embedding.rs`: gates the fastembed implementation behind `ml` and provides a no-ml stub with the same scorer surface so `HybridScorer` naturally falls back to BM25. - `crates/headroom-core/src/transforms/detection.rs`: gates the Magika tier behind `ml`; no-ml builds start at the existing unidiff/plain-text fallback tiers. - `crates/headroom-core/src/transforms/mod.rs`: gates the Magika module and re-exports behind `ml`. ## Testing - [x] Default build compiles (`cargo build -p headroom-core`) - [x] Lexical-only build compiles (`cargo build -p headroom-core --no-default-features`) - [x] Default tests pass (`cargo test -p headroom-core`) - [x] Lexical-only tests pass (`cargo test -p headroom-core --no-default-features`) - [x] Dependency tree checked for no-ml build (`cargo tree -p headroom-core --no-default-features` contains no `fastembed`, `magika`, or `ort` packages) - [ ] Manual testing performed ## Real Behavior Proof - Environment: Windows 11 review worktree, Rust/Cargo workspace. - Exact command / steps: - `cargo build -p headroom-core` - `cargo build -p headroom-core --no-default-features` - `cargo test -p headroom-core` - `cargo test -p headroom-core --no-default-features` - `cargo tree -p headroom-core --no-default-features` - Observed result: both feature configurations build and test cleanly. The no-default dependency tree does not include `fastembed`, `magika`, or `ort`, while the default build still compiles the ML path. - Not tested: model-backed `RUN_FASTEMBED_TESTS=1` cases that require downloading the embedding model; those remain env-gated as before. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes The no-ml build intentionally degrades embedding relevance to the existing unavailable-model behavior, so `HybridScorer` takes its BM25 fallback path. Magika detection is skipped when `ml` is disabled; detection then proceeds through unidiff and plain-text fallback tiers. --------- Co-authored-by: Matthew Jackson <mattjackson86@gmail.com> Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
6d897e8eaa
|
fix(memory): require explicit updates for supersession (#2188)
## Description The standalone Memory MCP `memory_save` handler currently treats vector similarity as update identity. A score of `0.70` can therefore supersede a valid but distinct memory that merely shares domain vocabulary. This change makes `memory_save` append-only. Supersession remains available through explicit update paths that receive an existing memory ID. Closes #2187. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that causes existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Remove vector-similarity-based auto-supersession from the standalone MCP `memory_save` handler. - Clarify in the tool description that corrections require an explicit update path with the existing memory ID. - Add a regression test proving that a high-scoring but distinct memory is neither searched for replacement nor updated. - Preserve the existing save result summary shape for compatibility. ## Testing - [x] Focused unit tests pass - [x] Linting passes (`ruff check` on changed files) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual focused test execution performed ### Test Output ```text uv run --with pytest --with numpy pytest tests/test_memory/test_mcp_server.py -q 9 passed, 21 warnings in 0.70s uvx ruff check headroom/memory/mcp_server.py tests/test_memory/test_mcp_server.py All checks passed! uvx ruff format --check headroom/memory/mcp_server.py tests/test_memory/test_mcp_server.py 2 files already formatted ``` The warnings are pre-existing pytest configuration and `datetime.utcnow()` deprecation warnings in the test environment. ## Real Behavior Proof - Environment: Python 3.13 with the MCP module stub and an async recording backend. - Exact command / steps: run `tests/test_memory/test_mcp_server.py`; the new regression supplies a search result with similarity `0.91`, then saves a distinct fact. - Observed result: `search_memories` and `update_memory` are not called; `save_memory` is called once with the new fact and requested importance. - Not tested: live embedding backends or migration of supersession chains created by earlier versions. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project style guidelines - [x] I have performed a self-review of my code - [x] I have commented the non-obvious identity boundary - [ ] Documentation changes are limited to the MCP tool description - [x] My changes generate no new warnings - [x] I have added tests that prove the fix is effective - [ ] New and existing unit tests pass locally; the focused MCP suite passes and full CI is pending - [ ] CHANGELOG update is not included because release notes are generated from conventional commits ## Screenshots (if applicable) Not applicable. ## Additional Notes This patch intentionally does not infer replacement identity from category, entity references, or a higher vector threshold: none of those alone proves that two statements are versions of the same fact. Exposing an explicit update tool from the standalone MCP server can be considered separately without retaining the unsafe automatic behavior. Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
e5b3a634df
|
fix(proxy): dedupe Codex WS request logging for accurate mixed-provider dashboards (#2189)
## Description Running Claude Code (Anthropic) and Codex (OpenAI) against the **same** Headroom proxy instance on one port produced incorrect, unstable dashboard data. The proxy core is provider-isolated and multi-provider-safe by design; the defect was in the observability layer. The Codex `/v1/responses` **WebSocket** handler was the only path in the proxy that wrote to the request logger by hand instead of through the unified `emit_request_outcome` funnel, and it did so twice per session close: the per-turn funnel record plus an unconditional cumulative session-summary `RequestLog`. This PR removes the duplicate summary log so Codex WS emits exactly one request log per turn, matching the HTTP provider paths. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Dropped the duplicate cumulative session-summary `RequestLog` in the Codex WS handler while preserving the per-turn `emit_request_outcome` path. - Preserved gated `request_messages` and `turn_id` on residual outcomes so dashboard telemetry keeps the useful attribution without double-counting tokens. - Ensured explicit `--anyllm-provider` wins over a leaked `HEADROOM_ANYLLM_PROVIDER` environment variable. - Registered retry delay settings that had drifted out of the settings registry. - Hardened tests against developer-shell `HEADROOM_*` / `ANTHROPIC_CUSTOM_HEADERS` leakage and stabilized several focused proxy/wrap test fixtures. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ .venv/bin/pytest tests/ -q -p no:cacheprovider 8529 passed, 537 skipped, 5831 warnings in 276.25s (0:04:36) $ .venv/bin/ruff check <touched files> All checks passed! ``` ## Real Behavior Proof - Environment: macOS (Darwin 25.4.0), Python 3.13.14, pytest 9.0.3, ruff via project venv, branch `fix/multi-provider-runtime`. - Exact command / steps: Ran the full test suite without pytest cache provider and Ruff on all touched files; used `git stash` to confirm the stale fake-config failures pre-existed this change. - Observed result: Full suite passed with no failures; Ruff passed; Codex WS now routes end-of-session logging through `emit_request_outcome`, emitting one request log per turn with the same accounting model as Anthropic HTTP turns. - Not tested: Live simultaneous Claude + Codex dashboard run. `mypy headroom` was not run to completion; a scoped run reported one pre-existing `settings_store.py:470` coercion error outside this diff. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A - server-side observability fix; no UI markup changed. ## Additional Notes - The proxy's multi-provider routing, header/auth isolation, and per-model cache keying are already correct and unchanged here; only the WS observability write path was double-counting. - Architectural assessment: `plans/reports/research-260714-0004-multi-provider-upstream-compression-report.md`; root-cause + resolution trail: `plans/reports/debug-assessment-260714-0011-dashboard-instability-mixed-claude-codex-report.md`. - No live simultaneous Claude + Codex dashboard run was performed; validation is from test coverage and code review of the WS logging path. --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
a352fa0168
|
fix(proxy/bedrock): wire PrefixCacheTracker updates into Bedrock backend paths (#2196)
## Description `update_from_response()` was only called from the direct-Anthropic-API branch of `handle_anthropic_messages`. Both Bedrock backend branches (streaming and non-streaming) returned before ever reaching it, so `PrefixCacheTracker` state stayed permanently empty for the life of a session on any `--backend bedrock` deployment: `extract_cache_stable_delta()` always saw no previous turn, and `--mode cache` fell back to full unmodified passthrough on every turn instead of compressing the append-only delta. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/proxy/handlers/anthropic.py`: non-streaming Bedrock branch now mirrors the direct-API branch — builds `next_original_messages`/`next_forwarded_messages` from the response, runs cache-miss attribution, and calls `prefix_tracker.update_from_response()` before returning. - `headroom/proxy/handlers/streaming.py`: `_stream_response_bedrock` gains `prefix_tracker`/`optimized_messages` parameters (previously absent entirely), accumulates raw SSE bytes only when a tracker is present, reconstructs the assistant message via the existing `_parse_sse_to_response` helper in the `finally:` block, then updates the tracker. Mirrors `_finalize_stream_response` and the OpenAI-via-backend sibling (`_stream_openai_via_backend`), which already had this wiring. - `tests/test_bedrock_prefix_tracker_wiring.py` (new): drives real `PrefixCacheTracker` instances (via `session_tracker_store`, not a fake) through both the non-streaming and streaming Bedrock paths using `TestClient`, and asserts the tracker's turn counter and last-forwarded/-original messages actually advance after a Bedrock call. A second non-streaming test drives two turns and asserts turn 2 sees a nonzero `frozen_message_count` once the cached total clears `min_cached_tokens`. Verified these tests fail against the pre-fix `anthropic.py`/`streaming.py` (turn counter stuck at 0) and pass against the fix. - `CHANGELOG.md`: added a `### Fixed` entry under `Unreleased`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run pytest tests/test_bedrock_prefix_tracker_wiring.py tests/test_backend_nonstreaming_cache_metrics.py tests/test_backend_streaming_cache_metrics.py tests/test_bedrock_streaming_input_tokens.py tests/test_cache/test_prefix_tracker.py tests/test_cache_prefix_overlay.py tests/test_cross_turn_cache_safety.py tests/test_proxy_anthropic_cache_stability.py -q collected 91 items tests/test_bedrock_prefix_tracker_wiring.py ... [ 3%] tests/test_backend_nonstreaming_cache_metrics.py .... [ 7%] tests/test_backend_streaming_cache_metrics.py .... [ 12%] tests/test_bedrock_streaming_input_tokens.py .. [ 14%] tests/test_cache/test_prefix_tracker.py .................................. [ 49%] tests/test_cache_prefix_overlay.py ......... [ 69%] tests/test_cross_turn_cache_safety.py ... [ 72%] tests/test_proxy_anthropic_cache_stability.py ......................... [100%] ======================== 91 passed, 1 warning in 9.15s ========================= $ uv run ruff check headroom/proxy/handlers/anthropic.py headroom/proxy/handlers/streaming.py tests/test_bedrock_prefix_tracker_wiring.py All checks passed! $ uv run mypy headroom/proxy/handlers/anthropic.py headroom/proxy/handlers/streaming.py Success: no issues found in 2 source files ``` ## Real Behavior Proof - Environment: personal fork deployed as a real proxy (macOS launchd service, `headroom install apply`) with `--backend bedrock --mode cache`, fronting a live Claude Code session. - Exact command / steps: ran a two-turn streaming conversation against the running Bedrock-backed proxy, then a third append-only turn, while temporarily adding debug logging around `prefix_tracker.get_frozen_message_count()` / `get_last_original_messages()` (removed before this commit; the automated tests above are the permanent record). - Observed result: before the fix, `prev_orig_len`/`prev_fwd_len` were always 0 on every turn including turn 2+ — the tracker never advanced past its cold-start state. After the fix, turn 2 shows `prev_orig_len`/`prev_fwd_len` populated from turn 1's response, and the append-only turn 3 correctly triggers the delta-compression path (`router:noop` transform, pipeline actually runs) instead of falling to the router-never-called passthrough. In a separate live session captured while validating this fix, one turn showed `cache_write=98242` in the PERF log, and the immediately following turn showed `cache_read=98242 cache_hit_pct=94` — direct proof that the Bedrock path is now feeding real cache-read/write data back into the tracker end-to-end on live traffic, not just synthetic test fixtures. - Not tested: the live full-suite run during development surfaced one pre-existing unrelated failure in `test_provider_model_fallback.py`, confirmed independently failing on the commit prior to this fix (i.e., not introduced by this change, not fixed by it either). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A — backend logic change, no UI surface. ## Additional Notes - No linked issue number: found via independent investigation of a personal deployment, not filed as a `headroomlabs-ai/headroom` issue first. - This is the more consequential of two related fixes from the same investigation; the sibling PR (`fix(proxy/savings): append history point on cache-only savings too`) fixes a savings-history reporting gap that this same `--mode cache` + Bedrock deployment surfaced. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
0537cbfde4
|
feat(dashboard): persist lifetime proxy metrics (#2198)
## Description Persist bounded, aggregate-only Lifetime dashboard metrics across proxy restarts and expose them through a new `/stats-lifetime` endpoint. The change keeps session/runtime stats separate from durable lifetime stats, gates sensitive dashboard metadata for loopback or explicitly trusted dashboard clients, and updates the dashboard Lifetime view to consume the new endpoint. Closes #2137 ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made - Added bounded persistent lifetime metrics state and wired proxy metric events into it. - Added `/stats-lifetime` with sensitive project/persistence details gated behind dashboard metadata access checks. - Extended loopback/dashboard metadata access policy for trusted dashboard client CIDRs without widening admin/debug endpoints. - Reorganized dashboard session/lifetime presentation around runtime counters versus durable aggregates. - Added focused tests for persistent aggregation, persistence, endpoint registration, loopback gating, trusted dashboard CIDRs, and recent request ordering. - Fixed current Ruff/mypy issues in the lifetime metrics normalization code. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] Type checking passes (`mypy`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text uv run --extra proxy --with pytest --with pytest-asyncio pytest tests/test_persistent_metrics.py tests/test_persistent_metrics_integration.py tests/test_persistent_metrics_persistence.py tests/test_proxy_loopback_gating.py tests/test_proxy_stats_recent_requests.py -q 53 passed, 1 warning uvx ruff==0.15.17 check headroom/proxy/forwarded_headers.py headroom/proxy/loopback_guard.py headroom/proxy/persistent_metrics.py headroom/proxy/savings_tracker.py headroom/proxy/server.py tests/test_forwarded_headers.py tests/test_persistent_metrics.py tests/test_persistent_metrics_integration.py tests/test_persistent_metrics_persistence.py tests/test_proxy_loopback_gating.py tests/test_proxy_project_savings.py tests/test_proxy_stats_recent_requests.py All checks passed! uv run --extra proxy --with mypy mypy headroom/proxy/persistent_metrics.py Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: Windows review worktree, Python 3.13.3 via uv. - Exact command / steps: Ran the focused persistent metrics, persistence, loopback gating, and recent request tests; ran CI-matching Ruff on touched files; ran mypy on the new persistent metrics module. - Observed result: `/stats-lifetime` is registered, non-loopback callers receive only non-sensitive aggregate data, loopback/trusted dashboard clients receive the full lifetime payload, admin/debug endpoints remain loopback-only, and persistent metrics normalize malformed stored state without type/lint errors. - Not tested: Full repository pytest suite, full dashboard browser screenshot pass, or live long-running proxy traffic. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes No changelog entry is required for this dashboard/internal metrics iteration. The endpoint intentionally exposes only aggregate lifetime data to ordinary network callers and strips project/persistence details unless the caller passes the dashboard metadata access policy. --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
551f473e04
|
fix(proxy): accept Codex websocket before upstream retries (#2203)
## Description Codex Desktop could abandon Headroom's local `/v1/responses` WebSocket handshake before Headroom's upstream retry strategy had a chance to recover. The ChatGPT-auth path waited for an upstream opening handshake with a minimum 30-second timeout before sending the local 101, while the reported Codex Desktop handshake expired after about 34 seconds. This change accepts validated ChatGPT-auth Codex WebSockets before opening the upstream connection, then keeps the existing upstream retries and HTTP fallback behind the established local session. API-key sessions retain connect-before-accept behavior so upstream `x-codex-*` headers can still be attached to their client-facing 101. The change is scoped to the pre-101 timing failure and does not address the separate large-context streaming investigation in #1944. Closes #2184 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Accept ChatGPT-auth Codex WebSocket clients before the upstream connect and retry loop. - Preserve API-key connect-before-accept ordering and upstream `x-codex-*` handshake-header forwarding. - Keep upstream retry, relay, usage-state refresh, and WebSocket-to-HTTP fallback behavior after the local 101. - Add a deterministic regression that blocks the first upstream opening handshake and proves the local acceptance deadline is independent of it. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_openai_codex_ws_lifecycle.py -q`) - [x] Linting passes (`uv run ruff check headroom/proxy/handlers/openai.py tests/test_openai_codex_ws_lifecycle.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text uv run pytest tests/test_openai_codex_ws_lifecycle.py -q 28 passed in 2.02s uv run ruff check headroom/proxy/handlers/openai.py tests/test_openai_codex_ws_lifecycle.py All checks passed! uv run ruff format headroom/proxy/handlers/openai.py tests/test_openai_codex_ws_lifecycle.py --check 2 files already formatted ``` ## Real Behavior Proof - Environment: Python 3.12, synced development worktree, local fake Codex client and upstream WebSocket, no live provider - Exact command / steps: Run `uv run pytest tests/test_openai_codex_ws_lifecycle.py::test_chatgpt_ws_accepts_before_stalled_upstream_connect -q`; the fake upstream blocks its first opening handshake while the client enforces a bounded local-accept deadline. - Observed result: `1 passed in 0.46s`; the ChatGPT-auth client receives its local 101 before the blocked upstream connect is released, and the handler continues into its existing upstream recovery path. - Not tested: live Codex Desktop pre-turn compaction against ChatGPT subscription infrastructure ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Additional Notes `CHANGELOG.md` is unchanged because the release pipeline generates it from conventional commits. No user documentation changes are required; the handler comments and ordered-flow docstring are updated with the auth-mode-specific behavior. The broader #1944 large-context disconnect surface remains out of scope. --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
2de07db281
|
fix(memory): audit passive context injection (#2212)
## Description Close the passive-memory observability loop by recording access for context rows that survive the final injection budget and tagging requests where context is actually appended. Closes #2211 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Track only memory IDs retained after ranking, similarity filtering, entry limits, and final text truncation. - Call optional backend `record_access` with stable de-duplication and fail-open error handling. - Extend structured injection logging to stamp `memory_injected=true` when injected bytes are positive. - Thread request tags through successful Anthropic, OpenAI Chat, OpenAI Responses, Gemini, and Codex WebSocket injection sites. - Add a static contract test that all current successful handler injection logs pass tags. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ uv run --with pytest --with pytest-asyncio --with numpy --with fastapi pytest \ tests/test_memory_handler_native_ops.py \ tests/test_memory_auto_tail.py \ tests/test_memory_handler_project_isolation.py \ tests/test_memory_injection_logging.py -q 51 passed $ uv run --with ruff ruff check <touched Python files> All checks passed! $ git diff --check (no output) ``` ## Real Behavior Proof - Environment: Python 3.13 with synthetic backend and handler fixtures - Exact command / steps: run the focused test set above - Observed result: only IDs present after the final text budget are access-recorded; access-write failures remain fail-open; positive injection logs stamp `memory_injected=true`; all six current successful injection call sites pass tags - Not tested: live provider requests, full repository suite, third-party backends without `record_access` ## Review Readiness - [x] I have performed a self-review - [ ] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes Access accounting is intentionally best-effort: unsupported backends and write failures do not delay or fail the upstream model request. Documentation and changelog changes are not needed for this internal observability fix. Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
1c9585d42e
|
fix(stats): tag streamed output token source (#2214)
## Description Preserve the existing SSE output-token fallback while making its provenance visible to request logs and downstream statistics. Closes #2213 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Tag provider-reported streaming output tokens with `output_tokens_source=provider`. - Tag the existing `total_bytes // 40` fallback with `output_tokens_source=estimated_bytes`. - Copy incoming tags before adding provenance so caller-owned dictionaries are not mutated. - Add focused coverage for both source values and the unchanged fallback estimate. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ uv run --with pytest --with pytest-asyncio --with fastapi --with httpx --with numpy pytest \ tests/test_proxy_streaming_request_logger.py \ tests/test_request_outcome.py \ tests/test_proxy_handler_helpers.py -q 77 passed $ uv run --with ruff ruff check headroom/proxy/handlers/streaming.py tests/test_proxy_streaming_request_logger.py All checks passed! $ uv run --with ruff ruff format --check headroom/proxy/handlers/streaming.py tests/test_proxy_streaming_request_logger.py 2 files already formatted ``` ## Real Behavior Proof - Environment: Python 3.13 with the real request logger and synthetic stream state - Exact command / steps: run the focused test set above - Observed result: parsed usage records `provider`; a 200-byte no-usage stream still records 5 output tokens and tags it `estimated_bytes` - Not tested: live provider stream, full repository suite ## Review Readiness - [x] I have performed a self-review - [ ] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes This PR does not change the fallback formula or token totals. Documentation and changelog changes are not needed for the new internal outcome tag. Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
ce52b30c8f
|
feat(memory): add explicit supersession repair (#2217)
## Description Add an explicit, reviewable way to detach one incorrect supersession edge while preserving both memories and all neighboring version history. Closes #2216 ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Add an atomic SQLite `detach_supersession(old_id, new_id)` primitive that requires reciprocal direct lineage. - Restore only the old memory's validity and clear only the selected edge. - Re-index both affected memories and refresh cache state through `HierarchicalMemory`. - Expose the operation through `LocalBackend`. - Add `headroom memory repair-supersession OLD_ID NEW_ID`, dry-run by default with explicit `--apply`. - Resolve unambiguous partial IDs for preview but pass full IDs to the mutation. - Add chain-locality, rejection, index/cache, dry-run, and apply-path tests. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ uv run --with pytest --with pytest-asyncio --with numpy --with fastapi --with httpx pytest \ tests/test_memory/test_supersession_repair.py \ tests/test_memory/test_hierarchical.py::TestSQLiteMemoryStore \ tests/test_cli/test_main_help_version.py -q 22 passed $ uv run --with ruff ruff check <touched Python files> All checks passed! $ uv run --with ruff ruff format --check <touched Python files> 6 files already formatted ``` ## Real Behavior Proof - Environment: Python 3.13, temporary SQLite databases, synthetic two- and three-version chains - Exact command / steps: run the focused test set above - Observed result: detaching `v1 -> v2` restores `v1` as current, leaves `v2 -> v3` intact, re-indexes both records, refreshes cache, and keeps CLI preview read-only until `--apply` - Not tested: live proxy process, external MemoryStore/VectorIndex plugins, full repository suite ## Review Readiness - [x] I have performed a self-review - [ ] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes This is intentionally separate from #2188: that PR prevents new false edges, while this PR repairs historical data. The CLI help requires stopping any proxy that is actively using the same database before `--apply`, because another process can retain an old in-memory index snapshot. External backend semantics and stronger cross-store rollback behavior are left visible for maintainer review before this Draft is marked ready. Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
528517cff8
|
fix(diff-compressor): CJK-aware relevance scoring for hunk selection (#2220)
## Description `score_hunks` boosts diff hunks whose content overlaps the query/context (+`SCORE_CONTEXT_WORD_WEIGHT` per match); the resulting score decides which hunks survive when `max_hunks_per_file` fires. It split the context on whitespace, so a spaceless CJK query became one blob that only matched a hunk containing the whole query verbatim — relevant hunks weren't boosted and got dropped. This adds CJK character bigrams to the query match set so a CJK query boosts the hunks it overlaps. Rust-only (`diff_compressor.py` is a thin shim over Rust; hunk scoring lives only in Rust). CJK-gated: for a pure-ASCII query `cjk_bigrams` returns an empty set and the new loop is a no-op, so non-CJK scoring is byte-identical and the 20 diff parity fixtures stay green. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `crates/headroom-core/src/transforms/diff_compressor.rs`: add `is_cjk_char` + `cjk_bigrams`, and a separate loop in `score_hunks` that boosts hunks containing each CJK query bigram. The existing ASCII word loop is untouched. - Rust unit test (`cjk_bigrams` extraction) + an end-to-end test (a CJK query promotes the overlapping hunk into the kept set; the no-query baseline drops it). ## Testing - [x] Unit tests pass (`cargo test`) - [x] Linting passes (`cargo clippy` / `cargo fmt`) - [x] New tests added for new functionality - [x] Manual testing performed (see Real Behavior Proof) ### Test Output ```text $ cargo test -p headroom-core --lib diff_compressor test result: ok. 23 passed; 0 failed $ cargo clippy -p headroom-core # clean ``` ## Real Behavior Proof - Environment: macOS (Darwin), Rust via cargo, branch `feat/diff-compressor-cjk` off `main`. - Exact command / steps: `cargo test -p headroom-core --lib diff_compressor` — the `cjk_query_boosts_matching_hunk_into_kept_set` test builds a diff with 4 hunks (first / plain / cjk / last), `max_hunks_per_file = 3` (one contested middle slot between the plain hunk at change-density `0.12` and the CJK hunk at `0.06`), and compresses it once with the CJK context `数据库连接超时排查` and once with no query. - Observed result: with no query the higher-density plain hunk takes the slot (the CJK hunk `数据库连接失败重试` is dropped); with the CJK context its bigrams (`数据` / `据库` / `库连` / `连接`) match → score `0.06 + 4×0.2 = 0.86` beats the plain hunk's `0.12` → the CJK hunk survives. Both directions are asserted; before this change the spaceless CJK query matched neither hunk and the CJK hunk was always dropped. - Not tested: the Python side — `diff_compressor.py` is a thin shim that delegates `compress()` straight to Rust, so hunk scoring has no Python twin; and no new parity fixtures were recorded, since the 20 existing diff fixtures contain no CJK and therefore stay byte-identical. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation — N/A (internal scoring) - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md — N/A: internal relevance-scoring fix, no user-facing surface change ## Additional Notes - Completes the relevance-scorer CJK sweep across the compressors (search, adaptive sizer, the shared BM25 tokenizer, and now diff). Rust-only — no Python parity mirror is needed because diff hunk scoring has no Python twin (the shim delegates `compress()` straight to Rust). |
||
|
|
5de12f75e3
|
docs(ccr): correct stale 5-minute TTL hints to 30 minutes (#2224)
## Description The CCR store default TTL is `DEFAULT_TTL = 1800s` (30 minutes — see `crates/headroom-core/src/ccr/mod.rs` and `config.py store_ttl_seconds=1800`), but several user-facing hints and docstrings still said "5 minutes", the old default. The opencode/openclaw retrieve tools surfaced `(default TTL: 5 minutes)` in their expiry hint — exactly the misleading message reported in #1023. (The CCR cache itself works; the row-drop store bridge that populates the retrieve store landed for #389.) This corrects the two plugin hints, the `InMemoryCcrStore` docstrings, the SQLite/backend default TTL comments, and the `smart_crusher` mirror comment. The `mod.rs` comment that references "the *old* 5-minute default" is intentionally left unchanged — it correctly describes history. ## Type of Change - [x] Documentation update ## Changes Made - `plugins/openclaw/src/tools/headroom-retrieve.ts` + `plugins/opencode/src/retrieve.ts`: retrieve-failure hint `5 minutes` → `30 minutes`. - `crates/headroom-core/src/ccr/backends/in_memory.rs`: two docstrings (`5 minutes by default`, `5-minute TTL`) → `30 minutes` / `30-minute`. - `crates/headroom-core/src/ccr/backends/mod.rs` + `sqlite.rs`: SQLite/default backend TTL comments `5-minute` → `30-minute`. - `headroom/transforms/smart_crusher.py`: mirror comment `defaults to 5 minutes` → `30 minutes`. ## Testing - [x] Linting passes (`ruff` / `cargo check`) - [x] Manual verification (see Real Behavior Proof) ### Test Output ```text $ ruff format --check headroom/transforms/smart_crusher.py # clean $ cargo check -p headroom-core # Finished, no errors ``` ## Real Behavior Proof - Environment: macOS (Darwin), branch `feat/ccr-ttl-hint-fix` off `main`. - Exact command / steps: grepped every `5 minutes` / `5-minute` TTL reference across the repo; confirmed the real default is `DEFAULT_TTL = Duration::from_secs(1800)` (`ccr/mod.rs:66`), that `InMemoryCcrStore::new()` uses `DEFAULT_TTL` (not a local 300s), and that `config.py` sets `store_ttl_seconds = 1800 # 30 minutes`. - Observed result: all stale CCR default-TTL "5 minutes" references now read "30 minutes"; the one historical reference (`mod.rs`: "the old 5-minute default") is left as-is because it is accurate. - Not tested: nothing runtime changed — these are docstring/comment/hint string edits only, so there is no behavior to exercise. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [ ] I have commented my code — N/A (this PR is comments/strings) - [x] I have made corresponding changes to the documentation (this *is* the doc change) - [x] My changes generate no new warnings - [ ] I have added tests — N/A (no behavior change) - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md — N/A: user-facing hint/docstring correction, no functional change ## Additional Notes - Surfaced while root-causing #1023: the "cache permanently empty / TTL: 5 minutes" report is resolved on `main` (the store-bridge for #389 populates the retrieve store), but the stale "5 minutes" strings the reporter actually saw were still in the tree. This PR fixes those. --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
daca1dd756
|
fix(cli/init): fail clearly on a target settings file with invalid JSON (#2227)
## Description
`headroom init` crashes with a raw traceback when a target's settings
file contains invalid JSON.
`_json_file` reads the JSON config files that init read-merge-writes
(Claude's `settings.json`, Codex's `hooks.json`, etc.):
```python
def _json_file(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
content = path.read_text(encoding="utf-8").strip()
if not content:
return {}
payload = json.loads(content) # unguarded
return payload if isinstance(payload, dict) else {}
```
These are user-owned files that people hand-edit, so a stray trailing
comma or an unquoted key is entirely plausible. When that happens
`json.loads` raises `json.JSONDecodeError` and it propagates all the way
out, so `headroom init` dies with a Python traceback instead of a usable
message.
Returning `{}` on the error would be worse, not better: every caller
does `payload = _json_file(path)` then `_write_json(path, payload)`, so
an empty dict would make init overwrite the user's real settings with
just the hooks/env block — silent data loss.
## Fix
Guard the parse and convert it into an actionable `ClickException` that
names the file and the parse error, leaving the file untouched:
```python
try:
payload = json.loads(content)
except json.JSONDecodeError as e:
raise click.ClickException(
f"{path} contains invalid JSON ({e}); fix it and re-run, or move it aside."
) from e
```
`click.ClickException` is already the project's convention for
user-facing init failures (e.g. the `'claude' not found in PATH`
messages). The user now gets a clear instruction, and their file is
never clobbered.
Closes #
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/init.py`: wrap the `json.loads` in `_json_file` and
raise a `ClickException` on `JSONDecodeError`.
- `tests/test_cli/test_init_cli.py`: new test asserting a malformed file
raises `ClickException` (matching "invalid JSON") and is left
byte-for-byte untouched.
- `CHANGELOG.md`: Bug Fixes entry.
## Testing
- [ ] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ uvx ruff@0.15.17 check headroom/cli/init.py tests/test_cli/test_init_cli.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/cli/init.py
Success: no issues found in 1 source file
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOM-kills this box (ML stack import), so I
reproduced the behavior with a dependency-free script mirroring
`_json_file` and left the full pytest to CI.
- Exact command / steps: wrote a settings file containing `{"env": {"A":
"B",}}` (trailing comma), then called the OLD unguarded reader and the
NEW guarded reader; also re-checked a valid file round-trips.
- Observed result: OLD raises a raw `json.JSONDecodeError` (the init
traceback); NEW raises a `ClickException` containing "invalid JSON" and
leaves the file byte-for-byte unchanged; valid JSON still parses to the
same dict.
- Not tested: a full `headroom init` end-to-end run; full local `pytest`
deferred to CI (OOM).
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable
## Additional Notes
The "unit tests pass locally" box is unchecked because the full suite
imports the ML stack, which I can't run here. The new test uses the
existing `_load_init_module` harness (the same one the neighbouring
`test_json_file_*` tests use), so it runs under the normal CI pytest
job; behaviour is additionally verified by the standalone proof above.
|
||
|
|
f71fef1ca6
|
fix(claude): treat non-zero claude --version exit as version-unknown … (#2233)
## Description
Treat a non-zero `claude --version` exit as an unknown Claude Code
version, even if the failing command prints a version-shaped string to
stdout or stderr.
This is a follow-up to the Remote Control gate work for #1779/#1883. The
callers rely on `None` to use the self-qualified "2.1.196+ / unknown"
warning path; accepting a version from a failed command can produce a
false exact-version warning.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `headroom/providers/claude/runtime.py`: return `None` from
`detect_claude_code_version` when the `claude --version` subprocess has
a non-zero return code.
- `tests/test_issue_1779_remote_control_gate.py`: add a regression test
where a failing process still prints `2.1.196 (Claude Code)` and must be
treated as unknown.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ uv run pytest tests/test_issue_1779_remote_control_gate.py -q
50 passed
$ uvx ruff==0.15.17 check headroom/providers/claude/runtime.py tests/test_issue_1779_remote_control_gate.py --output-format concise
All checks passed!
$ uvx ruff==0.15.17 format --check headroom/providers/claude/runtime.py tests/test_issue_1779_remote_control_gate.py
2 files already formatted
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.12/3.13 test environment, local
checkout of this PR branch.
- Exact command / steps: ran the focused Remote Control gate test file,
including the new regression that stubs `claude --version` as
`returncode=1` with version-shaped stdout.
- Observed result: `detect_claude_code_version("claude")` returns `None`
for the failed command, preserving the unknown-version path; existing
parser/gate tests still pass.
- Not tested: an actual failing Claude Code binary invocation on a user
machine; the subprocess behavior is covered by the regression stub.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
cbfa267c5f
|
fix(deps): enforce transformers security floor (#2201)
## Description Raise the production `transformers` dependency floor so the security workflow cannot resolve the CVE-2026-5241 vulnerable range reported by `pip-audit`, and refresh the small current-main test fixtures needed for the PR matrix to run green. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Raised direct optional `transformers` declarations for `proxy`, `ml`, and `voice` extras to `>=5.5.0,<6.0`. - Refreshed `uv.lock` metadata so `uv export --extra all` resolves a patched `transformers` version for the production audit set. - Kept the current-main test fixture fixes for the ZCode setup printer and deferred compression fallback metrics. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [ ] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv lock --check Resolved 238 packages in 1ms $ uv export --frozen --no-dev --no-emit-project --no-hashes --extra all --format requirements-txt | rg "^transformers==|^huggingface-hub==" huggingface-hub==1.16.1 transformers==5.13.1 $ uv export --frozen --no-dev --no-emit-project --no-hashes --extra all --format requirements-txt > requirements-prod.txt $ uvx pip-audit -r requirements-prod.txt No known vulnerabilities found $ uv run --with pytest --with pytest-asyncio --with fastapi --with httpx --with numpy pytest tests/test_cli/test_wrap_zcode.py::test_wrap_prints_proxy_urls tests/test_cold_start_fast_pass.py::test_fast_pass_failure_falls_back_to_full_deferral -q 2 passed $ uvx ruff==0.15.17 check headroom/cli/wrap.py headroom/proxy/handlers/anthropic.py --output-format concise All checks passed! $ git diff --check passed ``` ## Real Behavior Proof - Environment: Windows checkout plus the same frozen production dependency export shape used by the GitHub Actions security workflow. - Exact command / steps: raised the `transformers` floor, refreshed `uv.lock`, exported `--extra all` production requirements, ran `pip-audit`, then reproduced the focused ZCode and deferred-compression tests. - Observed result: the export resolves `transformers==5.13.1`; `pip-audit` reported no known vulnerabilities; the focused tests pass locally; CI is rerunning on the updated head. - Not tested: full GitHub Actions matrix locally; CI is running the complete suite on this PR. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A - dependency metadata and CI fixture fix. ## Additional Notes This PR is intentionally scoped to clearing the current red mainline security gate while keeping the small fixture updates needed by the branch test matrix. |
||
|
|
6413cc75a2
|
fix(wrap): read/write instruction files as UTF-8 on Windows (#1245)
## Description Fixes #1126. On a Windows (cp1252) locale, `headroom wrap` crashes with `UnicodeDecodeError` the first time it injects guidance into a user instruction file that contains non-ASCII prose (e.g. typographic quotes `“happy places”` or an em-dash). `_inject_rtk_instructions` and `_inject_memory_agents_md` both read the existing file and append/create it with a bare `read_text()` / `open()` / `write_text()`, so the default codec (cp1252, not UTF-8) chokes on the multi-byte characters. This is the same bug class already fixed for the `learn` pipeline (#1202) and earlier for other wrap paths — here it's the instruction-file injectors. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/cli/wrap.py`: in `_inject_rtk_instructions` and `_inject_memory_agents_md`, read the existing instruction file as `encoding="utf-8", errors="replace"` and append/create with `encoding="utf-8"`. The read only feeds the marker-existence check and the append doesn't rewrite existing bytes, so replacement can't corrupt the file. - `tests/test_cli/test_wrap_encoding.py`: new regression tests. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_cli/test_wrap_encoding.py tests/test_cli/test_wrap_hintfile_agents.py -q 16 passed $ ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_encoding.py All checks passed! ``` The new tests are **red on the old code, green with the fix**: injecting into a file with a typographic quote plus a stray `0x9d` byte (undefined in cp1252 and invalid UTF-8, so a bare `open()` fails on any locale) — the append and idempotent paths fail before the fix (4 failed) and pass after (6 passed). ## Real Behavior Proof - Environment: Windows 11, Python 3.10, against the real `headroom.cli.wrap` injectors (no live agent launch; the decode failure is at file read time). - Exact command / steps: `write_bytes` an `AGENTS.md` containing `"Be in “happy places” — really.\n"` plus a stray `0x9d` byte, then call `_inject_rtk_instructions(path)` / `_inject_memory_agents_md(path)`. - Observed result: **before** the fix → `UnicodeDecodeError: 'utf-8' codec can't decode byte 0x9d` (and on a real cp1252 locale, the same on the typographic quotes alone); **after** → both return `True`, the marker is present, the pre-existing prose is preserved, and re-running is idempotent. - Not tested: a full end-to-end `headroom wrap copilot` against a live Copilot CLI (verified at the injector level, which is where the decode crash lives). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
9e376afabe
|
fix(mcp): mcp status checks ~/.claude.json, not only ~/.claude/mcp.json (#990)
## Description `headroom mcp status` only inspected `~/.claude/mcp.json`, but servers registered via `claude mcp add` (user scope) live in `~/.claude.json`. So `status` printed `✗ No config file` even when headroom was registered and `claude mcp list` reported it Connected. This detects the registration across every location Claude Code uses. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Add `find_headroom_registration()` that checks `~/.claude.json`, `~/.claude/mcp.json`, then `./.mcp.json` (first match wins). - Use it in `mcp status` for both the "Configured" check and the proxy-URL lookup. ## Testing - [x] Unit tests pass (`pytest`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_cli/test_mcp_status.py -q 5 passed in 0.13s ``` ## Real Behavior Proof - Environment: Linux, Python 3.13, editable build of this branch - Exact command / steps: registered headroom in ~/.claude.json, then ran `headroom mcp status` - Observed result: prints `✓ Configured` with the ~/.claude.json path (previously `✗ No config file`) - Not tested: project-scoped ./.mcp.json discovery in a real multi-repo workflow ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
0c7087539d
|
fix: add DeepSeek V4/V3.2/R1 tokenizer mappings and context limits (#912)
The tokenizer registry routed deepseek-v4-pro, deepseek-v4-flash,
deepseek-chat, deepseek-reasoner, and other modern DeepSeek models
to the 2023-era deepseek-llm-7b-base tokenizer via prefix fallback.
This caused token counts off by 30-50%, broken context-limit detection
(V4-Pro supports 1M but got 32K), and inaccurate savings reports.
## Fix
3 files, +43/-2:
- **huggingface.py**: 16 new MODEL_TO_TOKENIZER entries with verified
HuggingFace IDs (deepseek-ai/DeepSeek-V4-Pro, V4-Flash, V3.2,
V3-0324, R1, R1-0528, Reasoner, Chat, Coder-V2, etc.)
- **openai_compatible.py**: 17 new _DEFAULT_CONTEXT_LIMITS entries
(V4-Pro/Flash -> 1M, R1/Reasoner -> 131K, V3 -> 128K, etc.)
- **openai.py**: 8 new _CONTEXT_LIMITS entries for LiteLLM-fallback.
Existing mappings untouched (backward compatible).
## Real behavior proof
- **Setup**: Windows 11, Python 3.13.14, headroom-ai 0.2.15 wheel +
source checkout at v0.24.0. No Rust extension built (headroom._core
unavailable). Touched files are at parity with v0.24.0.
- **Steps after patch**:
```
python3 -c "
from headroom.tokenizers.huggingface import get_tokenizer_name
for m in
['deepseek-v4-pro','deepseek-chat','deepseek-reasoner','deepseek-v4-flash']:
print(f'{m} -> {get_tokenizer_name(m)}')
from headroom.tokenizers.registry import get_tokenizer
for m in ['deepseek-v4-pro','deepseek-chat','deepseek-reasoner']:
print(f'{m}: {get_tokenizer(m)}')
"
```
- **Observed result**:
```
deepseek-v4-pro -> deepseek-ai/DeepSeek-V4-Pro
deepseek-v4-flash -> deepseek-ai/DeepSeek-V4-Flash
deepseek-chat -> deepseek-ai/DeepSeek-V3
deepseek-reasoner -> deepseek-ai/DeepSeek-R1
```
Previously ALL resolved to deepseek-ai/deepseek-llm-7b-base.
TokenizerRegistry routes correctly. Context limits verified
(1M / 131K / 128K). compress() import smoke-tested OK.
- **Not tested**: full proxy e2e with a live DeepSeek API key
(no available key). HuggingFace AutoTokenizer download confirmed
for V4-Pro/V3/R1 but produced GBK decode errors from hf_hub on
this zh-CN Windows locale during config fetch -- a separate
huggingface_hub issue unrelated to this change.
<!-- headroom-maintainer-template-completion:start -->
## Description
This PR prepares `fix: add DeepSeek V4/V3.2/R1 tokenizer mappings and
context limits` for review by documenting the intended change,
validation evidence, and remaining merge-readiness context.
Linked issues: None declared.
## Type of Change
- [x] Bug fix
- [ ] New feature
- [ ] Documentation
- [ ] Refactor
- [ ] Tests only
## Changes Made
- Commit: fix: add DeepSeek V4/V3.2/R1 tokenizer mappings and context
limits
- Touches `headroom/providers/openai.py`
- Touches `headroom/providers/openai_compatible.py`
- Touches `headroom/tokenizers/huggingface.py`
## Testing
- [x] GitHub checks reviewed
- [x] Metadata/template validation
- [ ] Local functional testing
### Test Output
```text
gh pr view 912 --repo chopratejas/headroom --json statusCheckRollup
- PR Governance / label: SUCCESS
- external / GitGuardian Security Checks: SUCCESS
```
## Real Behavior Proof
- Environment: GitHub PR metadata and checks for `chopratejas/headroom`
PR #912.
- Exact command / steps: Reviewed PR title, commits, changed files,
linked issues, labels, and check rollup; appended this maintainer
template completion block without replacing the author's original
description.
- Observed result: PR body now contains all required governance
sections, checked readiness fields, and a non-placeholder validation
evidence block.
- Not tested: This pass updated PR metadata only; code validation
remains represented by the linked GitHub checks and any author-provided
evidence above.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
<!-- headroom-maintainer-template-completion:end -->
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
|
||
|
|
36202f4d0b
|
fix(windows): unwedge compression on degraded ONNX runtimes (every request timing out at 30s, 0% savings) (#822)
## Summary Multiple Windows users reported (via Discord, on v0.23.0, `pip install "headroom-ai[all]"`) that the proxy delivers **zero compression** and adds **+30s latency to every request**: `Optimization failed: TimeoutError:` with `compression_first_stage ≈ 30000ms` on every optimization attempt, for the lifetime of the process. Log analysis showed the wedge starts at the **first message eligible for real compression** (earlier requests succeed because everything is skipped/excluded) — and never recovers, even though the Kompress model loaded successfully at startup. ### Root cause chain 1. `create_cpu_session_options` disabled ONNX Runtime's CPU memory arena on **all** platforms. On Windows this is catastrophic: every `Run()` falls back to per-node `VirtualAlloc`/free, slowing ModernBERT inference by 2–3 orders of magnitude (onnxruntime#11627). One reporter's perf summary showed max optimization overhead of **200,369ms** (~13 chunks × ~15s) — slow, not deadlocked. 2. The first slow inference outlives the proxy's 30s compression-stage timeout. `asyncio.wait_for` abandons the future but **cannot kill the executor thread**, which keeps holding the Kompress `BoundedSemaphore(1)`. 3. Every later compression blocks on an **unbounded** `semaphore.acquire()`, times out at exactly 30s, and leaks another thread — permanently wedging the proxy until restart. Two adjacent Windows bugs found in the same logs are fixed too: `subprocess.run(text=True)` without `encoding=` decodes child output with cp1252, so rtk's emoji output killed reader threads (`UnicodeDecodeError: 'charmap' codec can't decode byte 0x8f`); and the OpenAI handler logged `Optimization failed: ` with an empty message because `str(asyncio.TimeoutError())` is empty. ### Fixes - **`onnx_runtime.py`** — keep the CPU arena at ORT's default on Windows; Linux/macOS keep the legacy low-RSS behavior (arena disabled) bit-for-bit. New `HEADROOM_ONNX_CPU_ARENA` env overrides either way. All ONNX sessions (Kompress, image router, memory embedders) share this helper, so one fix covers them all. - **`kompress_compressor.py`** — three layers of wedge-proofing, each fail-safing to passthrough instead of blocking: - bounded semaphore acquire (`HEADROOM_KOMPRESS_ACQUIRE_TIMEOUT_SECONDS`, default 5s) - wall-clock budget per compress/compress_batch call (`HEADROOM_KOMPRESS_TIME_BUDGET_SECONDS`, default 20s — under the 30s stage timeout, so Kompress gives up before the request is abandoned). Batch bail never emits a partially-covered text. - preload canary (`HEADROOM_KOMPRESS_CANARY_SECONDS`, default 5s, one retry to forgive cold-start warmup): machines that can never finish inference inside the stage timeout get ML compression disabled up front with one actionable warning, instead of a guaranteed 30s timeout per request. - Setting any knob `<= 0` disables that guard (restores legacy behavior). First give-up logs at WARNING with remediation hints; repeats drop to DEBUG. - **`proxy/helpers.py`, `interceptors/astgrep.py`** — `encoding="utf-8", errors="replace"` on rtk/lean-ctx/ast-grep subprocess calls. - **`handlers/openai.py`** — failure log now includes request id + exception type, matching the Anthropic handler. ### Non-Windows perf - Session options on Linux/macOS are unchanged (pinned by tests). - The only new hot-path cost is one `time.monotonic()` + a bounded acquire per chunk: micro-benchmarked at sub-microsecond (bounded acquire measured marginally *faster* than the old context-manager acquire), vs 50–500ms of inference per chunk. - Real-model smoke run on macOS: identical compression output (ratio 0.262 on a 1020-word sample), canary passes, budget/acquire give-up paths verified against the real ONNX stack by forcing tiny env values. Related (same symptom, different root cause — **not** addressed here): #810 tracks the blocked-tiktoken-download hang, which produces the same per-request 30s `TimeoutError` signature. The bounded-acquire/budget changes in this PR limit the blast radius of Kompress-side slowness only. ## Validation - `.venv/bin/ruff check headroom/ tests/...` — clean - `.venv/bin/ruff format --check` — clean (355 files) - `.venv/bin/mypy` on all five changed source files — no issues - `python -m pytest tests/test_onnx_runtime.py tests/test_kompress_failsafe.py tests/test_subprocess_encoding.py` — 25 passed (new coverage: arena platform matrix + env overrides, stuck-semaphore passthrough for compress and batch, budget bail incl. mid-batch no-data-loss, canary trip/pass/retry/disable/error-safety, UTF-8 subprocess kwargs) - `python -m pytest tests/test_transforms_content_router.py tests/test_proxy_handler_helpers.py tests/test_codex_ws_compression_scheduler.py tests/test_proxy_warmup.py tests/test_proxy_pipeline_lifecycle.py` — 52 passed (existing suites for touched areas) <!-- headroom-maintainer-template-completion:start --> ## Description This PR prepares `fix(windows): unwedge compression on degraded ONNX runtimes (every request timing out at 30s, 0% savings)` for review by documenting the intended change, validation evidence, and remaining merge-readiness context. Linked issues: None declared. ## Type of Change - [x] Bug fix - [ ] New feature - [ ] Documentation - [ ] Refactor - [ ] Tests only ## Changes Made - Commit: fix(windows): unwedge compression on degraded ONNX runtimes - Commit: fix(kompress): run preload canary off the startup path - Touches `headroom/onnx_runtime.py` - Touches `headroom/proxy/handlers/openai.py` - Touches `headroom/proxy/helpers.py` - Touches `headroom/proxy/interceptors/astgrep.py` - Touches `headroom/transforms/kompress_compressor.py` - Touches `tests/test_kompress_failsafe.py` ## Testing - [x] GitHub checks reviewed - [x] Metadata/template validation - [ ] Local functional testing ### Test Output ```text gh pr view 822 --repo chopratejas/headroom --json statusCheckRollup - CI / changes: SUCCESS - CodeQL / Analyze (actions): SUCCESS - Evaluation Suite / smoke-test: SUCCESS - Init E2E / docker-init-e2e: SUCCESS - PR Governance / label: SUCCESS - Wrap E2E / docker-wrap-e2e: SUCCESS - CodeQL / Analyze (c-cpp): SUCCESS - CodeQL / Analyze (javascript-typescript): SUCCESS - CodeQL / Analyze (python): SUCCESS - CodeQL / Analyze (rust): SUCCESS - Evaluation Suite / weekly-suite: SKIPPED - CI / commitlint: SUCCESS ``` ## Real Behavior Proof - Environment: GitHub PR metadata and checks for `chopratejas/headroom` PR #822. - Exact command / steps: Reviewed PR title, commits, changed files, linked issues, labels, and check rollup; appended this maintainer template completion block without replacing the author's original description. - Observed result: PR body now contains all required governance sections, checked readiness fields, and a non-placeholder validation evidence block. - Not tested: This pass updated PR metadata only; code validation remains represented by the linked GitHub checks and any author-provided evidence above. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review <!-- headroom-maintainer-template-completion:end --> --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
cb6c828457
|
fix(proxy): one bad extension no longer aborts proxy startup (#2215)
## What
`install_all()` (the `headroom.proxy_extension` loader) previously let
any exception from an extension's `install()` **propagate and abort
proxy startup** — one broken or version-incompatible third-party
extension took the whole proxy down, and every other extension with it.
This makes extension loading resilient:
- catch a failing `install()`, log it (with traceback), record it as
**skipped**
- continue installing the rest — a failure disables that one extension,
not the proxy
- print a `SKIPPED` line to the console (the startup banner lists
*enabled* extensions before install runs, so a skip would otherwise be
logging-config dependent)
## Why
Found while testing several proxy extensions together in a clean venv: a
plugin built against a newer core API raised `ModuleNotFoundError` from
`install()` and crashed the proxy at startup. An extension that fails
its own environment/auth check should disable itself — it should not
take the whole proxy down.
## Real behavior proof
Before — one extension failing in `install()`:
```
... proxy did NOT come up (/livez never answered)
```
After — same setup, one extension deliberately broken:
```
[headroom] proxy extensions SKIPPED: myorg_ext (install failed — running without them; see logs)
/livez: 200 healthy # proxy up; the other extensions installed
```
Loader unit check (fake failing extension):
```
returned installed: ['good_ext'] # bad one excluded
bad_ext skipped (not in installed): True
good_ext survived: True
warning logged for bad_ext: True
```
## Tests
- `mypy headroom/proxy/extensions.py` → `Success: no issues found`
- `ruff check headroom/proxy/extensions.py` → `All checks passed!`
- Verified in-process (catch/skip/continue + logging) and end-to-end
against a running proxy (`/livez` 200 with a deliberately failing
extension).
## Maintainer Follow-up
- Added `tests/test_proxy_extensions.py` covering skip-and-continue
behavior for a failed extension and the missing-extension warning path.
- Removed an informal implementation comment from
`headroom/proxy/extensions.py`.
- Validation on `
|
||
|
|
79d8056fd7
|
fix(mcp): regenerate stale server.json (0.27.0 -> 0.32.0) (#2218)
## Description The committed `server.json` pinned version `0.27.0` while `pyproject.toml` is at `0.32.0`. `tests/test_mcp_registry/test_server_json.py::test_root_server_json_matches_builder` asserts the committed artifact equals `render_server_json()`, so it fails on `main`. This regenerates `server.json` from the current metadata. Found while getting the security PR (#2207) CI green. The two other pre-existing failures it was grouped with were **already fixed on `main`** by recent commits — `test_cold_start_fast_pass` (`record_compression_failed` added to the metrics double) and `test_cli/test_wrap_zcode` (watcher mock now passes the port) — so this PR only needs the `server.json` regen. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Regenerated `server.json` from `render_server_json()` so the committed artifact matches the current package version (`0.32.0`). ## Testing - [x] Unit tests pass (`pytest`) — the previously-failing tests - [x] Linting passes (`ruff check`) ### Test Output ```text $ pytest tests/test_mcp_registry/test_server_json.py::test_root_server_json_matches_builder \ tests/test_cold_start_fast_pass.py::test_fast_pass_failure_falls_back_to_full_deferral \ tests/test_cli/test_wrap_zcode.py::test_wrap_prints_proxy_urls -q 3 passed ``` ## Real Behavior Proof - Environment: branch off current `main` (`ea3d5a86`), Python 3.12, project `.venv`. - Steps: `python -c "from headroom.mcp_registry import render_server_json; open('server.json','w').write(render_server_json())"`, then ran the MCP registry test. - Observed: `server.json` `version` → `0.32.0`; `test_root_server_json_matches_builder` passes. - Not tested: full suite (single generated-artifact change). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [ ] Documentation changes (N/A) - [x] My changes generate no new warnings - [ ] Tests added (N/A — regenerates an artifact an existing test already guards) - [x] New and existing unit tests pass locally - [ ] CHANGELOG (N/A) ## Additional Notes `server.json` is a generated artifact (`headroom/mcp_registry/server_json.py`) — regenerate with `render_server_json()` after any version bump. 🤖 Generated with [Claude Code](https://claude.com/claude-code) |
||
|
|
ea3d5a86b7
|
fix(deps): clear Dependabot lockfile alerts (#2175)
## Description Clears the current dependency/security-audit blockers that are making unrelated PRs red: - `transformers 5.3.0` / `CVE-2026-5241`, fixed by requiring `transformers>=5.5.0` in the locked optional dependency set. - `sqlitedict <=2.1.0` via the optional `benchmark` extra's `lm-eval[api]` dependency. There is no patched `sqlitedict` release, so this PR removes the published/locked `benchmark` extra instead of shipping a known-vulnerable transitive dependency. - `esbuild >=0.27.3,<0.28.1` in the OpenCode plugin lockfile, fixed by forcing `esbuild@0.28.1` through the OpenCode npm override and regenerated lockfile. The benchmark code still invokes `python -m lm_eval`; researchers who need that harness should install `lm-eval[api]` in their benchmark environment until its transitive vulnerability has a patched release. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `pyproject.toml`: remove the `benchmark` optional extra, document external `lm-eval[api]` installation guidance, and require `transformers>=5.5.0`. - `uv.lock`: regenerate without the `benchmark` extra, removing `lm-eval` and `sqlitedict` lock entries and locking the patched transformers floor. - `plugins/opencode/package.json`: add an `overrides` entry for `esbuild@0.28.1`. - `plugins/opencode/package-lock.json`: regenerate the OpenCode lockfile with `esbuild@0.28.1`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [ ] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text uv lock --check rg -n -F 'sqlitedict' uv.lock # no matches rg -n -F 'name = "lm-eval"' uv.lock # no matches rg -n -F "extra == 'benchmark'" uv.lock # no matches rg -n -F '0.27.7' plugins/opencode/package-lock.json plugins/opencode/package.json # no matches npm ls esbuild --package-lock-only npm audit --package-lock-only # found 0 vulnerabilities git diff --check ``` Previous GitHub checks were green. After merging current `main`, fresh GitHub checks are running again; local targeted validation still passes. ## Real Behavior Proof - Environment: Windows 11, Python 3.13.3, uv, npm in `plugins/opencode`, Dependabot/pip-audit alert metadata from the failing PR jobs. - Exact command / steps: inspected the regenerated Python and npm lockfiles with `rg`, checked the uv lock with `uv lock --check`, checked OpenCode's dependency tree with `npm ls esbuild --package-lock-only`, and ran `npm audit --package-lock-only`. - Observed result: `uv.lock` no longer contains `sqlitedict`, `lm-eval`, or a `benchmark` extra marker; `transformers` resolves at the patched `>=5.5.0` floor; OpenCode's lock resolves `esbuild@0.28.1`; `npm audit --package-lock-only` reports 0 vulnerabilities; GitHub `Dependency audit (pip-audit)` passes. - Not tested: running the external `lm-eval` harness after installing it separately. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [ ] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A - dependency and lockfile security fix. ## Additional Notes The `benchmark` extra can be restored once the upstream `lm-eval[api]` dependency chain stops pulling a vulnerable `sqlitedict` release. --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> |
||
|
|
dbbef4bd41
|
fix(code-compressor): recover valid Python rewrites after local syntax rejection (#2202)
## Description A compile-invalid Python definition rewrite currently makes `CodeAwareCompressor` discard every otherwise valid rewrite in the file and return the original source at 0 percent reduction. The existing whole-file safety guard stays in place, while a Python-only recovery replay now preserves the rejected definition and keeps independent valid compression. The recovery reuses the current Python validation authority in `ast.parse()` plus `compile()`, runs only after the first assembled module already fails `_verify_syntax()`, and stays out of non-Python paths. Closes #1233 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Added a Python-only recovery replay after the first assembled module fails syntax validation. - Preserved only the invalid function or class rewrite while allowing independent valid definitions to remain compressed. - Kept the existing whole-file syntax guard and original-source fallback as the terminal safety check. - Added focused invalid-node, valid-modern-syntax, and fail-safe coverage. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_transforms/test_code_compressor.py::TestRealASTRuns::test_python_invalid_node_falls_back_locally tests/test_transforms/test_code_compressor.py::TestRealASTRuns::test_python_recovery_does_not_block_valid_modern_syntax tests/test_transforms/test_code_compressor.py::TestRealASTRuns::test_python_recovery_still_returns_original_when_all_candidates_invalid tests/test_transforms/test_code_compressor.py::TestEdgeCases::test_syntax_errors_in_input tests/test_transforms/test_code_compressor.py::TestRealASTRuns::test_ast_preserves_structure_for_python -v`) - [x] Linting passes (`uv run ruff check headroom/transforms/code_compressor.py tests/test_transforms/test_code_compressor.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text uv run pytest tests/test_transforms/test_code_compressor.py::TestRealASTRuns::test_python_invalid_node_falls_back_locally tests/test_transforms/test_code_compressor.py::TestRealASTRuns::test_python_recovery_does_not_block_valid_modern_syntax tests/test_transforms/test_code_compressor.py::TestRealASTRuns::test_python_recovery_still_returns_original_when_all_candidates_invalid tests/test_transforms/test_code_compressor.py::TestEdgeCases::test_syntax_errors_in_input tests/test_transforms/test_code_compressor.py::TestRealASTRuns::test_ast_preserves_structure_for_python -v 5 passed in 0.34s uv run ruff check headroom/transforms/code_compressor.py tests/test_transforms/test_code_compressor.py All checks passed! uv run ruff format headroom/transforms/code_compressor.py tests/test_transforms/test_code_compressor.py --check 2 files already formatted ``` ## Real Behavior Proof - Environment: Windows, synced `uv` environment with `dev` and `code` extras installed - Exact command / steps: run the focused invalid-node regression through public `compress(..., language="python")` - Observed result: `1 passed in 0.19s`; the invalid candidate stays original, the neighboring valid candidate remains compressed, and `headroom-PR-TARGET-1233-PROOF.md` records the base `ratio=1.0` whole-file rollback against the fixed head behavior. - Not tested: the stale future-import mismatch discussed in the old issue comment, already covered on current main ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Additional Notes - The stale future-import comment on #1233 is not the live slice here; current main already validates Python with `compile()` and already covers that ordering case. - This fix keeps the existing whole-file fail-safe and does not broaden into cross-language recovery or new syntax models. - `CHANGELOG.md` remains unchanged because Headroom generates release notes from conventional commits. --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
8522fcbc40
|
fix(code): quarantine Perl parser from code-aware compression (#2204)
## Description `headroom proxy` can wedge when code-aware compression enters the tree-sitter Perl external scanner and the native scan keeps the GIL indefinitely. Current main still has two routes into that scanner: explicit `perl` or `pl` hints flow through `CodeAwareCompressor`, and `detect_language()` can nominate Perl on non-Perl code because its prefilter matches generic sigils such as decorators, JSDoc tags, and shell variables before phase 2 parses every surviving candidate grammar. This change quarantines Perl at the code-aware compression funnel without widening scope. Perl remains recognized at the input boundary, but live proxy compression no longer requests a Perl parser. Non-Perl code keeps its existing code-aware behavior. Real Perl falls back through the existing safe Kompress or passthrough contract instead of entering tree-sitter. The diff stays inside `headroom/transforms/code_compressor.py` plus focused parser-safety regressions. Refs #2185 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Added a Perl quarantine list in `headroom/transforms/code_compressor.py` and used it to stop Perl candidate parsing during language detection. - Added a hard `_get_parser()` guard so no live code-aware path can construct a Perl parser. - Routed resolved explicit Perl hints through the existing safe fallback or passthrough contract before AST compression. - Added focused parser-safety regressions for non-Perl candidate bleed, explicit `perl` and `pl`, inferred real Perl, mixed fenced Perl content, fallback-disabled passthrough, and non-Perl negative space. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_perl_scanner_safety.py -q`) - [x] Linting passes (`uv run ruff check headroom/transforms/code_compressor.py tests/test_perl_scanner_safety.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text uv run pytest tests/test_perl_scanner_safety.py -q 8 passed in 1.93s uv run ruff check headroom/transforms/code_compressor.py tests/test_perl_scanner_safety.py All checks passed! uv run ruff format headroom/transforms/code_compressor.py tests/test_perl_scanner_safety.py --check 2 files already formatted ``` ## Real Behavior Proof - Environment: Windows, Python from the synced `uv` environment, `dev` and `code` extras installed, no provider call - Exact command / steps: Run `uv run pytest tests/test_perl_scanner_safety.py -q`. - Observed result: `8 passed in 1.93s`; the suite proves explicit `perl` and `pl`, inferred real Perl, mixed fenced Perl, and non-Perl negative-space routes all avoid Perl parser entry. - Not tested: the reporter's macOS payload and long-running concurrent workload ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Additional Notes - Use `Refs #2185`, not `Closes #2185`. The wedge surface is this PR's scope, but #2185 also carries a separate orphaned `headroom mcp serve` report that this slice does not address. - This is a reachability fix. It does not repair the upstream Perl scanner and it does not harden other grammars against the same class of native wedge. - `CHANGELOG.md` remains unchanged because Headroom generates release notes from conventional commits. - Merged PR https://github.com/headroomlabs-ai/headroom/pull/2114 addresses a different cooperative compression stall and stays separate from this native parser-entry slice. --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
2a954b69b4
|
feat(wrap): add ZCode desktop app support (#1845)
## Description Add `headroom wrap zcode` and `headroom unwrap zcode` commands for the ZCode desktop app (zcode.z.ai). ZCode is a desktop Electron IDE built by Z.AI, optimized for GLM-5.2 models. It has no CLI binary, so this follows the Pattern-B (proxy-only, print instructions) approach — same as Cursor, Cline, and Continue. **Upstream auto-detection:** `headroom wrap zcode` now reads `~/.zcode/v2/config.json` to detect the enabled provider and automatically configures the proxy upstream — no manual flags needed. Closes #1844 ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - New module: `headroom/providers/zcode/__init__.py` and `runtime.py` (ZCodeProxyTargets, ZCodeUpstream, build_proxy_targets, detect_upstream, upstream_to_proxy_urls, render_setup_lines) - New CLI command: `headroom wrap zcode` in `headroom/cli/wrap.py:4815` — starts proxy, injects RTK into AGENTS.md, prints Base URL setup instructions - New CLI command: `headroom unwrap zcode` in `headroom/cli/wrap.py:5960` — removes RTK markers, stops proxy - New helper: `zcode_config_dir()` in `headroom/install/paths.py` - Updated `_run_proxy_only_watcher` to accept `anthropic_api_url`/`openai_api_url` params - Updated README.md: ZCode row in compatibility matrix, unwrap list, wrap command list - Updated CHANGELOG.md: entry under [Unreleased] > Added ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) — pre-existing numpy type stubs issue prevents full mypy run - [x] New tests added for new functionality (24 tests in `tests/test_cli/test_wrap_zcode.py`) - [x] Manual testing performed ### Test Output ```text tests/test_cli/test_wrap_zcode.py ........................ [100%] 24 passed, 1 warning in 0.22s ``` ## Real Behavior Proof - Environment: macOS 15.5, Python 3.12.13, headroom installed via `pip install -e .[dev]` - Exact command / steps: `headroom wrap zcode --port 9000` then `headroom unwrap zcode --port 9000` - Observed result: Wrap detects provider from `~/.zcode/v2/config.json` (e.g. "Z.ai Coding"), starts proxy with correct upstream on port 9000, injects RTK into AGENTS.md, prints detected provider + upstream + Base URL setup instructions. Unwrap removes RTK markers, deletes empty AGENTS.md, stops proxy. - Not tested: Actual ZCode app integration (ZCode is a desktop Electron app; Base URL configuration is manual in Settings > Model Settings) ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project style guidelines - [x] I have performed a self-review of my code - [ ] I have commented my code, particularly in hard-to-understand areas — N/A: code follows existing patterns - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A — CLI-only changes ## Additional Notes - **Pattern-B approach:** ZCode is a desktop Electron app with no CLI binary. Same pattern as Cursor, Cline, and Continue — proxy-only, print instructions. - **Upstream auto-detection:** Reads `~/.zcode/v2/config.json`, finds the enabled provider, and passes its `baseURL` to the proxy. Falls back to Z.ai Anthropic endpoint if no config found. - **httpProxy investigation:** ZCode has an `httpProxy` setting in `~/.zcode/v2/setting.json`, but it is an Electron-level forward proxy (CONNECT tunneling), incompatible with headroom reverse proxy. The Base URL approach in Model Settings is the correct integration point. - **No dependencies added:** This PR adds zero new dependencies. --------- Co-authored-by: Epicism <epicism@Epiphanie.local> Co-authored-by: JerrettDavis <mxjerrett@gmail.com> Co-authored-by: Tejas Chopra <chopratejas@gmail.com> |
||
|
|
5709291914
|
chore(release): harden local artifact smokes (#1824)
## Description
Harden the release artifact workflow so maintainers can reproduce npm
and Python release checks locally and so OpenClaw packaging does not
depend on a not-yet-published SDK version. This follow-up also keeps the
OpenClaw loader export contract explicit and updates the PR after the
branch was merged with current `headroomlabs/main`.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [x] New feature (non-breaking change that adds functionality)
- [x] Documentation update
## Changes Made
- Added reusable local release smoke scripts for npm assets, Python
wheel/sdist artifacts, and the combined release gate.
- Switched the release workflow npm asset build to the reusable npm
builder.
- Made OpenClaw release asset building install against the just-built
local SDK tarball before rewriting packed metadata to the release
dependency range.
- Kept the OpenClaw source dependency registry-installable at
`headroom-ai@^0.22.3` while the packed artifact still ships
`^<release-version>`.
- Added `registerHeadroomPlugin` as a named export while preserving the
default `{ register }` OpenClaw loader contract.
- Added Windows development bootstrap docs/script and regression tests
for version sync, npm asset ordering, OpenClaw source installability,
and Python wheel smoke import isolation.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
uv run --with pytest python -m pytest tests/test_release_workflows.py scripts/tests/test_version_sync.py -q
44 passed, 1 warning
node --check scripts/build_npm_release_assets.mjs
node --check scripts/verify_npm_release_assets.mjs
python -m py_compile scripts/build_python_release_smoke.py scripts/release_smoke_all.py scripts/version-sync.py
python scripts/verify-versions.py
All versions aligned at 0.31.0
npm ci (plugins/openclaw)
added 88 packages, audited 89 packages, found 0 vulnerabilities
python scripts/release_smoke_all.py --out release-assets-local/all-pr1824-postmerge-fixed-20260710-104739
Verified npm release assets for 0.31.0
wheel metadata OK: headroom_ai-0.31.0-cp310-abi3-win_amd64.whl contains headroom/_core.pyd
sdist License-File metadata OK: ['LICENSE', 'NOTICE']
smoke-import OK: version=0.31.0 hello=headroom-core
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.14.3, Node v24.14.0, npm 11.9.0, uv
0.11.15, Rust/Cargo already installed in the local development
environment.
- Exact command / steps: Ran `python scripts/release_smoke_all.py --out
release-assets-local/all-pr1824-postmerge-fixed-20260710-104739` after
syncing this branch with current `headroomlabs/main`.
- Observed result: The command built `headroom-ai-0.31.0.tgz`,
`headroom-openclaw-0.31.0.tgz`,
`headroom_ai-0.31.0-cp310-abi3-win_amd64.whl`, and
`headroom_ai-0.31.0.tar.gz`; npm verifier passed; the wheel installed
into a fresh venv and imported `headroom._core`.
- Not tested: Full GitHub Actions release matrix and publish jobs with
real PyPI/npm/GitHub Packages credentials.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable
## Screenshots (if applicable)
N/A.
## Additional Notes
The post-merge full smoke initially failed because the OpenClaw source
package depended on `headroom-ai@^0.31.0`, which is not available on
public npm yet. The builder now installs OpenClaw against the just-built
local SDK tarball before build/pack, and then rewrites packed metadata
to `^0.31.0`.
---------
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
|
||
|
|
36577d9547
|
fix(search_compressor): don't let a date in a path hijack the line-number parse (#2084)
## Description
`SearchCompressor::parse_match_line` splits a grep/ripgrep line into
`(file, line_number, content)` by finding the **leftmost**
`<sep><digits><sep>` triplet, where `<sep>` is `:` or `-`. A path
segment that itself contains such a triplet hijacks the parse — and that
shape is everyday, not exotic:
| real ripgrep line | parsed as |
|---|---|
| `logs/2026-05-03/app.log:12:ERROR boom` | `("logs/2026", 5,
"03/app.log:12:ERROR boom")` |
| `advisories/CVE-2021-44228.md:8:Log4Shell` | `("advisories/CVE", 2021,
"44228.md:8:Log4Shell")` |
| `src/v1-2-beta/mod.rs:3:fn x()` | `("src/v1", 2, "beta/mod.rs:3:fn
x()")` |
| `migrations/20240101-002-add_users.sql-9-…` | `("migrations/20240101",
2, "add_users.sql-9-…")` |
**This is silent corruption, not a drop.** The parse *succeeds*, so the
line is never counted in `stats.lines_unparsed` and never falls back to
passthrough. The bogus path becomes the **grouping key** in
`parse_search_results`, so unrelated files collapse into one bucket, and
the bogus path + line number + mangled body are what get scored, capped,
and rendered into the compressed output handed to the model. **The LLM
is shown a file and a line that do not exist.**
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
One file, one function:
`crates/headroom-core/src/transforms/search_compressor.rs`.
`parse_match_line` becomes a 3-tier scan:
- **Colon tier** — leftmost `:\d+:` whose path part contains no
whitespace. `:` is grep's *match* separator and a path practically never
contains one (the Windows drive colon is already skipped by the existing
`scan_start` logic), so leftmost is right. The whitespace bound stops a
`foo.rs:12:` reference *inside the body* of a `-` context line from
hijacking the parse.
- **Dash tier** — **last** `-\d+-` whose path part contains no
whitespace. `-` is grep's *context* separator, and unlike `:` it
genuinely appears inside real paths (`2026-05-03`, `CVE-2021-44228`,
`20240101-002-…`), so the marker is the *last* triplet in the path
token, not the first.
- **Permissive tier** — the original leftmost-any rule, byte-for-byte
unchanged. Only reached when neither typed tier matched (e.g. a path
containing a space), so those lines behave exactly as before.
- Also tightened in the typed tiers: the closing separator must equal
the opening one — grep emits `file:12:body` or `file-12-body`, never a
mix.
- Added 4 tests: 2 reproducing the bug, 2 regression guards against the
naive fixes.
**Safety argument (verified by execution):** with `parse_match_line`
temporarily forced to the Permissive tier alone, all 18 pre-existing
`search_compressor` tests still pass — i.e. the fallback is a faithful
reproduction of today's rule, so the change can only *add* correct
parses on lines a typed tier claims, never remove one.
This is the next bug in a family the module already tracks: the doc has
a "Bug fixes vs Python" section and three `fixed_in_3e2_*` tests
hardening this same parser against Windows drive colons and dashes in
filenames. `pre-commit-config.yaml-42-…` (dash before a *non*-digit) is
covered; `2026-05-03` (dash before a digit run followed by another dash)
was not.
## Testing
- [x] Unit tests pass
- [x] Linting passes (`cargo clippy -p headroom-core --all-targets` → 0
warnings)
- [x] Formatting passes (`cargo fmt --all -- --check`)
- [x] New tests added (4: 2 reproducing the bug, 2 regression guards
against naive fixes)
- [x] Manual testing performed (see Real Behavior Proof)
### Test Output
**Before the fix** (new tests run against the unmodified scan rule):
```text
$ cargo test -p headroom-core --lib search_compressor
---- transforms::search_compressor::tests::date_stamped_path_is_not_misread_as_line_number_marker stdout ----
assertion `left == right` failed
left: Some(("logs/2026", 5, "03/app.log:12:ERROR boom"))
right: Some(("logs/2026-05-03/app.log", 12, "ERROR boom"))
---- transforms::search_compressor::tests::date_stamped_paths_are_not_collapsed_into_one_bogus_file stdout ----
assertion `left == right` failed
left: ["logs/2026"]
right: ["logs/2026-05-03/app.log", "logs/2026-05-04/app.log"]
test result: FAILED. 18 passed; 2 failed; 0 ignored
```
**After the fix:**
```text
$ cargo test -p headroom-core --lib search_compressor
test result: ok. 20 passed; 0 failed; 0 ignored; 835 filtered out
$ cargo test -p headroom-core --lib # whole crate — no regressions
test result: ok. 854 passed; 0 failed; 1 ignored
$ cargo test -p headroom-parity
test result: ok. 4 passed; 0 failed
$ cargo fmt --all -- --check -> OK
$ cargo clippy -p headroom-core --all-targets -> 0 warnings, 0 errors
```
Regression guards added for the two ways a naive fix breaks:
- `digit_terminated_path_still_parses_ripgrep_context_line` —
`logs/app.log.1-42-rotated line` (path ends in a digit, so the context
separator is digit-preceded).
- `body_line_reference_does_not_hijack_a_context_line` —
`src/main.py-44-see foo.rs:12:bar` (body quotes a `file:line:`
reference).
## Real Behavior Proof
Per CONTRIBUTING — unit tests alone don't prove user-visible behavior,
so this was reproduced against the **released build** (`headroom-ai`
0.26.0 from PyPI, the compiled `_core.abi3.so`), driving the **public
`SearchCompressor.compress()` API** on **real `rg` output over real
files on disk** — not fixtures or mocks.
- Environment: macOS (Darwin 25.5.0, arm64), Python 3.13, released
`headroom-ai` 0.26.0 (`site-packages/headroom/_core.abi3.so`); patched
build = this branch compiled with `cargo build --release -p
headroom-py`, rustc 1.96.0.
- Exact command / steps: created 20 real log files at
`logs/2026-05-01/app.log` … `logs/2026-05-20/app.log` (12 real `ERROR`
lines each); ran `rg -n ERROR logs > rg_big.txt` (240 real match lines);
then called
`SearchCompressor(SearchCompressorConfig()).compress(open("rg_big.txt").read())`
on the shipped 0.26.0 build and on the patched build, comparing
`files_affected`, the rendered output, and whether each referenced path
exists on disk.
- Observed result: on shipped 0.26.0, the 20 distinct real files
collapse into **1 bogus bucket** `logs/2026` (a path that does **not**
exist on disk), per-line paths are mangled to
`logs/2026:5:01/app.log:10:`, 19 of 20 files effectively vanish from the
output, and `lines_unparsed: 0` means **nothing signals the
corruption**. On the patched build, same input and same API:
`files_affected: 20` (matches reality), every path in the compressed
output exists on disk (`all_exist=True`), and per-file match counts and
line numbers are correct.
- Not tested: the end-to-end proxy path (`headroom-proxy` against a live
LLM provider) — I exercised the `SearchCompressor` public API directly,
which is the surface `SearchOffload` and the MCP `headroom_compress`
tool wrap. I also did not test Windows path behavior on an actual
Windows host (the existing `scan_start` drive-letter logic is untouched,
and its tests still pass).
**Observed on the SHIPPED 0.26.0 build (the bug, in the released
product):**
```text
SHIPPED headroom 0.26.0 | real `rg -n ERROR logs` output, 240 lines
lines_unparsed : 0 <-- corruption is SILENT: nothing reported as unparsed
original_match_count: 240
files_affected : 1 <-- 20 distinct real files collapsed into ONE bucket
=== compressed output actually handed to the model ===
logs/2026:5:01/app.log:10:ERROR failure 1 connection refused upstream timeout on 2026-05-01 ...
logs/2026:5:20/app.log:21:ERROR failure 12 connection refused upstream timeout on 2026-05-20 ...
logs/2026:5:01/app.log:11:ERROR failure 2 connection refused upstream timeout on 2026-05-01 ...
[... and 235 more matches in logs/2026]
[240 matches compressed to 5. Retrieve more: hash=39c894009014d42b856ddd8a]
=== do the file paths in that output exist on disk? ===
logs/2026 exists_on_disk=False
```
**Observed on the PATCHED build (same input, same API, only the patch
differs):**
```text
PATCHED headroom-core | same real `rg` output, 240 lines
lines_unparsed : 0
original_match_count: 240
files_affected : 20 <-- was 1 (bogus) on the shipped build
=== compressed output handed to the model ===
logs/2026-05-01/app.log:10:ERROR failure 1 connection refused upstream timeout on 2026-05-01 ...
logs/2026-05-01/app.log:11:ERROR failure 2 connection refused upstream timeout on 2026-05-01 ...
[... and 7 more matches in logs/2026-05-01/app.log]
logs/2026-05-02/app.log:10:ERROR failure 1 connection refused upstream timeout on 2026-05-02 ...
=== do the file paths in that output exist on disk? ===
logs/2026-05-01/app.log exists_on_disk=True
logs/2026-05-02/app.log exists_on_disk=True
...all distinct paths referenced, all_exist=True
```
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
## Additional Notes
**Known residual ambiguity (stating it rather than hiding it).** grep
output is inherently ambiguous — `logs/2026-05-03/x:12:y` *could*
legitimately be a file literally named `logs/2026` with context line 5.
The tiers pick the overwhelmingly more likely reading. Two contrived
cases still parse the old way, both preserved deliberately:
1. a path containing a whitespace character;
2. a `-`-context line whose body is a whitespace-free token containing
its own `-N-` triplet.
If you'd prefer a different disambiguation policy (e.g. only trusting
`:` and treating all `-` context lines as unparseable, or gating on
filesystem existence), I'm happy to rework — the tiering is deliberately
isolated to one function so the policy is easy to swap.
N/A checklist items: no documentation or CHANGELOG change (internal
parser fix, no public API or behavior contract change); no screenshots
(no UI surface).
---------
Signed-off-by: dosthcpp <drakedog19@gmail.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
|
||
|
|
6bdc8c44a3
|
docs(metrics): ship an importable Grafana dashboard (#2168)
## Description
<!-- Briefly explain the change and why it is needed. -->
The metrics docs describe the `headroom_*` Prometheus metric family and
suggest example Grafana panels, but ship no importable dashboard — users
have to build one by hand. This adds a ready-to-import Grafana dashboard
built **only** on documented metric names (`headroom_requests_total`,
`headroom_tokens_saved_total`, `headroom_tokens_input_total`, and the
`headroom_overhead_ms_*` millisecond summary), and links it from the
**Grafana Dashboard** section of `docs/content/docs/metrics.mdx`.
This is a docs/examples-only addition — no source code changes.
Closes #
## Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [x] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- Added `examples/grafana/headroom-dashboard.json` — a ready-to-import
Grafana dashboard (7 panels, uid `headroom-compression`) built entirely
on Headroom's documented `/metrics` names. Panels cover tokens saved,
input tokens, request rate, average processing overhead
(`headroom_overhead_ms_sum` / `headroom_overhead_ms_count` with
min/max), tokens-saved/sec, and request rate by pool. It uses **no
histograms** (the proxy emits none). The `pool`/`source` template
variables use regex matchers (`=~`) so they are optional and match
series without those labels.
- Updated `docs/content/docs/metrics.mdx` — linked the new dashboard
from the **Grafana Dashboard** section with import instructions, keeping
the existing ad-hoc PromQL query table alongside it.
## Testing
<!-- Check what you actually ran, then paste the real command output
below. -->
- [ ] Unit tests pass (`pytest`)
- [ ] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [ ] New tests added for new functionality
- [x] Manual testing performed
Docs/examples-only change, manually verified: the dashboard JSON is
well-formed and every PromQL query references only the documented
`headroom_*` metric names from `docs/content/docs/metrics.mdx`.
### Test Output
```text
$ python3 -c "import json; d=json.load(open('examples/grafana/headroom-dashboard.json')); print('valid JSON,', len(d['panels']), 'panels, uid', d['uid'])"
valid JSON, 7 panels, uid headroom-compression
```
PromQL queries used by the panels (all against documented `headroom_*`
metrics):
```text
sum(headroom_tokens_saved_total{pool=~"$pool", hook=~"$hook"})
sum(headroom_tokens_input_total{pool=~"$pool", hook=~"$hook"})
sum(rate(headroom_requests_total{pool=~"$pool", hook=~"$hook"}[$__rate_interval]))
sum(rate(headroom_overhead_ms_sum{pool=~"$pool", hook=~"$hook"}[$__rate_interval])) / clamp_min(sum(rate(headroom_overhead_ms_count{pool=~"$pool", hook=~"$hook"}[$__rate_interval])), 1)
sum(rate(headroom_tokens_saved_total{pool=~"$pool", hook=~"$hook"}[$__rate_interval])) by (pool)
max(headroom_overhead_ms_max{pool=~"$pool", hook=~"$hook"})
min(headroom_overhead_ms_min{pool=~"$pool", hook=~"$hook"})
sum(rate(headroom_requests_total{pool=~"$pool", hook=~"$hook"}[$__rate_interval])) by (pool)
```
## Real Behavior Proof
- Environment: local checkout of the PR branch; Python 3 for JSON
validation.
- Exact command / steps: ran the JSON-validation command above (see Test
Output) — parses cleanly, reports 7 panels and uid
`headroom-compression`; then read every panel target and confirmed each
PromQL query references only metric names documented in
`docs/content/docs/metrics.mdx` (`headroom_requests_total`,
`headroom_tokens_saved_total`, `headroom_tokens_input_total`,
`headroom_overhead_ms_{sum,count,min,max}`). No histogram metrics are
referenced.
- Observed result: JSON is valid and importable via Grafana's
**Dashboards → New → Import → Upload**; no datasource UID is hard-coded,
so the importer prompts for a Prometheus datasource. Queries match the
documented metric family.
- Not tested: a full live Grafana import against a running proxy
scraping real `/metrics` was not performed in CI. Verification was
limited to JSON validity and query/metric-name correctness against the
documented metrics.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
Additive docs/examples only — no source code, tests, or runtime behavior
changed.
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable
## Screenshots (if applicable)
N/A — dashboard is imported from JSON; see the PromQL and panel list
above.
## Additional Notes
<!-- Mention any N/A checklist items, tradeoffs, follow-ups, or
maintainer context. -->
Test-related checklist items are N/A: this is an additive docs/examples
change with no application code, so `pytest`/`mypy`/`ruff` and new unit
tests do not apply. The dashboard JSON was validated and its queries
checked against the documented metric names instead.
---------
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
|
||
|
|
021a762bf8
|
feat(compress): expose frozen_message_count in library-mode compress() (#2178)
## Description `read_lifecycle.apply()` already supports a frozen message prefix (`frozen_message_count`) — stale-Read replacements inside the prefix are skipped so compression never rewrites messages the provider's prompt cache has anchored. But only the proxy handlers can pass it: `ContentRouter` reads it from transform kwargs, `CompressConfig` has no such field, and the public `compress()` never forwards it. Library-mode callers that manage their own conversation loop (SDK integrations, offline evaluation, sidecar scoring) therefore can't stop transforms from rewriting already-sent history. On cached Anthropic traffic that's expensive: every byte after the first rewritten one stops billing as a 0.1× cache read and re-bills as a cache write (1.25× at the 5-minute TTL, 2× at the 1-hour TTL) — measured on live coding-agent traffic, retroactive stale-Read rewrites were the dominant cache-bust source once tool injection went session-sticky (PR-B7). Relates to #809 (cache-bust economics discussion); does not close it. ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `CompressConfig.frozen_message_count: int = 0` — documented field; default `0` preserves existing behavior exactly. - `compress()` forwards it through `pipeline.apply()` to the transforms, matching what the proxy handlers already do. - `compress()` docstring: added to the kwargs shorthand list. - CHANGELOG entry under Unreleased → Features. - Four tests in `tests/test_compress_api.py` (`TestFrozenMessageCount`). ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run pytest tests/test_compress_api.py tests/test_transforms/test_read_lifecycle.py \ tests/test_compression_safety_rails.py tests/test_compress_failure.py -q 59 passed, 1 warning in 3.05s $ uv run ruff check headroom/compress.py tests/test_compress_api.py All checks passed! $ uv run mypy headroom Success: no issues found in 471 source files ``` ## Real Behavior Proof - Environment: Linux, Python 3.12.3, this branch installed via `uv sync --extra dev` - Exact command / steps: build an Anthropic-format conversation with a stale Read (file read at message 2, edited at message 3), then: ```python r0 = compress(msgs, model="claude-sonnet-4-5-20250929") r5 = compress(msgs, model="claude-sonnet-4-5-20250929", frozen_message_count=5) ``` - Observed result: without frozen prefix the stale Read is rewritten; with frozen_message_count=5 the Read remains byte-identical. ```text without frozen prefix: stale Read rewritten: True transforms: ['read_lifecycle:stale:/app/config.py'] with frozen_message_count=5: Read byte-identical: True transforms: [] ``` - Not tested: proxy-mode code paths (untouched — they already pass `frozen_message_count` their own way); Rust crates (untouched). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A — library API change, no UI. ## Additional Notes Default `0` makes this a strict superset of current behavior — no caller sees any change without opting in. The motivation data comes from a proxy-side measurement tool that prices compression's cache effects on live Anthropic agent traffic (per-request cache-adjusted dollars); happy to share methodology in #809 if useful. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: JerrettDavis <mxjerrett@gmail.com> Co-authored-by: Tejas Chopra <chopratejas@gmail.com> |
||
|
|
5dbe3314a1
|
fix(auth): support GitHub Enterprise Copilot OAuth domain (#2192)
## Description Adds GitHub Enterprise OAuth domain support for Copilot auth. When `GITHUB_COPILOT_ENTERPRISE_URL` is set, the default OAuth domain resolves to that enterprise host; explicit `--domain` values still take precedence. Closes #1152 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/copilot_auth.py`: derive the default OAuth domain from `GITHUB_COPILOT_ENTERPRISE_URL` when present, falling back to `github.com` for unset or blank values. - `headroom/cli/copilot_auth.py`: keep explicit CLI domain overrides authoritative even when the enterprise env var is set. - `README.md`: document the enterprise OAuth environment setting and precedence. - Added regression tests for enterprise URL handling, blank/unset fallback, and explicit CLI override precedence. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text uv run pytest tests/test_copilot_auth.py tests/test_cli/test_copilot_auth.py -q 69 passed in 1.05s uvx ruff@0.15.17 check headroom/cli/copilot_auth.py headroom/copilot_auth.py tests/test_cli/test_copilot_auth.py tests/test_copilot_auth.py All checks passed! uvx ruff@0.15.17 format --check headroom/cli/copilot_auth.py headroom/copilot_auth.py tests/test_cli/test_copilot_auth.py tests/test_copilot_auth.py 4 files already formatted ``` ## Real Behavior Proof - Environment: Windows 11 development checkout, Python 3.13.3, with focused Copilot auth tests using monkeypatched enterprise env vars. - Exact command / steps: ran the Copilot auth unit/CLI tests plus ruff check and format-check against the touched auth files and tests. - Observed result: `GITHUB_COPILOT_ENTERPRISE_URL=https://ghe.example.com` resolves `default_oauth_domain()` to `ghe.example.com`; unset/blank enterprise env vars fall back to `github.com`; and `headroom copilot-auth login --domain github.com` still honors the explicit override when enterprise env vars are set. - Not tested: live OAuth against a real GitHub Enterprise Server instance. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A - CLI/auth behavior and README update. ## Additional Notes `GITHUB_COPILOT_ENTERPRISE_URL` takes precedence over `GITHUB_COPILOT_ENTERPRISE_DOMAIN`; explicit `--domain` remains authoritative for the login command. --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com> Co-authored-by: Tejas Chopra <chopratejas@gmail.com> |
||
|
|
a61f534426
|
fix(ccr): store pre-protection original, not tag placeholder, in CCR (#1208)
## Description
When `ContentRouter` protects custom tags (e.g. `<system-reminder>`)
into `{{HEADROOM_TAG_N}}` placeholders before invoking Kompress, CCR can
persist the protected **placeholder intermediate** as the entry's
`original_content` instead of the pre-protection source text. A later
**full retrieve** (or proactive expansion / model-initiated retrieve) of
such an entry then returns `{{HEADROOM_TAG_0}}` and the real protected
block is lost from the retrieval path. The immediate upstream request is
unaffected — `restore_tags` correctly restores the compressed output
before it goes upstream; the confirmed corruption is in CCR storage and
only surfaces on later retrieval/expansion.
This threads the pre-protection `content` through as `ccr_original` so
CCR stores the real source text while the model still sees the
placeholdered text.
Closes #1209
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `headroom/transforms/content_router.py`: `_try_ml_compressor` passes
`ccr_original=content` to `compressor.compress(...)` **only when tags
were actually protected** (untagged callers keep the historic call shape
— backward compatible).
- `headroom/transforms/kompress_compressor.py`: `compress()` gains a
`ccr_original` kwarg; `compress_batch()` gains a per-item
`ccr_originals` list (validated against `len(contents)`).
- All four CCR store sites store `ccr_original` when present, else
`content`: inline `compress()`, single-content
`compress()`→`compress_batch` delegation, `compress_batch` sequential
fallback, and `compress_batch` batched/GPU path. The stored original's
token count is recomputed from the stored text.
- `tests/test_ccr_tag_placeholder_regression.py` (new, 5 tests): router
boundary forwarding, untagged backward-compat, `ccr_originals` length
validation, and two store-site tests driving the real `compress()` /
batched `compress_batch()` all the way to `_store_in_ccr` (a tiny fake
model stands in for the 274MB ModernBERT).
## Testing
<!-- Check what you actually ran, then paste the real command output
below. -->
- [x] Unit tests pass (`pytest`)
- [ ] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ .venv/bin/python -m pytest tests/test_ccr_tag_placeholder_regression.py -q
============================= test session starts ==============================
platform darwin -- Python 3.12.12, pytest-9.1.1, pluggy-1.6.0
rootdir: /Users/.../headroom.worktrees/ccr-tag-placeholder
configfile: pyproject.toml
plugins: anyio-4.14.0
collected 5 items
tests/test_ccr_tag_placeholder_regression.py ..... [100%]
========================= 5 passed, 1 warning in 0.15s =========================
```
Fail-before / pass-after was confirmed against a freshly built Rust
`_core`: with the fix reverted the new tests fail (router forwards no
`ccr_original` → `None`/placeholder reaches the store; `compress_batch`
rejects the unknown `ccr_originals` kwarg with `TypeError`); with the
fix applied all 5 pass. The surrounding kompress/ccr/router suites stay
green (8 unrelated failures are pre-existing — identical with the patch
stashed — from missing optional test deps such as `pytest-asyncio`, not
caused by this change).
## Real Behavior Proof
- Environment: macOS (darwin), Python 3.12.12, locally built Rust
`_core` via `maturin develop`, pytest 9.1.1.
- Exact command / steps: `maturin develop` to build `_core`, then
`python -m pytest tests/test_ccr_tag_placeholder_regression.py -q`.
- Observed result: 5 passed with the fix applied; the same suite fails
before the fix (placeholder/`None` reaches `_store_in_ccr`;
`compress_batch` rejects `ccr_originals`).
- Not tested: end-to-end live proxy full-retrieve against a 274MB
ModernBERT model (tests use a fake model to keep them deterministic and
offline); `ruff`/`mypy` not run locally.
> Note: this fixes new CCR writes. Pre-existing entries written before
the fix keep their placeholder `original_content` until they expire.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable
## Additional Notes
Docs/CHANGELOG unchanged: this is an internal CCR correctness fix with
no public API or user-facing behavior change beyond correct
full-retrieve content. `ruff`/`mypy` were not run in the local build
environment.
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
|