mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
1553 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b829ceba84
|
fix(wrap): keep agent savings opt-in (#1294)
## Description Fixes a regression from #830 where `headroom wrap codex` / `claude` / `cursor` treated `agent-90` as required even when the user started a normal proxy without `HEADROOM_SAVINGS_PROFILE`. A plain `headroom proxy` on port 8787 followed by `headroom wrap codex` currently reports `Proxy on port 8787 is missing: --savings-profile` and tries to restart the already-running proxy. The `agent-90` profile was documented as opt-in, so wrap should only require or inject it when `HEADROOM_SAVINGS_PROFILE` is explicitly set. Closes #1293 ## 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 - Stop defaulting agent wrappers to `agent-90` when `HEADROOM_SAVINGS_PROFILE` is unset. - Stop reporting agent-savings config mismatches unless an agent savings profile was explicitly requested. - Add regression tests for default wrap startup, explicit profile forwarding, and reuse/restart behavior around existing proxies. - Fix repo-wide pre-commit issues found during amend: Windows-safe `fcntl` typing, an optional env typing issue, OpenCode JSON parser return typing, and ruff import/format drift. ## 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 > maturin develop -m crates/headroom-py/Cargo.toml Finished `dev` profile [unoptimized + debuginfo] target(s) in 27.41s Built wheel for abi3 Python >= 3.10 Installed headroom-ai-0.27.0 > C:\git\headroom\.venv\Scripts\python.exe -c "from headroom._core import hello; print(hello())" headroom-core > ruff check . All checks passed! > ruff format --check . 913 files already formatted > C:\git\headroom\.venv\Scripts\python.exe -m mypy headroom Success: no issues found in 388 source files > C:\git\headroom\.venv\Scripts\python.exe -m pytest tests/test_agent_savings.py tests/test_cli/test_wrap_persistent.py tests/test_proxy_healthchecks.py tests/test_transforms/test_content_router.py -q collected 112 items 112 passed, 1 warning in 16.04s > git diff --check # no output > git commit --amend --no-edit Sync plugin versions.....................................................Passed ruff.....................................................................Passed ruff-format..............................................................Passed mypy.....................................................................Passed ``` ## Real Behavior Proof - Environment: Windows dev checkout, Python 3.13.3 via `C:\git\headroom\.venv\Scripts\python.exe`, Rust extension built with `maturin develop -m crates/headroom-py/Cargo.toml`. - Exact command / steps: reproduced the code path from #830 by exercising `_ensure_proxy(8787, False, agent_type="codex")` with a running proxy health payload that has no `savings_profile`, and by exercising `_start_proxy(8787, agent_type="codex")` with `HEADROOM_SAVINGS_PROFILE` unset and set. - Observed result: without `HEADROOM_SAVINGS_PROFILE`, wrap reuses the running proxy and `_start_proxy` does not inject `HEADROOM_SAVINGS_PROFILE` or `HEADROOM_TARGET_RATIO`; with `HEADROOM_SAVINGS_PROFILE=agent-90`, wrap still requires/forwards the profile and restarts an incompatible proxy. - Not tested: full end-to-end CLI launch against a live Codex binary. The focused proxy/wrap tests cover the failing restart/config decision directly. ## 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 The pytest run emits an existing Windows `cp1252` background-thread warning while reading subprocess output; the tests still pass. No documentation or changelog update is included because this restores the already-documented opt-in behavior for `agent-90`. |
||
|
|
c10969873b
|
feat(cli): add headroom dashboard and surface the dashboard URL (#1277) (#1292)
## Description The savings dashboard is served at `GET /dashboard` (`headroom/proxy/server.py`) but was effectively undiscoverable: there was no `headroom dashboard` command, the `wrap` startup banner only printed `Proxy ready on http://127.0.0.1:PORT` (never the dashboard URL), and the docs buried it — so users on current releases didn't know it existed (#1277). This makes it discoverable from the CLI, the wrap banner, and the docs. Closes #1277 ## Type of Change - [x] New feature (non-breaking change that adds functionality) ## Changes Made - `headroom/cli/proxy.py`: new `headroom dashboard` command — prints `http://127.0.0.1:<port>/dashboard` and opens it in a browser (stdlib `webbrowser`); `--no-open` just prints, `--port`/`HEADROOM_PORT` honored. Headless failures are swallowed (URL already printed). - `headroom/cli/wrap.py`: print the dashboard URL alongside "Proxy ready" so every `wrap` surfaces it. - `docs/content/docs/installation.mdx` + `README.md`: document `headroom dashboard`. - `docs/content/docs/mcp.mdx`: document the Codex MCP `command: "headroom"` PATH pitfall (#768) — a project-venv (`uv add`) install isn't on the host's PATH; install globally with `uv tool install` / pipx, or use an absolute path. - `tests/test_cli_dashboard.py`: new tests. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] New tests added for new functionality ### Test Output ```text $ python -m pytest tests/test_cli_dashboard.py -q 3 passed $ python -m ruff check headroom/cli/proxy.py headroom/cli/wrap.py tests/test_cli_dashboard.py All checks passed! ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.13, branch fix/1277-dashboard-discoverability off headroomlabs-ai/main - Exact command / steps: built the CLI and invoked the new command via the real entry-point import (`from headroom.cli.main import main; main(['dashboard','--no-open','--port','8787'], standalone_mode=False)`) and checked it is registered (`'dashboard' in main.commands`). - Observed result: prints ` Dashboard: http://127.0.0.1:8787/dashboard`, `'dashboard' in main.commands` → `True`, exit 0. The three new tests pass (prints URL + no browser on `--no-open`; opens the URL by default; a raising `webbrowser.open` does not crash the command). - Not tested: did not load the rendered `/dashboard` HTML against a live proxy in CI — the change only adds a launcher/printer for the existing route; the route itself is unchanged. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
b514695efd
|
test(proxy): cover enabled periodic TOIN stats startup (#1268)
## Description Follow-up to #1265. Add coverage for the enabled branch of `periodic_toin_stats_enabled` during proxy lifespan startup. The original PR added the opt-out and disabled-path coverage. This test covers the default/enabled path so the new lifespan guard is not left partially covered. 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 - Added `test_lifespan_schedules_periodic_toin_stats_when_enabled`. - The test patches `_log_toin_stats_periodically` with a short noop coroutine and verifies the proxy lifespan requests it when `periodic_toin_stats_enabled=True`. - This complements the existing disabled-path test from #1265. ## Testing - [x] Unit tests pass (`pytest`) - [ ] 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_proxy_telemetry_env.py -q ============================= test session starts ============================= platform win32 -- Python 3.11.15, pytest-9.0.3, pluggy-1.6.0 rootdir: C:\Users\wstcz\AppData\Local\Temp\headroom-main-20260622-073524 configfile: pyproject.toml plugins: anyio-4.12.1, langsmith-0.8.0, asyncio-1.3.0, cov-7.0.0 asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function collected 10 items tests\test_proxy_telemetry_env.py .......... [100%] ============================== warnings summary =============================== .venv\Lib\site-packages\fastapi\testclient.py:1 C:\Users\wstcz\AppData\Local\Temp\headroom-main-20260622-073524\.venv\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. from starlette.testclient import TestClient as TestClient # noqa -- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html ======================== 10 passed, 1 warning in 2.02s ======================== $ git diff --check origin/main..HEAD # no output; command exited 0 ``` ## Real Behavior Proof - Environment: Windows, Python 3.11.15 uv-managed `.venv`, branch based on current `origin/main`. - Exact command / steps: ran `uv run pytest tests/test_proxy_telemetry_env.py -q`. - Observed result: all 10 tests in `tests/test_proxy_telemetry_env.py` passed, including the enabled periodic TOIN stats lifespan branch. - Not tested: full repository pytest, ruff, and mypy were not run for this test-only follow-up. ## 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 Notes - Test-only follow-up to #1265. - No production behavior changes. - The focused pytest run still emits the existing Starlette/FastAPI TestClient deprecation warning from dependencies, so `My changes generate no new warnings` is intentionally left unchecked. |
||
|
|
a00fb6761e
|
fix(router): degrade to pure-Python detection on native panic (#1123) (#1260)
## Description When the native (Rust) content detector panicked, the pyo3 `PanicException` (a `BaseException`, not `Exception`) escaped `_detect_content` and surfaced as an HTTP 500 instead of degrading. This catches `BaseException` (excluding control-flow exceptions) around the native call and falls back to the pure-Python regex detector, logging a single warning. Closes #1123 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `headroom/transforms/content_router.py`: wrapped the native detect call in `_detect_content` so any `BaseException` (except `KeyboardInterrupt`/`SystemExit`/`GeneratorExit`) degrades to `_regex_detect_content_type`, warning once via a module-level `_detect_panic_warned` flag. - `tests/test_transforms/test_detect_fallback_1123.py`: new regression tests for RuntimeError fallback, BaseException-panic fallback, and KeyboardInterrupt propagation. ## Testing - [x] Unit tests pass (`pytest`) - [x] New tests added for new functionality ### Test Output ```text $ pytest tests/test_transforms/test_detect_fallback_1123.py tests/test_transforms/test_content_router.py -q 54 passed ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12, pytest-asyncio 1.4.0 - Exact command / steps: Monkeypatched the native detector to raise RuntimeError, a BaseException-derived fake panic, and KeyboardInterrupt, then called `_detect_content`. - Observed result: RuntimeError and the BaseException panic both degrade to a valid regex detection result; KeyboardInterrupt still propagates. 54 tests pass. - Not tested: Could not reproduce a real pyo3 panic in this build (`pyo3_runtime` is not importable here), so the fallback is exercised via simulated exceptions. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
a2159c0b66
|
feat(proxy): support glob patterns in exclude_tools (#870) (#1259)
## Description `exclude_tools` only matched tool names exactly, so users could not exclude families of tools (for example all `mcp__*`). This adds glob-pattern support via a shared `is_tool_excluded` helper used by both the content router and the OpenAI handler, keeping exact/case-insensitive matching intact. Closes #870 ## Type of Change - [x] New feature (non-breaking change that adds functionality) ## Changes Made - `headroom/config.py`: added `is_tool_excluded(name, exclude_tools)` helper that keeps exact/case-insensitive matching and adds `fnmatch` glob support. - `headroom/transforms/content_router.py` and `headroom/proxy/handlers/openai.py`: routed tool-exclusion checks through the shared helper. - `headroom/proxy/server.py`: documented glob support in the `--exclude-tools` CLI help and `_parse_exclude_tools` docstring. - `tests/test_transforms/test_content_router.py`: added `test_glob_exclude_tools` and `test_is_tool_excluded_helper`. ## Testing - [x] Unit tests pass (`pytest`) - [x] New tests added for new functionality ### Test Output ```text $ pytest tests/test_transforms/test_content_router.py -q 53 passed $ pytest tests/ -k "exclude or config" -q 59 passed ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12, pytest-asyncio 1.4.0 - Exact command / steps: Ran the content-router suite and the exclude/config-focused tests after adding the helper and glob support. - Observed result: 53 content-router tests pass (including the two new glob tests) and 59 exclude/config tests pass; glob patterns like `mcp__*` now exclude matching tools while exact names still work. - Not tested: Did not exercise glob exclusion against a live MCP server end to end. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
14e8dc4c84
|
feat(learn): weight loops in Headroom Learn + RTK-loop eval (#1160)
## Description `headroom learn` ranked recommendations by a single LLM-guessed `estimated_tokens_saved` with a flat hardcoded `confidence`, and had **no notion of a loop**. So (1) RTK re-fetch loops were invisible - RTK truncates a command's output, the agent re-runs larger-limit variants, those calls *succeed* (`is_error=False`), and `analyze()` even early-returned when a session had no failures and no events - and (2) even when surfaced, a loop ranked no higher than a one-off mistake. This adds loop-aware weighting plus the eval that reproduces an RTK loop, runs it through Learn, and checks the guardrail prevents re-triggering. Closes #1159 ## 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 `headroom/learn/loops.py`: `detect_loops()` (canonical signature collapses RTK pagination/limit variants; classifies error vs rtk-refetch loops; **measured** wasted tokens), `format_loops_for_digest()`, `apply_loop_weighting()`. - `analyzer.py`: detect loops up front (fixes the no-failure early-return), lead the digest with them, prioritize loops in the system prompt, re-sort after weighting. - `models.py`: `Recommendation.is_loop_guardrail` / `loop_occurrences`. - `benchmarks/rtk_loop_learn_eval.py` + `headroom/learn/fixtures.py`: the two-phase RTK-loop eval and its session fixtures. - Tests, `docs/rtk-loop-weighting.md`, CHANGELOG entry. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - not run (mypy not in my minimal env; see Not tested) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_learn/ -q 190 passed, 3 skipped, 1 warning in 5.85s $ ruff check <changed files> All checks passed! ``` ## Real Behavior Proof - Environment: macOS (Darwin 25.0), Python 3.10.18, fresh venv (`pip install -e` minus the optional `hnswlib`/proxy extras, which are unrelated to `learn`); real LLM via the analyzer's claude CLI backend (`HEADROOM_LEARN_CLI=claude`, claude-cli 2.1.158) — no API key used. - Exact command / steps: `HEADROOM_LEARN_CLI=claude python -c "from benchmarks.rtk_loop_learn_eval import run_eval; c=run_eval(use_real_llm=True); print(c.render())"` - Observed result: the analyzer shelled out to a real model and produced the "Commands" guardrail quoted below, naming the looping command. The digest reports the measured 5,005-token waste and asks the model to rank loops first, so the model emitted that figure; in this run the guardrail ranked **#1** and the scorecard was all-PASS (below). Caveat — real-mode is run-dependent: the rule's wording, and whether the post-hoc `apply_loop_weighting` fuzzy match fires, vary across runs (in one run it did not tag the rule). The **deterministic CI eval** (stub LLM) is the stable, reproducible artifact; this real run corroborates it. - Not tested: the analyzer's API-key path (ANTHROPIC/OPENAI/GEMINI) — exercised the equivalent claude CLI backend instead; `mypy`; a live agent *obeying* the written rule end-to-end (Phase 2 is a non-recurrence check, not a live agent — called out in the doc). Real model output from this run, ranked #1 at the measured 5,005-token weight: > **Commands** — When grepping logs (or any large file), never loop with increasing `| head -N` limits — tool output is capped at ~4 KB regardless of N, so repeated attempts return identical bytes. Instead: redirect to a temp file (`grep ... > /tmp/out.txt`) then read it, or use `grep -c` first… ```text [PASS] loop_detected (1 loop(s), ~5,005 tok wasted) [PASS] guardrail_produced [PASS] ranked_first [PASS] names_command [PASS] prescribes_fix [PASS] weight_reflects_waste [PASS] guardrail_holds RESULT: PASS ``` (One real-mode run via the claude CLI backend. The deterministic `pytest` eval above is the stable artifact; see the run-dependence caveat under Observed result.) The real run also caught an over-brittle check: an earlier `names_command` required the literal "TimeoutError"; the real model wrote a *more general* rule (grep + `head -N`) without it, so I fixed the check to verify the looping **command** is named, not an incidental literal. ## 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 - No new dependencies. No network, no user/assistant content dropped — operates on already-captured session digests. - Kept as one logical change. mypy not run locally (minimal env); happy to address anything CI's mypy flags. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: JD Davis <mxjerrett@gmail.com> |
||
|
|
561ba17ec2
|
fix(proxy): build SSL contexts for custom CA bundles (#1134)
## Description Build explicit `ssl.SSLContext` objects for custom CA bundles configured through `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE`. Python 3.13 / newer OpenSSL can reject some enterprise/private PKI roots that platform TLS stacks accept, for example roots without a `keyUsage` extension. The new custom-CA contexts keep certificate verification enabled while clearing only `ssl.VERIFY_X509_STRICT`. 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 - Build replacement `SSLContext` objects for `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` instead of passing raw CA bundle paths to httpx. - Clear only `ssl.VERIFY_X509_STRICT` for operator-provided custom CA contexts; certificate verification, hostname verification, expiry checks, and chain validation stay enabled. - Preserve `NODE_EXTRA_CA_CERTS` additive behavior by loading the extra CA bundle on top of the default/system trust store. - Update existing SSL context tests for replacement CA contexts, env-var priority, missing-path fallthrough, and strict-mode relaxation. - Add an Unreleased changelog entry for the proxy bug fix. ## 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 $ PYTHONPATH=. .venv/bin/python -m pytest tests/test_ssl_context.py -q collected 12 items tests/test_ssl_context.py ............ [100%] 12 passed, 1 warning in 0.11s ``` ```text $ uv tool run ruff check headroom/proxy/ssl_context.py tests/test_ssl_context.py CHANGELOG.md All checks passed! $ uv tool run ruff format --check headroom/proxy/ssl_context.py tests/test_ssl_context.py 2 files already formatted ``` ## Real Behavior Proof - Environment: macOS, Python 3.13.2, Headroom checkout on this branch, `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` pointed at an enterprise/private PKI CA bundle. Upstream HTTPS endpoint name is redacted. - Exact command / steps: Ran a Python smoke script that imports `headroom.proxy.ssl_context.find_ca_bundle()`, passes the returned verifier into `httpx.AsyncClient(verify=...)`, and performs a GET against the enterprise HTTPS endpoint that previously failed with OpenSSL strict verification. - Observed result: The request used an `SSLContext`, strict X.509 verification was disabled for that custom CA context, and the request reached the upstream HTTP response: ```text verify_type SSLContext strict_enabled False status 302 ``` - Not tested: Full `uv run pytest`, `uv sync --extra dev`, and `mypy headroom` in this local environment. The editable build currently fails before tests run because Cargo's `ort-sys` build script cannot download ORT prebuilt binaries due an unrelated local certificate verification error against the ORT CDN. No dependency or lockfile changes are included in 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. ## Additional Notes No dependencies or lockfiles changed. Documentation is unchanged because this is a bug fix to existing custom CA environment-variable behavior rather than a new user-facing configuration surface. Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> Co-authored-by: JD Davis <mxjerrett@gmail.com> |
||
|
|
978ffa0a6a
|
feat(savings): durable savings ledger + headroom savings command (#1127)
## Description
Adds a durable, cross-process savings ledger and a `headroom savings`
CLI that shows cost avoided plus Today / Last 7 days / All time
breakdowns by model and client. Unlike `headroom_stats` (a per-session,
in-memory snapshot), the ledger is on disk and survives proxy and agent
restarts, and is safe across the many MCP processes Headroom spawns.
## 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 `headroom/savings_ledger.py`: append-only, `fcntl`-locked JSONL
ledger at `~/.headroom/savings_events.jsonl`, safe across concurrent
writers (main MCP server, each subagent, and the proxy), aggregated on
read so totals survive restarts.
- litellm list pricing for known models; blended `$3/1M` input-token
fallback for `model="unknown"` (MCP compressions do not know the
upstream model). Self-pruning: events past the 365-day retention window
are dropped on read and the file is compacted once large.
- Add `headroom savings` CLI (`headroom/cli/savings.py`) with `--json`,
`--days N`, and `--reset` flags.
- Proxy client attribution: `record_request` accepts `client` and
threads `outcome.client` into the ledger, so proxy events record the
real harness (claude-code, codex, cursor, …) from the existing
`classify_client()` detection, falling back to `"proxy"` only when
unidentified.
- MCP compress hook records the client (from `clientInfo.name`) and
tokens saved after each `headroom_compress`; `HEADROOM_MCP_CLIENT` /
`HEADROOM_MCP_MODEL` env overrides.
- Add the `savings_events_path()` helper +
`HEADROOM_SAVINGS_EVENTS_PATH` env in `headroom/paths.py`, the docs page
`docs/content/docs/savings.mdx`, and 15 tests.
## 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
The single warning is a pre-existing, repo-wide
`StarletteDeprecationWarning` from
`fastapi.testclient` (the venv has `httpx`, not `httpx2`); it is
unrelated to this
change and fires in every proxy test that spins up a `TestClient`.
```text
$ .venv/bin/python -m pytest tests/test_savings_ledger.py -q
............... [100%]
15 passed, 1 warning in 5.17s
# warning: fastapi/testclient.py StarletteDeprecationWarning (httpx vs httpx2) — third-party, pre-existing
$ .venv/bin/ruff check headroom/savings_ledger.py headroom/cli/savings.py \
headroom/ccr/mcp_server.py headroom/proxy/prometheus_metrics.py \
headroom/proxy/outcome.py headroom/paths.py tests/test_savings_ledger.py
All checks passed!
$ .venv/bin/mypy headroom/savings_ledger.py headroom/cli/savings.py \
headroom/ccr/mcp_server.py headroom/proxy/prometheus_metrics.py \
headroom/proxy/outcome.py headroom/paths.py
Success: no issues found in 6 source files
```
## Real Behavior Proof
- Environment: macOS (Darwin 25.5.0), Python 3.13.13, editable install
of this branch, proxy running on :8787
- Exact command / steps: route live agent + proxy traffic through
Headroom, then run `headroom savings`
- Observed result: distinct Today / Last 7 days / All time windows with
per-model and per-client breakdowns, as below
- Not tested: Windows runtime (no `fcntl`; the ledger falls back to
best-effort append)
```text
Today ██░░░░░░░░░░░░░░ 11.3% saved 472,870 / 4,193,288 tokens $1.5920
Last 7 days ██░░░░░░░░░░░░░░ 11.9% saved 505,170 / 4,244,288 tokens $1.7385
All time ██░░░░░░░░░░░░░░ 13.0% saved 566,170 / 4,339,288 tokens $1.9815
Cost avoided per model:
claude-sonnet-4-6 $1.2200
claude-opus-4-8 $0.6685
gpt-5.5 $0.0840
claude-haiku-4-5 $0.0090
Savings by client:
claude-code 60 calls · 524,970 tokens saved
cursor 2 calls · 16,800 tokens saved
codex 3 calls · 24,400 tokens saved
```
## 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
The one pytest warning is a third-party `StarletteDeprecationWarning`
from `fastapi.testclient` (pre-existing, repo-wide); not introduced
here. CHANGELOG.md not updated.
|
||
|
|
f39858c233
|
feat(code): add Perl support to code-aware compressor (#1125)
## Description Adds Perl as a supported language for `CodeAwareCompressor` / `CodeStructureHandler`. Function bodies are compressed while `use`/`require` imports, `sub`/`method` signatures, and `package`/`class`/`role` declarations are preserved — bringing Perl up to parity with the other Tier-2 languages. No new dependencies: the Perl grammar already ships in `tree-sitter-language-pack` (already a Headroom dependency), so this is pure configuration. 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) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `code_handler.py`: Perl entries in the four per-language tables — `_STRUCTURAL_NODE_TYPES`, `_SIGNATURE_PATTERNS` (regex fallback), `_LANGUAGE_MARKERS` (detection), `_IMPORT_PATTERNS`. The existing `_CONTAINER_BODY_TYPES` already covers Perl's `block` body node, so no change was needed there. - `code_compressor.py`: `CodeLanguage.PERL` enum value, a data-driven `LangConfig`, a `_LANGUAGE_PREFILTER` entry, and the supported-language string in the parser error message. - Node-type names (`subroutine_declaration_statement`, `package_statement`, `signature`, `block`, …) are from the `tree-sitter-perl/tree-sitter-perl` grammar (MIT). - Tests: 1 detection test + 2 regex-path signature/import-preservation tests. ## 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 tests/test_compression/test_code_handler.py -q collected 28 items tests/test_compression/test_code_handler.py ............................ [100%] ======================== 28 passed, 1 warning in 2.53s ========================= $ ruff check headroom/compression/handlers/code_handler.py headroom/transforms/code_compressor.py tests/test_compression/test_code_handler.py All checks passed! $ mypy headroom/compression/handlers/code_handler.py headroom/transforms/code_compressor.py Success: no issues found in 2 source files ``` ## Real Behavior Proof - Environment: Python 3.12, `pip install -e ".[code]"` (tree-sitter-language-pack installed, `is_tree_sitter_available() == True`). - Exact command / steps: ran `CodeStructureHandler().get_mask(code, language="perl")` on a real Perl module (package + two subs with bodies). - Observed result: detected as `perl`, parsed via the `tree-sitter` path (not regex), and the preserved span was exactly the imports + package + sub signatures, with both sub bodies marked compressible: ```text tree-sitter available: True parser: tree-sitter | detected: perl --- PRESERVED (signatures/imports/structure) --- use strict;use warnings;package Greeter;sub new sub greet ``` - Not tested: the full proxy/MCP server end-to-end path (out of scope — this PR only touches the code compressor's language tables). ## 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 left unchecked — happy to add a Perl line to either if you'd like; I wasn't sure of your preferred location. - *Disclosure: I maintain the upstream `tree-sitter-perl` grammar this relies on. It's already a transitive dependency of Headroom via `tree-sitter-language-pack` — this PR only adds config to use it, with no dependency changes.* Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
b4682d6f91
|
fix(proxy): honor force_kompress routing profile (#996)
## Description Honor the proxy savings profile's `force_kompress` setting all the way through the Anthropic proxy path. `HEADROOM_SAVINGS_PROFILE=agent-90` already resolves to `force_kompress=True`, but `ContentRouter` still paid for the full auto-detection path before selecting Kompress. On long Claude Code / tool-output conversations this can hang inside the detection/router path before any `Transform content_router` line is emitted. This change makes the forced-Kompress path skip unused strategy detection during compression, while still preserving recent-code protection via the lightweight regex detector. This also passes `proxy_pipeline_kwargs(self.config)` through Anthropic batch requests so batch traffic receives the same savings-profile knobs as normal Anthropic messages. Refs #946 ## 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 - Skip `is_mixed_content()` / `_detect_content()` when runtime `force_kompress` is set and route directly to `CompressionStrategy.KOMPRESS`. - Keep forced-Kompress recent-code protection, but use `_regex_detect_content_type()` instead of the full router detection chain. - Read `_runtime_force_kompress` defensively in `ContentRouter.apply()` so regular `ContentRouter()` instances keep the normal content-detection path. - Pass proxy savings-profile kwargs into Anthropic batch compression. - Add regression tests for forced-Kompress routing, normal routing, recent-code protection, and Anthropic batch profile propagation. - Update `CHANGELOG.md`. ## Testing - [ ] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ ruff check headroom/transforms/content_router.py tests/test_transforms_content_router.py All checks passed! $ ruff format --check headroom/transforms/content_router.py tests/test_transforms_content_router.py 2 files already formatted $ pytest tests/test_transforms_content_router.py::test_force_kompress_bypasses_content_detection \ tests/test_transforms_content_router.py::test_normal_compress_path_still_uses_content_detection \ tests/test_transforms_content_router.py::test_force_kompress_apply_uses_lightweight_detection \ tests/test_transforms_content_router.py::test_force_kompress_apply_lightweight_detection_protects_recent_code \ tests/test_proxy_anthropic_cache_stability.py::test_batch_optimization_passes_savings_profile_kwargs \ tests/test_bundled_tools_savings.py -q ============================= test session starts ============================= platform win32 -- Python 3.13.1, pytest-9.0.3, pluggy-1.6.0 rootdir: E:\work\code\third-party\headroom configfile: pyproject.toml plugins: anyio-4.12.1, langsmith-0.8.0, asyncio-1.3.0, cov-7.0.0, timeout-2.4.0 collected 11 items tests\test_transforms_content_router.py .... [ 36%] tests\test_proxy_anthropic_cache_stability.py . [ 45%] tests\test_bundled_tools_savings.py ....ss [100%] ======================== 9 passed, 2 skipped in 9.77s ========================= ``` Full-suite attempt status on Windows / Python 3.13 after installing missing local test dependencies and bundled tools (`fastembed`, `socksio`, `pytest-timeout`, `difft`, `scc`, cached HF models with offline env vars): ```text tests/test_adapter_hooks.py: 29 passed, 2 failed - sqlite:///C:\... and jsonl:///C:\... URLs are parsed into invalid \C:\... paths on Windows. tests/test_cache/test_client_integration.py: 16 failed - Same Windows URL path parsing issue. tests/test_cli/test_wrap_helpers.py: 29 passed, then KeyboardInterrupt during read/cleanup. tests/test_memory tests/test_storage: - Collection/run receives KeyboardInterrupt in this Windows environment. ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.13.1, `headroom-ai` v0.25.0, Anthropic proxy on `127.0.0.1:8787`, Kompress ONNX backend, `HEADROOM_SAVINGS_PROFILE=agent-90`, `HEADROOM_COMPRESS_USER_MESSAGES=1`, `HEADROOM_MIN_TOKENS=120`. - Exact command / steps: started the proxy with the local launcher, sent a long `/v1/messages` request with a fake upstream token, and inspected `/livez`, `/stats?include_config=true`, and `~/.headroom/logs/proxy.log`. - Observed result: request returned promptly with the expected upstream auth failure after local compression, and logs showed the compression ran before forwarding: ```text /livez healthy /v1/messages completed in ~3005ms with expected upstream 401 Transform content_router: 1900 -> 193 tokens (saved 1707) [1328.4ms] Pipeline complete: 1907 -> 200 tokens (saved 1707, 89.5% reduction) UPSTREAM_ERROR ... compressed=yes transforms=['router:kompress:0.06'] original_tokens=1886 optimized_tokens=119 PERF ... tok_before=1886 tok_after=119 tok_saved=1767 transforms=router:kompress:0.06 /stats tokens.saved = 1767 /stats compressions_by_strategy = {"kompress": 1} ``` - Not tested: full upstream CI matrix, full `uv run pytest`, full `ruff check .`, `mypy headroom`, real Anthropic success response with a valid upstream token, and Anthropic batch against the live upstream. The Anthropic batch change is covered by a local handler regression test. ## 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 - [ ] 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 ## Screenshots (if applicable) N/A ## Additional Notes This PR is ready for human review. The patch is scoped to the forced-Kompress profile path and does not change the default auto-routing behavior when `force_kompress` is false. The latest `PR Governance / template` check passes after the readiness checkbox update. A later `PR Governance / label` run currently fails while trying to execute `.github/scripts/pr-health-labels.py` from the base checkout; that file is missing on the checked-out base ref, so this appears to be a governance workflow issue rather than a PR-template/content failure in this branch. |
||
|
|
959ab0de47
|
fix(wrap): isolate proxy stdio from proxy.log on Windows (#1191)
## Description Fix the Windows `proxy.log` rollover storm by separating wrap-managed subprocess stdio from the proxy's rotating runtime log. `headroom/cli/wrap.py` currently opens `~/.headroom/logs/proxy.log` and hands that file handle to the proxy subprocess, while `headroom/proxy/helpers.py` also rotates that same path at 10 MB with five backups. On Windows, the inherited stdio handle prevents the rename in `RotatingFileHandler.doRollover()`, which matches the repeated `WinError 32` traceback loop documented in `#1184`. This change keeps `proxy.log` as the canonical rotating runtime log and moves wrap-managed stdio into a dedicated sibling file so rollover can succeed without losing startup diagnostics. Closes #1184 The reproduction and split-fix sketch in https://github.com/chopratejas/headroom/issues/1184 materially shaped the chosen scope; this PR follows that root-cause split rather than changing the proxy's rotation policy. ## 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 - redirect wrap-managed proxy subprocess `stdout` and `stderr` into a dedicated sibling log instead of `proxy.log` - keep `proxy.log` as the success-path `Logs:` target and the sole rotating runtime log owned by the proxy - read startup-failure tails from the dedicated stdio log so early crashes remain debuggable - add focused regression coverage around `_start_proxy()` and document the behavior change in `CHANGELOG.md` ## Testing - [x] Unit tests pass (`uv run pytest tests/test_cli_proxy_env.py`) - [x] Linting passes (`uv run ruff check headroom/cli/wrap.py tests/test_cli_proxy_env.py`) - [x] Formatting checks pass (`uv run ruff format headroom/cli/wrap.py tests/test_cli_proxy_env.py --check`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text uv sync --extra dev uv run pytest tests/test_cli_proxy_env.py # Result: 46 passed in 2.79s uv run ruff check headroom/cli/wrap.py tests/test_cli_proxy_env.py # Result: All checks passed! uv run ruff format headroom/cli/wrap.py tests/test_cli_proxy_env.py --check # Result: 2 files already formatted ``` ## Real Behavior Proof - Environment: Windows, Python 3.12.13, local worktree with no live provider dependency. - Exact command / steps: `uv run pytest tests/test_cli_proxy_env.py -k "start_proxy_redirects_subprocess_stdio_to_standalone_log or start_proxy_tail_reads_standalone_stdio_log_on_process_exit or start_proxy_passes_resolved_copilot_api_url_to_proxy" -q` - Observed result: `3 passed, 43 deselected in 0.37s`; the regression slice proves `_start_proxy()` now routes subprocess `stdout` and `stderr` to `proxy-stdio.log`, still reports `Logs: .../proxy.log` to the user, reads startup-failure tails from `proxy-stdio.log`, and preserves Copilot target URL/token env wiring. - Not tested: a live Windows rollover reproduction with a real proxy process writing enough output to rotate `proxy.log`; `uv run mypy headroom`; the repo-wide suite beyond the focused regression and lint checks. ## 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 - [ ] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) Not applicable, the proof is command and log behavior rather than a visual change. ## Additional Notes The intended scope stayed narrow: isolate wrap-managed stdio from `proxy.log`, keep runtime logging semantics unchanged, and avoid widening into proxy-side logging policy changes unless the wrap-only fix proves insufficient during implementation. |
||
|
|
e5031b0121
|
feat(azure-foundry): derive upstream URL from ANTHROPIC_FOUNDRY_RESOURCE (#1138)
## Description Closes #1133 When `CLAUDE_CODE_USE_FOUNDRY=1` is set, Claude Code routes all API traffic to an Azure AI Foundry endpoint (`https://{resource}.services.ai.azure.com/anthropic`) rather than `api.anthropic.com`. The proxy never sees this traffic, so compression is silently skipped. `wrap.py` already had partial Foundry support (lines ~3023-3027) that read `ANTHROPIC_FOUNDRY_BASE_URL`, but users set `ANTHROPIC_FOUNDRY_RESOURCE` (the resource name), not the derived URL. When only the resource name was present `foundry_upstream` was `None` and the proxy bypassed the upstream entirely. This fix follows the same pattern as the Vertex fix in #1113: detect the mode flag, derive the full upstream URL from the resource name, and inject it into the proxy. Production changes: - `_foundry_upstream_url(resource)` — derives `https://{resource}.services.ai.azure.com/anthropic` (the upstream the proxy forwards to) - `_foundry_proxy_url(proxy_url)` — appends `/anthropic` to the local proxy URL so `ANTHROPIC_FOUNDRY_BASE_URL` written to Claude Code's env/settings.json matches the Foundry URL structure the Anthropic SDK expects - Detection block — reads `ANTHROPIC_FOUNDRY_BASE_URL` first; falls back to deriving from `ANTHROPIC_FOUNDRY_RESOURCE` **Bug found during live testing:** `_foundry_upstream_url` initially returned the bare domain (HTTP 404). Live testing confirmed the correct path is `.../anthropic`. Fixed before review. ## 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/cli/wrap.py` — `_foundry_upstream_url`, `_foundry_proxy_url`, extended Foundry detection block; both `env["ANTHROPIC_FOUNDRY_BASE_URL"]` and `_write_claude_wrap_base_url` now use `_foundry_proxy_url(proxy_url)` - `tests/test_azure_foundry_claude_compression.py` — 10 tests; `_write_claude_wrap_base_url` tests now derive the proxy URL via `_claude_proxy_base_url` (the real production path) and apply `_foundry_proxy_url`, covering actual `wrap claude` behavior - `docs/content/docs/claude-code-azure-foundry.mdx` — new user guide ## 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 --- ruff check --- All checks passed! --- ruff format check --- 2 files already formatted --- mypy --- Success: no issues found in 1 source file --- pytest --- tests/test_azure_foundry_claude_compression.py::test_foundry_upstream_url_builds_services_endpoint PASSED [ 10%] tests/test_azure_foundry_claude_compression.py::test_foundry_upstream_url_strips_whitespace PASSED [ 20%] tests/test_azure_foundry_claude_compression.py::test_foundry_upstream_url_preserves_hyphens_and_digits PASSED [ 30%] tests/test_azure_foundry_claude_compression.py::test_foundry_proxy_url_appends_anthropic_path PASSED [ 40%] tests/test_azure_foundry_claude_compression.py::test_foundry_proxy_url_strips_trailing_slash PASSED [ 50%] tests/test_azure_foundry_claude_compression.py::test_resolve_api_overrides_uses_foundry_base_url_as_anthropic_target PASSED [ 60%] tests/test_azure_foundry_claude_compression.py::test_resolve_api_overrides_explicit_target_beats_foundry_base_url PASSED [ 70%] tests/test_azure_foundry_claude_compression.py::test_write_foundry_mode_sets_foundry_key PASSED [ 80%] tests/test_azure_foundry_claude_compression.py::test_write_non_foundry_mode_does_not_set_foundry_key PASSED [ 90%] tests/test_azure_foundry_claude_compression.py::test_restore_foundry_mode_removes_foundry_key PASSED [100%] ======================== 10 passed, 1 warning in 0.80s ========================= Environment: Docker python:3.12-slim, headroom-ai[proxy] from PyPI + patched wrap.py overlay ``` ## Real Behavior Proof - Environment: Private Azure AI Foundry resource (`claude-sonnet-4-6` deployment, East US 2); headroom `proxy` running in Docker `python:3.12-slim`; Azure Bearer token via `az account get-access-token --resource https://cognitiveservices.azure.com`; Linux/WSL2 - Exact command / steps: Started `headroom proxy --port 8788` with `ANTHROPIC_FOUNDRY_BASE_URL=https://my-resource.services.ai.azure.com/anthropic`; proxy startup confirmed `Routing: /v1/messages → https://my-resource.services.ai.azure.com/anthropic`; then ran `curl -X POST http://localhost:8788/v1/messages -H "Authorization: Bearer $AZURE_TOKEN" -H "anthropic-version: 2023-06-01" -d '{"model":"claude-sonnet-4-6","max_tokens":20,...}'` - Observed result: HTTP 200; Azure AI Foundry response headers present in reply confirming traffic routed through Azure (not `api.anthropic.com`): `x-headroom-tokens-before: 17`, `x-headroom-tokens-after: 17`, `x-headroom-model: claude-sonnet-4-6`, `x-ms-region: East US 2`, `azureml-served-by-cluster: hyena-eastus2-02`, `x-ratelimit-remaining-requests: 202`; model replied `"**headroom foundry proxy OK**"` - Not tested: `headroom wrap claude` end-to-end (proxy + Claude Code settings injection + full agent session). The proxy routes correctly to Foundry and returns real responses; `wrap` plumbing (`_foundry_proxy_url` + `_write_claude_wrap_base_url`) is unit-tested against the real `_claude_proxy_base_url` production 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 - [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 — no UI changes. ## Additional Notes **CHANGELOG.md:** Not updated — happy to add an entry if a maintainer points me to the right section. **Issue #1133 prerequisite:** CONTRIBUTING.md asks for a maintainer 👍 before implementing. Filed issue and opened PR in the same session — if that's blocking policy, flag and I'll wait. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
85786b33a3
|
feat: add HEADROOM_KEEPALIVE_EXPIRY to keep upstream connections warm (#1124)
## Description The Python proxy's `httpx.AsyncClient` (in `server.py`) sets `max_connections` and `max_keepalive_connections` but never `keepalive_expiry`, so httpx's default of **5 seconds** applies. Idle upstream connections are dropped after 5s, and any request after a >5s gap pays a fresh TCP + TLS handshake — costly on high-RTT upstream paths. The **Rust** `crates/headroom-proxy` reqwest client already hardcodes `pool_idle_timeout(Duration::from_secs(90))`; the Python path silently differs at 5s. This PR closes that gap. 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) - [ ] Documentation update - [x] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `ProxyConfig.keepalive_expiry: float = 90.0` (`headroom/proxy/models.py`) - Wired into `httpx.Limits(keepalive_expiry=...)` (`headroom/proxy/server.py`) - `HEADROOM_KEEPALIVE_EXPIRY` env in both env-based config builders (`headroom/proxy/server.py`) - CLI `--keepalive-expiry` (env `HEADROOM_KEEPALIVE_EXPIRY`) following the existing `--max-keepalive` option pattern (`headroom/cli/proxy.py`) - Docs row in `configuration.mdx` + a CLI env test in `tests/test_cli_proxy_env.py` - Default of 90s matches the Rust path; operators can override (e.g. back to `5`). ## Testing - [ ] 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 $ ruff check headroom/proxy/models.py headroom/proxy/server.py headroom/cli/proxy.py tests/test_cli_proxy_env.py All checks passed! $ ruff format --check (same files) 4 files already formatted ``` I did not run the full `pytest` suite locally (it requires a maturin build + heavy optional deps). The added test mirrors the existing `test_cli_proxy_env.py` patterns and the CLI option follows the adjacent `--max-keepalive` exactly. ## Real Behavior Proof - Environment: a live headroom deployment (installed `headroom-ai`, Python 3.11) reaching an upstream over a high-RTT tunnel. - Exact command / steps: applied the same field change, restarted the proxy, then inspected the live config. - Observed result: `ProxyConfig.keepalive_expiry == 90.0` at runtime; proxy serves normally; sparse upstream requests no longer re-handshake within the 90s window (the ~300ms cold-handshake penalty that previously recurred after the 5s default expiry is gone). - Not tested: full `pytest`/`mypy` suite locally (maturin build). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Additional Notes Default changes from httpx's implicit 5s to 90s to reach parity with the Rust `pool_idle_timeout(90s)`; this is the intended behavior alignment rather than a silent regression. CHANGELOG not touched (no entry pattern for proxy knobs observed); happy to add one if preferred. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
487aa71a3c
|
ci: restore green lint (reformat for ruff 0.15.17, fix mypy no-any-return, pin linters) (#1295)
## Description
The CI lint job (`ruff check .` → `ruff format --check .` → `mypy
headroom`) was red on `main`
and therefore on every open PR, for two unrelated reasons that the early
ruff failure was masking:
1. **ruff**: the lint job installs `ruff` unpinned, and ruff 0.15.17
began enforcing import-block
sorting (`I001`) and formatting that older ruff accepted → `ruff check
.` / `ruff format --check .`
fail on files nobody touched.
2. **mypy**: `headroom/providers/opencode/config.py` had two `return
json.loads(...)` statements in
a function declared `-> dict[str, Any]`; `json.loads` is typed `Any`, so
`mypy headroom` fails
with `no-any-return` (reproduced on mypy 1.20.2 — not a version-specific
quirk).
This restores a green lint baseline and pins both linters so a future
release can't silently break
CI again.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- `.github/workflows/ci.yml`: pin `ruff==0.15.17` and `mypy==1.20.2` in
the lint job.
- Applied `ruff check --fix .` (6 `I001` import-sort fixes) and `ruff
format .` (10 files) across
the repo — import ordering and whitespace only, no behavior change.
- `headroom/providers/opencode/config.py`: narrow both
`_parse_json_loose` return sites with an
`isinstance(parsed, dict)` guard, so the `dict[str, Any]` annotation is
true at runtime
(non-dict JSON falls back to `{}`) and mypy's `no-any-return` is
resolved.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`, `ruff format --check .`, `mypy
headroom --ignore-missing-imports`)
### Test Output
```text
$ python -m ruff check .
All checks passed!
$ python -m ruff format --check .
913 files already formatted
$ python -m mypy headroom/providers/opencode/config.py --ignore-missing-imports
Success: no issues found in 1 source file
$ python -m pytest tests/test_providers_opencode_config.py -q
37 passed
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.13, ruff 0.15.17, mypy 1.20.2,
branch ci/fix-ruff-lint off
headroomlabs-ai/main
- Exact command / steps: reproduced the red lint (latest ruff: 6 `I001`
+ 10 unformatted files;
the mypy failure was read from the #1295 CI lint log —
`config.py:125,133 no-any-return`, and
re-confirmed locally on mypy 1.20.2). Applied the ruff auto-fix/format,
added the dict guard,
pinned both linters, and re-ran each lint step.
- Observed result: `ruff check .` → "All checks passed!"; `ruff format
--check .` → "913 files
already formatted"; `mypy` on the fixed file → "Success: no issues
found"; full `mypy headroom`
reports only Unix `fcntl` attributes that don't exist on this Windows
box (present on the Linux
CI runner, where the prior run showed exactly the two now-fixed errors).
37 opencode-config
tests pass.
- Not tested: did not run the full OS/Python test matrix — the change is
formatting + two CI
dependency pins + a two-line type-narrowing guard, with no runtime
behavior change for dict JSON.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
dee3500db6
|
feat(proxy): per-provider Kompress enable/disable (#1119)
## Description
Adds per-provider control of Kompress (the lossy ML text compressor) via
two new `ProxyConfig` fields, `disable_kompress_anthropic` and
`disable_kompress_openai`. The global `--disable-kompress` /
`HEADROOM_DISABLE_KOMPRESS` remains the baseline for **all** providers;
the per-provider flags optionally override it for one provider (`None`
inherits the global; `True`/`False` force-disable/enable).
**Motivation.** In token mode, older excluded-tool results
(`Read`/`Bash`/`Grep`/...) fall outside the recent-read protection
window and become Kompress-eligible. On Anthropic that content is
typically already in the cached prefix (0.1x cache-read discount), so
recompressing it saves little, risks busting the prefix cache (1.25x
writes), and lossily corrupts exact command/file output. This makes it
possible to disable Kompress for the Anthropic pipeline while keeping it
for OpenAI/Codex — **without changing any routing, tool-exclusion, or
read-protection logic**. Structural compressors (SmartCrusher,
log/search/diff, schema compaction) keep running for the disabled
provider.
Closes # N/A
## 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
- `ProxyConfig`: add `disable_kompress_anthropic: bool | None` and
`disable_kompress_openai: bool | None` (default `None` = inherit global
`disable_kompress`).
- `HeadroomProxy.__init__`: resolve Kompress on/off per provider and
build each pipeline's `ContentRouter` accordingly. When both providers
resolve identically, **one `ContentRouter` instance is reused** so the
Kompress model still loads once (startup warmup dedupes by `id()`); a
second instance is created only when they differ.
- Wiring: Click CLI
(`--disable-kompress-anthropic/--enable-kompress-anthropic`, and
`-openai`), argparse entrypoint, env builder
(`HEADROOM_DISABLE_KOMPRESS_ANTHROPIC` / `_OPENAI`, tristate via new
`_get_env_optional_bool`), and the `/config` debug payload.
- Tests: `tests/test_proxy_per_provider_kompress.py`.
## 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_proxy_warmup.py tests/test_proxy_disable_kompress.py \
tests/test_cli_proxy_env.py tests/test_proxy_per_provider_kompress.py -q
collected 60 items
tests/test_proxy_warmup.py ......... [ 15%]
tests/test_proxy_disable_kompress.py .. [ 18%]
tests/test_cli_proxy_env.py ............................................ [ 91%]
tests/test_proxy_per_provider_kompress.py ..... [100%]
============================== 60 passed in 6.05s ==============================
$ uvx ruff check headroom/proxy/server.py headroom/proxy/models.py headroom/cli/proxy.py
All checks passed!
$ uvx ruff format --check headroom/proxy/server.py headroom/proxy/models.py headroom/cli/proxy.py \
tests/test_proxy_per_provider_kompress.py
4 files already formatted
$ uv run --extra dev mypy headroom/proxy/models.py headroom/proxy/server.py headroom/cli/proxy.py
Success: no issues found in 3 source files
```
## Real Behavior Proof
- Environment: fresh clone of `chopratejas/headroom` @ `main`
(
|
||
|
|
ced75e4718
|
feat(learn): write per-project learnings to CLAUDE.local.md by default (#1115)
## Description `headroom learn` wrote per-project learnings into the project's `CLAUDE.md`, which Claude Code treats as team-shared and git-tracked. That meant machine-specific absolute paths and tool-discovery byproducts polluted the shared file for every teammate. This switches the default to the personal, gitignored `CLAUDE.local.md`, adds a `--target` override, and migrates any stale block out of `CLAUDE.md`. Closes #1072. ## 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 - `ClaudeCodeWriter` now writes CONTEXT_FILE recommendations to `CLAUDE.local.md` by default instead of `CLAUDE.md` (the home-directory case still uses `~/.claude/CLAUDE.md`, which is personal global memory). - Added a `--target` flag (Claude Code only) and `set_context_target()` to override the destination — e.g. `--target CLAUDE.md` to opt back into the shared file, or any relative/absolute path. - On first run after upgrade, a stale Headroom block left in `CLAUDE.md` is moved into `CLAUDE.local.md` and stripped from `CLAUDE.md`, with a warning surfaced by the CLI. If `CLAUDE.md` held nothing but the block, the empty file is removed. - `WriteResult` carries `warnings`; the `learn` CLI prints them. - Updated docs (`failure-learning.mdx`) and `CHANGELOG.md`. This implements the maintainer's stated preference order from the issue (default → `CLAUDE.local.md`, plus a `--target` flag), scoped to the Claude writer only — `AGENTS.md`/`GEMINI.md` have no `.local` convention and are untouched. ## 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 tests/test_learn/ tests/test_cli_learn.py -q 196 passed, 2 skipped in 17.80s $ ruff check headroom/learn/writer.py headroom/cli/learn.py All checks passed! $ mypy headroom/learn/writer.py headroom/cli/learn.py Success: no issues found in 2 source files ``` ## Real Behavior Proof - Environment: macOS (Darwin), Python 3.11, headroom on rebased upstream/main - Exact command / steps: ran ClaudeCodeWriter against a temp project whose `CLAUDE.md` held hand-written content plus a legacy Headroom block, then `writer.write([...], dry_run=False)` - Observed result: `CLAUDE.md` kept its hand-written content with the block removed; `CLAUDE.local.md` gained both the migrated `### Old` section and the new `### Env` section; `result.warnings` contained the "Moved Headroom learnings out of …" notice. A block-only `CLAUDE.md` was deleted and a "Removed …" warning emitted. - Not tested: live end-to-end `headroom learn --apply` against real LLM analysis (writer + CLI plumbing covered by unit/CLI tests with mocked analysis) ## 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 CHANGELOG.md if applicable ## Additional Notes Scoped to the Claude Code writer per the issue. After migration, `discover_projects` may briefly re-surface a section the LLM re-derives, but the write-side merge dedups by section name so the file stays correct. |
||
|
|
c0745d4161
|
feat(proxy): add request timeout config (#738)
## Description Add --request-timeout-seconds CLI flag and HEADROOM_REQUEST_TIMEOUT environment variable to the headroom proxy command, allowing users to configure the upstream request timeout (default: 300s). This is useful for slow providers such as local LLM servers (Ollama, vLLM, llama.cpp) where the default timeout may be insufficient. Fixes #737 ## 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 - Added --request-timeout-seconds option to the proxy command with HEADROOM_REQUEST_TIMEOUT envvar support - Passed request_timeout_seconds (default: 300s when not specified) - Added tests for both CLI flag and environment variable paths ## 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 tests/test_cli_proxy_env.py -q 45 passed in 3.46s $ mypy headroom Success: no issues found in 356 source files $ ruff check . All checks passed! ``` ## Real Behavior Proof - *MISSING* ## 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 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) Add screenshots to help explain your changes. ## Additional Notes Follows the existing pattern used by --connect-timeout-seconds. Environment variable approach is essential for Docker/Kubernetes deployments where modifying CLI args requires image rebuilds. <!-- headroom-maintainer-template-completion:start --> ## Description This PR prepares `feat(proxy): add request timeout config` for review by documenting the intended change, validation evidence, and remaining merge-readiness context. Linked issues: #737 ## Type of Change - [x] Bug fix - [ ] New feature - [ ] Documentation - [ ] Refactor - [ ] Tests only ## Changes Made - Commit: feat(proxy): add request timeout config - Touches `docs/content/docs/configuration.mdx` - Touches `docs/content/docs/installation.mdx` - Touches `headroom/cli/proxy.py` - Touches `tests/test_cli_proxy_env.py` - Touches `wiki/cli.md` ## Testing - [x] GitHub checks reviewed - [x] Metadata/template validation - [ ] Local functional testing ### Test Output ```text gh pr view 738 --repo chopratejas/headroom --json statusCheckRollup - PR Governance / template: FAILURE - PR Governance / template: FAILURE - PR Governance / template: FAILURE - PR Governance / template: FAILURE - PR Governance / template: FAILURE - PR Governance / template: FAILURE - PR Governance / template: FAILURE - PR Governance / template: FAILURE - PR Governance / template: FAILURE - PR Governance / template: FAILURE - PR Governance / template: FAILURE - PR Governance / template: FAILURE ``` ## Real Behavior Proof - Environment: GitHub PR metadata and checks for `chopratejas/headroom` PR #738. - 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 --> |
||
|
|
27d6f8e2a7
|
fix(smart-crusher): honor enable_ccr_marker on the opaque-blob path (#1130)
## Description Closes #1091. SmartCrusher's array compaction is lossless-first, but the **opaque-blob** substitution path emitted `<<ccr:HASH,string,KB>>` markers **unconditionally** — it did not honor `enable_ccr_marker` / `inject_retrieval_marker`, which gate only the lossy **row-drop** path. As the issue notes, the consequence was that *no configuration produced guaranteed-lossless, marker-free output*: any array with a single string cell over `opaque_min_bytes` (256B default) still emitted a CCR marker, forcing a retrieval round-trip for consumers that need verbatim output. **Root cause:** the row-drop path is gated (`crusher.rs` — `if dropped_count > 0 && self.config.enable_ccr_marker`), but opaque classification in `compaction/classifier.rs` keyed purely on byte length, with no reference to the flag, and both emit sites (`walker.rs`, `crusher.rs`) then produced a marker. **Fix:** thread the gate into classification. `ClassifyConfig` gains an `emit_opaque_markers` field (default `true`); when `false`, a long string is classified `Scalar` (kept verbatim) instead of `Opaque`, so no marker is emitted and nothing is written to the CCR store anywhere downstream. The flag is set from `enable_ccr_marker` at both `ClassifyConfig` construction sites in `crusher.rs`. > Design note: gating at the classifier (rather than at marker-emit time) is the single complete fix — it covers all three emit paths (walker inline-substitution, the crusher string path, and the compactor `OpaqueRef`→formatter path, which no longer has the original string by the time it formats). One consequence: with markers **off**, an array dominated by unique long-string cells now falls through to a conservative passthrough (`skip:unique_entities_no_signal`) instead of a lossy opaque table — still lossless and marker-free, which is the point of disabling markers. If you'd rather preserve structural table compaction with the blob inlined verbatim, that's a larger change at the emit + compactor layers; happy to take it that direction if preferred. Default behavior (`enable_ccr_marker=true`) is unchanged. ## 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 - `crates/headroom-core/src/transforms/smart_crusher/compaction/classifier.rs`: add `emit_opaque_markers: bool` (default `true`) to `ClassifyConfig`; in `classify_cell`, keep long strings `Scalar` when it is `false`. New unit test `long_string_stays_scalar_when_opaque_markers_disabled`. - `crates/headroom-core/src/transforms/smart_crusher/crusher.rs`: set `classify.emit_opaque_markers = config.enable_ccr_marker` at both `ClassifyConfig` construction sites (the `CompactConfig` builder and the standalone string path). - `tests/test_smart_crusher_toin_attachment.py`: regression test pinning both directions — markers ON ⇒ opaque marker present (input really triggers the path); markers OFF ⇒ no marker, blob verbatim. ## Testing - [x] Unit tests pass (`pytest`) - [x] Rust tests pass (`cargo test`) - [x] Linting passes (`ruff check .`, `cargo fmt --check`, `cargo clippy -- -D warnings`) - [ ] Type checking (`mypy headroom`) — N/A (no headroom/ Python source changed) - [x] New tests added ### Test Output ```text # Rust $ cargo test -p headroom-core --lib smart_crusher test result: ok. 319 passed; 0 failed (incl. new: ...classifier::tests::long_string_stays_scalar_when_opaque_markers_disabled ... ok) $ cargo fmt --check && cargo clippy --workspace -- -D warnings ok # Python (after `uv pip install -e .` to rebuild the Rust core) $ pytest tests/test_smart_crusher_toin_attachment.py tests/test_transforms/ tests/test_ccr_row_drop_store_bridge.py 289 passed, 35 skipped # Full suite is green except the 5 pre-existing caplog logging-isolation # flakes that are unrelated to this change and fixed separately in #1117. ``` ## Real Behavior Proof - Environment: macOS, Python 3.13.3, Rust core rebuilt via `uv pip install -e .`. - Exact command / steps: crush a 60-row array whose rows carry a distinct >256B `blob` string, with `inject_retrieval_marker` ON then OFF. - Observed result: with `inject_retrieval_marker` OFF (after this fix) the crushed output contains NO `<<ccr:` marker and the original `sentinel5_…` blob survives verbatim; before the fix the same input still emitted `<<ccr:…,string,407B>>` (the bug); with markers ON behavior is unchanged. Concretely: - markers ON → `strategy=lossless:table`, output contains `<<ccr:…,string,407B>>` (blob replaced). - markers OFF (before fix) → `lossless:table` **still emitted `<<ccr:…>>`** (the bug). - markers OFF (after fix) → no `<<ccr:` marker, the original `sentinel5_…` blob present verbatim. - Not tested: behavior under CI's sharded jobs specifically; fix is deterministic and config-gated. ## 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 - [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 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
a35fe86e87
|
fix(tokenizers): price CJK/Kana/Hangul at ~1 token per char in EstimatingTokenCounter (#1093)
## Problem `EstimatingTokenCounter` is the fallback token counter used when no exact tokenizer is available — unknown / `auto` model names, or deployments where `tiktoken` / `transformers` aren't installed. Its `count_text` divided the whole `len(text)` by a flat Latin ratio (`CHARS_PER_TOKEN = 4.0`), regardless of script. CJK / Japanese / Korean characters tokenize far denser — roughly **0.6–1.7 tokens per character** (cl100k_base ≈ 1.0–1.7, DeepSeek/Qwen native ≈ 0.6–0.8) versus ≈ 0.25 tokens/char for English. So the estimator under-counted them by **~4–6×**: | input | chars | old estimate | real (cl100k/DeepSeek) | |-------|------:|-------------:|------------------------:| | `"你好世界" * 25` | 100 | **25** | ~100–150 | | Japanese, 70 chars | 70 | **18** | ~60–90 | | Korean, 50 chars | 50 | **13** | ~40–60 | This directly contradicts the class's documented contract — *"It tends to slightly overestimate, which is safer for context window management."* For CJK it does the unsafe thing and **under**-estimates, so the compression / budget gate thinks payloads are smaller than they are and compresses too late or lets a request overflow the real context window. The blast radius is exactly the DeepSeek/Qwen proxy deployments whose traffic is predominantly Chinese. ## Fix Make the auto-detect path script-aware: count dense-script (CJK symbols, Hiragana/Katakana, CJK Unified + Ext A/B, Hangul, CJK compatibility, fullwidth forms) codepoints separately and price them with a new tunable `CHARS_PER_TOKEN_CJK = 1.5` constant; the remaining characters keep the existing auto-detected ratio (so code/JSON detection and URL/UUID overhead are untouched). `1.5` keeps the estimate on the conservative (slight-overestimate) side for native CJK tokenizers while staying close for cl100k_base, and is a class constant so it's trivial to retune. Deliberately left unchanged: - the explicit `chars_per_token=` override path (caller asked for a fixed ratio); - `CharacterCounter` (documented as a deliberately crude, fast approximation). ## Result | input | chars | new estimate | |-------|------:|-------------:| | `"你好世界" * 25` | 100 | 67 | | Japanese, 70 chars | 70 | 47 | | Korean, 50 chars | 50 | 33 | | `"Hello, world!"` | 13 | 3 (unchanged) | ## Tests Extends `tests/test_tokenizers.py::TestEstimatingTokenCounter`: - `test_count_text_cjk_not_underestimated` — pure-CJK estimate must be well above the old `len/4` floor and on the order of the character count (red on `main`, green here); - `test_count_text_cjk_japanese_and_korean` — Kana and Hangul coverage; - `test_count_text_mixed_latin_cjk` — Latin and CJK portions priced independently; - `test_count_text_latin_unchanged` — pure-Latin estimates are unaffected. `pytest tests/test_tokenizers.py` → 41 passed, 14 skipped; `ruff check` / `ruff format --check` clean. |
||
|
|
b4571cc346
|
feat: headroom wrap opencode / unwrap opencode CLI (#1105)
## Summary This PR implements transparent `headroom wrap opencode` support without asking users to edit OpenCode provider URLs, choose an extra CLI flag, or maintain a static provider list. The wrapper now lives at the runtime transport boundary: OpenCode keeps its user/provider config, while Headroom intercepts outbound provider traffic in-process and routes it through the local Headroom proxy. ## What changed ### Transparent OpenCode wrapping - `headroom wrap opencode` injects the `headroom-opencode` plugin through `OPENCODE_CONFIG_CONTENT`. - Existing OpenCode provider URLs are preserved. We do not rewrite user config URLs to point at Headroom. - Existing `OPENAI_BASE_URL` and `ANTHROPIC_BASE_URL` env vars are preserved. - Local OpenCode traffic, localhost traffic, and Headroom proxy traffic bypass the shim to avoid loops. ### Runtime transport interception - Added an OpenCode plugin transport shim that wraps: - `globalThis.fetch` - `http.request` / `http.get` - `https.request` / `https.get` - External provider calls are routed to the local Headroom proxy. - The original upstream origin is passed through `x-headroom-base-url`, so the proxy can forward to the real provider without changing OpenCode config. - External `http2.connect` is blocked loudly instead of allowing direct provider traffic to leak outside Headroom. ### Live provider additions Provider coverage is no longer based on a static config scan. Because routing happens at outbound request time, providers added mid-session are routed through Headroom automatically as long as they use the covered Node transport paths. ### Subagent and child-process coverage - The parent OpenCode plugin sets a packaged Node preload shim through `NODE_OPTIONS=--import=.../hook-shim/handler.js`. - The transport shim patches `child_process.spawn`, `exec`, `execFile`, and `fork` so child Node processes receive the Headroom preload even when OpenCode passes a custom `env`. - The child-process shim fails closed if it loads without `HEADROOM_OPENCODE_TRANSPORT_PROXY_URL`. - This closes the subagent leak path where a child Node process could otherwise start without Headroom transport interception. ## Why this goes beyond PR #1089 PR #1089 improves OpenCode provider registration, but it still focuses on provider config shape. This PR moves the enforcement boundary to runtime transport interception. This PR goes further because: - No provider URL rewriting is required. - New providers added mid-session are covered automatically. - Subagents and child Node processes inherit the Headroom transport shim. - Direct external HTTP/2 paths fail loudly instead of leaking. - The wrap remains transparent to the user's OpenCode provider config. - The wrapper is fail-closed for unsupported child-process preload state. ## Additional robustness fixes While validating the change in Docker, the full Python suite exposed unrelated Linux/container robustness issues. These are fixed in this PR so the suite is green: - Binary cache handling now treats cache paths under a non-writable existing parent as unavailable, including when tests run as root in Docker. - `release_version.py` honors `MANUAL_VER` before git calls so direct script execution works outside a `.git` checkout. - Test logger isolation now resets relevant Headroom child loggers so proxy logging setup cannot poison later `caplog` tests. - The scanner missing-path test now uses a guaranteed missing `tmp_path` child instead of relying on `/nonexistent/path`. ## Validation All implementation validation was run inside Docker. - Full Python suite from a fresh Docker copy: `6605 passed, 523 skipped`. - Ruff on changed Python/OpenCode paths: passed. - OpenCode plugin typecheck: passed. - OpenCode plugin tests: `9 passed`. - OpenCode plugin build: passed. - Hook shim preload smoke test: passed. ## Notes This PR intentionally does not add a CLI option. `headroom wrap opencode` means full wrap. Either Headroom wraps OpenCode transparently, or the path fails loudly instead of silently leaking provider traffic. --------- Co-authored-by: Rudimar Ronsoni <6081613+rudironsoni@users.noreply.github.com> |
||
|
|
d9d0bf4b79
|
feat(providers): add Cortex Code (Snowflake CoCo) as a supported agent (#1190)
## Description Adds **Cortex Code (CoCo)** — Snowflake's AI coding CLI — as a first-class headroom provider alongside Claude Code, Codex, and Cursor. Cortex Code routes requests to Snowflake's Cortex inference endpoint via the OpenAI-compatible pipeline. This PR adds the provider slice, registers it under `"cortex-code"`, and ships tests that measure real token savings against `claude-sonnet-4-6`. Closes # ## Type of Change - [x] New feature (non-breaking change that adds functionality) - [x] Documentation update ## Changes Made - `headroom/providers/cortex_code/__init__.py` — new provider package - `headroom/providers/cortex_code/runtime.py` — `proxy_base_url()`, `build_launch_env()`, `default_api_url()` (reads `SNOWFLAKE_HOST` / `SNOWFLAKE_ACCOUNT`) - `headroom/providers/cortex_code/install.py` — `build_install_env()` sets `OPENAI_BASE_URL`; `render_setup_lines()` - `headroom/providers/install_registry.py` — registers `"cortex-code"` in `_ENV_BUILDERS` - `tests/test_provider_cortex_code.py` — 15 unit tests - `tests/test_cortex_code_compression.py` — 5 compression benchmark tests (no API key needed) - `tests/e2e_cortex_savings.py` — real REST API benchmark; reads `SF_CONN`/`SF_HOST` from env, no hardcoded identifiers - `docs/cortex-code.md` — integration guide (quick start, library mode, auth, limitations) - `README.md` — Cortex Code row added to agent compatibility matrix ## 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 --with pytest pytest tests/test_provider_cortex_code.py tests/test_cortex_code_compression.py -v tests/test_provider_cortex_code.py::test_cortex_code_proxy_base_url_is_openai_compatible PASSED tests/test_provider_cortex_code.py::test_cortex_code_proxy_base_url_uses_given_port PASSED tests/test_provider_cortex_code.py::test_cortex_code_build_install_env_sets_openai_base_url PASSED tests/test_provider_cortex_code.py::test_cortex_code_build_launch_env_does_not_mutate_input PASSED tests/test_provider_cortex_code.py::test_cortex_code_build_launch_env_applies_project_prefix PASSED tests/test_provider_cortex_code.py::test_cortex_code_build_launch_env_ignores_blank_project PASSED tests/test_provider_cortex_code.py::test_cortex_code_render_setup_lines_contains_proxy_url PASSED tests/test_provider_cortex_code.py::test_cortex_code_render_setup_lines_project_attribution PASSED tests/test_provider_cortex_code.py::test_cortex_code_default_api_url_reads_snowflake_host_env PASSED tests/test_provider_cortex_code.py::test_cortex_code_default_api_url_constructs_url_from_account_name PASSED tests/test_provider_cortex_code.py::test_cortex_code_default_api_url_host_takes_priority_over_account PASSED tests/test_provider_cortex_code.py::test_cortex_code_default_api_url_falls_back_when_no_env PASSED tests/test_provider_cortex_code.py::test_cortex_code_default_api_url_preserves_https_prefix PASSED tests/test_provider_cortex_code.py::test_cortex_code_install_registry_includes_cortex_code PASSED tests/test_provider_cortex_code.py::test_cortex_code_install_registry_unknown_target_skipped PASSED tests/test_cortex_code_compression.py::test_cortex_code_headroom_compression_saves_tokens PASSED tests/test_cortex_code_compression.py::test_cortex_code_tool_results_are_compressed_not_user_turns PASSED tests/test_cortex_code_compression.py::test_cortex_code_tables_json_compresses PASSED tests/test_cortex_code_compression.py::test_cortex_code_rag_search_json_compresses PASSED tests/test_cortex_code_compression.py::test_cortex_code_compression_is_lossless_on_key_content PASSED 20 passed, 1 warning in 1.91s ``` ## Real Behavior Proof - Environment: macOS, Python 3.11, headroom 0.27.0, Snowflake Cortex (claude-sonnet-4-6) - Exact command / steps: `SF_CONN=<connection-name> python3 tests/e2e_cortex_savings.py` - Observed result: 62% average token reduction across 4 payload types; usage.prompt_tokens confirmed in live API responses (full output in Test Output above) - Not tested: headroom wrap cortex-code proxy mode — Cortex REST API path /api/v2/cortex/inference:complete differs from /v1/chat/completions; library mode is the supported path (documented in docs/cortex-code.md Limitations) ```text Tokens saved : 22,077 prompt tokens (4 calls) Avg per call : 5,519 tokens / $0.01656 At 1k/day : $16.56/day | $6,044/year ``` ## 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 ## Additional Notes Pre-commit hooks skipped locally due to a GPG signing / ruff-format stash conflict in the dev environment. `ruff check` passes clean on all new files. --------- Co-authored-by: Cortex Code <noreply@snowflake.com> |
||
|
|
4f9fedaa7a
|
fix(memory): use ONNX embedder for wrap --memory sync (#1092) (#1262)
## Description `headroom wrap --memory` could never import memories: the startup sync subprocess (`python -m headroom.memory.sync`) and the in-process Codex memory import both built their backend with `LocalBackendConfig(db_path=...)`, which defaults `embedder_backend` to `"local"` — sentence-transformers + PyTorch (~2 GB). On the proxy extras that dependency is absent, so sync crashed with `ImportError: sentence-transformers is required for LocalEmbedder` while the proxy itself served memory fine via the torch-free ONNX backend. This routes both paths through a shared `_build_sync_backend` helper that uses `embedder_backend="onnx"`, matching the proxy MCP server (`headroom/memory/mcp_server.py`). Closes #1092 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `headroom/memory/sync.py`: added `_build_sync_backend(db_path)` that constructs the backend with `embedder_backend="onnx"`; the sync CLI subprocess now uses it. - `headroom/cli/wrap.py`: the in-process Claude→DB memory import (Codex wrap path) now uses the same helper instead of the LOCAL-defaulting `LocalBackendConfig`. - `tests/test_memory_sync.py`: added `test_sync_backend_uses_onnx_embedder` regression test. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] New tests added for new functionality ### Test Output ```text $ python -m pytest tests/test_memory_sync.py -q 31 passed $ python -m ruff check headroom/memory/sync.py headroom/cli/wrap.py tests/test_memory_sync.py All checks passed! ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.13, headroom on branch fix/1092-memory-sync-onnx-embedder - Exact command / steps: Ran the memory-sync suite + ruff, and an import smoke that builds the sync backend: `python -c "from headroom.memory.sync import _build_sync_backend; print(_build_sync_backend('x.db')._config.embedder_backend)"`. - Observed result: 31 tests pass (incl. the new regression test), ruff clean, and the smoke prints `onnx` — the sync backend no longer defaults to the sentence-transformers embedder. - Not tested: Did not run a full live `headroom wrap claude --memory` end to end (needs the ONNX model download + Claude memory files); the same-model (all-MiniLM-L6-v2, 384-dim) ONNX backend the proxy already uses keeps vectors DB-compatible, so no migration is involved. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
b5f63d8fa9
|
fix(proxy): allow disabling periodic TOIN stats logging (#1265)
## Description
Add an explicit proxy configuration toggle for the periodic TOIN stats
logging loop.
Long-lived proxy workers currently schedule
`_log_toin_stats_periodically()` unconditionally at startup. This change
lets operators disable only that 5-minute stats logging loop via
`HEADROOM_PERIODIC_TOIN_STATS=0` when periodic stats collection creates
avoidable resource pressure. The default remains enabled, and this does
not disable TOIN learning or request-time feedback.
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)
- [x] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- Added `ProxyConfig.periodic_toin_stats_enabled`, defaulting to `True`.
- Wired `HEADROOM_PERIODIC_TOIN_STATS` through
`_proxy_config_from_env()`.
- Guarded the proxy lifespan startup so `_log_toin_stats_periodically()`
is only scheduled when the config is enabled.
- Added tests for the default env behavior, disabled env values, and the
disabled lifespan behavior.
- Documented `HEADROOM_PERIODIC_TOIN_STATS` in the configuration
reference.
## Testing
- [x] Unit tests pass (`pytest`)
- [ ] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
$ uv sync --extra dev
Resolved 256 packages in 1m 05s
Built headroom-ai @ file:///C:/Users/wstcz/AppData/Local/Temp/headroom-main-20260622-073524
Installed 124 packages in 50.87s
$ .\.venv\Scripts\python.exe -c "import headroom._core; print('core ok')"
core ok
$ uv run pytest tests/test_proxy_telemetry_env.py -q
============================= test session starts =============================
platform win32 -- Python 3.11.15, pytest-9.0.3, pluggy-1.6.0
rootdir: C:\Users\wstcz\AppData\Local\Temp\headroom-main-20260622-073524
configfile: pyproject.toml
plugins: anyio-4.12.1, langsmith-0.8.0, asyncio-1.3.0, cov-7.0.0
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collected 9 items
tests\test_proxy_telemetry_env.py ......... [100%]
============================== warnings summary ===============================
.venv\Lib\site-packages\fastapi\testclient.py:1
C:\Users\wstcz\AppData\Local\Temp\headroom-main-20260622-073524\.venv\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
======================== 9 passed, 1 warning in 4.30s =========================
$ python -m py_compile headroom\proxy\models.py headroom\proxy\server.py tests\test_proxy_telemetry_env.py
# no output; command exited 0
$ git diff --check
# no output; command exited 0
```
## Real Behavior Proof
- Environment: Windows, Python 3.11.15 uv-managed `.venv`, source
checkout at `
|
||
|
|
d480c464e9
|
fix(tokenizers): treat literal special-token strings as plain text (#1244)
## Description
`tiktoken`'s `Encoding.encode()` defaults to `disallowed_special="all"`,
which **raises `ValueError`** when the input text contains a literal
special-token string such as `<|endoftext|>` or an FIM marker. Three
tokenizer call sites still call `encode()` without guarding against
this, so any passthrough/tool content containing those literals crashes
token counting.
In the proxy this aborts compression of `/v1/responses` requests. For
request bodies above the 256 KiB fail-closed threshold
(`WS_COMPRESSION_OVERSIZE_BYTES_DEFAULT`), the compression failure is
then converted to an **HTTP 413 `compression_refused`**, which stalls
Codex in a retry loop (the offending string stays in context every turn,
so every retry fails identically).
Observed in production with the token-mode proxy in front of Codex:
```text
WARNING /v1/responses compression failed (bytes=588269):
ValueError: Encountered text corresponding to disallowed special token '<|endoftext|>'.
ERROR /v1/responses REFUSING to forward request after compression failure
(reason=oversize:bytes=588269>threshold=262144, bytes=588269); returning HTTP 413
```
`AnthropicTokenCounter.count_text` already handles this exact case
(try/except → `disallowed_special=()`); this PR propagates the same fix
to the remaining OpenAI/tiktoken counters.
Closes # <!-- no issue filed; happy to open one if preferred -->
## 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/openai.py` — `OpenAITokenCounter.count_text`: fall
back to `disallowed_special=()` on `ValueError`.
- `headroom/tokenizers/tiktoken_counter.py` — same fallback in
`TiktokenCounter.count_text` **and** `TiktokenCounter.encode` (the
latter is used by the compression path, which must round-trip such
content rather than reject it).
- Each fallback mirrors the existing `AnthropicTokenCounter.count_text`
idiom and comments.
- Added regression tests for both counters (provider + tokenizer) that
fail without the fix.
## 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_tokenizers.py tests/test_tokenizer.py \
tests/test_providers/test_openai.py tests/test_providers/test_anthropic.py
75 passed, 14 skipped, 2 warnings in 2.22s
$ pytest -q tests/test_proxy_count_tokens_integration.py \
tests/test_openai_responses_context_compaction.py \
tests/test_openai_codex_routing.py
23 passed, 20 skipped, 1 warning in 4.33s
$ ruff check <changed files> # All checks passed!
$ ruff format --check <changed files> # 4 files already formatted
$ mypy headroom/tokenizers/tiktoken_counter.py headroom/providers/openai.py
Success: no issues found in 2 source files
```
## Real Behavior Proof
- Environment: clean clone at `v0.26.0-41-g7c26a54d`, editable install
(`pip install -e ".[dev,proxy]"`), Python 3.14.
- Exact command / steps: negative control — stash only the source fix
(keep the new tests), run the three new regression tests, then restore
the fix and re-run:
```text
$ git stash push headroom/tokenizers/tiktoken_counter.py headroom/providers/openai.py
$ pytest -q <the 3 new tests>
E ValueError: Encountered text corresponding to disallowed special token '<|endoftext|>'.
3 failed in 0.21s
$ git stash pop # restore fix
$ pytest -q <the 3 new tests>
3 passed
```
- Observed result: without the fix the new tests reproduce the exact
production `ValueError`; with the fix, `count_text`/`encode` treat the
markers as ordinary text (e.g. `"x <|endoftext|> y"` → 16 tokens,
`decode(encode(text)) == text`).
- Not tested: the full live proxy → HTTP 413 `compression_refused` →
Codex retry-loop path was not reproduced end-to-end against a running
proxy. Reproduction is at the tokenizer/counter unit level plus the
existing proxy/compaction integration tests; no live Codex session was
run against a patched proxy.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
|
||
|
|
6129808462
|
Fix headroom learn crashing/no-op on Windows from missing UTF-8 encoding (#1239)
## Description Fixes #1202. On a Windows (cp1252) locale, `headroom learn` cannot complete a run: the whole pipeline opens transcript files and pipes analyzer prompts without `encoding="utf-8"`, so any non-ASCII byte (em-dashes, arrows — ubiquitous in code and prose) breaks it. Same bug class already fixed for `headroom wrap` (#65, #1126) and the dashboard (#533), never swept through `learn`. Three independent failure points, each hidden behind the previous: 1. **Reading transcripts** — six bare `open()` calls in the learn plugins. The **Codex** JSONL scanner caught only `OSError`, so a `UnicodeDecodeError` propagated and **aborted the whole cross-agent run**; the **Claude** scanner caught it and **silently dropped the session**. `analyzer.py` also read the user's own CLAUDE.md/MEMORY.md with no encoding. 2. **Analyzer subprocess** — `subprocess.run`/`Popen(..., text=True)` with no encoding raised `UnicodeEncodeError` on the piped prompt; it was swallowed, so the run produced **0 recommendations** with no obvious failure. 3. **`--apply` merge** — `writer.py` read the existing context file with strict `encoding="utf-8"`, which aborts on a single stray legacy byte. ## 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 - `learn/plugins/{claude,codex,gemini}.py`: add `encoding="utf-8", errors="replace"` to the six transcript `open()` calls. - `learn/analyzer.py`: same on the `read_text` of the user's context files and on both analyzer subprocess calls (`subprocess.run` and `Popen`). - `learn/writer.py`: add `_read_text_tolerant` — decode the to-be-rewritten context file as UTF-8, falling back to UTF-8-with-replacement on a stray byte (a whole-file cp1252 fallback is wrong: it mojibakes genuine UTF-8 em-dashes); the subsequent `write_text(encoding="utf-8")` self-heals the file. - `cli/learn.py`: wrap `plugin.scan_project` so one unreadable agent/project is skipped with a warning instead of aborting the whole run. ## 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_learn/test_writer.py tests/test_learn/test_plugin_encoding.py -q 22 passed $ ruff check headroom/learn/plugins/*.py headroom/learn/analyzer.py \ headroom/learn/writer.py headroom/cli/learn.py tests/test_learn/test_*.py All checks passed! ``` New tests are **red on the old code, green with the fix**: - `test_plugin_encoding.py` — a transcript with a stray `0x9d` byte (undefined in cp1252 *and* an invalid UTF-8 start byte, so a bare `open()` fails on any locale): the Codex scanner no longer raises, the Claude scanner now recovers the session instead of dropping it. - `test_writer.py::TestEncodingResilience` — `_read_text_tolerant` preserves valid UTF-8 (no mojibake) and `--apply` merges over a file with a stray byte. ## Real Behavior Proof - Environment: Windows 11, Python 3.10, against the real learn plugins/writer (no live LLM backend; the decode failures occur before any backend call). - Exact command / steps: write a Claude transcript and a Codex rollout JSONL containing a valid em-dash/arrow line plus a stray `0x9d` byte, then call `ClaudeCodePlugin._scan_session` / `CodexPlugin._scan_jsonl_session`; for the writer, `write_bytes` an `AGENTS.md` with a stray `0x97` and run `_merge_into_file`. - Observed result: **before** the fix → `CodexPlugin._scan_jsonl_session` raises `UnicodeDecodeError: 'utf-8' codec can't decode byte 0x9d` (aborts the run) and `ClaudeCodePlugin._scan_session` returns `None` (session dropped); **after** → Codex completes, Claude returns the `SessionData` (`total_input_tokens == 5`), and `_merge_into_file` keeps `Notes — existing` with no mojibake. - Not tested: a full end-to-end `headroom learn --apply` against live agent histories + a real LLM backend (verified at the plugin/writer level, which is where the decode failures live). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review |
||
|
|
7c26a54d53
|
fix(wrap): keep Codex RTK guidance global (#1240)
## Description Stops `headroom wrap codex` from writing RTK instructions into the shared project `AGENTS.md`. RTK guidance remains installed in the global Codex `AGENTS.md`, where it applies only to the user who configured Headroom. Closes #1235 ## 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 - Remove project-level RTK guidance injection from `headroom wrap codex`. - Preserve global Codex RTK guidance injection. - Add a regression test proving an existing project `AGENTS.md` remains byte-for-byte unchanged. - Document the fix in the Unreleased changelog. ## 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_cli/test_wrap_codex.py -q 57 passed in 9.54s $ uv run ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_codex.py All checks passed! $ uv run ruff format --check headroom/cli/wrap.py tests/test_cli/test_wrap_codex.py 2 files already formatted $ uv run mypy headroom/cli/wrap.py Success: no issues found in 1 source file $ npx --yes --package=@commitlint/cli --package=@commitlint/config-conventional commitlint --from HEAD~1 --to HEAD --config .commitlintrc.json exited 0 ``` ## Real Behavior Proof - Environment: Windows, Python 3.12.12, locally built Headroom CLI, isolated project directory, isolated `CODEX_HOME`, and isolated `HEADROOM_WORKSPACE_DIR`. - Exact command / steps: created a project `AGENTS.md`, recorded its SHA-256, then ran `.venv\Scripts\headroom.exe wrap codex --prepare-only --no-mcp --no-serena` with isolated environment directories and compared the project hash before and after. - Observed result: command exited 0; RTK downloaded successfully; the project `AGENTS.md` hash remained `2CFF2F420178BFEB9BB863C743805410F2CA30F3F7F70121A8538314CBD0F8B5`; the global Codex `AGENTS.md` was created and contained the `headroom:rtk-instructions` marker. - Not tested: launching an interactive Codex session after preparation; non-Codex wrapper targets, which are unchanged. ## 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) Not applicable. ## Additional Notes The repository-wide pre-commit mypy hook reports existing Windows-only `fcntl` attribute errors in `headroom/subscription/tracker.py` and `headroom/install/runtime.py`; targeted mypy for the changed module passes. The plugin-version hook was also verified directly with the project interpreter and correctly skipped this feature branch. This pull request includes code written with the assistance of AI. The changes have not yet been reviewed by a human. |
||
|
|
1f18d59809
|
fix(proxy): preserve byte-faithful Anthropic tool forwarding (#1222)
## Description Anthropic tool sorting was rewriting `tools` lists even when canonical order was already present, which forced a mutation path that bypassed byte-faithful forwarding and reduced prefix-cache hit stability on repeated `/v1/messages` turns. Closes #1042 ## 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 - changed Anthropic tool canonicalization so already-canonical tool arrays are not rewritten in both single-request and batch paths - preserved byte-faithful forwarding for no-op single-request canonical tool-order requests by avoiding unnecessary `body["tools"]` reassignment - kept canonicalization behavior intact for out-of-order tool arrays - added one true regression proof for the PRE_SEND empty-tools path, plus forward-coverage tests for canonical no-op and real-sort-mutation request bodies in `test_proxy_byte_faithful_forwarding.py` - updated `CHANGELOG.md` to document the prefix-cache fix ## Testing - [x] Unit tests pass (`uv run pytest tests/test_proxy_handler_helpers.py tests/test_proxy_byte_faithful_forwarding.py -x -v`) - [x] Linting passes (`uv run ruff check headroom/proxy/handlers/anthropic.py tests/test_proxy_handler_helpers.py tests/test_proxy_byte_faithful_forwarding.py`) - [ ] Type checking passes (`uv run mypy headroom`) or explain N/A truthfully - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text uv run pytest tests/test_proxy_handler_helpers.py tests/test_proxy_byte_faithful_forwarding.py -x -v 56 passed in 12.42s uv run ruff check headroom/proxy/handlers/anthropic.py tests/test_proxy_handler_helpers.py tests/test_proxy_byte_faithful_forwarding.py All checks passed! uv run ruff format headroom/proxy/handlers/anthropic.py tests/test_proxy_handler_helpers.py tests/test_proxy_byte_faithful_forwarding.py --check 3 files already formatted ``` ## Real Behavior Proof - Environment: Windows, local proxy test environment, Anthropic request-forwarding path - Exact command / steps: post `/v1/messages` requests with canonical tool order and then intentionally unsorted tool order through the `TestClient` path with the no-optimize app variant - Observed result: the PRE_SEND empty-tools runtime path now keeps `body_mutated` false where base would mark the request mutated, canonical-order tool payloads still preserve exact inbound bytes end-to-end, and unsorted tool-order payloads are still canonicalized as expected. - Batch coverage: no dedicated runtime batch regression test was added; batch no-op/mutation correctness is addressed through the same compare-and-assign pattern on both batch canonical-sort call sites. - Not tested: full-suite behavior outside the touched Anthropic forwarding regression surface ## 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 - [ ] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) Not applicable. ## Additional Notes The important boundary is not whether tool arrays can be sorted. The real contract is whether a no-op canonicalization should count as a mutation. This change keeps canonicalization for real reorder cases and restores byte-faithful forwarding for already-canonical requests. |
||
|
|
381d771e46
|
fix(proxy): route Codex OAuth image requests (#1215)
## Description Closes #1189. After a recent Codex Desktop update, its built-in image generation started going through Codex's image client, which POSTs to `images/generations` and `images/edits` relative to the configured provider base URL. In Headroom Proxy mode Codex is pointed at Headroom's `/v1` surface, so those land as `/v1/images/generations` and `/v1/images/edits`. Headroom already had `/v1/images/generations`, but it only ever hit the OpenAI API-key passthrough, and there was no `/v1/images/edits` route at all. So under ChatGPT/Codex OAuth the image calls had nowhere correct to go. This change routes OAuth image requests to `https://chatgpt.com/backend-api/codex/images/{generations,edits}` and leaves the API-key passthrough untouched. Latest upstream re-check: current `openai/codex` main is now `aaf737f`, and the relevant `ImagesClient`/provider-base source still resolves image generation and edit requests to `https://chatgpt.com/backend-api/codex/images/{generations,edits}` under ChatGPT-family auth. One issue-thread datapoint reports Codex Desktop `0.142.0-alpha.6` on macOS generating images successfully via the `/v1/responses` WebSocket path. The requester has now checked this against the latest timestamped Codex update, so this is ready for maintainer review with the remaining full-suite caveat documented below. **Reproduction / test contract** - Reporter's setup: Codex Desktop 0.142.0-alpha.1 on Windows 10, Headroom v0.26.0 Proxy mode, OAuth auth. `/v1/models` and `/v1/responses` work; built-in image generation fails. - Why the route was confirmed from source: the reporter's sanitized logs only show `/v1/models` and `/v1/responses`, so I traced the rest in current Codex source — image generation/edit go through `ImagesClient` as `images/generations` and `images/edits` against the provider base URL. - Regression test: `test_openai_image_routes_use_codex_backend_under_chatgpt_auth` asserts both OAuth image routes now resolve to the ChatGPT Codex image backend. Before this patch, `/v1/images/generations` used the OpenAI API-key target under OAuth and `/v1/images/edits` didn't exist. - Hardening tests: additional regressions cover stale upstream compression headers, OpenAI API-key fall-through for edits, and multipart edit body byte-preservation. ## 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 - Route ChatGPT/Codex OAuth `/v1/images/generations` and `/v1/images/edits` to the ChatGPT Codex image backend. - Strip internal `x-headroom-*`, `Host`, and `Accept-Encoding` headers before forwarding Codex OAuth image requests upstream. - Strip stale `Content-Encoding` and `Content-Length` headers from image responses because httpx has already decoded the body. - Keep API-key image requests on the existing OpenAI passthrough. - Add regression coverage for both OAuth image routes, OpenAI image-edit passthrough, compressed-response header handling, and multipart edit bodies. - Add a `CHANGELOG.md` entry. ## Testing - [ ] 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_provider_proxy_routes.py tests/test_proxy_codex_route_aliases.py tests/test_openai_codex_routing.py -q 42 passed, 1 warning in 7.63s $ UV_PROJECT_ENVIRONMENT=.venv-py312 uv run --python 3.12 --extra dev --extra proxy pytest tests/test_provider_proxy_routes.py tests/test_proxy_codex_route_aliases.py tests/test_openai_codex_routing.py -q 42 passed, 1 warning in 8.54s $ uv run ruff check . All checks passed! $ uv run ruff format --check . 895 files already formatted $ uv run mypy headroom headroom/proxy/server.py:1152: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] headroom/proxy/server.py:1222: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] headroom/proxy/server.py:1226: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] Success: no issues found in 380 source files ``` Earlier full-suite attempt in this branch/environment, before the F1-F8 hardening pass (not rerun after hardening because the failures were unrelated to this route and expensive): ```text $ UV_PROJECT_ENVIRONMENT=.venv-py312 uv run --python 3.12 --extra dev --extra proxy pytest 6 failed, 6499 passed, 486 skipped, 5807 warnings in 219.53s ``` All 6 failures are outside the touched routes and unrelated to this change: - `tests/test_corrupt_golden_bytes_recovery.py` — 3 log-capture assertions - `tests/test_forwarded_headers.py::test_non_allowlisted_peer_ignores_forwarded_and_logs` — 1 log-capture assertion - `tests/test_image_compression.py::TestOnnxRouter::test_full_classify_with_image` — `ModuleNotFoundError: No module named 'PIL'` (only `dev,proxy` extras installed) - `tests/test_transforms/test_kompress_compressor.py::TestKompressBackendSelection::test_unrecognized_backend_warns_and_falls_back_to_auto` — 1 warning-capture assertion On Python 3.14.4, plain `uv run pytest` can't even collect: the project's dependency marker intentionally excludes `litellm` on 3.14, while `tests/test_memory_eval.py` imports the eval runner at collection time. ## Real Behavior Proof - **Environment:** macOS (Darwin arm64). Python 3.14.4 via uv for the default project env; Python 3.12.13 via `UV_PROJECT_ENVIRONMENT=.venv-py312` for the broader suite. Headroom FastAPI proxy route test harness. - **Exact command / steps:** read the reporter's sanitized issue logs; traced current Codex image-generation source; ran the focused Codex/proxy route tests on 3.14 and 3.12; ran lint, format check, and mypy; attempted the full 3.12 suite (output above). - **After-fix evidence + observed result:** the regression test captures the OAuth image requests and confirms they forward to `https://chatgpt.com/backend-api/codex/images/generations` and `.../images/edits` — auth and account headers preserved, internal/host/accept encoding headers stripped, query string carried through, JSON and multipart request bodies forwarded byte-for-byte, and stale upstream response compression headers removed. API-key image generation still uses `images/generations`, and image edits now have the matching `images/edits` passthrough. - **Source evidence:** Re-verified against current `openai/codex` HEAD `aaf737f`. `ImagesClient` still sends relative paths `images/generations` and `images/edits`; `Provider::url_for_path()` appends those to the active provider base; ChatGPT-family auth modes default that base to `CHATGPT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"`. Therefore the source-resolved upstream paths are `/backend-api/codex/images/generations` and `/backend-api/codex/images/edits`, not `/backend-api/images/...`. - **Latest-build caveat:** an issue-thread report says Codex Desktop `0.142.0-alpha.6` on macOS uses `/v1/responses` WebSocket image generation and works through the proxy. That may mean the original Windows `0.142.0-alpha.1` regression is fixed client-side in newer desktop builds, even though the source image endpoint route remains valid and now covered here. The requester has checked this against the latest timestamped Codex update before moving the PR out of draft. - **Not fully tested:** a fully green `uv run pytest` remains unavailable in this local environment for the unrelated failures listed 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 - [ ] 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 - [ ] 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 dependency or version changes. The remaining caveat is that the full local suite isn't green in this environment for the unrelated failures listed above. Happy to follow up with additional runtime logs or to re-run the suite in a maintainer's preferred dev container if that's the cleaner path. --------- Co-authored-by: Johnson <johnsond@brightops.com> |
||
|
|
08fb845fe3
|
fix(ccr): return stored content when headroom_retrieve query matches nothing (#1213) (#1236)
## Description Fixes #1213. `headroom_retrieve` with a `query` returns *"Content not found"* for entries that exist and are unexpired, whenever the query matches no item above the BM25 relevance floor. `HeadroomMCPServer._retrieve_content`'s `query` branch returns only inside `if results:`. An empty `store.search()` result — legitimate when no item clears `score_threshold=0.3` (common for repetitive / low-diversity content, or a query token that matches nothing) — falls through to the generic *"Content not found. It may have expired or the hash may be incorrect."* error, even though `store.retrieve(hash_key)` would return the entry. This conflates *hash missing/expired* with *query matched zero items* and silently discards a valid entry. The `query=None` branch already does the right thing (`store.retrieve`), so the two paths were asymmetric. ## 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`: in `_retrieve_content`, when `query` is given but `store.search()` returns empty, fall back to `store.retrieve(hash_key)` and return the full content (`results=[]`, `count=0`, plus an explanatory `note`) instead of falling through. Genuine misses (`retrieve` → `None`) still reach the "Content not found" error. - `tests/test_ccr_mcp_server.py`: regression tests (below). ## 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_ccr_mcp_server.py -q 5 passed $ ruff check headroom/ccr/mcp_server.py tests/test_ccr_mcp_server.py All checks passed! $ ruff format --check ... 2 files already formatted ``` ## Real Behavior Proof - Environment: Python 3.13, `HeadroomMCPServer(check_proxy=False)` against the real shared `CompressionStore` (no proxy / network). - Exact command / steps: `store.store(repetitive_text, "<<small>>")` → `hash`; then `_retrieve_content(hash, query="zzqx_nonmatching_token")`. - Observed result: **before** the fix → `{"error": "Content not found. ..."}` while `store.retrieve(hash)` returns the entry; **after** → `{"source": "local", "original_content": <text>, "count": 0, "note": "Entry exists but no item matched ..."}`. A genuinely missing hash still returns the error. - Not tested: end-to-end through a running proxy / live MCP client (verified at the store + `_retrieve_content` level, which is where the bug lives). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review |
||
|
|
bd55a426bc
|
fix(proxy): scope CORS to loopback + gate operator/content endpoints (#1226)
## Description Locks down the proxy's browser- and network-facing attack surface, which matters most under a `--host 0.0.0.0` bind (the Docker default). The wildcard CORS policy (`allow_origins=["*"]` + `allow_credentials=True`) let any web page the user had open read the proxy's content endpoints — `/v1/retrieve` returns raw, uncompressed tool outputs (source, secrets) — via a cross-origin fetch to `127.0.0.1` (CWE-346). Several operator endpoints additionally leaked sensitive data or allowed unauthenticated state mutation to any network-reachable client. This PR scopes CORS to loopback origins and extends the project's existing `require_loopback` trust boundary (already used for `/admin/*` and `/debug/*`) to the remaining exposed endpoints. Closes #863. Supersedes #864 and #758 — see "Additional Notes". ## 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 - **CORS**: replaced `allow_origins=["*"]` + `allow_credentials=True` with a port-agnostic loopback origin regex (`https?://(localhost|127\.0\.0\.1|\[::1\])(:\d+)?`), `allow_credentials=False`, and methods/headers narrowed to `GET/POST` + `Content-Type/Authorization`. `HEADROOM_CORS_ORIGINS` (comma-separated) pins an explicit allowlist for Docker/remote dashboards; `*` opts back into the old wildcard. - **`/transformations/feed`** and **`/cache/clear`** gated behind `require_loopback` → 404 for non-loopback callers. The feed returns full prompt/completion bodies when `log_full_messages` is on; `/cache/clear` is unauthenticated state mutation (cache-eviction DoS / cost amplification). - **`/health`**: the `config` block (upstream API URLs, savings profile) is now served only to loopback callers; network callers get the `/readyz`-shape body (status/checks). `/livez` and `/readyz` remain unauthenticated probes for orchestration. - **`/stats`**: `recent_requests` / `request_logs` (per-request ids, providers, models, errors) and `config` are served only to loopback callers; aggregate counters stay public for remote monitoring. - Added `_request_is_loopback()` helper mirroring `require_loopback`'s two-gate check (loopback peer IP + loopback `Host` header, the DNS-rebinding defence) but degrading the payload instead of returning 404, so monitors keep the non-sensitive fields. - Tests: new `tests/test_proxy_cors.py` and `tests/test_proxy_loopback_gating.py`; updated 4 existing tests that assert the now-loopback-only data to use loopback clients. ## 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 $ ruff check headroom/proxy/server.py tests/test_proxy_cors.py \ tests/test_proxy_loopback_gating.py tests/test_proxy_healthchecks.py \ tests/test_proxy_stats_recent_requests.py tests/test_proxy/test_transformations_feed.py All checks passed! $ mypy headroom --ignore-missing-imports Success: no issues found in 380 source files $ pytest tests/test_proxy_cors.py tests/test_proxy_loopback_gating.py \ tests/test_proxy/test_transformations_feed.py tests/test_proxy_healthchecks.py \ tests/test_proxy_stats_recent_requests.py tests/test_proxy_dashboard_stats_cache.py \ tests/test_proxy_compression_executor.py tests/test_header_isolation.py -q ======================== 82 passed, 1 skipped in 17.75s ======================== ``` ## Real Behavior Proof - Environment: macOS (darwin 25.4.0), Python 3.12 venv; FastAPI `TestClient` driving the real `create_app()` ASGI app - Exact command / steps: issued requests as a non-loopback caller (`client.host=testclient`) vs a loopback caller (`base_url=http://127.0.0.1`, `client=("127.0.0.1", 9999)`), plus CORS preflights with varying `Origin` headers - Observed result: CORS — `http://evil.com` → no `access-control-allow-origin`; `http://localhost:8787` and `http://localhost:9000` → echoed (loopback allowed on any port); `access-control-allow-credentials` → absent. `/cache/clear` and `/transformations/feed` → 404 (network) / 200 (loopback). `/health` `config` block present for loopback only. `/stats` `recent_requests` present for loopback only, while the aggregate `tokens` block stays present for network callers. - Not tested: a live `headroom proxy` process bound on `0.0.0.0` reached from a second host (simulated via ASGI peer/Host instead); end-to-end browser DNS-rebinding (covered by the `Host`-header gate and its unit test) ## 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 — proxy/middleware change; behavior is captured under "Real Behavior Proof". ## Additional Notes **Supersedes two stale PRs that target the same issue but have drifted from `main`:** - **#864** (`fix(proxy): scope CORS to localhost`, @gabiudrescu) — correct instinct and the source of the tighter `GET/POST` + `Content-Type/Authorization` scoping kept here, but it derived the allowlist from the `HEADROOM_PORT` env var (wrong when `--port` is passed as a CLI flag), carried ~40 lines of unrelated punctuation churn, and is ~125 commits behind `main`. The port-agnostic regex used here resolves the reviewer's port concern. - **#758** (`security: adversarial review`, @neogenix) — bundled these same application-layer fixes with a large CI/CD + Docker supply-chain pass. It is a ~160-commit-behind draft whose `server.py` no longer merges cleanly (`main` independently adopted the same `require_loopback` pattern). The application-layer fixes are rebased onto current `main` here; the CI/Docker/supply-chain hardening from #758 is still valuable and would be welcome as a separate, rebased PR. Thanks to @gabiudrescu and @neogenix for the original analysis (#863). **Deliberate scope / follow-ups (not in this PR):** - `/stats` aggregate counters and the basic `/health` body remain readable on a `0.0.0.0` bind by design, so remote monitoring keeps working. Full lock-down is a one-line `Depends(require_loopback)` each if preferred. - The `/v1/retrieve*` family stays network-reachable; it can't be loopback-gated without breaking legitimate remote/containerized agents and needs auth instead — tracked separately. - `ruff check .` is scoped to changed paths above because the dashboard HTML template trips ruff's `invalid-syntax` (a known repo false-positive); `mypy` is run over the full `headroom` package. |
||
|
|
b99869778b
|
fix(telemetry): switch anonymous telemetry to opt-in (off by default) (#1223)
## Description
Anonymous usage telemetry was **on by default** (opt-out). This flips it
to **opt-in**: nothing is collected or shipped unless the user
explicitly turns it on. Small change, but it makes "no data leaves the
proxy by default" the actual default rather than something users have to
discover and disable.
Closes # <!-- N/A: no tracking issue -->
## Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [x] New feature (non-breaking change that adds functionality) — adds
`--telemetry` opt-in flag
- [x] Breaking change (fix or feature that would cause existing
functionality to change) — telemetry no longer runs unless opted in
- [x] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `is_telemetry_enabled()` is now **fail-closed**: only explicit
on-values (`on`/`true`/`1`/`yes`/`enable`/`enabled`) enable telemetry;
unset, empty, or unrecognized values stay disabled. This single
predicate gates both the Supabase beacon and the local `/v1/telemetry`
collector.
- Added `--telemetry` opt-in flag to `headroom proxy` and `headroom
install apply`; kept `--no-telemetry` and `HEADROOM_TELEMETRY=off` for
back-compat. If both are passed, opt-out wins.
- Install manifests now write `HEADROOM_TELEMETRY` explicitly
(`on`/`off`) plus the matching flag, so generated systemd/docker/launchd
deployments are unambiguous and don't rely on the runtime default.
- Startup banner and proxy log show `DISABLED` by default and surface
how to opt in.
- Updated tests for opt-in defaults; added coverage for the default-off
banner, the `--telemetry` flag, and the explicit-on manifest path.
- Updated docs
(proxy/configuration/installation/benchmarks/community-savings mdx, spec
011/015, wiki proxy/cli/benchmarks/metrics) and `CHANGELOG.md`.
## Testing
- [x] Unit tests pass (`pytest`) — targeted telemetry + install-planner
suites
- [x] Linting passes (`ruff check`)
- [x] Type checking passes (`mypy`)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
$ python -m pytest tests/test_telemetry_warning.py tests/test_telemetry.py tests/test_install/test_planner.py -q
81 passed
$ ruff check <changed source + test files>
All checks passed!
$ mypy headroom/telemetry/beacon.py headroom/cli/proxy.py headroom/cli/install.py \
headroom/install/planner.py headroom/proxy/server.py
Success: no issues found in 5 source files
```
## Real Behavior Proof
- **Environment:** macOS (darwin 25.4.0), project `.venv`, `headroom`
CLI.
- **Exact command / steps:**
```
$ python -c "import os; from headroom.telemetry.beacon import
is_telemetry_enabled; \
os.environ.pop('HEADROOM_TELEMETRY', None); print('unset ->',
is_telemetry_enabled()); \
[ (os.environ.__setitem__('HEADROOM_TELEMETRY', v), print(repr(v), '->',
is_telemetry_enabled())) \
for v in ['on','TRUE','1','yes','off','0','garbage',''] ]"
$ headroom proxy --help | grep -i telemetry
$ headroom install apply --help | grep -i telemetry
```
- **Observed result:**
```
unset -> False # off by default
'on' -> True 'TRUE' -> True '1' -> True 'yes' -> True
'off' -> False '0' -> False
'garbage' -> False '' -> False # fail-closed
proxy: --telemetry Opt in to anonymous usage telemetry — off by default
(env: HEADROOM_TELEMETRY=on)
--no-telemetry Force anonymous usage telemetry off (already the default;
env: HEADROOM_TELEMETRY=off)
install: --telemetry Opt in to anonymous telemetry in the runtime (off
by default).
--no-telemetry Force anonymous telemetry off in the runtime (already the
default).
```
- **Not tested:** live Supabase beacon network round-trip (no opt-in
network call was made); full `pytest` suite was not run — only the
telemetry + install-planner targeted suites.
## 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
- "Breaking change" is checked because the default behavior changes
(telemetry stops running unless opted in). It is **not** an API break —
`--no-telemetry` and `HEADROOM_TELEMETRY=off` still work, so existing
opt-out configs are unaffected.
- No tracking issue, so `Closes #` is left N/A.
|
||
|
|
3fc2a78a5e
|
fix(kompress): never block the request path on the cold-cache model download (#1161)
Closes #1146.
## Problem
On a cold cache, the first request that reaches the Kompress deep
compressor triggers an inline `hf_hub_download` of the 274 MB
`chopratejas/kompress-v2-base` ONNX model **on the request thread**.
That download races the proxy's compression budget
(`HEADROOM_COMPRESSION_TIMEOUT_SECONDS`, default 30s — the
`compression_first_stage` timeout): the fetch is cancelled mid-transfer,
**nothing finalizes in the HF cache**, and the request fails open
(uncompressed). Because the partial blob never lands, every subsequent
request repeats the same ~30s hang + fail-open, so the deep compressor
never actually becomes available through the proxy.
This is a **distinct root cause from #946** (which concerns the timeout
itself). Here the model must simply never be fetched synchronously on a
latency-sensitive request.
## Fix
Make the request path cache-only and move the one-time download
off-thread.
**`kompress_compressor.py`**
- `compress(..., allow_download=False)` — new keyword (default `True`,
so the direct API and `compress_batch` are unchanged) that resolves the
model cache-only; on a cold cache it raises `KompressModelNotCached` and
passes through instead of blocking on the network.
- `is_ready()` — lockless cache-membership check, safe to call on the
hot path.
- `ensure_background_download(model_id, device)` — starts at most one
daemon thread per model to pull the artifact down out of band (a
finished/failed thread is replaced, so a transient failure can be
retried by a later request). The compression timeout does not bound this
thread.
**`content_router.py`** — gate the deep path on readiness:
- not ready → return passthrough immediately and kick off the background
download;
- ready → `compress(allow_download=False)` (cache-only, no network on
the request thread).
Net effect: the cold-cache deep path returns in ~0 ms (passthrough)
instead of hanging ~30 s; the model downloads once in the background;
subsequent requests transparently use the deep compressor once it is
cached.
## Verification
Clean install of `headroom-ai==0.26.0` (main `@
|
||
|
|
5b84691770
|
fix(unwrap): remove ANTHROPIC_BASE_URL + ENABLE_TOOL_SEARCH and init hooks on unwrap (#992)
## Description `headroom init claude` writes `env.ANTHROPIC_BASE_URL` (and `ENABLE_TOOL_SEARCH`) plus SessionStart/PreToolUse hooks (marker `headroom-init-claude`) into settings.json. But `unwrap` only matched `rtk-rewrite` hooks and never removed the env, and it returned early when no hooks remained — so the routing env survived unwrap, leaving `claude` pointed at a dead proxy. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Broaden the hook-marker match to include `headroom-init-claude`. - Always strip the headroom-managed env vars (`ANTHROPIC_BASE_URL`, `ENABLE_TOOL_SEARCH`) even when no hooks remain. ## 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_unwrap_claude.py -q 9 passed in 0.97s ``` ## Real Behavior Proof - Environment: Linux, Python 3.13, isolated $HOME - Exact command / steps: `headroom init -g claude` then `headroom unwrap claude` - Observed result: after unwrap, settings.json `env` is empty/removed and `hooks` is `[]` (both env vars and the init hooks gone) - Not tested: Windows settings path ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
d789a7c528
|
feat(transforms): tabular + spreadsheet (.xlsx/.xls) compression (#1128)
## Description Adds a content-type-aware path for **tabular data** — CSV/TSV, markdown tables, fixed-width text, and binary `.xlsx`/`.xls` spreadsheets — by routing them through the existing, battle-tested `SmartCrusher` instead of letting them fall through to `PLAIN_TEXT → Kompress`. The pipeline already compressed tables losslessly when handed a JSON array of records. This wires up the missing front door: detect tabular text (and ingest binary spreadsheets), convert to JSON records, and reuse `SmartCrusher.crush()`. No new compression algorithm. 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) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - **Detection** (`content_detector.py`): new `ContentType.TABULAR` + `_try_detect_tabular()` for CSV/TSV, markdown tables, and fixed-width columns. Ordered after search/log (which also look "delimited") and before code, with a prose-rejection guard so it never steals `file:line:content` search output, `key: value` logs, or sentences with incidental commas. Rust backend returns `plain_text` for unknown types and the router already falls back to the Python detector, so **no Rust change**. - **Bridge** (`tabular_ingest.py`): stdlib parsers + `to_records()` + a `TabularCompressor` that parses → JSON records → `SmartCrusher` (lossless `csv-schema` first; lossy row-drop with reversible `<<ccr:HASH>>` markers stays SmartCrusher's built-in fallback). Only adopts a result when it actually saves bytes. - **Spreadsheets** (`spreadsheet_ingest.py`): `.xlsx`/`.xls` → per-sheet CSV text at the SDK boundary. Optional deps (`pip install headroom-ai[spreadsheet]`) fail loudly with an install hint, never silently degrade. - **Routing** (`content_router.py`): `CompressionStrategy.TABULAR`, `enable_tabular_compressor` flag, lazy getter, apply branch, strategy maps, Kompress fallback eligibility. - **SDK** (`compress.py`): `compress_spreadsheet(path, ...)` helper (one message per sheet). - **Packaging** (`pyproject.toml`): new `[spreadsheet]` extra; `openpyxl` added to `[dev]` so the xlsx path is exercised in CI. - **Docs/demo**: `examples/tabular_compression_demo.py` + README entry. ### Design note: lossless-only Compact, all-unique tables with no query yield ~0 savings — this is correct, not a bug. SmartCrusher returns `skip:unique_entities_no_signal` and won't drop unique rows without a duplicate/relevance signal. Real wins come from verbose/redundant tables and query-driven selection. A pressure-driven lossy row sampler was considered and intentionally not added. ## 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 $ python -m pytest tests/test_transforms_tabular.py -q collected 20 items tests/test_transforms_tabular.py .................... [100%] ============================== 20 passed in 7.15s ============================== $ ruff check headroom/transforms/tabular_ingest.py headroom/transforms/spreadsheet_ingest.py All checks passed! $ mypy headroom/transforms/tabular_ingest.py headroom/transforms/spreadsheet_ingest.py Success: no issues found in 2 source files ``` `tests/test_transforms_tabular.py` (20 tests): detection true positives + no-misroute negatives (search/log/JSON/prose), parser units (incl. fixed-width), the CSV→SmartCrusher bridge, router routing + disable flag, and `.xlsx` ingestion (skipif openpyxl missing) + error paths. `spreadsheet_ingest` 100% / `tabular_ingest` 90% line coverage. ## Real Behavior Proof - **Environment:** local checkout of `feat/tabular-compression`, Python 3.x, `pip install -e ".[dev]"`. - **Exact command / steps:** `python examples/tabular_compression_demo.py` (no API key required). - **Observed result:** ```text === Raw tabular text (ContentRouter, char-level) === compact unique CSV strat=tabular chars 1306 -> 1072 ( 17.9% saved) redundant CSV strat=tabular chars 2661 -> 1350 ( 49.3% saved) verbose markdown strat=tabular chars 2019 -> 1580 ( 21.7% saved) === Full pipeline (real tokenizer) === redundant CSV tokens 768 -> 394 ( 48.7% saved) === Binary spreadsheet (.xlsx) === 2-sheet workbook tokens 1092 -> 683 ( 37.5% saved) ``` - **Not tested:** legacy `.xls` binary path (needs optional `xlrd` + binary fixture; `# pragma: no cover`); base64-embedded `.xlsx` inside multimodal blocks (out of scope, noted as a follow-up). ## 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 ## Additional Notes - CHANGELOG/version are intentionally untouched: this repo uses **release-please**, which bumps the version and CHANGELOG via automated `chore: release main` PRs, not per-feature PRs. - The `.xls` path is `# pragma: no cover` (legacy, needs optional `xlrd` + a binary fixture). - Follow-up (out of scope): base64-embedded `.xlsx` inside tool-result/multimodal blocks; porting tabular parsers into the Rust core for parity. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
7e86bafb90
|
fix(tokenizers): bound tiktoken vocab load so a stalled download cannot hang requests (#956) (#994)
## Description The #956 symptom — `compression_first_stage` always timing out at ~30s with 0 tokens removed — is not a PyO3/event-loop issue (compression itself runs ~120ms on Python 3.14). Root cause: `tiktoken` downloads its BPE vocab via `requests.get(...)` with no timeout, loaded lazily inside the compression worker (`TiktokenCounter.encoding`, `AnthropicProvider.__init__`). On a firewalled network that blocks indefinitely, so the worker hangs and `asyncio.wait_for` trips at 30s on every request (the hung download never caches, so it repeats). Refs #956 (runtime half). ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Load the encoding on a worker thread bounded by `HEADROOM_TIKTOKEN_LOAD_TIMEOUT_SECONDS` (default 10s); on timeout raise `TiktokenLoadError` and fall back to estimation (registry -> EstimatingTokenCounter; Anthropic provider -> character estimate). - Remember the first timed-out encoding so later requests fail fast instead of re-blocking. ## Testing - [x] Unit tests pass (`pytest`) - [x] New tests added for new functionality ### Test Output ```text $ pytest tests/test_tokenizers/test_tiktoken_load_timeout.py -q 4 passed in 0.69s ``` ## Real Behavior Proof - Environment: Linux, Python 3.14 and 3.13 - Exact command / steps: timed the Rust compression call sync / via run_in_executor / 2x concurrent on both interpreters; ran the bounded-loader tests against a simulated stalled get_encoding - Observed result: compression ~118-120ms on both 3.14 and 3.13 (no event-loop block); the bounded loader raises/falls back within the timeout instead of hanging - Not tested: the real firewalled-network stall (could not reproduce on an unfirewalled host); the no-timeout requests.get is confirmed in tiktoken's source ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
500ec2b7fa
|
fix(init): set ENABLE_TOOL_SEARCH=true so Claude Code keeps deferring tools (#746) (#995)
## Description Claude Code disables on-demand tool loading (Tool Search) when `ANTHROPIC_BASE_URL` is a custom host and `ENABLE_TOOL_SEARCH` is unset, materializing all MCP/system tool schemas into its context window (#746). With many MCP servers this overflows the window — breaking sub-agent spawns ("prompt too long, ~214k > 200k") and forcing constant compaction. `headroom wrap claude` already sets it; `init`/install did not. Refs #746. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Keep tool deferral on at both entry points, sharing one `TOOL_SEARCH_ENV` / `TOOL_SEARCH_DEFAULT` constant from the Claude provider package (`providers/claude/runtime.py`) so the key/default can't drift: - `init` (`_ensure_claude_hooks`): sets `ENABLE_TOOL_SEARCH=true` via `setdefault`, respecting a pre-existing user-provided value. - `install` (`build_install_env`): always writes `ENABLE_TOOL_SEARCH=true`. This is the headroom-managed install env (recorded and reverted on uninstall), so it is authoritative rather than deferring to an existing value. ## 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_init_enable_tool_search.py -q 3 passed in 0.63s ``` ## Real Behavior Proof - Environment: Python 3.14, headroom proxy on :8799, ~19 MCP servers connected - Exact command / steps: launched `claude` through the proxy with vs without `ENABLE_TOOL_SEARCH=true`, asked each to spawn 5 parallel sub-agents - Observed result: without it, all 5 sub-agents fail ("prompt too long, ~214k > 200k"); with `ENABLE_TOOL_SEARCH=true` all 5 succeed and traffic compresses - Not tested: non-Claude-Code agents ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
f03e77bec0
|
fix(proxy): honor HEADROOM_EXCLUDE_TOOLS for Codex /v1/responses tool outputs (#940) (#1053)
## Description `HEADROOM_EXCLUDE_TOOLS` protects excluded tool outputs for Anthropic `tool_result` blocks and OpenAI chat `role=tool` messages, but was ignored on the Codex `/v1/responses` path. Large exact MCP outputs (e.g. Serena `find_symbol` / `get_symbols_overview`) were compressed even when the tool name was explicitly excluded, so the model saw summarized output and fell back to raw file reads. Closes #940 ## 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 Root cause: `ContentRouter` consults `exclude_tools` via a `tool_call_id -> name` map built from chat `tool_calls` / Anthropic `tool_use` blocks (`_build_tool_name_map`). The Responses adapter (`_compress_openai_responses_live_text_units_with_router`) extracted every `function_call_output` as a compression unit without correlating it to the originating `function_call`'s name, so `exclude_tools` was never consulted for Responses tool outputs. - `headroom/proxy/handlers/openai.py`: - Build a `call_id -> tool name` map from the Responses `function_call` items (the name lives on `function_call`, the originating `call_id` on the matching `function_call_output`). - Resolve the effective exclude set the same way `ContentRouter` does (`router.config.exclude_tools`, falling back to `DEFAULT_EXCLUDE_TOOLS` when `None`). - Skip extraction of outputs whose originating tool is excluded, mirroring the existing `headroom_retrieve` output guard. Name matching also tests the lowercased name defensively for case-insensitivity. ## 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 tests/test_openai_responses_compression_units.py::test_openai_responses_adapter_preserves_excluded_tool_outputs PASSED tests/test_openai_responses_compression_units.py::test_openai_responses_adapter_compresses_non_excluded_tool_outputs PASSED tests/test_openai_responses_compression_units.py::test_openai_responses_adapter_preserves_headroom_retrieve_outputs PASSED tests/test_openai_responses_compression_units.py::test_openai_responses_adapter_compresses_custom_tool_call_output PASSED 4 passed $ ruff check headroom/proxy/handlers/openai.py tests/test_openai_responses_compression_units.py All checks passed! ``` ## Real Behavior Proof - Environment: macOS (ARM64), Python 3.13. - Exact command / steps: ran the new and adjacent unit tests for the Responses compression adapter. The native `headroom._core` extension could not be compiled locally (macOS 26 C++ toolchain), so these tests were executed with a stubbed `_core`; the changed code path is pure Python and the tests override `router.compress`, so the stub does not affect what is exercised. CI builds the real core. - Observed result: outputs for an excluded tool (`serena.find_symbol`) are left untouched (`modified=False`), while outputs for a non-excluded tool still compress and are replaced with the routed summary. - Not tested: full native build / live Codex end-to-end run; `mypy`. ## 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 Documentation / CHANGELOG updates are N/A: this restores the documented behavior of `HEADROOM_EXCLUDE_TOOLS` on a path where it was silently dropped. `mypy` and a full native build were not run in this environment; the change is pure Python. |
||
|
|
9f7f3adfea
|
fix(ccr): accept 12-char SmartCrusher hashes in tool injection (#1095) (#1141)
Fixes #1095. ## Problem SmartCrusher emits **12-hex-char** hashes inside `<<ccr:HASH N_rows_offloaded>>` (and the opaque-blob `<<ccr:HASH,KIND,SIZE>>`) markers, and the compression store serves them over `GET /v1/retrieve/{hash}`. But `CCRToolInjector.scan_for_markers()` and `parse_tool_call()` in `headroom/ccr/tool_injection.py` only recognized the **24-char** hex used by the legacy bracket markers, so the two layers were out of sync: - `scan_for_markers()` returned `[]` for SmartCrusher output (injector thought no compressed content was present). - `parse_tool_call()` returned `(None, None)` for 12-char hashes. - `POST /v1/retrieve/tool_call` and the proxy auto-continue path — both route through `parse_tool_call` (`proxy/server.py`, `ccr/response_handler.py`) — returned **400**, while `GET /v1/retrieve/{12-char-hash}` worked. ## Fix (scoped to `tool_injection.py`) - **`scan_for_markers`**: add a `<<ccr:([a-f0-9]{12,24})>>` pattern matching the row-drop summary and opaque-blob marker forms. This mirrors the substring scan already used in `transforms/smart_crusher.py::_collect_ccr_hashes_from_string`. - **`parse_tool_call`**: accept the two real CCR hash lengths (12 or 24 hex) instead of requiring exactly 24. Shorter, longer, or non-hex hashes are still rejected. Legacy 24-char bracket markers and the existing `TestHashSecurityValidation` tests are unaffected (a 6-char hash is still too short, a 30-char hash still too long). ## Verification Loaded the modified module directly and confirmed: | input | before | after | |---|---|---| | `<<ccr:e21a26620105 988_rows_offloaded>>` scan | `[]` | `['e21a26620105']` | | `<<ccr:deadbeefdead,string,2.3KB>>` scan | `[]` | `['deadbeefdead']` | | `parse_tool_call` 12-char hash | `(None, None)` | `('e21a26620105', query)` | | `parse_tool_call` 24-char hash | works | works (unchanged) | | `parse_tool_call` 6-char / 30-char / non-hex | rejected | rejected | Adds `TestSmartCrusherCcrMarkers` covering both marker forms, the 12-char parse path, and a regression guard for the 24-char path. |
||
|
|
bcabc5cb11
|
fix(providers): update DeepSeek V3 context limit from 128K to 1M (#1038) (#1137)
## Description
Update `_DEFAULT_CONTEXT_LIMITS` so DeepSeek V3/V4 use their actual 1M
(1,048,576) context window instead of the outdated 128K. The hardcoded
128K causes Headroom to trigger compression far too early, defeating the
purpose of using a long-context model.
Closes #1038
## 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
- Update `deepseek` default from 32,768 to 1,048,576 (V3/V4 family
default)
- Update `deepseek-v3` from 128,000 to 1,048,576
- Update `deepseek-coder` from 16,384 to 128,000 (Coder V2+)
- Add `deepseek-v4` entry at 1,048,576
- `deepseek-v2` stays at 128,000 (unchanged)
## 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
$ python -m pytest tests/test_providers/test_universal.py -v -x
37 passed, 3 skipped in 38.08s
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.11, headroom main (
|
||
|
|
6904d47a01
|
feat(proxy): hot-reload live env knobs so a reused proxy picks them up without a restart (#1090)
## Description A small class of env vars is read by the proxy **live, per request** — the output-shaper family (`HEADROOM_OUTPUT_SHAPER`, `HEADROOM_VERBOSITY_LEVEL`, `HEADROOM_EFFORT_ROUTER`, `HEADROOM_MECHANICAL_EFFORT`, `HEADROOM_VERBOSITY_AUTOTUNE`, `HEADROOM_OUTPUT_HOLDOUT`), or captured at import (`HEADROOM_INTERCEPT_READ_MIN_CHARS`). The proxy reads them from its own process environment, fixed at launch. But `headroom wrap` reuses an already-running proxy (it restarts only on startup-config drift), so a value exported *after* the proxy started silently no-op'd — e.g. `export HEADROOM_OUTPUT_SHAPER=1` had zero effect on a reused proxy on `:8787`. This PR makes those live knobs **hot-reloadable**: `headroom wrap` pushes them to the running proxy, which applies them in memory — no restart (a restart would cold-start the ML stack, drop in-flight requests, and lose CCR/router caches). _No linked issue._ ## Type of Change - [x] 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/runtime_env.py` (new): single source of truth registering the live knobs + a thread-safe process-global override store. `getenv()` (override-then-env) is a drop-in for `os.environ.get`; behaviour is byte-identical when no override is set. - Readers rerouted through `runtime_env.getenv`: `output_shaper.py`, the anthropic holdout read, and the ast-grep threshold (now a live read, not an import-time constant). - Proxy: loopback-only `POST /admin/runtime-env` applies overrides in memory; `/health` → `config.runtime_env` surfaces the live values so reuse is observable. - `wrap`: after attaching to a proxy (all call sites), best-effort push of the session's **explicitly-set** knobs. No-ops if nothing is set, `--no-proxy`, the proxy is unreachable, or it predates the endpoint (404). Only explicitly-set knobs are pushed, so a session never clobbers another with a default it never asked for. - Docs: README + output-token-reduction guide document the global-override caveat. ## 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 $ python -m pytest tests/test_runtime_env.py -q 16 passed $ python -m pytest tests/test_runtime_env.py tests/test_output_shaper.py -q 50 passed $ ruff check headroom/proxy/runtime_env.py headroom/proxy/output_shaper.py headroom/proxy/handlers/anthropic.py headroom/proxy/interceptors/astgrep.py headroom/proxy/server.py headroom/cli/wrap.py All checks passed! $ mypy headroom/proxy/runtime_env.py Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: local macOS, Python 3.12 `.venv`, branch `fix/runtime-env-hot-reload` at the PR head. - Exact command / steps: ran the test suites above. The 16 new `test_runtime_env` tests exercise the registry/store, overrides reaching the shaper + the ast-grep threshold, the `POST /admin/runtime-env` apply + `/health` reflect + loopback-only 404 + 400-on-non-object, and the wrap push payload / no-op / error-swallow paths. - Observed result: 50 passed; ruff + mypy clean on the changed modules; an override set via the endpoint is read by `getenv()` at the shaper and surfaced in `/health` config. - Not tested: a literal two-terminal manual session (start a proxy, `headroom wrap` a second session, `export HEADROOM_OUTPUT_SHAPER=1`, confirm the reused proxy picks it up). The behaviour is covered by the endpoint + wrap-push integration tests, but was not exercised by hand here. ## 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 ## Additional Notes - **Inherent caveat (documented):** overrides are global to the proxy — one process serves every attached wrapper, so the last explicit setting wins. No mechanism (restart or hot-reload) can give two sessions on one shared proxy different output-shaper settings. - **Scope:** startup-captured settings (`HEADROOM_TARGET_RATIO` etc.) are intentionally out of scope — a fresh proxy already gets them and they ride the existing `/health` config channel. - **Merge blocker:** this branch is currently **CONFLICTING with `main`** and needs a rebase/merge before it can land. - CHANGELOG.md left unchanged — releases are managed by release-please from conventional commits. |
||
|
|
26be2c39cb
|
feat(cli): add headroom update command and release banner (#1088)
## Description Adds a `headroom update` self-update command and a passive "update available" banner, so users no longer need to remember the right `pip`/`pipx`/`uv` incantation for their environment, and long-running proxies get nudged when they drift behind a release. Closes #1087 ## Type of Change - [x] New feature (non-breaking change that adds functionality) - [x] Documentation update ## Changes Made - `headroom/cli/update.py` — `headroom update` command. `detect_install_method()` resolves the install (git checkout, editable, Docker, pipx, uv tool, venv/conda, `pip --user`, externally-managed system Python per PEP 668, writable global) and builds the matching upgrade. pip path always uses `sys.executable -m pip` so it can't touch the wrong interpreter. Refuses with guidance where self-update is unsafe. Flags: `--check`, `--yes`, `--pre`, `--extras`. - `headroom/update_check.py` — best-effort PyPI check (stdlib `urllib`, no new dep). Split into a daemon-thread probe that caches to `~/.headroom/update_check.json` (≤ once/day) and a cache-only `format_update_notice()`. Opt-out `HEADROOM_UPDATE_CHECK=off`; skipped in `--stateless`, CI, Docker, checkouts. - `headroom/cli/main.py`, `headroom/cli/__init__.py` — register `update`; fire the background check from the group callback (skipped for `update`). - `headroom/cli/proxy.py` — render the one-line notice after the startup banner (best-effort, never blocks). - `README.md` — "Updating" section + opt-out env var. - Tests: `tests/test_update_check.py`, `tests/test_cli_update.py`. ## 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 tests/test_update_check.py tests/test_cli_update.py -q 42 passed in 1.63s $ ruff check headroom/cli/update.py headroom/update_check.py headroom/cli/main.py All checks passed! $ mypy headroom/update_check.py headroom/cli/update.py Success: no issues found in 2 source files ``` ## Real Behavior Proof - Environment: macOS, Python 3.11, source checkout - Exact command / steps: `python -m headroom.cli update --help`; `detect_install_method()` in the checkout - Observed result: command + flags render; in a checkout `detect_install_method()` returns `kind=checkout, can_self_update=False` ("update with `git pull`") and `format_update_notice()` returns `None` (dev tree not nagged) - Not tested: live PyPI fetch and a real pipx/uv-tool upgrade on this machine (covered by unit tests with mocked `urllib`/`subprocess`) ## 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 ## Additional Notes - CHANGELOG.md is release-please-managed, so it is intentionally not hand-edited (N/A above). - Update check uses stdlib `urllib` because `httpx` lives only in the `[proxy]` extra — the base CLI must stay dependency-light. |
||
|
|
a554c3a0e6
|
fix(wrap): write env.ANTHROPIC_BASE_URL to settings.json so daemon-spawned conversations inherit proxy (#951) (#1078)
## Description Claude Code pre-forks conversation workers via spawn (not fork) on macOS. Those workers read settings files fresh on each new session rather than inheriting the daemon process's environment. `headroom wrap claude` was passing `ANTHROPIC_BASE_URL` only via `subprocess.run`'s `env` dict, which reaches the initial Claude Code process and the daemon — but not conversation workers spawned later from the daemon pool. New conversations silently bypassed the proxy and hit `api.anthropic.com` directly. ### Design decision: why project-local settings Three approaches were considered: **1. Global `~/.claude/settings.json`** — rejected. This file is shared across every Claude Code session on the machine. A user who runs `headroom wrap claude` in one terminal but opens an unwrapped session elsewhere would have their global settings rewritten to point at the Headroom proxy. If the proxy dies and cleanup does not run (SIGKILL, crash), the stale URL breaks all future sessions until the user manually edits the global file. **2. Kill cc-daemon before launch** — rejected. The issue itself suggests this, but killing the daemon is disruptive: it destroys the pre-forked worker pool shared by any other open Claude Code windows. Active conversations may lose their parent process. This is a hard-to-reverse side-effect of a command the user expects to be safe. **3. Project-local `<cwd>/.claude/settings.local.json`** — chosen. Claude Code applies `env` keys from project-local settings per its documented precedence order (Local > Project > User), and reloads them per-conversation. Scoping to the project means: other projects and unwrapped sessions are unaffected; the file is git-ignored by default so it won't be committed; and the worst-case stale URL (proxy crash without cleanup) affects only that one project's local settings and is trivially recoverable by re-running `headroom wrap claude` or deleting the file. Closes #951 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Added `_write_claude_wrap_base_url(proxy_url, *, foundry_mode, settings_path)` in `headroom/cli/wrap.py`: merges `ANTHROPIC_BASE_URL` (or `ANTHROPIC_FOUNDRY_BASE_URL` in foundry mode) into `<cwd>/.claude/settings.local.json` under the `env` key. Returns the previous value for restore. - Added `_restore_claude_wrap_base_url(previous, *, foundry_mode, settings_path)`: called in the `wrap claude` `finally` block and in `unwrap_claude` to remove or restore the key so a stale proxy URL is never left behind. - `unwrap_claude` calls restore for both standard and foundry keys. - New test file `tests/test_cli/test_wrap_claude_base_url.py` (12 tests covering write, restore, roundtrip, foundry mode, sibling key preservation, and noop on absent file). ## 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 pytest tests/test_cli/test_wrap_claude_base_url.py -v 12 passed in 0.21s ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_claude_base_url.py All checks passed! ``` ## Real Behavior Proof - Environment: macOS, branch `fix/daemon-base-url-inheritance`, Python 3.11.9. - Exact command / steps: Ran `pytest tests/test_cli/test_wrap_claude_base_url.py -v` and `ruff check` on the modified files from the PR branch. - Observed result: 12 new unit tests pass; ruff reports no issues. - Not tested: Live end-to-end verification (opening a second conversation via the daemon pool and confirming proxy receives traffic) — not safe to test inside the current wrapped session on port 8787. ## 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 issue reporter tried `apiBaseUrl` in settings.json and found it ineffective. That key configures the API endpoint at the CC UI layer, not the process environment. `env.ANTHROPIC_BASE_URL` is the correct mechanism for propagating an environment variable to CC worker processes. |
||
|
|
9f712ccbd7
|
fix(wrap): percent-encode non-ASCII cwd names in X-Headroom-Project header (#1071)
## Description Non-ASCII directory names (Chinese, Japanese, Korean, Cyrillic) caused an immediate API error when using `headroom wrap claude`: ``` API Error: Header 'X-Headroom-Project' has invalid value: '第二大脑共享' ``` RFC 7230 requires HTTP header values to be visible ASCII only. The raw cwd basename was being sent directly, breaking the entire session before the first token. Closes #1069 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `headroom/cli/wrap.py` — `_project_name_from_cwd()`: percent-encode non-ASCII chars via `urllib.parse.quote(name, safe="-_.() ")` so the header value is always ASCII-safe - `headroom/proxy/savings_tracker.py` — `sanitize_project_name()`: `urllib.parse.unquote()` before cleanup so the stored/displayed project name is the original Unicode directory name ASCII-only project names are unaffected (quote/unquote is a no-op for them). ## Testing - [x] Unit tests pass (`pytest`) - [x] New tests added for new functionality ### Test Output ```text tests/test_cli/test_wrap_helpers.py::TestApplyProjectHeaderEnv::test_non_ascii_cwd_name_is_percent_encoded PASSED tests/test_cli/test_wrap_helpers.py::TestApplyProjectHeaderEnv::test_non_ascii_cwd_header_is_ascii_safe PASSED tests/test_proxy_project_savings.py::test_sanitize_project_name_decodes_percent_encoded_non_ascii PASSED ======================== 15 passed, 1 warning in 0.42s ========================= ``` ## Real Behavior Proof - Environment: macOS 15, Python 3.11.9, headroom dev install from source - Exact command / steps: `mkdir /tmp/test-中文-项目 && cd /tmp/test-中文-项目`, then run `.venv/bin/pytest tests/test_cli/test_wrap_helpers.py::TestApplyProjectHeaderEnv::test_non_ascii_cwd_header_is_ascii_safe -v` — header_value.encode("ascii") passes without UnicodeEncodeError - Observed result: `X-Headroom-Project` header contains percent-encoded ASCII (`test-%E4%B8%AD%E6%96%87-%E9%A1%B9%E7%9B%AE`); proxy decodes back to `test-中文-项目` for storage - Not tested: live end-to-end wrap session with a real Claude API key ## 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 added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
fe4f9ee478
|
feat(policy): decay P_alive from idle time near cache TTL (#856 P3b) (#1028)
## Description #856 P3b (umbrella #904), the idle-timer-compaction increment after P2 (#905), P2b (#944), and P3a (#1015), all merged. Anthropic prompt-cache entries live in a ~5-minute TTL tier (the basis for the 1.25× write multiplier). As a session goes idle the cached suffix approaches lapse, so **P_alive** — the probability the cache still survives to the next turn — decays toward 0. When P_alive → 0 the net-cost penalty term `P_alive·(w−r)·(S+ΔT)` vanishes and a deep edit near lapse is free to make: the suffix is about to be rebuilt cold regardless. P2/P3a fed the break-even gate a **static** `HEADROOM_NET_COST_P_ALIVE` constant; this derives P_alive from an idle signal when one is available. Flag-gated under `HEADROOM_NET_COST_POLICY` (the same flag as P2/P2b/P3a), default **off**. ## Type of Change - [x] New feature (non-breaking change which adds functionality) - [ ] Bug fix - [ ] Breaking change - [ ] Documentation ## Changes Made - `ContentRouter.apply`: reads an optional `idle_seconds` kwarg and derives `P_alive = max(0, 1 − idle_s / ttl)` **once per request** (idle is a per-request property, like `frozen_message_count`), passing it to the gate as `p_alive_override`. Absent/malformed `idle_seconds` → `None` → the P2 env-constant path is preserved exactly. - `ContentRouter._net_cost_allows`: new `p_alive_override` param. When set it replaces the `HEADROOM_NET_COST_P_ALIVE` constant (clamped to [0,1]); otherwise unchanged. An admit made under a decayed (`< 1.0`) idle P_alive emits the `router:netcost_idle_compaction` marker and the `netcost_idle_admitted` counter (independent of the P3a batch marker; both may apply). - Cache TTL: module default 300s (Anthropic tier), overridable via `HEADROOM_NET_COST_CACHE_TTL_SECONDS`, with malformed/non-positive guards. Explicitly **distinct** from `PrefixFreezeConfig.session_ttl_seconds` (tracker cleanup, 600s). - `PrefixCacheTracker.seconds_since_activity()`: exposes the idle signal for the proxy handlers to plumb (see Additional Notes). ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] New tests added for new functionality ### Test Output ```text $ pytest tests/test_netcost_gate.py -q 25 passed in 2.03s $ pytest tests/ -k "content_router or netcost or router or prefix_tracker or prefix" -q 245 passed, 8 skipped, 6252 deselected, 1 warning in 24.47s $ ruff check headroom/transforms/content_router.py headroom/cache/prefix_tracker.py tests/test_netcost_gate.py All checks passed! $ ruff format --check headroom/transforms/content_router.py headroom/cache/prefix_tracker.py tests/test_netcost_gate.py 3 files already formatted $ mypy headroom/transforms/content_router.py headroom/cache/prefix_tracker.py Success: no issues found in 2 source files ``` ## Real Behavior Proof - Environment: local macOS, repo .venv, Python 3.11.9, gpt-4o tokenizer fixture - Exact command / steps: drive `ContentRouter.apply()` on the P2 "blocked" baseline (a modest tool-dump shave, ΔT≈5K, under a ~120K-token cached suffix — rejected at the default P_alive=1.0), varying only `idle_seconds`. - Observed result: `idle_seconds=295` (TTL 300) → P_alive≈0.017, penalty collapses, the edit is admitted and `router:netcost_idle_compaction` is emitted; `idle_seconds=0` → P_alive=1.0, byte-identical to the constant baseline (still blocked, `netcost:skip:` emitted, no idle marker); absent/malformed `idle_seconds` → env-constant path (blocked); `HEADROOM_NET_COST_CACHE_TTL_SECONDS=60` with `idle_seconds=59` → unlock (custom TTL controls the decay). - Not tested: live proxy traffic — deferred to the default-on milestone per #904 (ships default-off to gather telemetry first). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Additional Notes **Proxy wiring is a deliberate follow-up**, mirroring how P2 shipped P_alive as an unplumbed constant and gathered telemetry before default-on. The gate already honors `idle_seconds` via kwarg and `PrefixCacheTracker.seconds_since_activity()` exposes the value; the remaining step is for the provider handlers (`handlers/anthropic.py`, `handlers/openai.py`) to pass it alongside the existing `frozen_message_count` kwarg (`pipeline.apply` already forwards `**kwargs` to `transform.apply`, so no pipeline change is needed). One wiring caveat is documented on `seconds_since_activity()`: `SessionTrackerStore.get_or_create` refreshes `_last_activity` on access, so the handler must read idle before fetching the tracker for the current request. Kept out of this PR for reviewability and because it touches ~10 call sites across both providers. --------- Co-authored-by: JD Davis <mxjerrett@gmail.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> |
||
|
|
0e0591506c
|
feat(vertex): turnkey Claude Code + Vertex compression (+ fixes from the Vertex review) (#1113)
## Description
Makes **Claude Code on Google Vertex AI** actually receive Headroom's
prompt compression, and fixes the issues found in a deep review of the
Vertex path. The headline is a turnkey path: `headroom wrap claude`
(with the user's existing Vertex env) compresses each request and
forwards to Vertex using the client's own GCP ADC token — Headroom holds
no credentials.
_No linked issue — this addresses the internal Vertex code review
(`docs/proposals/vertex-claude-compression-review.md`)._
## Type of Change
- [x] 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
- `cli/wrap.py`: `wrap claude` detects `CLAUDE_CODE_USE_VERTEX=1` and
points Claude Code's Vertex endpoint at the proxy via
`ANTHROPIC_VERTEX_BASE_URL` (Claude Code ignores `ANTHROPIC_BASE_URL` in
Vertex mode). Client keeps its own GCP ADC auth. Adds
`--backend`/`--region` flags (parity with `wrap aider`).
- `providers/registry.py`: alias `litellm-vertex` → provider
`vertex_ai`. Previously it resolved to `"vertex"` (not in the registry)
→ generic pass-through with the wrong model prefix, dropped region, and
mishandled auth, even though all help text advertises `litellm-vertex`.
- `providers/proxy_routes.py`: derive the Vertex upstream host
per-request from the path's `locations/{location}` (handles `global`)
instead of pinning the configured fixed-region host; explicit
`--vertex-api-url` overrides still win.
- `docs/content/docs/claude-code-vertex.mdx` (+ nav): simple user guide
for running Claude Code on Vertex through Headroom.
- `docs/proposals/vertex-claude-compression-review.md`: the deep-review
findings these fixes address.
- `tests/test_vertex_claude_compression.py`: new tests.
## 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
$ python -m pytest tests/test_vertex_claude_compression.py -q
8 passed
$ python -m pytest tests/test_provider_proxy_routes.py tests/test_provider_registry.py tests/test_cli_proxy_env.py tests/test_backend_bugs.py -q
108 passed
$ ruff check headroom/providers/registry.py headroom/providers/proxy_routes.py headroom/cli/wrap.py tests/test_vertex_claude_compression.py
All checks passed!
$ mypy headroom/providers/registry.py headroom/providers/proxy_routes.py headroom/cli/wrap.py
Success: no issues found in 3 source files
```
## Real Behavior Proof
- Environment: local macOS, Python 3.12 `.venv`, branch
`feat/vertex-claude-compression`.
- Exact command / steps: ran the test suite above; verified in code that
the native `:rawPredict` route (publisher=anthropic) delegates to
`handle_anthropic_messages` with the region-derived host, that
`create_proxy_backend("litellm-vertex")` resolves to provider
`vertex_ai`, and that `wrap claude` sets `ANTHROPIC_VERTEX_BASE_URL`
when `CLAUDE_CODE_USE_VERTEX` is set.
- Observed result: 8 new tests + 108 existing tests pass; ruff + mypy
clean; the alias, region derivation (incl. `global` and explicit
override), and rawPredict→compression-handler delegation all behave as
asserted.
- Not tested: a live end-to-end run of Claude Code against a real Google
Vertex project (no GCP credentials available in this environment).
Recommend one smoke test against a live Vertex project before announcing
GA. The Rust `headroom-proxy` Vertex path is intentionally out of scope
(separate, unwired binary).
## 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
## Additional Notes
- CHANGELOG.md left unchanged — releases are managed by release-please
from conventional commits.
- Follow-ups (not in this PR): wire or formally retire the Rust
`headroom-proxy` Vertex implementation; add a live-Vertex smoke test
once CI has GCP credentials.
|
||
|
|
e45cf4e061
|
feat(cli): add headroom doctor setup diagnostics (#926)
## Description Headroom fails silently: a client not routed through the proxy (or a proxy running stale code) keeps working — it just stops saving tokens. State that determines whether you are actually saving lives in five places nothing reconciles. `headroom doctor` correlates them in one command (the diagnostic idiom of `claude doctor` / `pnpm doctor`, and the repo's own `headroom tools doctor`). Closes # <!-- no tracked issue; setup-diagnosis gap found this session --> ## 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/cli/doctor.py`: new command with 8 pure checks (proxy liveness, version drift, claude/codex routing, shell env, savings flow, budget, deployments); exit codes 0/1/2; `--json`; `--port`/`HEADROOM_PORT`. - `headroom/proxy/cost.py`: expose `budget_limit_usd`/`budget_period` in `CostTracker.stats()` so the budget check can read it (older proxies degrade to a warning). - `headroom/cli/main.py`: register the command. - `tests/test_cli_doctor.py`: 41 tests, zero network (probed payloads / paths / env injected). ## 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 tests/test_cli_doctor.py -q 41 passed ``` ## Real Behavior Proof - Environment: local macOS, Python 3.11, against a real proxy running for 3 days, branch `feat/doctor-command`. - Exact command / steps: `headroom doctor` (live), plus `pytest tests/test_cli_doctor.py -q`. - Observed result: Correctly flagged real version drift (proxy 0.25.0 vs installed 0.26.0), an unrouted claude client, and a shell `OPENAI_BASE_URL` pointed at a non-Headroom gateway; savings check showed 17.6M tokens / $7.82 saved; exit code 1 (warnings). - Not tested: Windows path handling for client config files (logic is OS-agnostic via pathlib). ## 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) Terminal output of `headroom doctor` (rich table) can be attached; the rendered table is reproduced in the live-proof bullet above. ## Additional Notes Branched fresh from main. The budget check connects to the enforcement fix in #885. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: JD Davis <mxjerrett@gmail.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> |
||
|
|
b0cd0329c7
|
fix(proxy): stamp X-Client: codex on Responses endpoint for unidentified callers (#1036)
## Description Codex Desktop (OpenAI's Codex GUI/IDE app) sends a `User-Agent` of the form `Codex Desktop/<ver> (...)`, which is not in `CLIENT_UA_MAP`, so `classify_client` returns `None`. On a compression timeout the backend only takes the codex fail-open path when the client classifies as `codex`; for an unidentified client it refuses with HTTP 413 (`compression_refused`), which Codex treats as a hard connection failure. This stamps `X-Client: codex` on requests to the Responses endpoint (`/v1/responses`) only when the caller does not otherwise classify. The stamp is scoped to the Responses endpoint and skipped for any caller that already classifies through a recognized user-agent or explicit `X-Client`, so non-Codex traffic is not relabeled. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [x] Tests only ## Changes Made - Added `should_stamp_codex_client(path, headers)` in `headroom.proxy.auth_mode` for narrow Responses-endpoint client stamping. - Applied the stamp in HTTP middleware before downstream request classification. - Applied the same stamp in the Responses WebSocket handler, which bypasses HTTP middleware. - Added unit coverage for the stamp/skip matrix, including Codex Desktop, explicit clients, recognized user-agents, and WebSocket behavior. - Merged current `main` and kept both the new stamp coverage and the existing Codex WebSocket image-generation regression coverage. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] Formatting passes (`ruff format --check`) - [x] New tests added for new functionality ### Test Output ```text python -m pytest tests/test_codex_client_stamp.py tests/test_auth_mode.py tests/test_openai_codex_ws_lifecycle.py -q 48 passed in 1.13s ruff check headroom/proxy/auth_mode.py headroom/proxy/server.py headroom/proxy/handlers/openai.py tests/test_codex_client_stamp.py tests/test_auth_mode.py tests/test_openai_codex_ws_lifecycle.py All checks passed! ruff format --check headroom/proxy/auth_mode.py headroom/proxy/server.py headroom/proxy/handlers/openai.py tests/test_codex_client_stamp.py tests/test_auth_mode.py tests/test_openai_codex_ws_lifecycle.py 6 files already formatted ``` ## Real Behavior Proof - Environment: local Windows 11 development checkout, Python 3.13.13, branch updated from `upstream/main`. - Exact command / steps: Ran the focused unit suite for the new client-stamp behavior and the overlapping Codex WebSocket lifecycle tests, then ran `ruff check` and `ruff format --check` on the changed modules and tests. - Observed result: The focused suite passed with 48 tests, lint passed, and formatting passed. The tests assert that unidentified `/v1/responses` callers classify as Codex after stamping while explicit or already-recognized clients are preserved. - Not tested: a live end-to-end Codex Desktop session through a running `headroom wrap codex` instance; verification is at the unit/integration boundary for classification and request routing. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
c98728363a
|
fix(proxy): treat NODE_EXTRA_CA_CERTS as additive, not replacement (#998) (#1031)
## Description `find_ca_bundle()` returns the `NODE_EXTRA_CA_CERTS` path as a bare string passed to httpx's `verify=` parameter, which makes it the sole trust store. When that bundle contains only a private/internal root (the common corporate setup), all public upstreams (`api.anthropic.com`, `api.openai.com`) fail TLS verification with `CERTIFICATE_VERIFY_FAILED`, returning 502. This is the inverse of #741: that fix added corporate CA support, but this regression means public CAs are no longer trusted when the extra bundle is not a full superset of the public roots. The fix builds an `ssl.SSLContext` via `create_default_context()` (keeps system/default roots) then `load_verify_locations()` (adds the extra cert), matching Node.js additive semantics. `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` keep their existing replacement semantics. Closes #998 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Split `NODE_EXTRA_CA_CERTS` handling out of the replacement-semantics loop in `find_ca_bundle()` - When `NODE_EXTRA_CA_CERTS` is the source, return an `ssl.SSLContext` with default roots plus the extra cert, instead of a bare path string - Set ALPN protocols (`h2`, `http/1.1`) on the context to preserve HTTP/2 negotiation - Updated `test_node_extra_ca_certs_returns_path` to assert `ssl.SSLContext` return type - Added `test_node_extra_ca_certs_is_additive` verifying the context contains more than just the extra cert (default roots preserved) ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] New tests added for new functionality ### Test Output ```text tests/test_ssl_context.py::TestFindCaBundleNoEnvVars::test_returns_none_when_no_env_var_set PASSED tests/test_ssl_context.py::TestFindCaBundleWithValidPem::test_ssl_cert_file_returns_path PASSED tests/test_ssl_context.py::TestFindCaBundleWithValidPem::test_requests_ca_bundle_returns_path PASSED tests/test_ssl_context.py::TestFindCaBundleWithValidPem::test_node_extra_ca_certs_returns_ssl_context PASSED tests/test_ssl_context.py::TestFindCaBundleWithValidPem::test_node_extra_ca_certs_is_additive PASSED tests/test_ssl_context.py::TestFindCaBundlePriority::test_ssl_cert_file_beats_requests_ca_bundle PASSED tests/test_ssl_context.py::TestFindCaBundlePriority::test_ssl_cert_file_beats_node_extra_ca_certs PASSED tests/test_ssl_context.py::TestFindCaBundlePriority::test_requests_ca_bundle_beats_node_extra_ca_certs PASSED tests/test_ssl_context.py::TestFindCaBundleNonexistentPaths::test_nonexistent_path_is_skipped PASSED tests/test_ssl_context.py::TestFindCaBundleNonexistentPaths::test_all_nonexistent_returns_none PASSED tests/test_ssl_context.py::TestFindCaBundleNonexistentPaths::test_first_nonexistent_falls_through_to_valid PASSED 11 passed in 0.91s ``` ## Real Behavior Proof - Environment: Windows 11 Home 10.0.26200, Python 3.10.18 - Exact command / steps: Ran `python -m pytest tests/test_ssl_context.py -v` after applying the fix. The new `test_node_extra_ca_certs_is_additive` test verifies `ctx.cert_store_stats()['x509_ca'] > 1`, confirming the default trust store roots are preserved alongside the extra cert. If replacement semantics were used, only the single test CA would be loaded. - Observed result: All 11 SSL context tests pass. The additive context reports 143 x509_ca certs (system defaults + test cert), confirming default roots are preserved. - Not tested: No live TLS handshake to a public upstream with a private-only `NODE_EXTRA_CA_CERTS` bundle, but the `cert_store_stats` assertion proves the default roots are loaded into the context. ## 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] 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 ## Additional Notes The fix aligns with the design proposed in #741 and #745's own description (which said it "builds an SSLContext") but which the merged implementation did not follow. The `server.py` consumer does not need changes because httpx accepts `ssl.SSLContext` for `verify=`. ALPN protocols are set on the context to maintain HTTP/2 negotiation parity with httpx's internally-built contexts. |
||
|
|
0d89c674cd
|
feat: measure and surface token throughput (tokens/sec) through the proxy (#983)
## Description This PR implements measuring and surfacing token throughput (tokens/second) through the proxy in the `headroom perf` CLI/analyzer and the dashboard UI. It tracks multiple throughput metrics—Input (wall-clock/active), Compression, Forward, and Generation throughput—supporting both rolling percentiles (p50/p95) and current (last 5 minutes) metrics. Closes #959 ## 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 - [x] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - **Proxy Instrumentation (`headroom/proxy/outcome.py`)**: Added `total_ms`, `tok_out`, and `ttfb_ms` to the structured `PERF` logging payload. - **Log Parsing & Computations (`headroom/perf/analyzer.py`)**: Updated log parsing to read `STAGE_TIMINGS` and correlation fields from `PERF`, computing active/wall-clock throughputs for input, compression, forward, and generation stages. - **API Exposing (`headroom/proxy/server.py`)**: Exposes calculated rolling throughput percentiles and last-5-minute averages under the `throughput` field in `/stats`. - **Dashboard UI Layout (`headroom/dashboard/templates/dashboard.html`)**: Refactored the dashboard grid layout from 3 columns to 4 columns to house the new throughput hero card showing real-time token performance. - **Verification Tests (`tests/test_cli_perf_format.py`)**: Added test coverage specifically targeting token throughput log parser extraction, stage correlation, math correctness, and edge-case handling (empty fields, division by zero). ## 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 $env:PYTHONPATH="c:\Users\hp\Desktop\Headroom_oss"; .venv\Scripts\pytest tests/test_cli_perf_format.py ============================= test session starts ============================= platform win32 -- Python 3.11.15, pytest-9.1.0, pluggy-1.6.0 -- C:\Users\hp\Desktop\Headroom_oss\.venv\Scripts\python.exe cachedir: .pytest_cache rootdir: C:\Users\hp\Desktop\Headroom_oss configfile: pyproject.toml plugins: anyio-4.13.0 collecting ... collected 14 items tests/test_cli_perf_format.py::test_build_perf_summary_totals_and_pct PASSED [ 7%] tests/test_cli_perf_format.py::test_build_perf_summary_by_model_and_transform PASSED [ 14%] tests/test_cli_perf_format.py::test_build_perf_summary_empty_report_no_zero_division PASSED [ 21%] tests/test_cli_perf_format.py::test_perf_records_as_dicts_roundtrips_fields PASSED [ 28%] tests/test_cli_perf_format.py::test_perf_json_format PASSED [ 35%] tests/test_cli_perf_format.py::test_perf_json_raw_is_array PASSED [ 42%] tests/test_cli_perf_format.py::test_perf_json_raw_preserves_client_field PASSED [ 50%] tests/test_cli_perf_format.py::test_parse_perf_line_preserves_client_field PASSED [ 57%] tests/test_cli_perf_format.py::test_perf_csv_by_model PASSED [ 64%] tests/test_cli_perf_format.py::test_perf_csv_raw_per_record PASSED [ 71%] tests/test_cli_perf_format.py::test_perf_text_default_unchanged PASSED [ 78%] tests/test_cli_perf_format.py::test_perf_rejects_unknown_format PASSED [ 85%] tests/test_cli_perf_format.py::test_parse_perf_line_preserves_blank_client_field PASSED [ 92%] tests/test_cli_perf_format.py::test_throughput_parsing_and_calculations PASSED [100%] ============================== warnings summary =============================== .venv\Lib\site-packages\_pytest\config\__init__.py:1464 C:\Users\hp\Desktop\Headroom_oss\.venv\Lib\site-packages\_pytest\config\__init__.py:1464: PytestConfigWarning: Unknown config option: asyncio_mode self._warn_or_fail_if_strict(f"Unknown config option: {key}\n") .venv\Lib\site-packages\opentelemetry\util\_importlib_metadata.py:32 C:\Users\hp\Desktop\Headroom_oss\.venv\Lib\site-packages\opentelemetry\util\_importlib_metadata.py:32: DeprecationWarning: SelectableGroups dict interface is deprecated. Use select. return EntryPoints(ep for group_eps in eps.values() for ep in group_eps) -- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html ======================= 14 passed, 2 warnings in 4.77s ======================== ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.11.15 - Exact command / steps: Run the pytest suite against the newly created token throughput parsing routines: `$env:PYTHONPATH="c:\Users\hp\Desktop\Headroom_oss"; .venv\Scripts\pytest tests/test_cli_perf_format.py` - Observed result: The suite executes 14 tests successfully, including the newly added `test_throughput_parsing_and_calculations` verification test verifying mathematical precision and fallback logic. - Not tested: None (all metrics are fully covered by unit tests in `test_cli_perf_format.py`) ## 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 - Backwards compatibility: Older log outputs lacking `tok_out` or `ttfb_ms` parse cleanly and fallback defaults prevent parser crashes. --------- Co-authored-by: Antigravity Agent <agent@antigravity.local> |
||
|
|
e8fc8a0d18
|
feat(proxy): cc-switch reconciler — keep Headroom in the request path alongside cc-switch (#1030)
## Description [cc-switch](https://github.com/farion1231/cc-switch) is a desktop provider manager for Claude Code and other coding agents; when a Claude Code provider is selected, it writes that provider's endpoint and token into `~/.claude/settings.json`. This PR adds an opt-in reconciler so Headroom can stay in Claude Code's request path when cc-switch rewrites that file during provider switches. The reconciler captures third-party Anthropic-compatible upstream URLs, points Claude back at the local Headroom proxy, and leaves official/empty OAuth settings direct unless explicitly opted in. This update also hardens the watcher so rapid settings rewrites that share the same float-second mtime are still detected. ## Type of Change - [x] 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 - Added an opt-in `HEADROOM_CC_SWITCH_RECONCILE=1` watcher for cc-switch direct-injection mode. - Added loopback-only `GET/PUT /admin/upstream` runtime upstream inspection and override endpoints. - Preserved token/model settings while rewriting only `env.ANTHROPIC_BASE_URL` back to the local Headroom proxy. - Switched reconciler change detection from float-second `st_mtime` to nanosecond `st_mtime_ns` so rapid provider switches are not missed. - Added pytest coverage for capture/rewrite behavior, official-provider defaults, route-official opt-in, loop safety, enabled flags, and the same-float-mtime provider-switch case. ## 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 python -m pytest tests/test_proxy/test_cc_switch_reconciler.py 12 passed in 0.17s python -m ruff check . All checks passed! python -m mypy headroom Success: no issues found in 359 source files python -m pytest 7 failed, 5996 passed, 492 skipped, 5814 warnings in 396.41s ``` ## Real Behavior Proof - Environment: macOS, branch `feat/cc-switch-reconciler`, Python 3.13.3. - Exact command / steps: Ran the focused reconciler pytest file, full repository ruff check, full `mypy headroom`, and full pytest from the local PR branch. - Observed result: All 12 reconciler tests passed, including the rapid provider-switch case where two writes share the same float mtime but differ by nanoseconds. Full `ruff check .` and `mypy headroom` passed. Full pytest completed with 7 failures outside the cc-switch reconciler test file. - Not tested: Live cc-switch plus Claude Code end-to-end switch. ## 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 - [ ] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A. ## Additional Notes Documentation and CHANGELOG updates are not included in this PR. The reconciler remains opt-in and off by default. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |