Commit graph

1553 commits

Author SHA1 Message Date
JD Davis
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`.
2026-06-22 19:06:04 -05:00
Parideboy
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>
2026-06-22 19:05:38 -05:00
wstczyw
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.
2026-06-22 18:57:38 -05:00
Parideboy
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>
2026-06-22 18:57:05 -05:00
Parideboy
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>
2026-06-22 18:55:22 -05:00
Purva Kandalgaonkar
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>
2026-06-22 18:49:08 -05:00
Mark Phelps
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>
2026-06-22 18:48:19 -05:00
Ztkent
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.
2026-06-22 18:47:57 -05:00
Veesh Goldman
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>
2026-06-22 18:47:41 -05:00
weijie_chen
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.
2026-06-22 18:44:32 -05:00
Rod Boev
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.
2026-06-22 15:55:43 -05:00
Shawn
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>
2026-06-22 15:49:14 -05:00
jimu
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>
2026-06-22 15:46:07 -05:00
Parideboy
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>
2026-06-22 15:14:40 -05:00
gglucass
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`
(8894ee0c); `uv run --extra dev`; Python 3.10.18 on macOS.
- Exact command / steps: set `HEADROOM_DISABLE_KOMPRESS_ANTHROPIC=1`,
build the proxy from env, and inspect each pipeline's `ContentRouter`:
  ```
HEADROOM_DISABLE_KOMPRESS_ANTHROPIC=1 uv run --extra dev python - <<'PY'
  import dataclasses
from headroom.proxy.server import _proxy_config_from_env, HeadroomProxy
  cfg = _proxy_config_from_env()
print("config.disable_kompress_anthropic =",
cfg.disable_kompress_anthropic)
print("config.disable_kompress_openai =", cfg.disable_kompress_openai)
  cfg = dataclasses.replace(cfg, optimize=False, cache_enabled=False,
rate_limit_enabled=False, cost_tracking_enabled=False,
                            code_aware_enabled=False)
  p = HeadroomProxy(cfg)
a = p.anthropic_pipeline.transforms[-1]; o =
p.openai_pipeline.transforms[-1]
  print("anthropic enable_kompress =", a.config.enable_kompress)
  print("openai    enable_kompress =", o.config.enable_kompress)
  print("separate router instances =", a is not o)
  PY
  ```
- Observed result: `config.disable_kompress_anthropic = True`,
`config.disable_kompress_openai = None`; `anthropic enable_kompress =
False`, `openai enable_kompress = True`, `separate router instances =
True`. `headroom proxy --help` lists `--disable-kompress-anthropic /
--enable-kompress-anthropic` and `--disable-kompress-openai /
--enable-kompress-openai`.
- Not tested: end-to-end token-savings impact on live Anthropic traffic
(reasoned from the 0.9 cache-read discount, not measured here); a
follow-up could instrument per-provider/per-transform savings.

## 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

