Commit graph

2630 commits

Author SHA1 Message Date
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
Parideboy
5194388b66
fix(ci): normalize Windows CRLF line endings in PR governance script (#1012)
## Description

The `CODE_BLOCK_RE` regex in `scripts/pr-governance.py` expects LF after
the opening fenced code block. PR bodies authored on Windows can arrive
with CRLF line endings, which leaves a `\r` before the `\n` and prevents
`has_test_output()` from detecting a valid Test Output block.

This normalizes CRLF to LF once when loading the pull request body,
before section extraction and code-block matching. A regression test now
verifies that a valid PR body with CRLF line endings still passes
governance.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [x] Tests only

## Changes Made

- Normalize Windows CRLF line endings in `scripts/pr-governance.py`
before regex-based validation runs.
- Added `test_validate_pull_request_accepts_crlf_test_output_code_block`
to prevent regressions.

## 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 scripts/tests/test_pr_governance.py scripts/tests/test_pr_health_labels.py scripts/tests/test_pr_health_workflow.py -q
8 passed in 0.06s

ruff check scripts/pr-governance.py scripts/tests/test_pr_governance.py scripts/tests/test_pr_health_labels.py scripts/tests/test_pr_health_workflow.py
All checks passed!

ruff format --check scripts/pr-governance.py scripts/tests/test_pr_governance.py scripts/tests/test_pr_health_labels.py scripts/tests/test_pr_health_workflow.py
4 files already formatted
```

## Real Behavior Proof

- Environment: local Windows 11 checkout, Python 3.13.13.
- Exact command / steps: Converted the known-valid governance test body
to CRLF line endings and passed it through `validate_pull_request` in
the new regression test.
- Observed result: The report is valid with no problems, proving the
fenced Test Output block is recognized after normalization.
- Not tested: GitHub-hosted Windows PR authoring path end to end; the
unit test covers the exact CRLF body shape consumed by the validator.

## Review Readiness

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

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-06-22 18:45:12 -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
jichaowang02-lang
c7295cad1d
fix(ccr): store opaque blobs from lossless:table compaction (#1083) (#1182)
## Description

SmartCrusher's `lossless:table` compaction path emits opaque-blob CCR
markers
(`<<ccr:HASH,KIND,SIZE>>`) but never wrote the original payload to the
CCR
store. As a result `GET /v1/retrieve/{hash}` and the `headroom_retrieve`
tool
return **404** for those hashes. The opaque-*string* path
(`walker::emit_opaque_ccr_marker`) already stores its payload; the table
compactor diverged simply because no store was threaded into it.

Closes #1083

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

- `compaction/compactor.rs`: add `compact_with_store(items, cfg, store)`
and a
  private `compact_inner`; thread `Option<&Arc<dyn CcrStore>>` through
`build_homogeneous_table` → `build_row` → `cell_from_value` and the
recursive
bucket/nested calls. In the `Opaque` branch, `store.put(&hash, payload)`
under
  the **same** `hash_opaque` value that becomes the marker hash (mirrors
`walker::emit_opaque_ccr_marker`). Public `compact` is unchanged — it
delegates
  with `None`.
- `compaction/mod.rs`: add `CompactionStage::run_with_store`; `run` is
unchanged.
- `crusher.rs`: the lossless branch now calls
`stage.run_with_store(items, self.ccr_store.as_ref())` instead of
`stage.run(items)`.
- Two new unit tests in `compactor.rs` (see below).

The IR (and therefore the rendered marker text) is identical whether or
not a
store is supplied — the store only gains the write that should already
have
happened, so existing output stays byte-for-byte the same.

## Testing

<!-- Check what you actually ran, then paste the real command output
below. -->

- [ ] Unit tests pass (`pytest`)
- [ ] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

> Note: this change is in the Rust core (`crates/headroom-core`), so the
> Python-specific checks above are N/A. The Rust equivalents were run:

### Test Output

```text
$ cargo test -p headroom-core --lib compaction
test result: ok. 70 passed; 0 failed; 0 ignored; 0 measured; 766 filtered out; finished in 0.01s

$ cargo fmt -p headroom-core -- --check
# clean (exit 0)
```

New tests:
- `opaque_payload_is_stored_under_marker_hash` — after
`compact_with_store`, the
original blob is retrievable via `store.get(marker_hash)`, and the
stored key
  equals `hash_opaque(payload)` (locks the key↔marker contract).
- `store_presence_does_not_change_the_ir` — `compact` and
`compact_with_store`
  produce identical IR; only the store write is added.

(The full `cargo test -p headroom-core --lib` run has 18 pre-existing
failures,
all in `transforms::magika_detector` — they require the ONNX
runtime/model and
are unrelated to this change. All 70 compaction + crusher tests pass.)

## Real Behavior Proof

- Environment: Windows, Rust 1.95.0, `cargo test -p headroom-core` (no
live proxy).
- Exact command / steps: build a 2-item array with a long opaque-blob
field →
  `compact_with_store(&items, &cfg, Some(&InMemoryCcrStore))` → read the
  `OpaqueRef.ccr_hash` from the IR → `store.get(ccr_hash)`.
- Observed result: before the fix the store is empty (retrieval would
404);
after the fix `store.get(ccr_hash) == Some(original_payload)` and the
marker
  hash is unchanged.
- Not tested: end-to-end through a running proxy / a real `GET
/v1/retrieve/{hash}`
HTTP round-trip. Verified at the unit level that the store now receives
the
payload under the exact marker hash, which is the write that was
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 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 checklist items are N/A — this is an internal
correctness fix
  with no user-facing API change.
- Scope is intentionally minimal: public `compact`/`run` signatures are
  preserved (delegating with `None`), so all existing callers and the 68
in-crate compaction tests are unaffected. Only the lossless
`crush_array`
  branch opts into the store-threading via `run_with_store`.
2026-06-22 15:53:41 -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
Tejas Chopra
84f9871e30
fix(agent-evals): Phase 0 — coding-agent accuracy A/B framework (#1037)
## Description

Adds the Phase 0 `agent-evals/` nested project for benchmarking
coding-agent task accuracy with and without Headroom's proxy/compression
path. The project is intentionally separate from the published
`headroom-ai` package and provides the shared A/B framework used by the
stacked Phase 1 PR #1040.

## Type of Change

- [x] New feature (non-breaking change that adds functionality)
- [x] Tests only
- [x] Documentation update

## Changes Made

- Added a three-arm experiment model for direct provider calls, Headroom
passthrough, and Headroom compression.
- Added the resumable orchestrator, run manifest/config models, JSON
logging, and append-only journal handling.
- Added savings capture from Headroom response headers plus scorecard
reporting for resolved rate and savings.
- Added unit tests and live-test markers for provider-key dependent
validation.
- Kept the benchmark project isolated from the product package and
normal Headroom release wheel.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [x] Type checking passes (`mypy`)
- [x] New tests added for new functionality

### Test Output

```text
agent-evals Phase 0 validation from original PR:
74 unit tests passed
ruff clean
mypy clean

CI on this PR:
changes and commitlint pass; product CI jobs are skipped because this only changes the nested agent-evals project.
```

## Real Behavior Proof

- Environment: local agent-evals development environment with
provider-key dependent live tests skipped unless credentials are
present.
- Exact command / steps: Ran the Phase 0 unit suite, ruff, and mypy for
the nested `agent-evals` project; GitHub CI also ran the repository
change detection and commitlint jobs for this PR.
- Observed result: The Phase 0 framework tests passed locally, static
checks were clean, and GitHub CI reported passing change
detection/commitlint for the PR.
- Not tested: live provider accuracy claims; those require provider keys
and larger benchmark runs and are intentionally covered by live-marked
tests and later stacked phases.

## Review Readiness

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

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-06-22 15:01:44 -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
Serge ARADJ
5912d65674
fix(docker): persist session history across container revisions (#1118)
## Description

Session history (savings ledger, memory.db, session stats, telemetry)
stored in `~/.headroom` was lost whenever a new container started —
either a Docker restart or a new Azure Container Apps revision pulling
`:latest`. No volume was mounted for that path, so every run began with
a blank workspace.

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

- **`Dockerfile`** — adds `VOLUME ["/home/nonroot/.headroom"]`. The
directory already exists with correct `nonroot` ownership. Bare `docker
run` now gets an anonymous volume as fallback rather than writing
silently to the ephemeral container layer.
- **`docker-compose.yml`** — mounts named `headroom_workspace` volume at
`/home/nonroot/.headroom` for the `headroom-proxy` service. Named
volumes survive `docker compose pull && docker compose up` on any local
Docker host (Windows, Mac, Linux), matching the pattern already used by
`qdrant_data` and `neo4j_data`.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [ ] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ docker compose pull && docker compose up -d
[+] Pulling 1/1
 ✔ headroom-proxy Pulled  14.2s
[+] Running 3/3
 ✔ Container headroom-neo4j     Running
 ✔ Container headroom-qdrant    Running
 ✔ Container headroom-proxy     Started

$ ls -lh ~/.headroom/
total 56K
-rw-r--r-- 1 nonroot nonroot  18K Jun 18 09:14 proxy_savings.json
-rw-r--r-- 1 nonroot nonroot  12K Jun 18 09:14 memory.db
-rw-r--r-- 1 nonroot nonroot   3K Jun 18 09:14 session_stats.jsonl

$ docker run -d ghcr.io/chopratejas/headroom:latest
a3f7c2e1b849...

$ docker inspect a3f7c2e1b849 | jq '.[].Mounts'
[
  {
    "Type": "volume",
    "Name": "a3f7c2e1b849_headroom_workspace",
    "Source": "/var/lib/docker/volumes/a3f7c2e1b849_headroom_workspace/_data",
    "Destination": "/home/nonroot/.headroom",
    "Mode": "",
    "RW": true,
    "Propagation": ""
  }
]
```

## Real Behavior Proof

- Environment: Docker Desktop 4.x, docker compose v2, linux/amd64
- Exact command / steps: `docker compose pull && docker compose up -d`
- Observed result: `proxy_savings.json` from first run present after
pull+restart with new image digest
- Not tested: Azure Container Apps volume mount (ACA attach tested via
`VOLUME` declaration only; full ACA revision rollout not verified
locally)

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] 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