- Opened as a draft pending maintainer review.
- Backward compatible: with no per-provider override set, behavior is
identical to today (both pipelines follow the global `disable_kompress`,
sharing a single `ContentRouter`).
- Tradeoff: disabling Kompress for a provider forgoes only *lossy text*
savings; structural compressors still run. On Anthropic the forgone
slice is low-value due to the 0.9 cache-read discount and avoids
cache-bust risk on already-cached tool output.
- CHANGELOG.md / docs not updated here — happy to add if desired.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:07:10 -05:00
Focused Instability
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.
2026-06-22 15:05:06 -05:00
nervousapps
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 -->
2026-06-22 14:53:14 -05:00
Zhenjia ZHOU
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>
2026-06-22 11:11:46 -05:00
Leoy
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.
2026-06-22 11:11:11 -05:00
Rudimar Ronsoni
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>
2026-06-22 11:07:12 -05:00
sfc-gh-nashukla
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>
2026-06-21 22:18:47 -07:00
Parideboy
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>
2026-06-21 20:08:12 -07:00
wstczyw
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 `bc12ace` plus this branch.
- Exact command / steps: synced dev dependencies with `uv sync --extra
dev`, verified `headroom._core` imports, then ran `uv run pytest
tests/test_proxy_telemetry_env.py -q`.
- Observed result: all 9 tests in `tests/test_proxy_telemetry_env.py`
passed. The new tests prove the env var defaults to enabled, `0` /
`false` / `off` / `no` disable it, and disabled config avoids requesting
the periodic TOIN stats coroutine during app lifespan startup.
- Not tested: full repository pytest, ruff, and mypy were not run for
this narrowly scoped proxy config change.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [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
- [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

- Default behavior is unchanged: periodic TOIN stats logging remains
enabled unless explicitly disabled.
- This only disables the periodic stats logging task. It does not
disable TOIN learning, request-time feedback, or stats retrieval
elsewhere.
- `My changes generate no new warnings` is left unchecked because the
focused pytest run emits a pre-existing Starlette/FastAPI TestClient
deprecation warning from dependencies.
- `CHANGELOG.md` is left unchecked because this is a small unreleased
configuration toggle and docs/tests cover the behavior.
2026-06-21 19:55:23 -07:00
Umut Keltek
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
2026-06-21 19:39:04 -07:00
jichaowang02-lang
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
2026-06-21 10:37:37 -07:00
Terminal Chai
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.
2026-06-21 10:11:06 -07:00
Rod Boev
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.
2026-06-21 10:07:29 -07:00
Johnson Deng
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>
2026-06-21 09:25:49 -07:00
jichaowang02-lang
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
2026-06-21 09:15:49 -07:00
Tejas Chopra
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.
2026-06-21 00:50:55 -07:00
Tejas Chopra
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.
2026-06-20 21:26:04 -07:00
xspawnnn
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 `@9f7f3ad` + this patch),
HF cache empty:

- **Cold-cache router request**: returns in **56 ms**, passthrough
(output == input), fetch handed to a background daemon thread — vs. the
pre-fix inline hang.
- **A/B on the same `compress()` call** with a slow-fetch stand-in:
pre-fix (`allow_download=True`) blocked **24.00 s** on the request
thread; post-fix (`allow_download=False`) returned **59 ms**.
- **4 new regression tests** in
`tests/test_kompress_request_nonblocking.py` (cache-only passthrough;
one-thread-per-model background download; router skips deep path when
not ready; router stays cache-only when ready) — `4 passed`.

## Files
- `headroom/transforms/kompress_compressor.py` — non-blocking load +
cache-only `compress`
- `headroom/transforms/content_router.py` — gate deep path on
`is_ready()`; background fetch when cold
- `tests/test_kompress_request_nonblocking.py` — regression coverage

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:33:05 -05:00
Eyal Mizrachi
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>
2026-06-19 11:30:59 -05:00
Ashish
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>
2026-06-19 11:30:20 -05:00
Eyal Mizrachi
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>
2026-06-19 11:27:09 -05:00
Eyal Mizrachi
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>
2026-06-19 11:26:26 -05:00
Aniruddh Jha
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.
2026-06-19 08:54:02 -05:00
jichaowang02-lang
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.
2026-06-18 22:56:53 -07:00
Shengbo_Wang
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 (6904d47)
- Exact command / steps: python -m pytest
tests/test_providers/test_universal.py::TestOpenAICompatibleProvider::test_get_context_limit_deepseek_v3_is_1m
-v
- Observed result: PASSED - deepseek-v3 returns 1048576, deepseek-v4
returns 1048576, deepseek returns 1048576, deepseek-v2 returns 128000
- Not tested: no manual proxy testing with a live DeepSeek endpoint

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Additional Notes

The context limit values are based on the DeepSeek V3 technical report
and API documentation, which specify a 1M-token context window. DeepSeek
Coder V2+ also supports 128K, up from the original Coder's 16K.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-18 10:38:24 -07:00
Tejas Chopra
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.
2026-06-18 09:50:50 -07:00
Focused Instability
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.
2026-06-18 11:22:20 -05:00
Focused Instability
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.
2026-06-18 11:21:04 -05:00
Focused Instability
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>
2026-06-18 11:19:43 -05:00
Focused Instability
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>
2026-06-18 11:15:40 -05:00
Tejas Chopra
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.
2026-06-18 00:56:23 -07:00
Ashish
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>
2026-06-17 23:28:23 -05:00
gglucass
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>
2026-06-17 11:45:20 -05:00
Shengbo_Wang
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.
2026-06-17 11:41:49 -05:00
Shlok Tiwari
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>
2026-06-17 09:42:38 -05:00
Zhenjia ZHOU
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>
2026-06-17 08:41:16 -05:00