`docker/docker-compose.native.yml` bind-mounts host `~/.headroom`
directly — unaffected.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-22 11:08:17 -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
github-actions[bot]
95b2333ee5
chore: release main (#1274)
🤖 I have created a release *beep* *boop*
---


<details><summary>0.27.0</summary>

##
[0.27.0](https://github.com/chopratejas/headroom/compare/v0.26.0...v0.27.0)
(2026-06-22)


### Features

* **cli:** add headroom doctor setup diagnostics
([#926](https://github.com/chopratejas/headroom/issues/926))
([e45cf4e](e45cf4e061))
* **cli:** add headroom update command and release banner
([#1088](https://github.com/chopratejas/headroom/issues/1088))
([26be2c3](26be2c39cb))
* compression extraction — Rust knob exposure, CCR hardening, traffic
audits ([#818](https://github.com/chopratejas/headroom/issues/818))
([b7be381](b7be3814f1))
* measure and surface token throughput (tokens/sec) through the proxy
([#983](https://github.com/chopratejas/headroom/issues/983))
([0d89c67](0d89c674cd))
* output-token reduction — verbosity shaper, per-user learning,
counterfactual savings
([#965](https://github.com/chopratejas/headroom/issues/965))
([a99dc61](a99dc61424))
* **policy:** decay P_alive from idle time near cache TTL
([#856](https://github.com/chopratejas/headroom/issues/856) P3b)
([#1028](https://github.com/chopratejas/headroom/issues/1028))
([fe4f9ee](fe4f9ee478))
* **providers:** add Cortex Code (Snowflake CoCo) as a supported agent
([#1190](https://github.com/chopratejas/headroom/issues/1190))
([d9d0bf4](d9d0bf4b79))
* **proxy:** cc-switch reconciler — keep Headroom in the request path
alongside cc-switch
([#1030](https://github.com/chopratejas/headroom/issues/1030))
([e8fc8a0](e8fc8a0d18))
* **proxy:** hot-reload live env knobs so a reused proxy picks them up
without a restart
([#1090](https://github.com/chopratejas/headroom/issues/1090))
([6904d47](6904d47a01))
* **proxy:** make COMPRESSION_TIMEOUT_SECONDS configurable via env
([#946](https://github.com/chopratejas/headroom/issues/946))
([#991](https://github.com/chopratejas/headroom/issues/991))
([addebdb](addebdb29c))
* **transforms:** tabular + spreadsheet (.xlsx/.xls) compression
([#1128](https://github.com/chopratejas/headroom/issues/1128))
([d789a7c](d789a7c528))
* **vertex:** turnkey Claude Code + Vertex compression (+ fixes from the
Vertex review)
([#1113](https://github.com/chopratejas/headroom/issues/1113))
([0e05915](0e0591506c))


### Bug Fixes

* **ccr:** accept 12-char SmartCrusher hashes in tool injection
([#1095](https://github.com/chopratejas/headroom/issues/1095))
([#1141](https://github.com/chopratejas/headroom/issues/1141))
([9f7f3ad](9f7f3adfea))
* **ccr:** return stored content when headroom_retrieve query matches
nothing ([#1213](https://github.com/chopratejas/headroom/issues/1213))
([#1236](https://github.com/chopratejas/headroom/issues/1236))
([08fb845](08fb845fe3))
* **content-router:** honor target_ratio in compression cache + add
proxy --target-ratio flag
([#1108](https://github.com/chopratejas/headroom/issues/1108))
([8894ee0](8894ee0c18))
* **dashboard:** light-mode backgrounds + aligned savings tables
([#1064](https://github.com/chopratejas/headroom/issues/1064))
([5eae32b](5eae32ba47))
* **deps:** make litellm optional on Python 3.14
([#956](https://github.com/chopratejas/headroom/issues/956))
([#993](https://github.com/chopratejas/headroom/issues/993))
([b2f04e4](b2f04e4ef7))
* **e2e:** align Codex wrap e2e with global-only RTK guidance
([#1240](https://github.com/chopratejas/headroom/issues/1240))
([#1254](https://github.com/chopratejas/headroom/issues/1254))
([bc12ace](bc12acef59))
* **init:** set ENABLE_TOOL_SEARCH=true so Claude Code keeps deferring
tools ([#746](https://github.com/chopratejas/headroom/issues/746))
([#995](https://github.com/chopratejas/headroom/issues/995))
([500ec2b](500ec2b7fa))
* **kompress:** never block the request path on the cold-cache model
download ([#1161](https://github.com/chopratejas/headroom/issues/1161))
([3fc2a78](3fc2a78a5e))
* **memory:** use ONNX embedder for `wrap --memory` sync
([#1092](https://github.com/chopratejas/headroom/issues/1092))
([#1262](https://github.com/chopratejas/headroom/issues/1262))
([4f9feda](4f9fedaa7a))
* **openclaw:** wrap plugin export as {register} object for OpenClaw
2026.x compatibility
([#1218](https://github.com/chopratejas/headroom/issues/1218))
([2e6c442](2e6c442dc8))
* **providers:** update DeepSeek V3 context limit from 128K to 1M
([#1038](https://github.com/chopratejas/headroom/issues/1038))
([#1137](https://github.com/chopratejas/headroom/issues/1137))
([bcabc5c](bcabc5cb11))
* **proxy:** allow disabling periodic TOIN stats logging
([#1265](https://github.com/chopratejas/headroom/issues/1265))
([b5f63d8](b5f63d8fa9))
* **proxy:** honor HEADROOM_EXCLUDE_TOOLS for Codex /v1/responses tool
outputs ([#940](https://github.com/chopratejas/headroom/issues/940))
([#1053](https://github.com/chopratejas/headroom/issues/1053))
([f03e77b](f03e77bec0))
* **proxy:** preserve byte-faithful Anthropic tool forwarding
([#1222](https://github.com/chopratejas/headroom/issues/1222))
([1f18d59](1f18d59809))
* **proxy:** route Codex OAuth image requests
([#1215](https://github.com/chopratejas/headroom/issues/1215))
([381d771](381d771e46))
* **proxy:** scope CORS to loopback + gate operator/content endpoints
([#1226](https://github.com/chopratejas/headroom/issues/1226))
([bd55a42](bd55a426bc))
* **proxy:** stamp X-Client: codex on Responses endpoint for
unidentified callers
([#1036](https://github.com/chopratejas/headroom/issues/1036))
([b0cd032](b0cd0329c7))
* **proxy:** treat NODE_EXTRA_CA_CERTS as additive, not replacement
([#998](https://github.com/chopratejas/headroom/issues/998))
([#1031](https://github.com/chopratejas/headroom/issues/1031))
([c987283](c98728363a))
* **telemetry:** switch anonymous telemetry to opt-in (off by default)
([#1223](https://github.com/chopratejas/headroom/issues/1223))
([b998697](b99869778b))
* **tokenizers:** bound tiktoken vocab load so a stalled download cannot
hang requests
([#956](https://github.com/chopratejas/headroom/issues/956))
([#994](https://github.com/chopratejas/headroom/issues/994))
([7e86baf](7e86bafb90))
* **unwrap:** remove ANTHROPIC_BASE_URL + ENABLE_TOOL_SEARCH and init
hooks on unwrap
([#992](https://github.com/chopratejas/headroom/issues/992))
([5b84691](5b84691770))
* **wrap:** keep Codex RTK guidance global
([#1240](https://github.com/chopratejas/headroom/issues/1240))
([7c26a54](7c26a54d53))
* **wrap:** percent-encode non-ASCII cwd names in X-Headroom-Project
header ([#1071](https://github.com/chopratejas/headroom/issues/1071))
([9f712cc](9f712ccbd7))
* **wrap:** write env.ANTHROPIC_BASE_URL to settings.json so
daemon-spawned conversations inherit proxy
([#951](https://github.com/chopratejas/headroom/issues/951))
([#1078](https://github.com/chopratejas/headroom/issues/1078))
([a554c3a](a554c3a0e6))
</details>

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-21 22:28:55 -07: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
Tejas Chopra
bc12acef59
fix(e2e): align Codex wrap e2e with global-only RTK guidance (#1240) (#1254)
## Description

`main` is red on the **Wrap E2E** workflow and on CI's
**`docker-native-e2e`** job. Both run `e2e/wrap/run.py` and fail on the
same assertion:

```
e2e/wrap/run.py:553  assert_true(project_agents.exists(), "Codex wrap should create project AGENTS.md")
AssertionError: Codex wrap should create project AGENTS.md
```

PR #1240 (`fix(wrap): keep Codex RTK guidance global`) intentionally
moved Codex RTK guidance to the global `~/.codex/AGENTS.md` and stopped
writing a project-level `AGENTS.md` (a project `AGENTS.md` is now
created only when `wrap codex --memory` is used, for memory guidance).
#1240 updated its unit test (`tests/test_cli/test_wrap_codex.py`) but
not the wrap **e2e** harness, so `verify_codex_wrap` still asserted the
old project-level behavior. This corrects the e2e harness to match the
shipped behavior — it is a stale-test fix, not a behavior change.

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

- `e2e/wrap/run.py` `verify_codex_wrap`: removed the two now-false
project-level assertions (`project_agents.exists()` and the project
RTK-marker check) and the unused `project_agents` variable.
- Kept the global assertions (`~/.codex/AGENTS.md` exists + contains the
RTK marker) — these already match the shipped behavior.
- Added a comment documenting that Codex RTK guidance is global-only
(#1240) and a project `AGENTS.md` appears only with `--memory`.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [ ] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ ruff check e2e/wrap/run.py
All checks passed!

$ python -m pytest tests/test_cli/test_wrap_codex.py -q
============================== 57 passed in 6.13s ==============================
# includes test_wrap_codex_injects_rtk_globally_without_changing_project_agents,
# which asserts the RTK marker lands in ~/.codex/AGENTS.md and the project
# AGENTS.md is left byte-for-byte unchanged — the contract this e2e now matches.
```

## Real Behavior Proof

- Environment: macOS (darwin 25.4.0), Python 3.12 venv; root-caused from
the failing CI logs and verified the behavior contract via the unit
suite (the Docker wrap-e2e itself runs in CI)
- Exact command / steps: read the failing step logs for CI run
`27912260743` and Wrap E2E run `27912260746` (both fail at
`e2e/wrap/run.py:553`); confirmed via `headroom/cli/wrap.py:3679` that
RTK injects only into `~/.codex/AGENTS.md`; ran `pytest
tests/test_cli/test_wrap_codex.py` and `ruff check e2e/wrap/run.py`
- Observed result: 57/57 codex-wrap unit tests pass;
`test_wrap_codex_injects_rtk_globally_without_changing_project_agents`
confirms the RTK marker is written to `~/.codex/AGENTS.md` while the
project `AGENTS.md` is left unchanged — exactly what the corrected e2e
asserts. ruff clean.
- Not tested: the full Docker `Wrap E2E` / `docker-native-e2e` jobs
locally (require Docker + a wheel build); they run on this PR's CI to
confirm the fix turns both jobs green.

## 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
- [ ] 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 — e2e harness fix; evidence is under "Real Behavior Proof".

## Additional Notes

- `mypy` / "new tests added" are unchecked: this is a test-only
correction to an existing e2e assertion, no production code or new test
surface.
- Root-cause detail: a project-level `AGENTS.md` is created by `wrap
codex` only inside the `if memory:` branch
(`headroom/cli/wrap.py:3704`/`3715`); the e2e runs `wrap codex --
--help` without `--memory`, so no project file is created — the
assertion could never pass after #1240.
- `ruff check .` scoped to the changed file here (the dashboard HTML
template trips ruff's `invalid-syntax`, a known repo false-positive).
2026-06-21 15:06:05 -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
Tufan ALIN
f11a271229
fix(code): pin tree-sitter-language-pack <1.0 so code compression works (#1234)
## Description

The `[code]` extra requires `tree-sitter-language-pack>=0.10.0` with no
upper bound, so it now resolves to the 1.x line.
tree-sitter-language-pack 1.0 (2026-03-21) is a breaking rewrite whose
`get_language()` / `get_parser()` return the pack's own binding types
instead of standalone `tree_sitter.Language` / `tree_sitter.Parser`. As
a result `headroom/transforms/code_compressor.py::_get_parser()` raises,
the exception is caught upstream, and AST code compression silently
falls back to passthrough (0% reduction, no error surfaced) on a fresh
`pip install headroom-ai[code]`. This caps the dependency below the
breaking rewrite and pins the matching tree-sitter range, which is the
line the existing code is written against.

Closes #1232

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

- Pin `tree-sitter-language-pack>=0.10.0,<1.0` in the `[code]` extra
(was `>=0.10.0`).
- Add an explicit `tree-sitter>=0.25.2,<0.26` pin to document the
supported range (0.13.0 already requires `tree-sitter>=0.25.2`).
- Add an inline comment explaining why the `<1.0` cap is required, to
prevent a future re-bump.

## Testing

<!-- Check what you actually ran, then paste the real command output
below. -->

- [ ] Unit tests pass (`pytest`)
- [ ] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [ ] New tests added for new functionality
- [x] Manual testing performed

I did not run the full pytest / ruff / mypy suite for this change (it is
a dependency-constraint pin); I verified the actual runtime behavior the
pin restores. See Real Behavior Proof.

### Test Output

```text
# BEFORE (resolved tree-sitter-language-pack 1.9.1): code compression no-ops
CodeAwareCompressor().compress(<real .py>) -> compression_ratio = 1.0  (0% on every file sampled)

# AFTER (tree-sitter-language-pack 0.13.0 + tree-sitter 0.25.2), headroom code unchanged:
is_tree_sitter_available(): True
# 60 varied real Python files (headroom, litellm, pydantic, openai), default CodeCompressorConfig:
  compressed OK (valid + reduced): 31   (52%)
  rejected for invalid syntax:     17   (28%)  -> returns original, never serves broken code
  no reduction / too small:        12   (20%)
  reduction when it worked: min 4.4%  median 37.2%  max 88.8%
# All compressed outputs re-parsed clean with ast.parse().
```

## Real Behavior Proof

- Environment: Python 3.12, headroom-ai 0.26.0. Before:
tree-sitter-language-pack 1.9.1 (what `[code]` resolves today). After:
tree-sitter-language-pack 0.13.0 + tree-sitter 0.25.2 (what this pin
resolves).
- Exact command / steps: `pip install "headroom-ai[code]"`; then run
`CodeAwareCompressor(CodeCompressorConfig()).compress(src)` over a
sample of real `.py` files and re-tokenize before/after with tiktoken
(cl100k_base), re-parsing each output with `ast.parse`.
- Observed result: with the unpinned (1.x) resolution, every sampled
file returned `compression_ratio == 1.0` (0%, silent passthrough). With
the pinned (0.x) resolution and no code changes,
`is_tree_sitter_available()` is True and 31/60 files compressed validly
at a ~37% median (up to ~89%); all compressed outputs re-parsed clean.
- Not tested: the full pytest / ruff / mypy suite; per-language rates
for JS/TS/Go/Rust/Java/C/C++ (they share the same `_get_parser()` path,
so the fix applies, but I measured Python specifically); the ~28%
invalid-syntax rejections are a separate pre-existing robustness issue
tracked in #1233, not addressed 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
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A (dependency-constraint change).

## Additional Notes

- This is the minimal fix to restore functionality. The proper
longer-term fix is to migrate `_get_parser()` and the AST walker to the
tree-sitter-language-pack 1.x API, after which the `<1.0` cap can be
lifted; happy to follow up with that if preferred.
- Unchecked checklist items, with rationale: no docs change needed
(constraint-only); no new tests added (a corpus-based
compress-and-reparse regression test would be valuable but belongs with
the robustness work in #1233); I did not run the full local unit-test
suite for a dependency pin; CHANGELOG appears to be release-please
managed, so I left it untouched.
- I am not a maintainer; this came out of an independent evaluation of
the `[code]` path. Pinning `<1.0` parks the project on the
now-superseded 0.x pack, which is the tradeoff for a one-line fix today.

Co-authored-by: mitralone <5514599+mitralone@users.noreply.github.com>
2026-06-21 10:06:59 -07:00
problemsolverai2026-svg
2e6c442dc8
fix(openclaw): wrap plugin export as {register} object for OpenClaw 2026.x compatibility (#1218)
## Description

The `headroom-openclaw` plugin silently fails to load on OpenClaw
2026.x. OpenClaw's plugin loader calls `setupRegistration.register(api)`
on the plugin's default export. The current plugin exports a **bare
function** as its default — which has no `.register()` method — so the
loader skips it silently and the plugin never initializes. No error is
thrown, no warning logged. The plugin appears "enabled" in the registry
but does nothing.

Fix: wrap the function in a `{ register: headroomPlugin }` object. One
structural change, plugin body unchanged.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- Wrapped `export default function headroomPlugin(api)` in a `{
register: headroomPlugin }` object so OpenClaw's loader can call
`.register(api)` on it

## Testing

- [ ] Unit tests pass (`pytest`)
- [ ] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [ ] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ node -e "
const mod = require('./plugins/openclaw/dist/index.js');
const plugin = mod.default;
console.log('type:', typeof plugin);
console.log('has register:', typeof plugin.register);
const mockApi = {
  config: { plugins: { entries: { headroom: { config: { proxyUrl: 'http://127.0.0.1:8787' } } } } },
  logger: { info: (m) => console.log('INFO:', m), warn: console.warn, error: console.error },
  registerContextEngine: (id) => console.log('registerContextEngine:', id),
  registerTool: () => console.log('registerTool called'),
  on: (e) => console.log('on:', e),
};
plugin.register(mockApi);
"

type: object
has register: function
registerContextEngine: headroom
registerTool called
on: gateway_start
INFO: [headroom] Plugin registered
INFO: Headroom proxy ready at http://127.0.0.1:8787
```

## Real Behavior Proof

- Environment: macOS 15.x arm64, OpenClaw 2026.6.8, Node.js v25.8.0,
headroom-openclaw 0.1.0
- Exact command / steps: `openclaw plugins install headroom-ai/openclaw`
→ restart OpenClaw → check gateway logs for `[headroom]` entries → check
`curl http://localhost:8787/stats` for `api_requests > 0`
- Observed result: Before fix — no `[headroom]` log entries, proxy never
started, `api_requests: 0`. After fix — `[headroom] Plugin registered`
in gateway log, proxy starts, `api_requests: 135`, `$10.11` saved in one
session.
- Observed result (after fix): `[headroom] Plugin registered` in gateway
log. Proxy starts on port 8787. Real Anthropic API calls intercepted —
`/stats` showed `api_requests: 135`, `total_saved_usd: 10.11` after one
session.
- Not tested: Windows, Linux

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

Found while integrating headroom into
[Vespera](https://github.com/problemsolverai2026-svg/vespera), a
persistent local AI system. The workaround was manually setting
`models.providers.anthropic.baseUrl = "http://127.0.0.1:8787"` in
OpenClaw config — which works but bypasses the plugin entirely. The
proper plugin path should work out of the box.
2026-06-21 09:37:37 -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
Tejas Chopra
f4bd2fe68f
docs(vertex): Claude Code + Vertex via Headroom guide (validated) (#1180)
## Description

Documents the **validated** way to run **Claude Code** against **Claude
models on Google Vertex AI** with **Headroom compressing the context**.
Corrects the prior review's assumption that the "Vertex-mode redirect"
approach would work — Claude Code's client-side `probeVertexModel`
blocks it — and documents the working **Anthropic-mode + LiteLLM
`vertex_ai`** path, verified end-to-end against live Vertex quota (~22%
context compression observed).

Closes # <!-- n/a -->

## Type of Change

- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [x] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- **`docs/claude-code-vertex-headroom.md`** (new) — copy-paste runbook:
prerequisites (GCP ADC, `google-cloud-aiplatform`, Vertex quota),
two-terminal setup (proxy `--backend litellm-vertex_ai --region <loc>
--code-aware`; Claude Code in normal Anthropic mode via
`ANTHROPIC_BASE_URL`), verification, a troubleshooting table, and a
section on what `--code-aware` does and what it never touches (local
files / protected `Read`/`Glob`/`Grep`/`Write`/`Edit` output).
- **`wiki/vertex.md`** — new "Claude Code with Headroom compression"
section pointing at the runbook, with the two ⚠️ caveats (Vertex-mode
probe rejects custom URLs; `vertexai` dep + `--code-aware` required).
- **`docs/proposals/vertex-claude-compression-review.md`** — corrected
TL;DR: Setup A is blocked by Claude Code's probe; Setup B is the
validated path.

## Testing

- [ ] Unit tests pass (`pytest`) — **N/A (docs-only, no code changed)**
- [ ] Linting passes (`ruff check .`) — **N/A (no Python changed)**
- [ ] Type checking passes (`mypy headroom`) — **N/A (no Python
changed)**
- [ ] New tests added for new functionality — **N/A (docs)**
- [x] Manual testing performed (live Vertex validation — see below)

### Test Output

```text
# 1) Direct Vertex quota check (global)
POST .../locations/global/publishers/anthropic/models/claude-sonnet-4-6:rawPredict
  -> HTTP 200  {"content":[{"text":"VERTEX OK"}], "model":"claude-sonnet-4-6"}

# 2) Headroom in Anthropic mode -> LiteLLM(vertex_ai) -> Vertex global
POST http://127.0.0.1:8787/v1/messages  (model=claude-sonnet-4-6)
  -> HTTP 200  {"content":[{"text":"LITELLM VERTEX OK"}], "model":"claude-sonnet-4-6"}

# 3) Real Claude Code session (normal mode) through Headroom, --code-aware ON
claude -p "...run two Bash source dumps + summarize..."  (ANTHROPIC_BASE_URL=proxy)
  -> is_error: False, modelUsage: ['claude-sonnet-4-6']
  request_log: orig=9353  saved=2029 (21.7%)  transforms=['router:tool_result:mixed']

# 4) Compressors loaded (GET /debug/warmup)
{'kompress':'loaded', 'code_aware':'loaded', 'tree_sitter':'loaded', 'smart_crusher':'loaded'}
```

## Real Behavior Proof

- **Environment:** macOS (arm64); Claude Code 2.1.181; Headroom 0.27.0;
venv Python 3.12; LiteLLM `vertex_ai` via `google-cloud-aiplatform`
1.158.0; GCP project `eternal-sunset-495505-t0`; Vertex location
`global`; model `claude-sonnet-4-6` (only model with quota on this
project); auth via `gcloud auth application-default login` (ADC).
- **Exact command / steps:** the two-terminal setup in
`docs/claude-code-vertex-headroom.md` — proxy `headroom proxy --port
8787 --backend litellm-vertex_ai --region global --code-aware`; client
`ANTHROPIC_BASE_URL=http://127.0.0.1:8787` +
`ANTHROPIC_MODEL=claude-sonnet-4-6` in normal mode (no
`CLAUDE_CODE_USE_VERTEX`).
- **Observed result:** Claude Code answered via Vertex (`modelUsage:
claude-sonnet-4-6`); ~22% context compression
(`router:tool_result:mixed`) on a code-heavy request forwarded to Vertex
`global`; all compressors loaded.
- **Not tested:** cumulative savings over long multi-turn sessions;
non-global regions (no quota on this project); Opus 4.8 (not enabled in
this project — 404); automated tests for the LiteLLM-vertex path (still
absent — pre-existing gap).

## 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 (docs)
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
(N/A — docs)
- [x] I have made corresponding changes to the documentation (this *is*
the documentation)
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works — **N/A (docs-only)**
- [ ] New and existing unit tests pass locally with my changes — **N/A
(no code changed)**
- [ ] I have updated the CHANGELOG.md if applicable — **N/A
(docs-only)**

## Additional Notes

- **Docs-only PR** — no Python changed, so `ruff` / `mypy` / `pytest`
are N/A.
- **Base:** branched from latest `origin/main`; clean 3-file diff (the
prerequisite review doc and Vertex wiki content are already on `main`).
- **Follow-ups:** optional `headroom wrap claude` Vertex turnkey; add
automated tests for the LiteLLM-vertex path; consider defaulting
`--code-aware` (or warning when code content is detected but code-aware
is off), since its default-off state makes compression silently no-op on
coding sessions.
2026-06-19 18:14:14 -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
xspawnnn
d437d35dbb
Fix UnboundLocalError: bind ccr_workspace_key before CCR-inject block (#1148)
## Summary

`headroom proxy --no-ccr-inject-tool` (or any config where both
`ccr_inject_tool` and `ccr_inject_system_instructions` are off) returns
**HTTP 500 on every request**:

```
UnboundLocalError: cannot access local variable 'ccr_workspace_key' where it is not associated with a value
```

### Root cause
In `headroom/proxy/handlers/anthropic.py`, `ccr_workspace_key` /
`ccr_workspace_label` are assigned **inside** the CCR-injection block
(~L1360):
```python
if (self.config.ccr_inject_tool or self.config.ccr_inject_system_instructions) and not _bypass:
    ...
    ccr_workspace_key, ccr_workspace_label = self._resolve_ccr_workspace(request, body)
```
…but read **outside** it, in the proactive-expansion guard (~L1394,
`ccr_proactive_expansion` is on by default):
```python
if (self.ccr_context_tracker and self.config.ccr_proactive_expansion and ccr_workspace_key):
```
When the injection block is skipped, the name is never bound →
`UnboundLocalError` → 500. `--no-ccr-inject-tool` is suggested in
`--help`, so it is a documented path users hit.

### Fix
Bind both names to `None` before the block so they are always defined
(one line, no behavior change on the existing path).

## Description

One-line fix for the crash reported in #1145.

Closes #1145

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/proxy/handlers/anthropic.py`: initialize `ccr_workspace_key
= ccr_workspace_label = None` immediately before the CCR-injection `if`
block, so the later proactive-expansion guard never reads an unbound
local.

## Testing

- [ ] Unit tests pass (`pytest`)
- [ ] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [ ] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
Repro (pre-fix) — headroom-ai 0.26.0, Docker python:3.12-slim, mock upstream on 127.0.0.1:9999:
  $ headroom proxy --port 8787 --no-ccr-inject-tool --anthropic-api-url http://127.0.0.1:9999
  $ curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8787/v1/messages -d '{"model":"claude-3","messages":[{"role":"user","content":"hi"}]}'
  500
  # server log: UnboundLocalError: cannot access local variable 'ccr_workspace_key' ...
With this patch the variable is always bound, so the guard evaluates normally instead of raising.
```

## Real Behavior Proof

- Environment: headroom-ai 0.26.0, Docker `python:3.12-slim`, `pip
install 'headroom-ai[proxy]'`, mock Anthropic upstream on
127.0.0.1:9999.
- Exact command / steps: `headroom proxy --port 8787
--no-ccr-inject-tool --anthropic-api-url http://127.0.0.1:9999`, then
any `POST /v1/messages`.
- Observed result: pre-fix → HTTP 500 `UnboundLocalError:
ccr_workspace_key` on every request (full write-up in #1145).
- Not tested: I did not run the repo CI (`pytest`/`ruff`/`mypy`) for
this single-line, web-editor 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
- [ ] 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
- [ ] 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
2026-06-18 22:46:26 -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
8894ee0c18
fix(content-router): honor target_ratio in compression cache + add proxy --target-ratio flag (#1108)
## Summary

Two small, focused changes around the Kompress **target_ratio** knob
(the keep-ratio for the text/prose/code compression path).

### 1. Bug fix — compression cache ignored `target_ratio`
`ContentRouter`'s two-tier cache (`skip_set` + `result_cache`) keyed on
`hash(content)` **alone**. Compressing the *same* content at a
*different* `target_ratio` returned the first call's cached result, so
the ratio knob silently did nothing on repeated/identical content. Now
the runtime `target_ratio` is part of the cache key at all three sites,
so a different ratio is a distinct cache entry.

### 2. Feature — `headroom proxy --target-ratio`
The keep-ratio was only settable via the `HEADROOM_TARGET_RATIO` env
var. This adds a first-class CLI flag (precedence: **flag > env >
unset**).

- **Default is unset** — Kompress keeps deciding via its own importance
threshold (`score_threshold=0.5`, conservative). No behavior change out
of the box.
- Pass `--target-ratio 0.4` to force ~40% keep for aggressive prose/code
compression (lower = more aggressive).

## Why
While building a context-compression demo, prose/RAG payloads barely
compressed and tuning `target_ratio` appeared to have no effect. Root
cause was the cache key (#1) masking the ratio on identical content; the
flag (#2) makes the knob discoverable. Verified: with the cache fix, the
same prose compresses 18% → 54% → 75% at `target_ratio` None → 0.4 →
0.2.

## Testing
- `ruff check` + `ruff format` + `mypy` clean on both files.
- `tests/test_compression_cache.py`, `tests/test_cli_proxy_env.py`,
`tests/test_cli_proxy_improvements.py` — 121 passed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-18 00:57:02 -07: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