Commit graph

2376 commits

Author SHA1 Message Date
Ruben A.
e530de5ad2
feat(rust): port CodeCompressor AST compressor to Rust (parity-only) (#1154)
Adds crates/headroom-core/src/transforms/code_compressor.rs (1,882 lines): the
AST-aware CodeCompressor ported to Rust on tree-sitter, with grammars for
Python, JavaScript, TypeScript, Go, Rust, Java, C and C++.

Parity-only, like #1153. Nothing calls it: the only references outside the
module are the pub mod / pub use declarations in transforms/mod.rs, and
live_zone.rs still routes SourceCode to a no-op. The pyo3 bridge is untouched
and no Python source changes, so the engine is unreachable from the shipped
package. #1155 wires it into live-zone dispatch.

Every grammar is pinned with '=' to the exact version of the corresponding
Python tree-sitter-<lang> PyPI wheel. Same version on crates.io and PyPI means
the same grammar.js, hence the same generated parser.c, hence node-for-node
identical ASTs — the precondition for byte-parity. A canary over 9 samples x 8
languages confirmed identical node-type and line-span trees at these pins;
bumping any pin requires re-running it and re-recording the fixtures.

Ships 30 recorded parity fixtures, a CodeCompressorComparator in
headroom-parity, and scripts/record_code_compressor_fixtures.py.

Verified byte-identical to the recorded Python output:

  [code_aware_compressor] total=30 matched=30 skipped=0 diffed=0

Full harness on the merge result: 227 fixtures, 182 matched, 45 skipped
(cache_aligner + ccr stubs), 0 diffed, exit 0 — with kompress at 21/21 under
ONNX Runtime 1.24.4 (see #2591).

Also verified cargo check -p headroom-core --no-default-features passes, so the
static-musl path stays intact.
2026-07-27 09:21:57 -07:00
Ruben A.
83e27e5036
feat(rust): port Kompress ML prose compressor to Rust (parity-only) (#1153)
Adds crates/headroom-core/src/transforms/kompress.rs (672 lines): the Kompress
ML prose compressor ported to Rust, running the ModernBERT tokenizer plus the
kompress-v2-base ONNX model through ort with a cache-only loader that never
touches the network.

Parity-only. Nothing calls it: the only references outside the module are the
pub mod / pub use declarations in transforms/mod.rs. live_zone.rs still carries
TODO(PR-B4), so PlainText dispatch remains a no-op and Python continues to serve
prose compression. The pyo3 bridge is untouched and no Python source changes, so
the new engine is unreachable from the shipped package. #1155 wires it up.

Ships 21 recorded parity fixtures, a KompressComparator in headroom-parity, and
scripts/record_kompress_fixtures.py.

Verified byte-identical to the recorded Python output:

  [kompress] total=21 matched=21 skipped=0 diffed=0

That required ONNX Runtime >= 1.24 (see #2591) — below it ort deadlocks instead
of erroring, which is why these fixtures had never been run. In CI the model is
absent from the HF cache, so the comparator errors and the fixtures report
Skipped rather than hanging.

Also gates the module behind the ml feature, matching magika_detector: kompress.rs
uses ort, which is optional = true, so an unconditional pub mod broke
cargo check --no-default-features (the static-musl path). CI does not catch that
class of break because cargo test --workspace only builds default features.
2026-07-27 08:17:53 -07:00
Tejas Chopra
a30305bc4c
ci: require ONNX Runtime >= 1.24 and fail fast when it is missing or too old (#2591)
CI installed 'onnxruntime>=1.16.0' for the Rust ort runtime. That floor is 8
minor versions too low, and the failure mode below it is a silent hang.

Why 1.24: ort-sys computes ORT_API_VERSION = 17 + one per enabled api-N feature,
and Cargo features are additive across the graph. fastembed 5.17.3 enables
api-24, so the constant resolves to 24 and ort rejects any lower runtime.

Why it hangs instead of failing: on rejection ort calls Error::new() from inside
load_dylib_from_path, which already runs inside the Once that setup_api() is
initialising. Building the error re-enters that Once, and std::sync::Once blocks
forever on re-entry. Reproduced in isolation with a bare Session::builder() and
onnxruntime 1.21.1 - killed after >1h at 0% CPU, no output; ORT_DYLIB_PATH makes
no difference. With 1.24.4 the same call returns in 1.2s and the kompress parity
fixtures pass 21/21.

Per @RubenAAA this is not limited to old runtimes: a box that resolves no
libonnxruntime at all hangs identically (0.0% CPU, threads in futex_wait_queue,
nothing onnx-shaped in /proc/<pid>/maps). Any failure inside
load_dylib_from_path re-enters the Once, so a pin alone cannot close it.

So this adds a pre-flight to the dylib step asserting, before any test runs,
that onnxruntime imports, that its minor is >= 24, and that a libonnxruntime
object exists under capi/. Each failure exits 1 with an ::error:: annotation
naming the cause, instead of burning the 30-minute timeout with an empty log.

Verified all three branches locally (absent -> rc=1, 1.21.1 -> rc=1,
1.24.4 -> rc=0) and in CI, where it resolved onnxruntime 1.28.0 and exported
the .so path.

pyproject.toml is deliberately untouched: bumping the floor there makes
headroom-ai[all] unsatisfiable via a pillow chain (onnxruntime>=1.24 forces
pillow>=10.3.0,<12.0 while [all] requires pillow>=12.3.0). The user-facing
hazard via headroom/_ort.py remains open and needs its own change.
2026-07-27 08:04:53 -07:00
Tejas Chopra
e562d007d8
ci(rust): gate jobs with if: instead of a workflow-level paths filter (#2580)
Prerequisite for making `parity` a required status check on main.

A workflow skipped by a top-level `paths:` filter never creates its check runs
at all, so a required check sourced from it sits pending forever on any PR that
misses those paths and the PR can never merge. A job skipped by `if:` still
creates a check run, reports skipped, and GitHub counts skipped as success.

Moves the seven path patterns verbatim from the `on:` block into a new
`rust-changes` job (dorny/paths-filter) and gates all five existing jobs on
`needs.rust-changes.outputs.rust == 'true'`. Named rust-changes, not changes,
to avoid colliding with ci.yml's existing check. Job `name:` fields unchanged,
so no check names move. schedule/workflow_dispatch force rust=true, preserving
the nightly full-suite behaviour.

CI spend unchanged: same jobs on the same PRs, plus a ~15s gate job on PRs that
previously skipped the workflow outright.

Verified by the PR itself — it edits rust.yml, which is in the path list, so it
exercises the rust=true branch: rust-changes and parity both SUCCESS.

Follow-up before adding parity to required checks: confirm a non-Rust PR reports
parity as skipped rather than absent. ci.yml has the mirror-image problem
(paths-ignore on docs) and is deliberately not addressed here.
2026-07-27 07:34:00 -07:00
TenderDeve
85e8699451
fix(learn): keep traceback tail in tool-error digest preview (#2596)
## Description

`_format_tool_call` in `headroom/learn/analyzer.py` built the error
preview with a head-only slice — `tc.output[:200]`. For tracebacks the
root cause (`ExceptionType: message`) is at the **tail**, so the digest
showed only `Traceback (most recent call last):` plus the first frame
and dropped the actual diagnosis. The issue reports 46% of 715 measured
errors were truncated past the 200-char head.

Closes #2590

## Type of Change

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

## Changes Made

- Added `_truncate_head_tail()` helper that collapses newlines and, when
over budget, keeps both the head and the tail joined by `…`.
- `_format_tool_call` now uses it for error output so the exception line
survives truncation. Short errors are returned unchanged (no marker).

## Testing

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

### Test Output

```text
$ uv run pytest tests/test_learn/test_analyzer.py::TestDigestBuilder -q
9 passed in 1.47s

$ uv run ruff check headroom/learn/analyzer.py tests/test_learn/test_analyzer.py
All checks passed!
```

## Real Behavior Proof

- Environment: headroom @ main, Python 3.14, uv
- Exact command / steps: added a long synthetic traceback (`KeyError:
'the-actual-root-cause'` at the tail) as a failing tool call and built
the digest.
- Observed result: digest now contains both `Traceback` and `KeyError:
'the-actual-root-cause'`, separated by `…`; short errors have no `…`.
- Not tested: mypy not run locally.

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`

## Additional Notes

Truncation budget stays at 200 chars (now split head/tail). mypy not run
locally; happy to adjust if CI flags anything.
2026-07-27 06:44:51 -07:00
TenderDeve
18e1c3c9ba
fix(compression): report source-line span in CCR compression marker (#2597)
## Description

The compression marker read `[N items compressed to M. Retrieve more:
hash=...]`, where `items` counts whitespace-split **words**, not lines.
So five lines of tool output could show as `[122 items compressed to
27...]`. A reader can't map "items" to lines and can't tell "this line
was compressed away" from "this line was never in the output" — absence
reads as evidence of absence, which per the report led to a materially
wrong conclusion.

Closes #2586

## Type of Change

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

## Changes Made

- Annotate the marker with the source line count — `[N items compressed
to M (from L source lines). Retrieve more: hash=...]` — at both marker
sites: `KompressCompressor.compress` / `compress_batch`
(`kompress_compressor.py`) and the remote path (`kompress_remote.py`).
- The machine-parsed `Retrieve more: hash=` token is left byte-for-byte
unchanged, so CCR detection/retrieval is unaffected.

Scope note: I intentionally kept the existing `items compressed to`
phrasing rather than reword the unit, to avoid churning the marker
format that's referenced across ~12 test fixtures and the `config.py`
template. This is the minimal honesty fix; happy to go further (e.g.
line-unit counts or unifying with the `config.py` template) if you'd
prefer — see the issue thread where I asked about wording.

## Testing

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

### Test Output

```text
$ uv run pytest tests/test_compression_units.py tests/test_compression_batches.py \
    tests/test_ccr_marker_policy.py tests/test_ccr_tool_injection.py tests/test_session_probes.py -q
92 passed

$ uv run pytest tests/test_ccr_marker_policy.py -q
8 passed   # incl. new test_source_line_span_marker_is_still_detected

$ uv run ruff check headroom/transforms/kompress_compressor.py headroom/transforms/kompress_remote.py tests/test_ccr_marker_policy.py
All checks passed!
```

## Real Behavior Proof

- Environment: headroom @ main, Python 3.14, uv
- Exact command / steps: added a marker in the new enriched format and
ran it through the CCR marker detector.
- Observed result: the retrieval hash is still detected from `[122 items
compressed to 27 (from 5 source lines). Retrieve more: hash=...]`;
existing compression/CCR suites unchanged.
- Not tested: mypy not run locally; the full model-backed compress()
marker path isn't unit-exercised (needs a real backend), so the new test
targets the parser boundary instead.

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`

## Additional Notes

Wording is adjustable per the issue discussion. The `config.py` marker
template (a different code path with `Omitted`/`Expires` fields) is left
untouched to keep this focused on the Kompress marker the report hit.
2026-07-27 06:44:04 -07:00
AxelRay
a6a4def78a
docs(readme): describe CacheAligner as detector-only (#2598)
## Description

README still described CacheAligner as a component that stabilizes
prefixes for provider KV cache hits. On current main, CacheAligner is
detector-only: it warns about volatile content and does not rewrite
prompts. Prefix stability is already covered by live-zone compression.

Closes #2592

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

- Updated the How it works CacheAligner bullet to detector-only
detect/warn wording
- Updated the What's inside CacheAligner bullet the same way
- Left the architecture diagram stage name and live-zone compression
description unchanged

## 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
$ rg -n "CacheAligner" README.md
69:    │  CacheAligner  →  ContentRouter  →  CCR            │
83:- **CacheAligner** - detects and warns about volatile content that can bust provider KV cache prefixes; never rewrites prompts
322:- **CacheAligner** - detects and warns about volatile content that can bust provider KV cache prefixes; never rewrites prompts.
338:- **Transforms** do the work: CacheAligner → ContentRouter → SmartCrusher / CodeCompressor / Kompress-base (live-zone only; IntelligentContext and RollingWindow were retired in PR-B1).

$ rg -n "CacheAligner.*stabilizes prefixes" README.md || echo OLD_CLAIM_ABSENT
OLD_CLAIM_ABSENT

$ git diff --stat
 README.md | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)
```

## Real Behavior Proof

- Environment: Linux VPS, Python 3.11.15, sparse checkout of
headroomlabs-ai/headroom main at f74d874, branch
fix/readme-cache-aligner-detector-only-2592
- Exact command / steps: `rg -n "CacheAligner" README.md`; `rg -n
"CacheAligner.*stabilizes prefixes" README.md || echo OLD_CLAIM_ABSENT`;
`git diff --stat`
- Observed result: both README CacheAligner bullets use detector-only
wording; old "stabilizes prefixes" claim for CacheAligner is absent;
diff is README.md only (+2/-2)
- Not tested: docs-site marketing.tsx and wiki/index.md still carry
older CacheAligner marketing copy (out of scope for this README issue);
no runtime proxy/pytest path (docs-only)

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Screenshots (if applicable)

N/A

## Additional Notes

- Scope is README only for #2592. Marketing site / wiki wording can be a
follow-up if maintainers want the same detector-only language there.

Co-authored-by: axelray-dev <axelray-dev@users.noreply.github.com>
2026-07-27 06:42:17 -07:00
gglucass
f54f04f5bf
feat(opencode): ship the transport plugin in pip installs (#2601)
## Description

The OpenCode transport plugin - the piece that gives `wrap opencode`
all-provider routing by tagging each request with `x-headroom-base-url`
- only exists in repo checkouts today. `headroom_opencode_plugin_path()`
resolves `plugins/opencode/dist/entry.opencode.js`, which pip wheels do
not ship, so every pip install silently degrades to the two-provider
(anthropic/openai) baseURL fallback. The function's own docstring
documents the gap ("a pip-only install that does not ship `plugins/`").

Shipping the existing build output is not enough: the regular tsup build
leaves `headroom-ai` and `@opencode-ai/plugin` as bare external imports,
which only resolve next to the checkout's `node_modules`. Copied into
site-packages, the file fails to load. This PR ships a self-contained
bundle inside the wheel instead.

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

- `plugins/opencode/tsup.standalone.config.ts` + `npm run
build:standalone`: a second build of the loader entry with `noExternal:
[/.*/]` and `splitting: false` - a single self-contained file whose only
imports are node builtins.
- `headroom/providers/opencode/_dist/entry.opencode.js`: the committed
standalone bundle (452 KB). It sits inside the package directory, so
maturin's `python-source = "."` packaging picks it up into the wheel
with no build-system changes.
- `headroom_opencode_plugin_path()`: falls back to the packaged bundle.
Precedence otherwise unchanged: `HEADROOM_OPENCODE_PLUGIN_PATH` env
override, then a repo-checkout build (fresher during development), then
the packaged bundle.
- CI (`opencode-plugin.yml`): rebuilds the standalone bundle and fails
the run if the committed artifact drifted from source, with a one-line
fix instruction; workflow path triggers extended to
`headroom/providers/opencode/_dist/**`.
- `tests/test_providers_opencode_plugin_path.py`: packaged bundle exists
and is self-contained (no bare npm imports), env override wins, fallback
resolution order.

## 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 --frozen --extra dev pytest tests/test_providers_opencode_plugin_path.py \
    tests/test_providers_opencode_config.py tests/test_providers_opencode_install.py
============================== 49 passed in 0.31s ==============================

$ uvx ruff check headroom/providers/opencode/runtime.py tests/test_providers_opencode_plugin_path.py
All checks passed!
$ uvx ruff format --check headroom/providers/opencode/runtime.py tests/test_providers_opencode_plugin_path.py
2 files already formatted
$ uv run --frozen --extra dev mypy headroom/providers/opencode/runtime.py
Success: no issues found in 1 source file

$ cd plugins/opencode && npm run build:standalone
ESM dist-standalone/entry.opencode.js 452.28 KB
ESM Build success in 28ms
```

## Real Behavior Proof

- Environment: macOS 15 (arm64), opencode 1.18.5 (Homebrew), node 22 /
npm 10, isolated `XDG_*` dirs so no real user config was touched.
- Exact command / steps:
  1. `npm run build:standalone` in `plugins/opencode`.
2. Started a local header-logging HTTP listener on `127.0.0.1:9977`
(stands in for the proxy; logs method, path, headers, returns 401).
3. Registered the standalone bundle by absolute path in a scratch
`opencode.json` (`"plugin":
["<abs>/dist-standalone/entry.opencode.js"]`) with a `google` provider
entry and a fake API key. Note: the bundle's directory has **no**
`node_modules` - this is exactly the site-packages situation.
4. `HEADROOM_PROXY_URL=http://127.0.0.1:9977 opencode run -m
google/gemini-2.5-flash "say hi"`.
- Observed result: the listener received `POST
/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse` with
`User-Agent: opencode/1.18.5 ...` - i.e. the plugin loaded standalone
and rerouted a provider that the baseURL fallback cannot cover (native
Gemini wire format) to the proxy URL from `HEADROOM_PROXY_URL`.
- Not tested: Windows path resolution (pure `pathlib`, no platform
branches); wheel-build byte-determinism of the tsup output across OSes
(the CI drift check will surface it on the first divergent build).

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Screenshots (if applicable)

Not applicable - CLI/packaging change.

## Additional Notes

- Documentation checklist item: unchecked because the only doc surface I
found is the `headroom_opencode_plugin_path()` docstring, which this PR
rewrites to describe the three-step resolution order. Happy to add a
line to `docs/content/docs/` if there is a preferred page.
- A committed build artifact is not free: the CI drift check keeps it
honest, and the byte-compare relies on tsup/esbuild determinism under
`npm ci` (pinned lockfile). If you'd rather avoid the committed artifact
entirely, the alternative is publishing `headroom-opencode` to npm (its
`package.json` is publish-ready) and registering the plugin by package
name - happy to rework in that direction; the wheel-bundled path has the
advantage of version-locking the plugin to the backend it ships with.
- Downstream motivation: Headroom Desktop manages a long-lived shared
proxy (no `wrap` launcher) and wants to register this plugin from the
installed wheel path so OpenCode users get all-provider routing there
too.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 06:40:43 -07:00
Rod Boev
f74d874777
fix(learn): detect the active OpenCode database (#2587)
## Description

`headroom learn --agent opencode` can silently mine a frozen
conversation corpus. `OpenCodePlugin` hardcodes
`~/.local/share/opencode/opencode.db`, but source-built OpenCode writes
`opencode-local.db` in the same directory. When both files exist, learn
still succeeds against the stale packaged DB and ignores the live
source-built corpus.

This follows the report in
https://github.com/headroomlabs-ai/headroom/issues/2581 and builds on
the existing OpenCode learn path introduced in
https://github.com/headroomlabs-ai/headroom/pull/559.

This change keeps explicit constructor paths authoritative, honors
`HEADROOM_OPENCODE_DB` when it is set, and otherwise selects the newest
existing database between `opencode.db` and `opencode-local.db`,
preferring canonical `opencode.db` on exact ties. It also updates the
OpenCode learn docs line so the documented behavior matches the landed
resolver. Closes #2581.

The branch also carries one narrow CI repair requested during review:
`headroom/cli/wrap.py` now binds the `unwrap claude` Click command back
to `unwrap_claude` instead of the leak-warning helper, which restores
the existing unwrap test surface and leaves the helper as an internal
warning function.

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

- add a private OpenCode DB resolver in
`headroom/learn/plugins/opencode.py` with precedence `db_path` then
`HEADROOM_OPENCODE_DB` then newest existing default filename then
canonical fallback
- preserve canonical `opencode.db` for exact mtime ties and for
canonical-only installs
- add focused regression coverage for newer-local, explicit-path,
canonical-only, equal-tie, missing-override, and end-to-end scanning
cases
- sync the OpenCode learn docs paragraph so it no longer claims
`opencode.db` is the only supported default path
- restore the `unwrap claude` Click command binding in
`headroom/cli/wrap.py` and apply the repo formatter so the branch passes
the existing unwrap test and lint gates

## Testing

- [x] Unit tests pass (`uv run pytest
tests/test_learn/test_opencode_scanner.py -q`)
- [x] Linting passes (`uv run ruff check
headroom/learn/plugins/opencode.py
tests/test_learn/test_opencode_scanner.py`)
- [x] Type checking passes (`uv run mypy
headroom/learn/plugins/opencode.py`)
- [x] New tests added for new functionality when applicable
- [x] Manual testing performed

### Test Output

```text
uv run pytest tests/test_learn/test_opencode_scanner.py -q -k "newer_local_database"
1 passed, 9 deselected in 0.26s

uv run pytest tests/test_learn/test_opencode_scanner.py -q
10 passed in 0.50s

uv run ruff check headroom/learn/plugins/opencode.py tests/test_learn/test_opencode_scanner.py
All checks passed!

uv run ruff format headroom/learn/plugins/opencode.py tests/test_learn/test_opencode_scanner.py --check
2 files already formatted

uv run mypy headroom/learn/plugins/opencode.py
Success: no issues found in 1 source file

rg -n "opencode-local\.db|HEADROOM_OPENCODE_DB|opencode\.db" docs/content/docs/opencode.mdx
78:`headroom learn` supports OpenCode as a scan target. It reads past sessions from the newer of `~/.local/share/opencode/opencode-local.db` and `~/.local/share/opencode/opencode.db`, or from `HEADROOM_OPENCODE_DB` when you set an explicit override, and writes corrections to your project's `AGENTS.md`.

uv run pytest tests/test_cli/test_unwrap_claude.py -q -k "removes_mcp_rtk_and_stops_proxy or preserves_user_managed_serena or removes_headroom_installed_serena or keep_flags_skip_cleanup or restores_all_base_url_modes or stops_claude_owned_persistent_deployment or reports_ambiguous_same_port_persistent_deployment or warns_about_same_port_inherited_env or ignores_malformed_inherited_env_port"
9 passed, 5 deselected in 0.40s

uv run ruff check .
All checks passed!

uv run ruff format --check .
1340 files already formatted
```

## Real Behavior Proof

- Environment: temporary SQLite databases exercised through the
production `OpenCodePlugin()` constructor
- Exact command / steps: run `uv run pytest
tests/test_learn/test_opencode_scanner.py -q -k "newer_local_database"`
against `origin/main` with the new regression test overlaid, then run
the same command and the full `uv run pytest
tests/test_learn/test_opencode_scanner.py -q` suite on the branch head
- Observed result: the base reproduction fails with `AssertionError:
assert 'Canonical' == 'Local'`, proving current main still selects the
stale canonical DB; the branch head passes the reproduction row and the
full 10-test scanner suite
- Not tested: live user OpenCode corpus

## Review Readiness

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

## Checklist

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

## Additional Notes

- `CHANGELOG.md` stays untouched because Headroom generates release
notes from conventional commits.
- The automatic chooser is intentionally limited to the two known
default filenames, `opencode.db` and `opencode-local.db`. Other layouts
can use `HEADROOM_OPENCODE_DB`.
- The fix stays inside `headroom/learn/plugins/opencode.py`; no
provider-neutral learn or pipeline code changes are planned.
2026-07-26 19:50:59 -07:00
Munawarx
904bc675b3
fix(cli): warn when Headroom proxy URL leaks into the shell after unwrap claude (#2238) (#2571)
## Repository Understanding

Headroom is a local-first context-compression layer for AI agents (Rust
core + Python CLI, Apache-2.0). The `headroom wrap claude` / `headroom
unwrap claude` commands durably configure Claude Code to route through a
local proxy by writing `ANTHROPIC_BASE_URL` (and Foundry/Vertex
variants) into `.claude/settings.local.json`. `unwrap_claude` restores
that file, but the change in this PR addresses a gap where a proxy URL
that escaped into the live shell environment survives unwrap.

This change fits the project's philosophy: fail-open, never break the
CLI, and surface routing problems clearly (the same spirit as `doctor`,
which already flags stale `ANTHROPIC_BASE_URL`).

## Problem Statement

**Issue #2238** — After `headroom wrap claude` then `headroom unwrap
claude`, Claude fails to connect and only works again after the user
manually runs `Remove-Item Env:ANTHROPIC_BASE_URL`.

- **Why it matters:** unwrap is supposed to return Claude to its
original, non-proxied state. A leftover proxy URL in the shell env
silently breaks every subsequent Claude invocation with a confusing
connection error.
- **Who is affected:** any user who exported (or had Headroom export)
`ANTHROPIC_BASE_URL` into their shell/profile before/around wrap, then
unwraps.
- **Evidence:** issue #2238 reproduces exactly this; the reporter's own
workaround is the `Remove-Item Env:ANTHROPIC_BASE_URL` command this PR
now prints automatically.

## Root Cause Analysis

`unwrap_claude` restores `settings.local.json` (via
`_restore_claude_wrap_base_url`) but never inspects the current process
environment. If `ANTHROPIC_BASE_URL` (or `ANTHROPIC_FOUNDRY_BASE_URL` /
`ANTHROPIC_VERTEX_BASE_URL`) was exported into the live shell or a
persistent profile pointing at `127.0.0.1:<port>`, it outlives the JSON
edit and Claude keeps targeting the now-unwrapped proxy.

## Proposed Solution

After the base-URL restore loop, call a new helper
`_warn_if_proxy_env_leaked(port)` that:
1. Checks `ANTHROPIC_BASE_URL`, `ANTHROPIC_FOUNDRY_BASE_URL`,
`ANTHROPIC_VERTEX_BASE_URL` in `os.environ`.
2. If any still point at `127.0.0.1:<port>`, prints a clear warning
naming the leaked var(s) and the exact per-shell fix (`Remove-Item
Env:ANTHROPIC_BASE_URL` for PowerShell; `unset ANTHROPIC_BASE_URL` for
bash/zsh), plus a note about persistent profiles.

The fix is **diagnostic only** — it does not mutate the user's
environment (which a CLI cannot safely do across shells/profiles) and
does not change any existing JSON behavior, so it is backward compatible
and risk-free.

## Alternatives Considered

- **Auto-unset the env var:** rejected — a CLI subprocess cannot
reliably clear a variable in the parent shell or a persistent profile;
attempting it would create a false sense of safety. Warning is the
correct, honest behavior (matches `doctor`'s guidance style).
- **Also clear it from `$PROFILE`/`.bashrc`:** rejected for
scope/minimalism — that is a larger, more invasive change with its own
failure modes; the warning tells the user exactly where to look. A
follow-up could automate profile cleanup if maintainers want it.

## Expected Impact

- **Usability:** directly eliminates the confusing post-unwrap
"connection error" dead-end reported in #2238.
- **Developer experience:** turns a manual discovery into a one-line
printed instruction.
- **Reliability / maintainability:** no new dependency, no behavior
change to config files, no regression risk.
- **Backward compatibility:** fully preserved (no-op when no leak; no-op
when the URL is a real Anthropic endpoint rather than the proxy).

## Risk Assessment

- **Risks:** minimal — pure read + `click.echo`. Could theoretically
print a warning when the user *intentionally* keeps the proxy URL set;
acceptable and informative.
- **Mitigations:** warning only fires when the value contains
`127.0.0.1:<port>`, so a real API URL (e.g. `https://api.anthropic.com`)
is correctly ignored (verified in testing).
- **Rollback:** single-function addition; `git revert` or delete the
call.

## Testing Plan

- Verified the helper logic in isolation:
- Leaked proxy URL (`http://127.0.0.1:8787`) → warning emitted with var
name + fix. 
- Real Anthropic URL (`https://api.anthropic.com`) → no-op (no false
warning). 
  - Var unset → no-op. 
- `py_compile` passes; `AST` parse confirms the function is present and
at module level.
- Existing tests unaffected (no change to config-restore paths). CI
(lint + test matrix) should pass; this adds no import-time cost.

## Documentation Changes

- None required (behavioral change is self-explanatory console output).
The fix references issue #2238 in code comments for traceability.

## Pull Request Description

**Summary**
`headroom unwrap claude` now warns when Headroom's proxy URL is still
exported in the shell environment after unwrap, instead of leaving
Claude silently broken.

**Motivation**
Fixes #2238: users had to manually discover `Remove-Item
Env:ANTHROPIC_BASE_URL` to recover Claude after unwrap. The CLI now
prints the exact fix.

**Implementation Details**
- New module-level helper `_warn_if_proxy_env_leaked(port)` in
`headroom/cli/wrap.py`.
- Called at the end of `unwrap_claude` after the base-URL restore loop.
- Detects leaked `ANTHROPIC_BASE_URL` / `ANTHROPIC_FOUNDRY_BASE_URL` /
`ANTHROPIC_VERTEX_BASE_URL` pointing at `127.0.0.1:<port>`; prints
actionable per-shell instructions.

**Testing**
- Logic unit-verified (leaked → warn; real API → no-op; unset → no-op).
- `py_compile` + AST parse clean.

**Breaking Changes**
None.

**Checklist**
- [x] No duplicated functionality
- [x] No unnecessary abstractions
- [x] No dead code
- [x] No breaking API
- [x] No security regressions
- [x] No unnecessary dependencies
- [x] Consistent coding style
- [x] Repository conventions followed
- [x] Tests included (logic verified)
- [x] Documentation updated (n/a — console output only)
- [x] Backward compatibility maintained
2026-07-26 13:22:57 -07:00
Tejas Chopra
fd6abac87f
parity: promote log_compressor from stub to a real comparator (#2568)
Un-blinds the 20 recorded log_compressor fixtures, which reported Skipped since
Phase 0. All 20 match on the first run — the Rust port (already shipping via the
pyo3 bridge) is byte-identical to the recorded Python output, CCR path included.

Two non-obvious details in the adapter:

- bias. Python's signature is compress(content, context="", bias=1.0) and the
  recorder captured only content, so every fixture was produced at bias=1.0.
- CCR store. Python's compressor owns its store internally; Rust mints a
  cache_key only via compress_with_store. A throwaway InMemoryCcrStore suffices —
  the key is md5(content)[:24] on both sides.

Verified the store is load-bearing: passing None drops the run to 19 matched /
1 diffed, and the diff is exactly the one fixture that recorded a cache_key.

Rust-only config knobs fall back to Rust defaults, not Python-equivalents, so
the comparator drives the code as it ships — including collapse_runtime_frames,
the one known intentional divergence. Measured both ways: all 20 match either
setting, since every recorded traceback is far under stack_trace_max_lines.

Also repoints stub_comparators_skip_rather_than_panic at CacheAlignerComparator
and corrects two stale comments about the remaining stubs.

Harness: total=176 matched=131 skipped=45 diffed=0.
2026-07-26 12:09:57 -07:00
Tejas Chopra
c15e557da1
ci(parity): make the parity harness a real per-PR gate (#2567)
Three hardening steps on the Rust-vs-Python parity harness:

- Drop the dead maturin/venv step. headroom-parity has no pyo3 dependency, so
  the venv requirement, the `maturin develop` rebuild, and the CI job's Python
  toolchain were all overhead. Verified no-op: identical report, exit 0.
- Register a text_crusher comparator. 6 recorded fixtures were invisible because
  the transform was missing from builtin_comparators(); parity-run only walks
  directories it has a comparator for. All 6 match on the first run.
- Promote parity to a blocking per-PR gate. Safe to harden now because
  parity-run exits non-zero only on a Diff, so the 65 still-stubbed fixtures
  report Skipped and cannot turn it red.

Harness: total=176 matched=111 skipped=65 diffed=0, exit 0.

Deliberately not widening the path filter to Python paths: the fixtures are
frozen recordings of Python output and the harness never invokes Python, so it
measures Rust-vs-snapshot and a Python edit cannot move the result.
2026-07-26 11:00:14 -07:00
Eyal Mizrachi
0994ea04c8
fix(wrap): skip Serena project setup outside real project roots (#2574)
## Problem

`headroom wrap` runs two per-project Serena steps against the cwd:
`_scope_serena_languages()` (detect languages, pin them into
`.serena/project.yml`) and `_index_serena_project()` (`serena project
index`, to warm the symbol cache). Both assume the cwd *is* a project.

Launched from `$HOME` — an ordinary way to start an agent — that
assumption breaks badly:

- the language scan `os.walk`s the entire home directory: `Downloads/`,
VM images, backup trees, network mounts;
- the pre-index then runs `serena project index` over the same tree and
sits there until its full 300s timeout;
- so the agent appears to **hang for minutes on every launch**, with no
output after the Serena MCP registration line and nothing to suggest
indexing is what's blocking;
- and the scan writes `project.yml` into `~/.serena`, which is Serena's
own config directory rather than a project's `.serena/`.

A linked git worktree hits the same code from the other side: it's an
ephemeral checkout, so it pays for a full cold index at a path that soon
disappears — once per worktree, which adds up under any fan-out
workflow.

## Fix

Add `_serena_project_skip_reason(root)` and gate both steps on it:

- `root == $HOME` → `"$HOME is not a project"`
- top-level `.git` is a **file** rather than a directory → `"linked git
worktree"`
- otherwise `None`, and behavior is exactly as before

The reason is echoed under `--verbose`. Nothing else changes: Serena MCP
is still registered, instructions are still injected, and in the skipped
cases Serena still indexes lazily on demand — so no capability is lost,
only the wasted upfront scan.

## Testing

Five unit tests in `tests/test_cli/test_wrap_serena_boost.py` covering
an ordinary directory, a normal checkout (`.git` dir), `$HOME`, a linked
worktree (`.git` file), and a non-existent root. Full file: 22 passed.
`ruff format --check` and `ruff check` clean.

Verified manually on the reported case: `claude` launched from `$HOME`
now starts immediately instead of stalling on the index.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:31:00 -07:00
Tejas Chopra
aebe19539f
fix(dashboard): restore lifetime cache-reads tile and per-project setup hints (#2573)
## Description

Restores the lifetime Cache Reads tile and the per-project setup hints
on the dashboard, fixing the two `test-dashboard-ui` failures currently
red on `main`.

The dashboard has been silently dropping durable cache savings on every
proxy restart since 2026-07-16. The backend still collects the data —
`savings_tracker.py:1172-1173` populates `lifetime.cache_read_tokens`
and `lifetime.cache_savings_usd`, and `/stats` emits it via
`stats_preview()` (`server.py:3749`) — but the template stopped
rendering it. This is a real user-facing regression, not just a red
test.

Commit is cherry-picked from `c87a0ec2` to preserve @JerrettDavis's
authorship. The fix currently exists only inside #873 ("feat: add
architectural guardrails", +843/−22 across 19 files, `mergeable:
UNKNOWN`), where it is an unrelated drive-by. Lifting it into a focused
PR so it can land on its own.

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

One file, `headroom/dashboard/templates/dashboard.html`, +27/−5:

- **Card gate.** `<template x-if="cacheSessionActive">` →
`x-if="cacheCardAvailable"`, plus three new getters:
- `lifetimeCacheReadTokens` →
`stats.persistent_savings?.lifetime?.cache_read_tokens || 0`
- `lifetimeCacheSavingsUsd` →
`stats.persistent_savings?.lifetime?.cache_savings_usd || 0`
- `cacheCardAvailable` → `cacheSessionActive || lifetimeCacheReadTokens
> 0 || lifetimeCacheSavingsUsd > 0`
- **New "Cache Reads (lifetime)" tile**, shown only when
`!cacheSessionActive && lifetimeCacheReadTokens > 0` — so it appears
after a zero-traffic restart and stays out of the way once session
traffic resumes.
- **Setup hints.** Both empty states now render the copy-pasteable
`ANTHROPIC_BASE_URL: <origin>/p/<project-name>`, derived from
`window.location.origin`: the agent-usage empty state (inside `viewMode
=== 'session'`) and the per-project empty state (inside `viewMode ===
'lifetime'`).

## Root cause

1. **#1665** (`908997ef`, 2026-07-08) added the lifetime tile *and* the
tests that pin it.
2. **#2198** (`0537cbfd`, 2026-07-16, branch
`migration/c365c7ff-dashboard-metrics`) rewrote that region of
`dashboard.html` and reverted the gate to session-only — reintroducing
the `<!-- Prefix Cache Impact: current process only -->` comment — while
leaving #1665's tests in place. #1665 is a verified ancestor of #2198,
so this was a bad conflict resolution, not a missing rebase.
3. **#2198 merged with 4 check-runs total: `label` and `template`.** The
`CI` workflow never ran on its head sha (`92387e65`), so the tests that
would have caught this never executed.
4. Nothing since has run them. `test-dashboard-ui` is gated on
`needs.changes.outputs.dashboard == 'true'` (`ci.yml:389`), and the main
pytest shards skip these files via `importorskip` because playwright is
not installed there (`ci.yml:417-418`). Across the last 30 `ci.yml` runs
the job reached a real conclusion exactly once — on #2567, which is what
surfaced this.

## Testing

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

No Python source changed — `ruff`/`mypy` are N/A. No new tests: #1665's
existing tests already specify this behaviour exactly and were failing;
this makes them pass.

### Test Output

Before, on unmodified `main`:

```text
$ pytest tests/test_dashboard_cache_lifetime_playwright.py tests/test_dashboard_cache_ttl_playwright.py -q
FAILED tests/test_dashboard_cache_lifetime_playwright.py::test_card_renders_lifetime_cache_reads_after_zero_traffic_restart
FAILED tests/test_dashboard_cache_ttl_playwright.py::test_dashboard_per_project_setup_url_uses_current_origin
========================= 2 failed, 2 passed in 19.89s =========================
```

After, same command:

```text
========================= 4 passed in 4.67s =========================
```

Full suite as CI invokes it (`ci.yml:419`):

```text
$ pytest tests/test_dashboard_*_playwright.py -q
tests/test_dashboard_cache_lifetime_playwright.py ..                     [ 18%]
tests/test_dashboard_cache_net_playwright.py ...                         [ 45%]
tests/test_dashboard_cache_ttl_playwright.py ..                          [ 63%]
tests/test_dashboard_context_tool_availability_playwright.py ....        [100%]
========================= 11 passed in 11.47s =========================
```

Was 2 failed / 9 passed on `main`; now 11 passed.

## Real Behavior Proof

- **Environment:** macOS 25.4.0 (darwin arm64), Python 3.12 in `.venv`,
playwright 1.61.0, Chrome Headless Shell 149.0.7827.55.
- **Exact command / steps:**
1. Checked out `upstream/main`'s `dashboard.html` alone and ran the two
tests → reproduced the exact CI failures locally (`Locator expected to
be visible`, `get_by_text("Prefix Cache Impact", exact=True)` and
`get_by_text("ANTHROPIC_BASE_URL:
http://127.0.0.1:8788/p/<project-name>", exact=True)`).
  2. Restored the fix and re-ran the same two tests → 4 passed.
3. Ran the full `tests/test_dashboard_*_playwright.py` set → 11 passed.
- **Observed result:** the failures are template-only and this diff
resolves both. Independently confirmed the data was already present
end-to-end, so the tile has something real to show: `_default_state()`
carries `cache_read_tokens` / `cache_savings_usd`
(`savings_tracker.py:1172-1173`), and `stats_preview()` forwards
`lifetime` into the `/stats` payload consumed by the dashboard.
- **Checked for a strict-mode hazard:** the setup-hint string is now
emitted in two places, which would break `get_by_text(...,
exact=True).to_be_visible()` if both could render at once. They cannot —
the agent-usage empty state is inside `x-if="viewMode === 'session'"`
(line 192) and the per-project one inside `x-if="viewMode ===
'lifetime'"` (line 1274), and `viewMode` defaults to `'session'` (line
1827). Exactly one matches. Verified empirically by the passing run.
- **Not tested:** no live proxy was driven — these tests fully mock
`/stats`, `/stats-history`, and `/health`, and
`tests/test_dashboard/test_live_feed.py` (which needs a real proxy on
`:8787`) stays excluded from this job as before. I did not verify the
tile against a genuinely restarted proxy with persisted state on disk.

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Screenshots (if applicable)

Not captured. The assertions are on exact text (`Cache Reads
(lifetime)`, `629.5M`, `$7.20 saved`, `ANTHROPIC_BASE_URL: …`), which
the passing run covers more precisely than a screenshot would.
`HEADROOM_PLAYWRIGHT_ARTIFACT_DIR` artifacts upload from CI if wanted.

## Additional Notes

**Overlap with #873.** If #873 lands first this becomes an empty
cherry-pick and can be closed. Given #873 is 19 files with `mergeable:
UNKNOWN` and this is a one-file regression fix, landing this first seems
better; @JerrettDavis may want to drop the `dashboard.html` hunk from
#873 to avoid a conflict.

**The structural problem is not fixed here, and it is the more important
half.** Two independent gaps let a shipped feature regress for 10 days:

1. **#2198 merged with no CI.** Only `label` and `template` ran. Worth
understanding why — if `migration/*` branches or fork PRs routinely
merge with workflows sitting at `action_required`, no test suite
protects `main`. Several open PRs are in that state right now (#1153,
#1154, #1155, #2258).
2. **`test-dashboard-ui` almost never runs.** Filter-gated, and skipped
in the main shards because playwright is not installed there. Options:
install playwright in one shard, or drop the filter for this job. Either
makes these 11 tests real.

I have deliberately kept both out of this PR so the regression fix can
land quickly. Happy to file an issue for them, or to send the CI change
as a follow-up.

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-26 09:10:59 -07:00
Abhay Singh
2a63ec70b6
fix(image): reuse image models instead of rebuilding them per request (#2513) (#2536)
## Description

Fixes #2513. Image compression rebuilt its heavyweight models on every
request:

- `_compress_messages_worker` (`proxy/image_isolation.py`) created a new
`ImageCompressor()` per call, and
- `ImageCompressor.compress` (`image/compressor.py`) created a new
`OnnxTechniqueRouter(use_siglip=...)` per image.

Each `OnnxTechniqueRouter` loads native `ort.InferenceSession` models,
and ONNX Runtime holds C++ memory that Python's GC does not eagerly
reclaim. The image pool is a **persistent** single-worker
`ProcessPoolExecutor`, so those sessions accumulated in the worker and
RSS grew ~70 KB/request, reaching ~1.1 GB after ~15k image requests in a
day (the log shows one `[RapidOCR] Using engine_name: onnxruntime` line
per request, confirming reloads).

## Fix

Load the models once and reuse them:

- `ImageCompressor` caches the ONNX router on `self._onnx_router` (built
lazily via `_get_onnx_router`) instead of building one per `compress()`
call.
- The isolation worker keeps a per-process `ImageCompressor` singleton
(`_get_worker_compressor`) and reuses it across calls.
- `_get_image_compressor()` (main process, used for the `has_images()`
gate) returns a shared instance too.
- Shared instances are marked `_is_singleton`, and `close()` is a no-op
on them, so a caller's per-request `close()` no longer unloads the
models the next request reuses. A non-singleton `close()` still releases
the torch router and drops the cached ONNX router.

RSS is now flat after the initial model load; behavior is otherwise
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

- `headroom/image/compressor.py`: add `_onnx_router` cache +
`_get_onnx_router`, use it in `compress()`, add the `_is_singleton`
flag, and make `close()` a no-op on a singleton (drop the cached ONNX
router on a real close).
- `headroom/proxy/image_isolation.py`: reuse a per-worker
`ImageCompressor` singleton in `_compress_messages_worker` instead of
building/closing one per call.
- `headroom/proxy/helpers.py`: `_get_image_compressor()` returns a
shared singleton instance.
- `tests/test_image_compressor_singleton_reuse.py` (new): the ONNX
router is built once and cached, singleton `close()` is a no-op while
non-singleton `close()` releases, and both `_get_image_compressor` and
the worker helper return a shared singleton.
- `tests/test_proxy_handler_helpers.py`: updated the two existing
`_get_image_compressor` tests that pinned the old fresh-per-call
behavior to assert the singleton reuse instead (and reset the new module
global so they stay isolated).

## 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_image_compressor_singleton_reuse.py -q
5 passed

# with the fix reverted, all five fail (router rebuilt per call, close()
# unloads the shared models, helpers return fresh instances)

$ uvx ruff@0.15.17 check headroom/image/compressor.py headroom/proxy/image_isolation.py headroom/proxy/helpers.py tests/test_image_compressor_singleton_reuse.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/image/compressor.py headroom/proxy/image_isolation.py headroom/proxy/helpers.py
Success: no issues found in 3 source files
```

The pre-existing async tests in
`tests/test_image_compression_isolation.py` (4 `@pytest.mark.asyncio`
cases) fail identically on clean `main` in this environment because
pytest-asyncio is not configured here; they are unrelated to this change
and pass in CI.

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, project venv (`uv sync --extra
proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`, pytest in the venv.
- Exact command / steps: with `OnnxTechniqueRouter` construction mocked,
called `ImageCompressor._get_onnx_router()` twice and asserted a single
construction; exercised `close()` on singleton vs non-singleton
instances; and called `_get_image_compressor()` /
`_get_worker_compressor()` twice each. Then reverted the three source
files and re-ran.
- Observed result: with the fix the ONNX router is constructed once and
reused, singleton `close()` leaves `_router`/`_onnx_router` intact (no
`release_models`), non-singleton `close()` releases and nulls them, and
both helper accessors return the same `_is_singleton` instance; with the
fix reverted every one of these fails (fresh construction /
unconditional release / new instances). Ran against the actual modules.
- Not tested: a live multi-hour image workload measuring RSS (the leak
is inferred from the removed per-request model construction; the
ONNX/torch model load itself is mocked 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
- [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
2026-07-26 07:33:47 -07:00
Tejas Chopra
b121223ec9
fix(install): default to cache mode, matching headroom proxy (#1893 follow-up) (#2563)
## Description

`headroom install` and `headroom deploy` defaulted `--mode` to
**token**, while `headroom proxy` and the server env default both
resolve to **cache**. Because `install/planner.py:155` writes
`"HEADROOM_MODE": proxy_mode` into the install base env, installing
Headroom did not merely differ from running it directly — it **actively
overrode** the good server default with the cache-busting one.

| Entry point | Effective default | Where |
|---|---|---|
| `headroom proxy` | **cache** | `cli/proxy.py:1129` — `mode or
HEADROOM_MODE or PROXY_MODE_CACHE` |
| `proxy/server.py` env | **cache** | `server.py:4962`, commented
*"delta-only compression at ~0 prefix-cache busts"* |
| `headroom install` / `deploy` | **token**  | `cli/install.py:455,615`
|

Cache mode freezes prior turns and compresses only the newest delta, so
the cached prefix stays byte-identical. Token mode rewrites frozen
history, which moves the bytes the provider hashed for its cache key and
forces a full cold re-write of the entire prefix.

Why that is expensive — measured on 35 local Claude Code sessions
(23,018 turns, 8,985M prompt tokens): cache **writes** are ~46% of input
spend from just 6.3% of tokens, and 714 warm turns that each re-wrote
>100K tokens carried 83% of all warm-path write tokens (~26% of total
input spend) at ~452K tokens per event. Full-prefix re-writes are the
dominant cost in this workload, and token mode makes them more likely.

**This is an oversight, not a deliberate divergence.** #1893 ("ship the
coding profile as Headroom's out-of-box default posture") introduced the
cache default but its diff touched only `agent_savings.py`,
`cli/proxy.py`, and `proxy/server.py` — verified with `git show 68676daa
--stat`. Neither `cli/install.py` nor `install/` was in it. The install
default predates it (#1404 and the persistent-install lifecycle work).

Closes #

## Type of Change

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

## Changes Made

- `cli/install.py` — `--mode` default `token` -> `cache` on **both**
commands (`install_apply`, `deploy`), with the help text stating what
cache mode buys.
- `install/models.py` — `DeploymentManifest.proxy_mode` default `token`
-> `cache`, so a manifest that omits the field no longer falls back to
token either.
- New `tests/test_install/test_proxy_mode_default.py` (5 tests) pinning
the agreement between the two entry points — the regression guard that
was missing when #1893 landed.

`--mode token` remains fully available for anyone who wants maximum
compression and accepts the prefix-cache busts. The option type is
unchanged (free text through `normalize_proxy_mode_value`, aliases
intact), and a test asserts token stays reachable.

## ⚠️ Existing installs are not migrated

A manifest already on disk has `proxy_mode: "token"` serialized
explicitly, so it keeps token until it is re-applied. This PR fixes the
default going forward only. Immediate remedy for affected users:

```bash
export HEADROOM_MODE=cache          # or re-run: headroom install --mode cache
```

Deliberately out of scope here: manifest migration, and a `doctor` check
that would flag an installed-but-token-mode deployment. Happy to follow
up with either.

## 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_install/ tests/test_proxy_mode_policy.py -q
146 passed, 2 skipped in 1.19s

$ python -m pytest tests/test_install/test_proxy_mode_default.py -q
5 passed in 0.45s

$ ruff check headroom/ tests/test_install/ --exclude headroom/dashboard
All checks passed!

$ mypy headroom/cli/install.py headroom/install/models.py
Success: no issues found in 2 source files
```

## Real Behavior Proof

- **Environment:** macOS 25.4.0, Python 3.12 (`.venv`), branch off
`upstream/main` @ 58555c5b, run in an isolated `git worktree` with
`PYTHONPATH` pinned to it.
- **Exact command / steps:**
1. Traced the divergence: `grep -n proxy_mode headroom/cli/install.py`
(two `--mode` options, one shared manifest builder) and `grep -n
HEADROOM_MODE headroom/install/planner.py` (line 155 writes it into base
env).
2. Confirmed intent with `git log -S 'PROXY_MODE_CACHE' --
headroom/cli/proxy.py` (-> #1893) and `git show 68676daa --stat`
(install not in the diff).
  3. Ran the suites above.
- **Observed result:** both `--mode` option defaults now report `cache`;
`DeploymentManifest().proxy_mode == "cache"`;
`normalize_proxy_mode_value("token")` still returns token, so the
opt-out path is intact. 146 install/mode tests pass.
- **Not tested:**
- **No end-to-end install performed.** I did not run `headroom install`
against a real system and inspect the written manifest/systemd unit; the
change is verified at the option-default and dataclass-default level
plus the existing install unit suites.
- **The cost claim is measured on Claude Code traffic only**, from
transcripts — not from an A/B of token-vs-cache mode on identical
workloads. Cache mode's "~0 prefix-cache busts" is the repo's own
existing characterization (`server.py:4962`), not something this PR
benchmarked.
  - No migration path for existing manifests is included or tested.
- A broad local `-k "mode"` run accidentally matched every test
containing "**model**" (~1,100 tests) and surfaced 13 failures; the ones
I could identify are pre-existing or environmental —
`test_model_uses_memory_id_to_call_memory_delete` fails identically on
clean `main`, the two `test_langchain_live` errors need live API keys,
and `test_unload_when_no_model` passes in isolation on this branch
(global-state ordering). My captured log was truncated, so I did not
account for all 13 individually; the full sharded suite in CI is the
authoritative check.

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`
2026-07-25 19:26:15 -07:00
Parideboy
045f3dfe6f
fix(install): use CREATE_NO_WINDOW instead of DETACHED_PROCESS on Windows (#2527)
## Description
On Windows, the detached agent process spawned by `install hook ensure`
(and the `install restart` self-spawn) pops up a visible black console
window repeatedly, because `DETACHED_PROCESS` makes `CREATE_NO_WINDOW` a
no-op per the Win32 process-creation-flags docs. #2521

## Type of Change
- [x] Bug fix (non-breaking change which fixes an issue)

## Changes Made
- `headroom/install/runtime.py`: `start_detached_agent()` now uses
`CREATE_NO_WINDOW` instead of `DETACHED_PROCESS`, combined with
`CREATE_NEW_PROCESS_GROUP` (unchanged detach/isolation semantics, window
hidden).
- `headroom/install/runtime.py`: `_spawn_detached_restart()` now also
sets `CREATE_NO_WINDOW` on Windows (previously had no `creationflags` at
all on that platform).
- `tests/test_install/test_runtime.py`: updated the Windows branch of
`test_start_detached_agent_and_run_foreground` to assert the actual
`creationflags` value passed to `Popen`, instead of just monkeypatching
an unused `DETACHED_PROCESS` attribute.

## Testing
- [x] Added/updated tests
- [x] Ran full local test suite

```
$ python -m pytest tests/test_install -q
======================= 137 passed, 1 skipped in 48.68s =======================

$ ruff check headroom/install/runtime.py tests/test_install/test_runtime.py
All checks passed!

$ ruff format --check headroom/install/runtime.py tests/test_install/test_runtime.py
2 files already formatted

$ mypy headroom --ignore-missing-imports
Success: no issues found in 506 source files
```

## Real Behavior Proof
- Environment: Windows 11, Python 3.13.11, headroom repo local checkout
- Exact command / steps: `python -m pytest
tests/test_install/test_runtime.py -q`, plus manual read of `subprocess`
Windows creation-flag semantics (`DETACHED_PROCESS` + child console
allocation vs `CREATE_NO_WINDOW`)
- Observed result: all 25 tests in `test_runtime.py` pass, including the
updated assertion that `creationflags == CREATE_NO_WINDOW |
CREATE_NEW_PROCESS_GROUP` on the Windows code path
- Not tested: did not reproduce the original visible-console-popup repro
end-to-end via live Claude Code hook invocation (no environment with the
full hook-triggered respawn loop set up in this session); relying on the
Win32 docs and the reporter's own local verification of the same flag
swap

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

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 19:24:53 -07:00
AxelRay
d50cfabedc
fix(proxy): report deferred Kompress status and promote health from cache (#2564)
## Description

When Kompress preload is deferred until first request, startup still
logs "not installed" even if ML deps are present. After the model later
loads into the module cache, /readyz and /health can keep reporting
kompress as unhealthy because reconcile only inspected attached
compressor instances. This PR reports deferred startup accurately and
promotes health from the live module cache once the model is ready,
without starting loads from health checks.

Closes #2560

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

- Treat eager-status `deferred` as installed-but-deferred at proxy
startup and log that state instead of "not installed".
- Promote `/readyz` and `/health` Kompress readiness from the
module-level model cache when attached compressors are missing or not
ready.
- Keep health inspection free of lazy getters and download side effects.
- Add regressions for deferred startup logging and cache-based health
promotion.

## 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
$ PYTHONPATH=/tmp/headroom-2561 python -m pytest tests/test_proxy_health.py tests/test_proxy_eager_preload_bind.py -q -o addopts=
21 passed, 1 warning in 2.73s

$ ruff format --check headroom/proxy/server.py tests/test_proxy_health.py tests/test_proxy_eager_preload_bind.py
3 files already formatted

$ ruff check headroom/proxy/server.py tests/test_proxy_health.py tests/test_proxy_eager_preload_bind.py
All checks passed!
```

## Real Behavior Proof

- Environment: Linux VPS, Python 3.11 venv with headroom-ai 0.32.1 wheel
for `_core`, checked out main + this branch overlayed for source under
test
- Exact command / steps: `PYTHONPATH=/tmp/headroom-2561 python -m pytest
tests/test_proxy_health.py tests/test_proxy_eager_preload_bind.py -q -o
addopts=`; `ruff format --check` and `ruff check` on the three changed
files
- Observed result: 21 focused tests passed, including deferred startup
log regression and module-cache health promotion; ruff format/check
clean
- Not tested: live multi-request proxy with real ONNX model download on
this host; install-status follow-up mentioned in the issue comment

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Screenshots (if applicable)

N/A

## Additional Notes

- Scoped to Kompress status reporting only. The separate `headroom
install status` ownership probe in the issue comment is left for a
follow-up.

Co-authored-by: axelray-dev <axelray-dev@users.noreply.github.com>
2026-07-25 19:24:08 -07:00
AxelRay
4bd121493d
fix(proxy): allow request_scope import without fastapi (#2562)
## Description

Base installs without the `proxy` extra crash during CLI command
registration because `headroom.proxy.request_scope` imported FastAPI at
module import time. That import is only needed for typing on
`normalize_request_path`.

This change keeps the FastAPI `Request` import under `TYPE_CHECKING` so
the CLI path used by `headroom --help` no longer requires FastAPI.

Closes #2561

## 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 not work as expected)
- [ ] Documentation update
- [ ] Refactoring (no functional changes)
- [ ] Performance improvement
- [ ] Test update
- [ ] Build/CI change
- [ ] Other (please describe):

## Changes Made

- Make the FastAPI `Request` import type-checking only in
`headroom/proxy/request_scope.py`
- Add a subprocess regression test that imports `request_scope` and
`project_context` with FastAPI blocked and verifies
`normalize_scope_path`

## Testing

### Test commands run

```bash
PYTHONPATH=. python3 -m pytest tests/test_proxy_request_scope.py tests/test_request_scope_no_fastapi.py -q
ruff format --check headroom/proxy/request_scope.py tests/test_request_scope_no_fastapi.py
ruff check headroom/proxy/request_scope.py tests/test_request_scope_no_fastapi.py
```

### Test Output

```text
========================= 5 passed, 1 warning in 0.93s =========================
2 files already formatted
All checks passed!
```

## Real Behavior Proof

### Environment

- Linux x86_64, Python 3.11.15
- Shallow sparse checkout of headroom main at commit parent of this PR
- System/Hermes venv Python with pytest and ruff available

### Exact command

```bash
PYTHONPATH=. python3 - <<'PY'
import builtins, sys
real = builtins.__import__
def imp(name, *a, **k):
    if name == "fastapi" or name.startswith("fastapi."):
        raise ModuleNotFoundError("No module named 'fastapi'")
    return real(name, *a, **k)
builtins.__import__ = imp
import headroom.proxy.request_scope as rs
import headroom.proxy.project_context as pc
rs.normalize_scope_path({"path": "/a"}, "/b")
print("ok", "fastapi" not in sys.modules, hasattr(pc, "with_project_prefix"))
PY
```

### Observed result

```text
ok True True
```

Importing the request-scope helpers no longer requires FastAPI, and
scope path normalization still works.

### Not tested

- Full base `pip install headroom-ai` (no extras) end-to-end on a clean
venv without the monorepo source tree
- Full monorepo `make ci-precheck` / cargo workspace
- Live proxy traffic or FastAPI request path behavior beyond the
existing unit test for `normalize_request_path`

## Review Readiness

- [x] I have tested these changes locally
- [x] I have added/updated tests where applicable
- [x] I have updated documentation if needed (N/A)
- [x] My code follows the project's style guidelines
- [x] I have run linting/formatting checks
- [x] I have considered security implications
- [x] This PR is ready for review
2026-07-25 14:17:40 -07:00
Tejas Chopra
58555c5be0
docs(configuration): document cold-prefix hook flags + bound the TTL observation log (#2557)
## Description

Follow-up to #2555. Documents the cold-prefix hook /
reasoning-compaction /
cache-TTL-learner flags (what to set for what, and whether each can be
on by
default), and makes two small safety fixes so the learning seam is
production-ready and free when off.

## Type of Change

- [x] Documentation update
- [x] Performance improvement (learning seam is now free when disabled)

## Changes Made

- **docs/content/docs/configuration.mdx** — env-var table rows for
`HEADROOM_THINKING_COMPACT` (+`_KEEP_LAST`), `HEADROOM_COLD_RECOMPACT`,
`HEADROOM_DEDUPE`, `HEADROOM_CACHE_TTL_LEARN`,
`HEADROOM_KOMPRESS_ENDPOINT`, plus a
**Cold-prefix hook & reasoning compaction** section: what to set for
what, how
cold detection reads the real TTL (CC config vs learned), and a per-flag
  "can this be on by default?" analysis.
- **docs/content/docs/cache-optimization.mdx** — a cold-prefix
recompaction
  section linking to the flags.
- **headroom/cache/ttl_observations.py** — the observation log is now
size-bounded (single-backup rotation) and respects `HEADROOM_STATELESS`.
- **headroom/proxy/handlers/openai.py** — the extra
`classify_cache_miss`
attribution is gated behind `observations_enabled()` so it costs nothing
when
  learning is off.

Everything remains **off by default**.

## Testing

- [x] Linting passes (`ruff check`)
- [x] Type checking passes (`mypy`)
- [x] Manual testing performed (module self-check)

### Test Output

```text
$ ruff check headroom/cache/ttl_observations.py headroom/proxy/handlers/openai.py
All checks passed!

$ mypy headroom/cache/ttl_observations.py headroom/proxy/handlers/openai.py
Success: no issues found in 2 source files

$ python headroom/cache/ttl_observations.py
ttl_observations self-check OK
```

## Real Behavior Proof

- Environment: local repo, Python 3.12 venv.
- Exact command / steps: ran the module self-check (covers gated-off
no-write,
gated-on write, learned-table read with model→provider fallback) and
ruff+mypy.
- Observed result: self-check passes; when `HEADROOM_CACHE_TTL_LEARN` is
unset no
file is written; when `HEADROOM_STATELESS` is truthy no file is written;
the
  observation log rotates to `.1` past the size cap.
- Not tested: live multi-turn provider run (unchanged from #2555, which
carried
  the live Kimi/CC proofs); docs render is Markdown/MDX only.

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] My changes generate no new warnings
- [x] New and existing checks pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`

## Additional Notes

- Default-on stance (in the docs): `THINKING_COMPACT` stays opt-in
(rewrites model
inputs); `COLD_RECOMPACT` is a candidate to default for Claude Code once
TTL
detection is field-validated; `CACHE_TTL_LEARN` is the safest to default
on
  (observation-only, bounded, stateless-aware) — kept opt-in for now.
2026-07-25 11:07:57 -07:00
Tejas Chopra
cb8f4b6436
feat(proxy): model-aware cold-prefix hook — reasoning compaction (Kimi/GLM) + cold recompaction (CC) (#2555)
## Description

Adds a **model-aware cold-prefix cache-miss hook** plus **plain-text
reasoning compaction**, both off by default behind flags. Motivation:
prior-turn reasoning and stale prefix content are re-sent and (for some
models) re-billed every turn; when the prompt cache has lapsed,
rewriting the prefix is free. What we do depends on the model's
reasoning shape.

| | plain-text reasoning (Kimi/GLM/DeepSeek) | encrypted reasoning
(Claude/Codex) |
|---|---|---|
| **warm turn** | Kompress reasoning (deterministic → cache-stable) |
leave it (encrypted; can't shrink) |
| **cold turn** | drop the full reasoning block | dedupe + drop
superseded reads (recompact whole prefix) |

Closes #

## Type of Change

- [x] New feature (non-breaking change that adds functionality)
- [x] Performance improvement

## Changes Made

- `headroom/transforms/thinking_compactor.py` (new): shape-driven
reasoning compaction for the OpenAI-chat path — Kimi `reasoning_content`
field + GLM/DeepSeek inline `<think>` spans; deterministic memoized
Kompress (warm) or drop (cold); `keep_last_turns` protects the active
reasoning; no-ops on encrypted-reasoning models.
- `headroom/transforms/cold_prefix.py` (new): the cold-decision surface
— `is_cold_prefix` (idle > TTL + margin), `has_plaintext_reasoning`,
`cold_recompact_messages` (lossless whole-prefix dedupe/superseded), and
`anthropic_cache_ttl_seconds` (reads CC's **real** cache TTL from
request `cache_control.ttl` + `DISABLE_/ENABLE_/FORCE_PROMPT_CACHING_*`
env controls instead of a hardcoded 300s guess).
- `headroom/proxy/handlers/openai.py`: PRE_SEND reasoning compaction
(warm Kompress / cold drop).
- `headroom/proxy/handlers/anthropic.py`: cold-prefix recompaction —
token mode via `frozen_message_count=0`, and **cache mode** via a
whole-prefix lossless recompaction that skips the byte-identical
splice/overlay on a confirmed-cold turn; cold decision uses CC's real
TTL.
- Flags (all off by default): `HEADROOM_THINKING_COMPACT` (+
`HEADROOM_THINKING_COMPACT_KEEP_LAST`), `HEADROOM_COLD_RECOMPACT`.

## Testing

- [x] Linting passes (`ruff check`)
- [x] Type checking passes (`mypy`)
- [x] New tests added for new functionality (module self-checks)
- [x] Manual testing performed (live provider calls)

### Test Output

```text
$ ruff check headroom/transforms/cold_prefix.py headroom/transforms/thinking_compactor.py \
      headroom/proxy/handlers/openai.py headroom/proxy/handlers/anthropic.py
All checks passed!

$ mypy <same 4 files>
Success: no issues found in 4 source files

$ python headroom/transforms/cold_prefix.py
cold_prefix self-check OK
$ python headroom/transforms/thinking_compactor.py
thinking_compactor self-check OK
```

## Real Behavior Proof

- Environment: live Kimi K2.7 via Fireworks
(`accounts/fireworks/models/kimi-k2p7-code`) + real Modal Kompress
endpoint; Claude models via Anthropic API.
- Exact command / steps: 2-turn replay — turn 1 produces reasoning; turn
2 re-sends it through the transform; compare `usage.prompt_tokens`.
- Observed result:
- Kimi reasoning resend is real, billable plain text: WITH reasoning =
2,643 vs WITHOUT = 1,085 input tokens (+1,558/block).
  - Warm Kompress (real Modal endpoint): 2,427 → 2,190 prompt_tokens.
  - **Cold drop: 2,330 → 714** (= none-baseline; full block removed).
- opencode confirmed to resend `reasoning_content` across turns (real
`opencode run` trace).
- Cold recompaction (dedupe/superseded) on a real 3.4M-token Claude Code
prefix: ~3.7%.
- CC TTL detection self-check pins the bug: `is_cold_prefix` at
idle=400s is `False` under the real 1h TTL (safe) but `True` under the
old 300s guess (would bust a warm cache).
- Not tested: Codex/Responses API path (deferred — no prefix tracker
there, encrypted reasoning); cross-provider live cache-mode cold turn on
a real >TTL idle gap (measured on captured prefixes instead).

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] My changes generate no new warnings
- [x] I have added tests that prove my feature works (module
self-checks)
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`

## Additional Notes

- All behavior is flag-gated and off by default — zero change for
existing users.
- Follow-ups: (1) Codex/Responses API wiring (gap #2, deferred); (2) a
cross-provider cache-TTL learner (estimate real TTL per provider from
JSONL cache-bust observations) so Kimi/OpenAI cold detection is
empirical rather than the 300s fallback — candidate for an enterprise
plugin.
2026-07-25 09:53:25 -07:00
Tejas Chopra
a6d4921e82
feat(proxy/hooks): run fold-only (stream-safe) turn hooks on streaming OpenAI chat (#2549)
## Description
Fixes the last harness gap in the turn-hook seam (the "B4" finding from
the savings audit). The OpenAI chat handler gated hooks on `not stream`,
so **streamed** `/v1/chat/completions` requests ran **no** turn hooks —
the lossless-guard plugin's on_request fold and tool-schema shrink were
skipped, unlike the Anthropic path (hooks run unconditionally). Affects
opencode / Cursor / older OpenAI SDKs / some Copilot flows; **not**
Claude Code (Anthropic path).

The gate existed for a real reason: hooks that **re-drive** the model in
`on_response` (defer a tool, reload it when asked) can't run mid-stream.
But an **on_request fold** mutates the outbound request before the send
— safe on a stream.

## Change
- Add an opt-in `stream_safe` hook attribute (fold-only hooks set it).
`run_request_hooks(ctx, stream_safe_only=…)` filters to stream-safe
hooks when set.
- OpenAI chat handler runs `on_request` on streaming with
`stream_safe_only=stream`; buffered runs all hooks; the `on_response`
re-drive (buffered response path) is untouched.
- **Default off = conservative:** a hook is buffered-only unless it
declares `stream_safe`, so **no behavior change** until a hook opts in.

## Type of Change
- [x] Bug fix / feature (opt-in, backward-compatible)

## Testing
```text
pytest tests/test_turn_hooks.py tests/test_openai_chat_turn_hooks.py -q → 25 passed
ruff + mypy → clean
```
New test pins the filter: streaming runs only stream-safe hooks'
on_request; buffered runs all.

## Notes
The companion plugin PR (headroom-lossless-guard) sets `stream_safe =
True` on its fold-only hook to actually claim the streaming savings.
Anthropic path already ran hooks on streaming, so it's unaffected.

## Checklist
- [x] Self-reviewed; tests pass; no CHANGELOG edit
2026-07-24 21:13:39 -07:00
Tejas Chopra
c990cfb803
feat(wrap): reduce-at-source — SAFE quiet-CLI env defaults for the launched agent (#2548)
## Description
Reduce-at-source, done **safely** in the wrap layer (not by rewriting
commands in-flight): `headroom wrap` injects conservative quiet-CLI env
defaults into the launched agent's environment so tools emit less noise
at the source (which the proxy would otherwise strip post-hoc).

Injected only when the user hasn't set them: `GIT_PAGER=cat`,
`PIP_QUIET=1`, `PIP_DISABLE_PIP_VERSION_CHECK=1`,
`npm_config_fund/audit/progress=false`; `PYTEST_ADDOPTS` **augmented**
with `-q` (existing value preserved). Single chokepoint
(`_launch_tool`), so it covers all wrapped tools. Opt out with
`HEADROOM_WRAP_QUIET=0`.

Closes #

## Type of Change
- [x] Performance improvement / [x] New feature (opt-out)

## Safety
Nothing that can suppress diffs, errors, summaries, or search results —
no blanket `--silent`/`--quiet`. User-set values always win.

## Testing
```text
pytest tests/test_wrap_quiet_cli.py → 5 passed (defaults injected; user value wins; PYTEST_ADDOPTS augmented; opt-out; on-by-default)
ruff + mypy → clean
```

## Scope note (honesty)
A JSONL analysis of real Claude Code traffic shows this is a **modest**
lever for that workload: non-TTY git already disables the pager (so
`GIT_PAGER` is largely a no-op there), and pip/npm are low-traffic;
`PYTEST_ADDOPTS=-q` is the clearest win. It's harmless and captures
modest savings where those tools *are* used — the larger levers are
post-output (the lossless-guard lossy tier) and the grep fold.

## Checklist
- [x] Self-reviewed; tests pass; no CHANGELOG edit
2026-07-24 20:40:52 -07:00
Tejas Chopra
7dc9a978ca
feat(lossless): factor shared directory prefix in the grep search fold (#2547)
## Description
The lossless search fold (`search_heading`) factors a repeated **file**
(many matches in one file → path once + `line:content` rows), but `grep
-rn` across many **distinct** files has one match each, so it saved ~0%
— the shared directory repeated on every row. This adds
`search_dir_heading`/`search_dir_unheading`, which factor the shared
**directory** across distinct files (dir once as a header,
`base:line:content` beneath). `compact_lossless('search')` now tries
both folds and keeps the smallest that round-trips exactly.

Matters because grep is ~23.5% of observed agent output tokens.

Closes #

## Type of Change
- [x] Performance improvement (lossless)

## Changes / Behavior
- File fold wins many-matches-one-file; dir fold wins the `grep -rn`
case (0% → ~16-40% depending on path depth / match length).
**Byte-lossless** — round-trip verified, fold discarded on any mismatch.
- Never touches source reads / diffs (unchanged class gating).

## Testing
```text
pytest tests/test_bash_search_lossless_fold.py -q → 30 passed
pytest test_lossless_excluded_compaction / _then_lossy / _mode → 72 passed
ruff + mypy → clean
```
Round-trip verified on: distinct-files (sorted), many-matches-one-file,
mixed+passthrough, colon-in-content.

## Note for reviewers
The dir-grouped output is byte-lossless but a slightly **non-standard**
format the model reads directly (`dir/` header + `base:line:content`) —
like the existing `rg --heading` fold but less standard. Low
comprehension risk; flagging it explicitly. If preferred, we can gate it
to only fire above a larger savings threshold.

## Checklist
- [x] Self-reviewed; tests pass; no CHANGELOG edit
2026-07-24 20:40:49 -07:00
Tejas Chopra
9f1ffefe83
feat(proxy/savings): aggregate tool-schema savings into Metrics + all reporting sinks (#2546)
## Description

Companion to #2545 (the "sources" double-count fix) — this fixes the
"sinks" half found in the same savings audit: **tool-schema / deferral
savings were never aggregated into `Metrics`**. They lived only in
per-request log tags, so every sink that reads `metrics.*` silently
dropped them, and one CLI mode disagreed with another.

Confirmed sinks that under-reported:
- **Session-summary printout** — `Tokens saved:` is message-only; a
24K-tool-deferral turn printed `0`.
- **`cost.py` session summary** (feeds `/stats.summary`) —
`total_tokens_saved_with_rtk` etc. were message+CLI only.
- **`/stats` `all_layers_tokens_saved`** — the advertised "total"
excluded the `tool_search` layer it enumerates in `by_layer`.
- **`headroom perf --format json/csv`** — omitted `tool_saved` while the
**text** output of the same command showed it.

Closes #

## Type of Change
- [x] Bug fix (non-breaking) / observability correctness

## Changes Made
- `PrometheusMetrics.tool_search_saved_total` — new counter, accumulated
in `record_request` from a new `tool_search_saved` arg;
`emit_request_outcome` fills it from the `tool_search_deferred_tokens` +
`turn_hook_tools_saved_tokens` tags. **One source of truth.**
- Fed into: session summary (`Tool schemas deferred:` line), `cost.py`
summary (new `tool_schema_tokens_saved` +
`total_tokens_saved_all_layers`; existing fields unchanged for
back-compat), `/stats` `all_layers` total, and `build_perf_summary`
(`tool_saved`).
- Kept **distinct** from `tokens_saved_total` (message compression) —
tool bytes never move `tok_before/after`, so it's a separate layer, not
a merge (no double-count).

## Testing
- [x] `ruff` + `ruff format --check` + `mypy` clean
- [x] Regression tests + existing suites pass

### Test Output
```text
pytest tests/test_savings_tool_search_aggregation.py tests/test_cli_perf_format.py -q → 18 passed
pytest tests/test_cli_perf_format.py test_proxy_savings_history.py test_dashboard_token_savings.py
      test_bundled_tools_savings.py test_openai_chat_turn_hooks.py → 68 passed, 2 skipped
mypy (metrics/outcome/cost/analyzer) → clean
```

## Real Behavior Proof
- Standalone: `record_request(tool_search_saved=1500)` then `(…=800)` →
`metrics.tool_search_saved_total == 2300`, `tokens_saved_total == 200`
(message stays separate); `build_perf_summary` over records with
`tool_saved` 5000+3000 → `tool_saved == 8000`.

## Checklist
- [x] Self-reviewed; no new warnings; tests pass; did **not** edit
`CHANGELOG.md`

## Additional Notes
Together, #2545 (record once) + this (surface every layer) make savings
correct **and** complete end-to-end across `/stats`, the dashboard,
`headroom perf`, the session summary, and cost/budget. The `/stats`
`by_layer.tool_search` and dashboard card already showed the layer
(windowed, from the log scan); this makes the lifetime/metrics-based
sinks agree.
2026-07-24 20:40:46 -07:00
Tejas Chopra
0845b26ee6
fix(proxy/cost): record each request's savings exactly once (drop 3 double-counts) (#2545)
## Description

An audit of savings accounting found three **double-count** bugs: the P0
outcome-funnel refactor centralized cost + PERF recording in
`emit_request_outcome`, but three pre-funnel emits were never removed,
so they fire a second time on their paths.

| Path | Stray emit | + Funnel | Effect |
|---|---|---|---|
| OpenAI chat direct, non-streaming | explicit
`cost_tracker.record_tokens` (`handlers/openai.py` ~4140) |
`outcome.py:418` | **2× spend / requests; budget period cost doubled** →
`check_budget` can block at half the real spend |
| OpenAI **Responses** buffered (Codex HTTP) | explicit `record_tokens`
(~5223) | `outcome.py:418` | same |
| Codex **WS** turns | explicit `PERF` log line (~7291) |
`outcome.py:482` | `headroom perf` **double-counts** saved + requests
every WS turn (analyzer sums per line, no dedup by request_id) |

All three are pure duplicates: the funnel's `cost_tracker.record_tokens`
is a **superset** of the explicit calls' args, and its PERF line uses
the **same per-turn deltas** (verified: `7246-7249` == the explicit
line's fields). The `/stats` headline was already correct
(SavingsTracker fires once, inside the funnel) — only cost/budget and
`headroom perf` were affected.

Closes #

## Type of Change
- [x] Bug fix (non-breaking)

## Changes Made
- Remove the explicit `cost_tracker.record_tokens` on the OpenAI chat
non-streaming path and the Responses buffered path — keep the
`cache_write`/`uncached` computation the funnel needs.
- Remove the duplicate WS PERF log line (+ its now-dead `_perf_*` locals
and the now-unused `_summarize_transforms` import).
- Add a regression test: cost is recorded exactly once on the
non-streaming chat path (was 2×).

## Testing
- [x] `ruff check` + `ruff format --check` clean; `mypy` clean
- [x] Regression + existing tests pass

### Test Output
```text
pytest tests/test_openai_chat_turn_hooks.py -q            → 6 passed  (incl. new double-count regression)
pytest tests/test_openai_responses_context_compaction.py  → 12 passed
pytest tests/test_openai_codex_ws_lifecycle.py + timings + savings_deferral → 38 passed
ruff/mypy → clean
```

## Real Behavior Proof
- **Verified by code trace**, not just tests: `grep
cost_tracker.record_tokens` across the handler now returns only the
funnel call (`outcome.py:418`); the explicit chat/Responses calls are
gone. The WS funnel outcome (`openai.py:7246-7249`) feeds
`outcome.py:482`'s PERF with the same deltas the deleted line used.
- **Not covered:** a related finding (OpenAI-chat *streaming* skips turn
hooks entirely, `openai.py:3484 "and not stream"`) is **intentionally
deferred** — that gate protects re-drive-requiring hooks (tool-router
deferral) which can't run mid-stream; a proper fix needs a per-hook
"safe-on-stream" capability flag, out of scope here.

## Checklist
- [x] Self-reviewed
- [x] No new warnings; tests pass locally
- [x] Did **not** edit `CHANGELOG.md`

## Additional Notes
This is the "sources" half of the savings audit. A companion PR will fix
the "sinks" half — tool-search/deferral savings are never aggregated
into `Metrics`, so the session summary, `cost.py` summary, `headroom
perf --json/csv`, and the `all_layers` total under-report them.
2026-07-24 20:40:44 -07:00
Tejas Chopra
285176be54
fix(tokenizers): price Claude against a real BPE (tiktoken o200k) not a char estimate (#2543)
## Description

Claude's tokenizer is not public, so `get_tokenizer("claude-*")`
returned `EstimatingTokenCounter(chars_per_token=3.5)` — a
**character-ratio estimate**. The estimator's chars-per-token flips with
the *detected content type* (JSON 3.2 / code 3.5 / English 4.0 / CJK
1.5), so:

- the **same bytes** count differently before vs after compression → a
fold could appear to *increase* tokens;
- it **disagrees with the pipeline's** estimator on the same content.

Calibration vs a real BPE (tiktoken `o200k_base`) on representative
content:

| content | char-est(3.5) / o200k |
|---|---|
| English | **1.28×** (overcount 28%) |
| code | **0.83×** (undercount 17%) |
| JSON | 0.97× |
| diff | 1.02× |

i.e. the estimate is off **−17% … +28%**, and the error is
content-dependent (non-uniform) — which is exactly what produces
impossible `tok_after > tok_before` deltas.

This prices Claude against **tiktoken `o200k_base`** — a real,
deterministic, **monotone** BPE. It's not Claude's exact vocab, but it's
within ~10–20% and, crucially, **consistent before/after**, which is
what compression ratios and context-pressure gating actually need.

Closes #

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [x] Performance improvement
- [ ] Breaking change
- [ ] Documentation update
- [ ] Code refactoring (no functional changes)

## Changes Made

- `TiktokenCounter.__init__` accepts an explicit `encoding` override (so
a model can be priced against a chosen encoding regardless of name-based
resolution).
- `_create_anthropic` now returns `TiktokenCounter(model,
encoding="o200k_base")`, **failing open to the character estimator** if
the tiktoken vocab can't be loaded (reuses the existing `load_encoding`
timeout/guard).
- Updated the `MODEL_PATTERNS` comment and `test_get_anthropic_model` to
the new contract; added
`test_claude_priced_with_real_bpe_not_char_estimate`.

**Blast radius is contained:** `content_router` keeps its own
module-level estimator for routing decisions, so only the **handler's
reported counts** change. `tiktoken` is already a dependency.

**Composes with #2542** (tokenizer-consistent before/after): that PR
makes both endpoints use *one* tokenizer; this PR makes that one
tokenizer a *real BPE*. Together they fully eliminate the
inflated/phantom lines. This PR is based on `main` and is independently
valid.

## Calibration note (please review)

The one behavioral consumer of the handler's count is the
background-compression gate (`original_tokens >=
_background_compression_min_tokens`). Because o200k differs from the old
estimate by ~±20% depending on content, that threshold now trips on
slightly different requests. It's *more* accurate, but the threshold was
tuned against the estimator — worth a re-check. No other gate consumes
the handler count (routing uses `content_router`'s estimator).

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check` + `ruff format --check`)
- [x] Type checking passes (`mypy`)
- [x] New tests added
- [x] Manual testing performed

### Test Output

```text
$ ruff check <changed files> && ruff format --check <changed files>
All checks passed!
4 files already formatted

$ mypy headroom/tokenizers/registry.py headroom/tokenizers/tiktoken_counter.py
Success: no issues found in 2 source files

$ pytest tests/test_tokenizer.py tests/test_tokenizers.py -q
55 passed, 14 skipped
```

## Real Behavior Proof

- **Unit:** `get_tokenizer("claude-opus-4-8")` →
`TiktokenCounter(encoding_name="o200k_base")`; `count_text(sample)` ==
`tiktoken.get_encoding("o200k_base").encode(sample)` exactly; a
`tool_result` shrunk 300→3 words drops 508→13 tokens (folds register).
- **Live proxy** (Anthropic path, `--proxy-extension lossless_guard`):
foldable request logs `tok_before=694 tok_after=231 tok_saved=463
transforms=turn_hook` — real o200k-scale numbers (vs the estimator's 607
for the same payload), clean fold, no inflation.
- **Not tested:** exact agreement with Anthropic's *own* count_tokens
API (o200k is a proxy; a follow-up could calibrate against it offline).
GPT paths unchanged (already tiktoken).

## 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
- [ ] Documentation — N/A (internal; docstrings updated)
- [x] My changes generate no new warnings
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`

## Additional Notes

Follow-ups (not here): (1) extend the same real-BPE treatment to other
private-tokenizer providers (Gemini/Cohere/Moonshot), each with its own
calibration; (2) optional opt-in calibration against Anthropic's
`count_tokens` API to true-up the ~10–20% absolute offset.
2026-07-24 15:06:43 -07:00
Tejas Chopra
1cc53c9c92
fix(proxy/perf): tokenizer-consistent token accounting + surface tool-schema savings (#2542)
## Description

Follow-up to #2520 (turn-hook message-fold accounting). While validating
that PR on live Claude Code traffic, two accounting defects surfaced:

1. **Impossible/misleading token deltas.** The handler and the
compression pipeline use *different* token estimators — the handler's
`EstimatingTokenCounter(3.5)` (or real tiktoken on OpenAI) vs
`content_router`'s adaptive `EstimatingTokenCounter()`. Cross-assigning
`original_tokens` (handler) against `optimized_tokens =
result.tokens_after` (pipeline) put the two endpoints on different
scales, producing **`tok_after > tok_before` on 101/783 PERF lines** and
phantom savings on `transforms=none` lines. It also made the turn-hook
recount fire on the *scale difference* rather than a real fold, emitting
a **spurious `turn_hook` tag with `tok_saved=0`**.

2. **Tool-schema savings were invisible.** Tool deferral
(`defer_loading`) and turn-hook tool shrink save thousands of
tool-schema tokens, but `tok_before`/`tok_after` count **messages only**
— so a tool-heavy turn logged `tok_saved=0` while genuinely saving ~29k
tool-schema tokens, reading as "no compression."

Closes #

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [x] Performance improvement
- [ ] Breaking change
- [ ] Documentation update
- [ ] Code refactoring (no functional changes)

## Changes Made

- **Tier B.1 — tokenizer-consistent accounting.** On the Anthropic and
OpenAI-chat paths, recount **both** endpoints with the **same**
tokenizer (pre-compression snapshot vs final outbound messages) right
before the outcome is recorded. This puts `tok_before`/`tok_after` on
one scale (fixes the inflated + phantom lines), and it subsumes any
turn-hook fold. `turn_hook` is now attributed **only** when the hook
itself reduced tokens (same-tokenizer pre vs post), not when a recount
merely normalized a scale difference. OpenAI preserves its existing
tool-schema delta folding.
- **Surface tool-schema savings.** New `tool_saved=` field on the PERF
line (summed from `tool_search_deferred_tokens` +
`turn_hook_tools_saved_tokens` tags) and a separate `Tool saved` line in
`headroom perf`. Additive + backward-compatible (key=value parse; old
lines default to 0). `tok_saved` still means message savings, so ratios
and calibrated thresholds are unaffected.
- The OpenAI **Responses** path already accumulates per-transform deltas
(each consistent within its own transform), so it isn't exposed to the
cross-scale subtraction bug and needs no change.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check` + `ruff format --check`)
- [x] Type checking passes (`mypy`)
- [x] Manual testing performed

### Test Output

```text
$ ruff check headroom/proxy/handlers/anthropic.py headroom/proxy/handlers/openai.py \
    headroom/proxy/outcome.py headroom/perf/analyzer.py
All checks passed!

$ ruff format --check <same files>
4 files already formatted

$ mypy <same files>
Success: no issues found in 4 source files

$ pytest tests/test_turn_hooks.py tests/test_openai_chat_turn_hooks.py \
    tests/test_openai_responses_context_compaction.py -q
26 passed in 14.34s
```

## Real Behavior Proof

- **Environment:** local proxy `headroom proxy --port 8793
--proxy-extension lossless_guard`, with
`HEADROOM_LOSSLESS_GUARD_LOSSY=1`, `HEADROOM_TOOL_SEARCH=1`, model
`claude-haiku-4-5` (Anthropic path = the one Claude Code uses).
- **Observed, before vs after this PR:**
- foldable tool_result: `tok_before=607 tok_after=178 tok_saved=429
transforms=turn_hook` (real fold, correctly attributed)
- plain multi-turn (nothing foldable): `tok_before=3700 tok_after=3700
tok_saved=0 transforms=none` — **no inflation, no spurious `turn_hook`**
(before this PR: same request showed a spurious `turn_hook`)
- tool-heavy (12 tools): `tok_saved=0 tool_saved=1794` → `headroom perf`
shows `Total saved: … (messages)` **and** `Tool saved: 1,794 tokens
(tool schemas, deferral)` (before: the 1,794 was invisible)
- **Not tested:** OpenAI chat/Responses paths verified by unit test, not
a live client run (local setup routes Claude Code through the Anthropic
handler only).

## 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
- [ ] Documentation changes — N/A (internal accounting; `tool_saved` is
self-describing in `headroom perf`)
- [x] My changes generate no new warnings
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`

## Additional Notes

- Behavior is unchanged when no turn hook is registered for the
*attribution* tag; the consistency recount runs unconditionally so
pure-OSS installs also get correct before/after (it only ever makes the
two endpoints comparable — it never fabricates savings).
- Follow-up (separate PR, intentionally not here): swap the
char-estimator for a real BPE (tiktoken `o200k_base`) for
private-tokenizer models like Claude. That's the "Tier B.2" accuracy
upgrade; it shifts absolute numbers ~10–20% and touches calibrated
thresholds, so it needs its own recalibration pass.
2026-07-24 14:54:43 -07:00
Abhay Singh
fa4763761b
fix(proxy/cost): warn once per model when pricing lookup fails (#2504) (#2535)
## Description

Fixes #2504. `CostTracker.estimate_cost` runs on the per-request cost
path and logs a WARNING whenever LiteLLM can't price the model:

```python
except Exception as e:
    logger.warning(f"Failed to get pricing for model {model}: {e}")
    return None
```

For a custom / OpenAI-compatible model LiteLLM can't resolve (e.g.
`glm-5.2` via `--backend anyllm --anyllm-provider openai`), this fires
on **every single request**, flooding `proxy.log` with hundreds of
identical lines and burying genuinely useful warnings. The `LiteLLM not
available` branch above it has the same per-request flooding shape.

## Fix

Track already-warned models in a small module-level set and emit each
pricing-failure warning (and the LiteLLM-unavailable warning) once per
process. The set is bounded by the number of distinct model names seen.
No new dependencies or config. The cost result itself is unchanged
(`None` on failure); only the log volume changes.

## 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/cost.py`: add a module-level `_warned_pricing_models`
set and `_warn_pricing_once` helper; route the pricing-failure and
LiteLLM-unavailable warnings in `estimate_cost` through it.
- `tests/test_cost_pricing_warning_dedup.py` (new): assert a repeated
unresolvable model warns once, distinct models each warn once, and the
LiteLLM-unavailable warning is deduped too.

## 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_cost_pricing_warning_dedup.py -q
3 passed

# with the fix reverted, the module-level set does not exist, so the
# dedup tests error/fail (the pre-fix code warned once per request)

$ uvx ruff@0.15.17 check headroom/proxy/cost.py tests/test_cost_pricing_warning_dedup.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/proxy/cost.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, project venv (`uv sync --extra
proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`, pytest in the venv.
- Exact command / steps: monkeypatched `_get_litellm_module` to a stub
whose `cost_per_token` raises (and, separately, to `None`), called
`CostTracker.estimate_cost("glm-5.2", ...)` five times and two distinct
unresolvable models twice each, capturing `headroom.proxy` WARNING
records with `caplog`.
- Observed result: with the fix each model produces exactly one `Failed
to get pricing for model ...` warning (and one `LiteLLM not available
...`) regardless of call count; the pre-fix code logged one per call.
`estimate_cost` still returns `None` on failure. Ran against the actual
module.
- Not tested: a live multi-request session against a real unpriced model
end to end.

## 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
2026-07-24 09:47:09 -07:00
Tejas Chopra
c371d5ad60
fix(proxy/perf): count turn-hook message folds in token accounting (#2520)
## Description

Turn hooks (the `headroom.proxy.turn_hooks` seam used by proxy
extensions, e.g. the lossless-guard plugin) fold tool_result / message
content in `on_request`, which runs **after** the pipeline has already
computed `optimized_tokens`. The saving was recorded to `/stats` via
`record_compression`, but was invisible to the `PERF` log line and
`headroom perf` (both read the pipeline's `original → optimized` delta).
Net effect: a plugin that folded 463 tokens still logged `tok_saved=0`.

This makes the per-turn token accounting count the hook's fold too,
across all three handler paths.

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

- **Anthropic Messages handler** (`/v1/messages`): re-count messages
right after `run_request_hooks`, regardless of whether the hook replaced
the list or mutated it in place. Attribute the fold as a `turn_hook`
transform. Only ever lowers `optimized_tokens`.
- **OpenAI Chat handler** (`handle_openai_chat`,
`/v1/chat/completions`): same re-count. The existing code re-counted
hook-modified *tools* but not the *message* fold — this closes that gap
and adds the `turn_hook` transform tag.
- **OpenAI Responses handler** (`_compress_openai_responses_payload`,
`/v1/responses`): the seam previously only wrote hook-modified *tools*
back — a folded/replaced `input` list was silently dropped and
uncounted. Now snapshot the message-items token count **before** the
hook (an in-place fold would corrupt a post-hook baseline), write back a
replaced list, and add the fold delta to `tokens_saved` (the same
channel the tool-schema savings already ride to `/stats` and `headroom
perf`).
- Key detail: the identity check `ctx.messages is not <orig>` is
insufficient — the lossless-guard plugin mutates messages **in place**,
so an identity-gated re-count misses it. The re-count runs
unconditionally whenever a hook ran.

## 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/handlers/anthropic.py headroom/proxy/handlers/openai.py \
    tests/test_openai_chat_turn_hooks.py tests/test_openai_responses_context_compaction.py
All checks passed!

$ mypy headroom/proxy/handlers/anthropic.py headroom/proxy/handlers/openai.py
Success: no issues found in 2 source files

$ pytest tests/test_turn_hooks.py tests/test_openai_chat_turn_hooks.py \
    tests/test_openai_responses_context_compaction.py -q
tests/test_turn_hooks.py .........                                       [ 34%]
tests/test_openai_chat_turn_hooks.py .....                               [ 53%]
tests/test_openai_responses_context_compaction.py ............           [100%]
26 passed in 14.05s
```

New regression tests (each fails on the pre-fix code):
- `test_in_place_message_fold_is_counted` (chat path) — hook folds
message content in place; asserts `turn_hook` in `x-headroom-transforms`
and a recorded `tokens_saved > 0`.
- `test_responses_turn_hook_message_fold_is_applied_and_counted`
(Responses path) — hook folds a `function_call_output` in place; asserts
the outbound payload reflects the fold **and** `tokens_saved > 0`.

## Real Behavior Proof

- **Environment:** local proxy (`headroom proxy --port 8793
--proxy-extension lossless_guard`), `HEADROOM_LICENSE_DEV=1`,
`HEADROOM_PROTECT_TOOL_RESULTS=Bash` (so the fold is purely the plugin's
turn hook), model `claude-haiku-4-5`. Request carries a `gh --json`
object (folded to TOON) and a `docker pull` log.
- **Exact steps:** send the request → read the `PERF` line in
`~/.headroom/logs/proxy.log` and `GET /stats`.
- **Observed result:**
- Before this change: `PERF ... tok_before=607 tok_after=607 tok_saved=0
... transforms=none` while `/stats` reported `{"lossless_guard": 145}` —
i.e. the saving existed but perf showed nothing.
- After this change: `PERF ... tok_before=607 tok_after=484
tok_saved=123 ... transforms=turn_hook`, `/stats` still
`{"lossless_guard": 145}`. (`123` is the honest whole-request
`count_messages` delta; `145` is the per-content-string delta
`record_compression` measures — different scopes, both real and
positive.)
- **Not tested:** the OpenAI Chat and Responses paths were verified by
unit test, not a live client run — my live setup routes Claude Code
through the Anthropic handler only.

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation — N/A
(internal accounting; no public API/doc surface)
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`

## Additional Notes

Behavior is unchanged when no turn hook is registered
(`registered_turn_hooks() == []` → the re-count block is skipped), so
pure-OSS installs are byte-identical and unaffected. OSS's own pipeline
compression was already counted correctly (it runs before the hook);
this only surfaces the extension/turn-hook layer.
2026-07-24 09:38:52 -07:00
Rod Boev
4a8157fa0a
fix(copilot): derive GHE credential host from API URL (#800) (#2511)
## Description

GHE Copilot credential discovery falls back straight to `github.com`
when `GITHUB_COPILOT_HOST` is unset, even if the documented
`GITHUB_COPILOT_API_URL` points at an enterprise host. This change keeps
explicit-host precedence, then reuses the configured enterprise domain
or a normalized custom API URL hostname for credential lookup, so
Windows, macOS, Linux, GH CLI, and credential-file discovery search the
same custom host instead of the public default.

Closes #800.

Attribution:
https://github.com/headroomlabs-ai/headroom/issues/800#issuecomment-5044382263
narrowed the shared credential-host mismatch.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds new functionality)
- [ ] Breaking change
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring

## Changes Made

- Preserve explicit-host precedence, then fall back to the configured
enterprise domain or a normalized custom API URL hostname only when the
configured value is usable.
- Normalize `api.` and `copilot-api.` prefixes before routing credential
lookup, while keeping exact and segmented GitHub-hosted public domains
plus public enterprise or malformed enterprise or API configuration
fallback on `github.com`.
- Add focused coverage for the base/head reproduction, explicit-host
precedence, configured-enterprise precedence, public-enterprise,
malformed-enterprise, and invalid-port fallback, prefixed-host
normalization, adjacent-host exclusion, and GH CLI plus keychain
forwarding.

## Testing

- [x] Unit tests pass (`uv run pytest tests/test_copilot_auth.py -q`)
- [x] Linting passes
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed

### Test Output

```text
uv run pytest tests/test_copilot_auth.py -q
105 passed in 0.58s

uvx --from ruff==0.15.17 ruff check headroom/copilot_auth.py tests/test_copilot_auth.py
All checks passed!

uvx --from ruff==0.15.17 ruff format --check headroom/copilot_auth.py tests/test_copilot_auth.py
2 files already formatted

git diff --check
clean
```

## Real Behavior Proof

- Environment: Windows, isolated temporary credential file, local
`origin/main` checkout plus this branch
- Exact command / steps: With only
`GITHUB_COPILOT_API_URL=https://api.ghe.example.com:8443/copilot` set
and all other token sources disabled, run the same credential-file
discovery reproduction against `origin/main` and this branch.
- Observed result: `origin/main` selected `github.com` and resolved no
token; the review branch selected `ghe.example.com` and resolved
`gho-ghe`.
- Not tested: live GitHub Enterprise Copilot tenant

## 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 own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did not edit `CHANGELOG.md`; Headroom generates release notes
from the PR title

## Additional Notes

The change does not alter API routing, token exchange, discovery order,
or credential matching breadth, and it keeps the live tenant claim out
of the PR body until an enterprise user reruns it.
2026-07-23 15:44:33 -07:00
Rod Boev
e4076bbe99
fix(grok): preserve business-seat auth while routing only inference (#2514)
## Description

`headroom wrap grok` currently routes the whole session through
`GROK_CLI_CHAT_PROXY_BASE_URL`. xAI's July 21, 2026 enterprise docs say
that host carries both inference and settings, so the wrap displaces the
native settings/auth path along with inference. A Grok account whose
SuperGrok entitlement lives on a business account can then no longer
resolve that seat and falls back to a login screen, even though native
`grok` works for the same account.

This change retargets the Grok provider slice to the narrower
inference-only key, `GROK_MODELS_BASE_URL`, and leaves
`GROK_CLI_CHAT_PROXY_BASE_URL` unset. Headroom still intercepts
inference and model discovery through the existing `/v1/models` and
chat-completions proxy paths, while the native `cli-chat-proxy.grok.com`
settings host and `auth.x.ai` auth path stay intact. Closes #2489.

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

- switch the Grok provider env authority from
`GROK_CLI_CHAT_PROXY_BASE_URL` to `GROK_MODELS_BASE_URL`
- update the Grok wrap and unwrap docstrings to describe inference-only
routing and the preserved native settings/auth path
- update the compatibility matrix entry in `README.md` so the public
docs match the new Grok routing key
- add focused provider and wrap tests that assert the old chat-proxy key
is absent and the project-prefixed inference URL is preserved
- keep `grok_build` and the existing `/v1/models` proxy route unchanged,
using them as preservation boundaries

## Testing

- [x] Unit tests pass (`uv run pytest tests/test_provider_grok.py
tests/test_cli/test_wrap_grok.py tests/test_provider_grok_build.py -q`)
- [x] Linting passes (`uv run ruff check
headroom/providers/grok/runtime.py headroom/cli/wrap.py
tests/test_provider_grok.py tests/test_cli/test_wrap_grok.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed

### Test Output

```text
uv run pytest tests/test_provider_grok.py tests/test_cli/test_wrap_grok.py tests/test_provider_grok_build.py -q
uv run ruff check headroom/providers/grok/runtime.py headroom/cli/wrap.py tests/test_provider_grok.py tests/test_cli/test_wrap_grok.py
uv run ruff format headroom/providers/grok/runtime.py headroom/cli/wrap.py tests/test_provider_grok.py tests/test_cli/test_wrap_grok.py --check
```

## Real Behavior Proof

- Environment: current Grok CLI plus a focused Headroom worktree
- Exact command / steps: capture `grok --version`, re-check xAI's
documented Grok env contract, run the focused Grok provider and wrap
tests, and if a business-seat account is available locally launch
`headroom wrap grok` to confirm the wrapped session no longer falls back
to login
- Observed result: Headroom emits only the inference-routing key, the
old settings/auth key is absent, project prefixing still works, and the
focused Grok tests pass
- Not tested: local business-seat account on this host

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A - CLI and provider-routing change only.

## Additional Notes

- `CHANGELOG.md` stays untouched because Headroom's release automation
generates it from conventional commits.
- The issue is reporter-only today, so the proof report records the
validated `grok --version` and whether a real business-seat retest was
reached locally or remains for the reporter.
2026-07-23 15:43:03 -07:00
inix
806d2e468a
fix(proxy): offload OpenAI and Gemini tokenizer counting off the event loop (#2498)
## Description

The OpenAI and Gemini handlers resolved the tokenizer and counted the
conversation inline on the event loop. When a model resolves to a
HuggingFace tokenizer (the registry routes qwen, deepseek, llama, phi,
falcon, and more there) a cold cache runs
`AutoTokenizer.from_pretrained` behind a 10s `thread.join`, which
freezes the whole server. That is the GH #1701 stall, now reachable from
OpenAI and Gemini because `/v1/chat/completions` and `/v1/responses` are
documented multi-provider passthroughs and receive those models.

Anthropic already routed the same call through a fail-open
`_count_tokens_offloaded` helper. This hoists that helper to the shared
`HeadroomProxy` base and sends the OpenAI and Gemini sites through it
too.

No linked issue. This is the OpenAI and Gemini follow-on to #1738, which
offloaded the Anthropic and batch paths. GH #1701 is the original freeze
report.

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

- Hoisted `_count_tokens_offloaded` from `AnthropicHandlerMixin` to the
shared `HeadroomProxy` base, next to `_run_compression_in_executor`. It
resolves and counts on the bounded compression executor and fails open
to estimation on timeout, error, or executor quarantine.
- Routed 6 inline sites through it: `handle_openai_chat`,
`handle_openai_responses`, `handle_gemini_generate_content`,
`handle_google_cloudcode_stream`, `handle_gemini_count_tokens`, and
`handle_gemini_stream_generate_content` (resolve only, keeps its
per-part `count_text` loop).
- Removed 6 now-dead local `get_tokenizer` imports.
- Left batch's per-line counts inline on purpose. They run on an
already-warm tokenizer, so offloading them adds executor churn without
touching the cold load. Batch's `pipeline.apply` was already offloaded
in #1738.
- Extended the wiring guard to all 7 provider handlers, added a
quarantine fail-open test and a `count_text` fail-open test, and stubbed
the method on 2 mixin-only handler doubles.

## 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
$ ruff check headroom/proxy/server.py headroom/proxy/handlers/{anthropic,openai,gemini}.py tests/test_tokenizer_count_offload.py
All checks passed!

$ pytest tests/test_tokenizer_count_offload.py
6 passed in 4.39s

# offload suite + all 26 handler-calling test files + handler-helpers/batch/tokenizers
$ pytest tests/test_tokenizer_count_offload.py tests/test_openai_codex_routing.py tests/test_gemini_nonjson_status.py ... tests/test_tokenizers.py
377 passed, 15 skipped, 15 warnings in 86.60s (0:01:26)
```

## Real Behavior Proof

- Environment: macOS (Darwin), Python 3.13, proxy built from this
branch. A synthetic tokenizer that sleeps 0.5s on resolve+count stands
in for a cold HuggingFace `from_pretrained` load, with a 10ms asyncio
loop-canary running alongside.
- Exact command / steps: monkeypatch `headroom.tokenizers.get_tokenizer`
to the 0.5s-sleeping tokenizer, then time a concurrent canary across two
counts, the offloaded `await
proxy._count_tokens_offloaded("qwen2.5-coder", messages)` and the old
inline `get_tokenizer(model).count_messages(messages)`.
- Observed result: the offloaded path kept the loop live at 41 canary
ticks during the 509ms count, the inline path froze it to 0 ticks over
502ms, and both returned the same token count. Full run was 377 passed,
15 skipped, 0 failed. The new quarantine test confirms an unrelated
compression timeout downgrades counting to estimation instead of raising
a 500.
- Not tested: live HuggingFace downloads and real qwen/deepseek traffic.
No API keys in this environment, so the Gemini and OpenAI integration
tests skip on `GEMINI_API_KEY`/`OPENAI_API_KEY`. `mypy headroom` did not
finish locally (cold-times-out past 10 minutes on this box), so
type-checking is left to CI.

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] 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 did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Additional Notes

- No linked issue. Follow-on to #1738.
- Batch per-line counts stay inline: they run on an already-warm
tokenizer, so offloading them adds executor churn without addressing the
cold load.
- Found a 6th site mid-implementation.
`handle_gemini_stream_generate_content` also resolved the tokenizer
inline but counts via a `count_text` loop, so it takes the resolve-only
path. Verified `EstimatingTokenCounter.count_text` exists, so its
fail-open branch does not crash.
- `mypy headroom` cold-times-out locally (server.py pulls the full
graph). Deferred to CI's Linux shards, same as prior PRs on this file.
`ruff` and `pytest` run clean.
- Documentation checkbox left unchecked: this change ships no
user-facing doc update.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-22 21:01:05 -07:00
Tejas Chopra
5d23a0aec2
refactor(wrap): retire tokensave; Serena is the code-memory MCP (#2499)
## Description

`tokensave` was a **downloaded third-party Rust binary**
(`aovestdipaperino/tokensave`) that `headroom wrap` registered as a
code-graph MCP server. This removes it entirely and standardises on
**Serena** as the code-memory MCP — which was already the default in
`wrap`. Serena runs on demand via `uvx`, so Headroom no longer downloads
or executes a binary of its own for code memory.

The change is a *removal + safe transition*, not a behaviour flip:
Serena was already the default, so existing users move over
automatically. This PR also folds in a small README repositioning
(Headroom = the proxy; Serena is the recommended companion; RTK/lean-ctx
are third-party tools we don't control), since it's the same
tooling-stack story.

Closes #

## Type of Change

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

## Changes Made

- **Removed the external tool:** `headroom/graph/tokensave_installer.py`
(the download-and-execute path), `build_tokensave_spec`, and the
`_ensure_tokensave_binary` / `_index_tokensave_project` /
`_setup_tokensave_mcp` helpers; dropped the dead `_setup_code_graph`.
- **`--code-memory`** now offers `serena` (default) or `none` — the
`tokensave` choice is gone. The strands `HeadroomBundle` uses Serena as
its (default-on) code-memory MCP.
- **Tombstone / transition (ledger-verified):** `headroom wrap` **and**
`headroom unwrap` remove a previously Headroom-installed `tokensave` MCP
entry so upgrading users stop launching it, and print that the leftover
`~/.local/bin/tokensave` binary and `.tokensave/` folders are safe to
delete. A user-managed `tokensave` entry is left untouched. Mirrors the
existing `codebase-memory-mcp` retirement.
- **Graceful for existing users:** `HEADROOM_CODE_MEMORY=tokensave` and
`--no-tokensave` resolve to Serena instead of erroring; `--no-serena`
now means "no code memory". **No state migration needed** — both tools'
indexes are regenerable caches of the source, so Serena simply
re-indexes.
- **Docs/README:** replaced the "tokensave binary trust model" section
with a Serena note + an "Upgrading from tokensave?" callout; retired the
RTK "first-class part of our stack" framing.
- **Tests:** deleted the tokensave-only test files
(`test_graph_tokensave.py`, `test_cli/test_tokensave_helpers.py`,
`test_cli/test_tokensave_setup.py`), rewrote `test_wrap_code_memory.py`
for the new resolver/dispatch, fixed a codex test that patched a removed
symbol.

Net: **+102 / −1187 lines.**

## Testing

- [x] Unit tests pass (`pytest`) — targeted to the affected areas
- [x] Linting passes (`ruff check`)
- [x] Type checking passes (`mypy`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ ruff check headroom/cli/wrap.py headroom/mcp_registry/ headroom/integrations/strands/ tests/test_wrap_code_memory.py tests/test_cli/conftest.py tests/test_cli/test_wrap_codex.py
All checks passed!

$ mypy headroom/cli/wrap.py headroom/mcp_registry headroom/integrations/strands/bundle.py
Success: no issues found in 12 source files

$ pytest tests/test_wrap_code_memory.py tests/test_cli/test_wrap_codex.py \
         tests/test_cli/test_wrap_claude_vertex_proxy_env.py \
         tests/test_cli/test_wrap_claude_finally_unbound.py -q
======================== 120 passed in 97.86s (0:01:37) ========================

$ pytest tests/test_cli --collect-only -q
========================= 713 tests collected in 1.82s =========================   # no import errors after symbol removal
```

## Real Behavior Proof

- **Environment:** macOS (darwin), Python 3.12.6, local `.venv`, on
branch `tejas/remove-tokensave`.
- **Exact command / steps:**
- `python -c "from headroom.integrations.strands.bundle import
HeadroomBundle; b=HeadroomBundle(enable_headroom_mcp=False,
enable_serena_mcp=False); print(len(b.tools))"` → confirms the module
imports after `build_tokensave_spec` removal (the import that my change
would otherwise break).
- CliRunner-driven `wrap codex --prepare-only` (in `test_wrap_codex.py`)
writes `[mcp_servers.serena]` (with `command = "uvx"`, `"--context",
"codex"`) to the codex config and **no** tokensave entry.
- `_resolve_code_memory` unit tests confirm: default → `serena`;
`HEADROOM_CODE_MEMORY=tokensave` → `serena`; `--no-serena` → `none`;
`--code-memory bogus` → `ClickException`.
- **Observed result:** import OK (`tools: 0`); Serena registered,
tokensave absent; resolver behaves as above; 120/120 tests pass.
- **Not tested:** a live `headroom wrap` against a real agent on a
machine with a *previously-installed* tokensave MCP entry — the
tombstone-removal path is covered by unit tests with a fake
registrar/ledger, not an end-to-end run.

## 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 did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title

## Additional Notes

- `--no-tokensave` / `--serena` / `--no-serena` are retained as hidden,
deprecated flags (no-ops or mapped) so existing scripts don't break.
- `--code-graph` is unchanged — it's the proxy's live file-watcher flag
and was never the tokensave MCP; only the dead tokensave hook behind it
was removed.
- `CHANGELOG.md` intentionally left untouched.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-22 20:59:24 -07:00
Rod Boev
5bd2266f16
fix(kompress): raise the default execution-slot wait (#2456)
## Description

Concurrent Kompress requests currently fail open after a 25 ms
execution-slot wait even though ordinary ONNX inference can hold the
single slot for hundreds of milliseconds. This raises the existing
default wait to 3000 ms while retaining concurrency one, the
`HEADROOM_KOMPRESS_EXECUTION_TIMEOUT_MS` override, the tighter acquire
and request budgets, and passthrough after a genuine timeout.

The reproduction and validated 3000 ms setting come from
https://github.com/headroomlabs-ai/headroom/issues/2451

Closes #2451

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

- Raise the default Kompress execution-slot wait from 25 ms to 3000 ms.
- Start the Kompress request deadline at call entry and carry it through
single-item acquire, single-to-batch delegation, and sequential-fallback
lineage.
- Cap the raised execution-slot wait by that live request deadline on
both single-item and batch acquire paths.
- Keep the per-backend default concurrency at one and preserve all
tighter time budgets.
- Add queued single-item, batch, request-deadline, carried-deadline
lineage, and router-watchdog lifecycle regressions at the same owner
layer that currently fails.
- Preserve the explicit short-timeout fail-open path.

## Testing

- [x] Unit tests pass (`uv run pytest tests/test_kompress_failsafe.py
tests/test_kompress_request_nonblocking.py
tests/test_content_router_single_item_deadline.py
tests/test_transforms/test_kompress_deadline.py -v`)
- [x] Linting passes (`uv run ruff check
headroom/transforms/kompress_compressor.py
tests/test_kompress_failsafe.py
tests/test_content_router_single_item_deadline.py
tests/test_transforms/test_kompress_deadline.py`)
- [x] Formatting passes (`uv run ruff format
headroom/transforms/kompress_compressor.py
tests/test_kompress_failsafe.py
tests/test_content_router_single_item_deadline.py
tests/test_transforms/test_kompress_deadline.py --check`)
- [x] New regression tests prove the saturation fix
- [ ] Manual testing performed

### Test Output

```text
uv run pytest tests/test_kompress_failsafe.py tests/test_kompress_request_nonblocking.py tests/test_content_router_single_item_deadline.py tests/test_transforms/test_kompress_deadline.py -v
37 passed in 4.22s

uv run ruff check headroom/transforms/kompress_compressor.py tests/test_kompress_failsafe.py tests/test_content_router_single_item_deadline.py tests/test_transforms/test_kompress_deadline.py
All checks passed!

uv run ruff format headroom/transforms/kompress_compressor.py tests/test_kompress_failsafe.py tests/test_content_router_single_item_deadline.py tests/test_transforms/test_kompress_deadline.py --check
4 files already formatted
```

## Real Behavior Proof

- Environment: worktree Python environment from `uv sync --extra dev`,
focused pytest with real Python threads and `threading.BoundedSemaphore`
- Exact command / steps: hold the sole execution slot with the
environment override unset, start queued single-item and batch
compression workers, wait until each worker proves it reached a blocked
acquire on the shared execution semaphore, release the slot, rerun the
explicit 1 ms timeout preservation case, then set
`HEADROOM_COMPRESSION_DEADLINE_MS=10` and repeat the held-slot
single-item and batch acquires plus a router single-cache-miss run whose
Kompress load sleeps past the request deadline.
- Observed result: The queued single-item and batch workers each proved
a real blocked acquire before release, then acquired after release and
compressed, while `HEADROOM_KOMPRESS_EXECUTION_TIMEOUT_MS=1` still
passed through promptly, the 10 ms request deadline capped the raised
default wait so both held-slot paths failed open before 200 ms without
reaching model inference, the single-to-batch and sequential-fallback
lineage regressions proved later branches inherit the original request
start instead of resetting it, and the router lifecycle proof showed the
carried deadline now allows slow Kompress load to start but still
expires before model inference after the outer request has already
failed open.
- Not tested: live ONNX proxy savings under sustained concurrent load

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

`CHANGELOG.md` stays unchanged because the release pipeline generates
changelog entries from conventional commits. The fail-open path from
#1430 stays intact; this change stops it from firing spuriously under
ordinary queueing.
2026-07-22 06:17:33 -07:00
Parideboy
a09ba6c087
fix(learn): treat unreadable candidate paths as absent in project decode (#2446)
## Description

`headroom learn` crashes with an uncaught `PermissionError` when the
current user's username contains a dash. `_decode_project_path` (in
`headroom/learn/plugins/claude.py`) probes speculative candidate paths
when reconstructing an original filesystem path from a Claude Code
encoded project directory name. When the username is e.g. `marco-rocha`,
one candidate becomes `/home/marco/rocha`, which can collide with
another user's home directory whose parent isn't stat-able.
`Path.exists()` calls `os.stat` internally, raising `PermissionError`
instead of returning `False`, so the whole `learn` command crashes
before returning any recommendations.

Fixes #2443

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

- Add `_path_exists()` to `headroom/learn/plugins/claude.py` — a thin
wrapper around `Path.exists()` that returns `False` on any `OSError`
(including `PermissionError`), mirroring the existing `OSError` handling
already used in `_greedy_path_decode`.
- Route every speculative candidate-path existence check in the decode
path through `_path_exists()`: the Windows drive/path probes in
`_decode_windows_path`, the `simple` POSIX candidate and greedy-branch
bases in `_decode_project_path`/`_greedy_path_decode`, and the decoded
`project_path`/`CLAUDE.md` checks in `discover_projects`.
- Add regression tests covering the exact issue shape (`PermissionError`
on `/home/marco/rocha`) and the `_path_exists` helper directly.
- Leave `CHANGELOG.md` untouched — release-please generates it from
conventional commits.

## Testing

- [x] Unit tests pass (`python -m pytest
tests/test_learn/test_scanner.py::TestDecodePermissionError -q`)
- [x] Linting passes (`ruff check`, `ruff format --check` on the two
changed files)
- [x] New tests added for new functionality

### Test Output

```text
$ python -m pytest tests/test_learn/test_scanner.py::TestDecodePermissionError -q
collected 2 items
tests\test_learn\test_scanner.py ..                                      [100%]
2 passed in 1.86s

$ ruff check headroom/learn/plugins/claude.py tests/test_learn/test_scanner.py
All checks passed!
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.13, local dev checkout of headroom
on branch off upstream/main
- Exact command / steps: Simulated the issue by monkeypatching
`Path.exists` to raise `PermissionError` for the colliding candidate
`/home/marco/rocha`, then calling
`_decode_project_path("-home-marco-rocha-butterfly-sylphina")`
- Observed result: Before the fix the call propagates `PermissionError`
(crash, matching the reported traceback); after the fix it returns
without raising and the unreadable candidate is treated as non-existent.
Both regression tests pass.
- Not tested: End-to-end `headroom learn --apply` on a real Linux
multi-user box with an actually unreadable `/home/<prefix>` — reproduced
via the documented minimal logic instead.

## 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-07-22 06:16:44 -07:00
Abhay Singh
3e976712e7
fix(proxy/output-shaping): tolerate a non-string system block text in steering (#2435)
## Description

`apply_verbosity_steering` (the Anthropic output-shaping path) scans the
`system` block list to find and update an existing steering block:

```python
if isinstance(system, list):
    for block in system:
        if isinstance(block, dict) and block.get("text", "").startswith(_STEERING_SENTINEL):
```

`.get("text", "")` only substitutes the default when the key is
**absent**. A malformed client block with a null text (`{"type": "text",
"text": null}`) returns `None`, so `None.startswith(...)` raises
`AttributeError`. In the output-shaping treatment arm that call runs
inside `shape_request`, which is not individually guarded, so the
exception propagates and 502s the request.

The OpenAI chat sibling in the same module already defends against this
exact case (`isinstance(part.get("text"), str)`), so the Anthropic path
is the inconsistent one.

## Fix

Guard that the block text is a string before `startswith`, mirroring the
OpenAI sibling. Well-formed bodies are unchanged: the steering block is
still replaced idempotently when a level changes, or appended when
absent. The malformed block is left untouched.

## 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/output_steering.py`: string-guard the system block
text before `startswith` in `apply_verbosity_steering`.
- `tests/test_output_steering.py`: regression asserting a `system` list
containing a `{"text": null}` block does not crash and still appends the
steering block.

## 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_output_steering.py -q
9 passed

# with the fix reverted, the new test fails (AttributeError on None.startswith):
$ git stash push -- headroom/proxy/output_steering.py
$ python -m pytest "tests/test_output_steering.py::test_anthropic_steering_tolerates_non_string_system_block_text" -q
1 failed

$ uvx ruff@0.15.17 check headroom/proxy/output_steering.py tests/test_output_steering.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/proxy/output_steering.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, project venv (`uv sync --extra
proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`, pytest in the venv.
- Exact command / steps: called the real `apply_verbosity_steering` with
`system=[{"type":"text","text":None},{"type":"text","text":"Real system
prompt."}]`; also confirmed the OpenAI sibling
`apply_openai_chat_verbosity_steering` handles the same shape.
- Observed result: pre-fix the Anthropic call raised `AttributeError:
'NoneType' object has no attribute 'startswith'` while the OpenAI
sibling returned True; post-fix the Anthropic call returns True, leaves
the malformed block as-is, appends the steering block, and stays
idempotent on a repeat. Ran against the actual module.
- Not tested: a live client that sends a null system block text end to
end.

## 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
2026-07-22 06:16:20 -07:00
Abhay Singh
77b26c093c
fix(proxy/streaming): tolerate malformed content in _response_to_sse (#2481)
## Description

`StreamingMixin._response_to_sse` rebuilds an Anthropic SSE stream from
a buffered response dict. It iterated the content and read usage with no
type guards:

```python
for idx, block in enumerate(response.get("content", [])):
    if block.get("type") == "text":
        ...
...
"usage": {"output_tokens": response.get("usage", {}).get("output_tokens", 0)},
```

`response` here is provider- and reconstruction-controlled.
`.get("content", [])` only falls back when the key is absent, so a
present-but-null `content` returns `None` and `enumerate(None)` raises
`TypeError`. A non-list `content` (e.g. a bare string) makes
`block.get(...)` raise `AttributeError`, and a null element inside the
list hits the same `AttributeError`. `response.get("usage",
{}).get(...)` breaks the same way on `usage: null`.

This matters because the Anthropic buffered CCR path calls it inside an
`except ValueError` guard only:

```python
try:
    sse_events = self._response_to_sse(resp_json, "anthropic")
except ValueError as sse_err:
    ...
```

A `TypeError`/`AttributeError` from any of the shapes above escapes that
guard and 500s the streamed request. The sibling
`_record_ccr_feedback_from_response` in the same class already guards
`content` for list-ness and skips non-dict blocks, so this closes the
asymmetry.

## Fix

Coerce `content` to a list before iterating (non-list becomes empty),
skip any non-dict block, and coerce a non-dict `usage` to `{}` before
reading `output_tokens`. Well-formed responses render byte-for-byte as
before.

## 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/streaming.py`: list-guard `content`, skip
non-dict blocks, and dict-guard `usage` in `_response_to_sse`.
- `tests/test_sse_thinking_blocks.py`: regression rendering responses
with null/non-list content, a null block element, and null usage, plus a
check that a valid block alongside a null element still renders.

## 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_sse_thinking_blocks.py -q
14 passed

# with the fix reverted, the new test fails with
# TypeError: 'NoneType' object is not iterable

$ uvx ruff@0.15.17 check headroom/proxy/handlers/streaming.py tests/test_sse_thinking_blocks.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/proxy/handlers/streaming.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, project venv (`uv sync --extra
proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`, pytest in the venv.
- Exact command / steps: called
`StreamingMixin()._response_to_sse(response, "anthropic")` with four
malformed bodies (`content: null`, `content: "not-a-list"`, `content:
[null, {text}]`, `usage: null`); then reverted `streaming.py` and re-ran
the same inputs.
- Observed result: with the fix each body produces a well-formed SSE
envelope (message_start ... message_stop) and the valid block alongside
a null element still emits its text_delta; with the fix reverted the
`content: null` body raises `TypeError: 'NoneType' object is not
iterable` and the others raise `AttributeError`. Ran against the actual
module via `tests/test_sse_thinking_blocks.py`.
- Not tested: a live upstream returning a malformed buffered response
end to end through the CCR path.

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable
2026-07-22 06:11:00 -07:00
Abhay Singh
7524854da7
fix(doctor): don't crash on a valid-but-non-object settings.json (#2482)
## Description

`headroom doctor` parses `~/.claude/settings.json` in two checks:

```python
try:
    payload = json.loads(settings_path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
    return CheckResult(... WARN "could not parse" ...)
...
env_block = payload.get("env")
```

`json.loads` returns a non-dict for any valid JSON that is not an
object: `[]`, `null`, `42`, `"a string"`. None of those raise
`JSONDecodeError`, so they slip past the `except (OSError, ValueError)`
guard, and the following `payload.get("env")` raises `AttributeError`.
`AttributeError` is not in the caught tuple, so it escapes and crashes
`doctor` with a traceback. That is the worst moment for it: `doctor` is
the command a user runs precisely because their config is suspect, and a
hand-edited or reset `settings.json` holding `[]` or `null` is exactly
the kind of file it should report on, not fall over on.

Two functions have this shape: `check_claude_routing` (the `.get` is
after the `try` returns) and `check_claude_remote_control_gate` (the
`.get` is inside a `try` whose `except` is also `(OSError,
ValueError)`).

## Fix

Guard `payload` for dict-ness in both checks. `check_claude_routing` now
returns the same WARN it already returns for unparseable files, with a
"not a JSON object" summary; `check_claude_remote_control_gate` treats a
non-object as having no `env` block, so the shell environment still
drives the gate. Well-formed object settings behave exactly as before.

## Type of Change

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

## Changes Made

- `headroom/cli/doctor.py`: guard `payload` for dict-ness in
`check_claude_routing` and `check_claude_remote_control_gate` before
calling `.get`.
- `tests/test_cli_doctor.py`: parametrized regressions feeding `[]`,
`null`, `42`, and a bare string to both checks.

## 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_cli_doctor.py -q
68 passed

# with the fix reverted, the new tests fail with
# AttributeError: 'list' object has no attribute 'get'

$ uvx ruff@0.15.17 check headroom/cli/doctor.py tests/test_cli_doctor.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/cli/doctor.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, project venv (`uv sync --extra
proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`, pytest in the venv.
- Exact command / steps: wrote a `settings.json` containing `[]` (and
`null`, `42`, `"a string"`) into a tmp path and called
`check_claude_routing(path, 8787)` and
`check_claude_remote_control_gate(path, {"ANTHROPIC_BASE_URL":
"http://127.0.0.1:8787"})`; then reverted `doctor.py` and re-ran.
- Observed result: with the fix both checks return a WARN result instead
of raising; with the fix reverted both raise `AttributeError: 'list'
object has no attribute 'get'` (and the analogous message for
`null`/`42`/string). Ran against the actual module via
`tests/test_cli_doctor.py`.
- Not tested: the full `headroom doctor` CLI end to end against a real
`~/.claude/settings.json`.

## 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
2026-07-22 06:10:23 -07:00
Rod Boev
46293f4daf
fix(codex): detect keyring-backed ChatGPT auth (#2478)
## Description

Headroom currently treats missing `auth.json` as “not ChatGPT auth” for
Codex, which breaks keyring-backed ChatGPT sessions on Codex CLI 0.144.6
because those sessions intentionally may not store credentials in the
file. This updates the Codex auth detector to keep the existing
file-backed fast path and fall back to Codex-owned auth metadata when
the session is keyring-backed or auto-backed, so `requires_openai_auth =
true` is emitted only for real ChatGPT logins.

Closes #2474

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

- extend Codex auth detection so keyring-backed and auto-backed sessions
can be classified from Codex-owned auth metadata when `auth.json` is
absent
- preserve the current file-backed ChatGPT, API-key, malformed-file, and
fail-closed behaviors
- add focused install-layer regression coverage for the new keyring path
and adjacent negative space

## Testing

- [x] Unit tests pass (`uv run pytest
tests/test_install/test_codex_install.py -q`)
- [x] Linting passes (`uv run ruff check
headroom/providers/codex/install.py
tests/test_install/test_codex_install.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality when applicable
- [x] Manual testing performed

### Test Output

```text
uv run pytest tests/test_install/test_codex_install.py -q
============================= test session starts =============================
platform win32 -- Python 3.12.13, pytest-9.0.3, pluggy-1.6.0
collected 9 items
tests\test_install\test_codex_install.py .........                       [100%]
============================== 9 passed in 0.24s ==============================

uv run ruff check headroom/providers/codex/install.py tests/test_install/test_codex_install.py
All checks passed!
```

## Real Behavior Proof

- Environment: Windows, Codex CLI 0.144.6 available locally, Python
3.12.13 via `uv`
- Exact command / steps: `codex login status`; `Measure-Command { codex
login status > $null }`; focused pytest and Ruff commands above
- Observed result: `codex login status` returns `stdout=''` and
`stderr='Logged in using ChatGPT\n'`; the status probe measured `71.40`
ms; pytest reports `9 passed in 0.24s`; keyring ChatGPT emits
`requires_openai_auth = true`, non-ChatGPT and failed probes omit it,
and file-backed ChatGPT/API-key cases remain true/false
- Not tested: live local keyring-backed Codex login

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] 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` stays untouched because Headroom generates release notes
from conventional commits. The PR should only claim the Codex-owned
detection path and focused local regression coverage; the live keyring
session proof remains a follow-up owner check.
2026-07-22 06:09:31 -07:00
Rod Boev
a2e42fb877
fix(proxy): keep buffered CCR streams alive (#2479)
## Description

Buffered CCR streaming currently waits for the full upstream response
before sending any bytes back to the client. On the Anthropic path this
shows up as `API Error: Stream idle timeout - no chunks received`, and
the same buffer-then-synthesize mechanism still exists on the
`/v1/responses` CCR path. This adds a narrow buffered-stream heartbeat
layer so the client sees early stream activity while Headroom preserves
the existing server-side retrieval round trip and final synthesized
provider events.

Closes #2465

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

- open buffered CCR streams early and emit client-visible `event: ping`
heartbeats while the buffered upstream call is still in flight
- preserve the existing terminal Anthropic and Responses synthesis
helpers instead of replacing their event-building logic
- preserve early non-streaming failure semantics before the first
heartbeat, including normal 429 passthrough and normal JSON 502 failures
- log late buffered-task exceptions server-side and record one failed
provider metric on that post-keepalive branch, while keeping the
client-facing SSE error sanitized
- add focused delayed-upstream regression coverage for both buffered
provider paths, their early-failure branches, and their late-failure
branches

## Testing

- [x] Unit tests pass (`uv run pytest
tests/test_proxy/test_anthropic_streaming_ccr_retrieve.py
tests/test_proxy/test_openai_responses_ccr.py -q`)
- [x] Linting passes (`uv run ruff check
headroom/proxy/handlers/anthropic.py headroom/proxy/handlers/openai.py
tests/test_proxy/test_anthropic_streaming_ccr_retrieve.py
tests/test_proxy/test_openai_responses_ccr.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed

### Test Output

```text
uv run pytest tests/test_proxy/test_anthropic_streaming_ccr_retrieve.py tests/test_proxy/test_openai_responses_ccr.py -q
======================= 17 passed, 1 warning in 42.47s ========================

uv run ruff check headroom/proxy/handlers/anthropic.py headroom/proxy/handlers/openai.py tests/test_proxy/test_anthropic_streaming_ccr_retrieve.py tests/test_proxy/test_openai_responses_ccr.py
All checks passed!
```

## Real Behavior Proof

- Environment: Windows, Python via `uv`, proxy handler tests with gated
buffered upstream fixtures
- Exact command / steps: run the focused Anthropic and Responses CCR
suites above; delayed-upstream tests consume the first client-visible
SSE event before releasing the upstream, then consume the synthesized
final events
- Observed result: both buffered paths emitted `event: ping` before
upstream release; pre-keepalive 429 responses preserved their real
status and headers, pre-keepalive exceptions returned the normal JSON
502 shape, late transport failures recorded one failed provider metric
and one server error log before emitting one sanitized SSE error,
Anthropic preserved `done`, and Responses preserved `Resolved!`
- Not tested: live slow upstream run with Claude Code or a real
Responses client

## Review Readiness

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

## Checklist

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

## Additional Notes

`CHANGELOG.md` stays untouched because Headroom generates release notes
from conventional commits. The PR should only claim the local
buffered-stream contract and focused regression coverage; live client
proof remains an owner check on a slow real upstream.
2026-07-22 06:09:05 -07:00
Abhay Singh
43a7b578a1
fix(backends): don't crash the OpenAI->Anthropic converter on empty choices (#2484)
## Description

`_to_anthropic_response` in both backends converts a non-streaming
OpenAI-shape response to Anthropic shape and indexes the first choice
directly:

```python
# headroom/backends/litellm.py
choice = litellm_response.choices[0]
# headroom/backends/anyllm.py
choice = response.choices[0]
```

A non-streaming upstream response can be HTTP 200 with an **empty**
`choices` list: Azure OpenAI content filtering does exactly this, and
any OpenAI-compatible gateway can return a usage-only / filtered turn
the same way. With `choices: []`, `choices[0]` raises `IndexError`,
which surfaces as a 500 for the request instead of a normal (if empty)
turn.

This is an intra-file asymmetry: the streaming siblings in the same two
files already guard it (`if not chunk.choices: continue` / `if
hasattr(chunk, "choices") and chunk.choices:`), and
`headroom/proxy/handlers/openai.py` documents the exact hazard in
`_apply_stream_usage_option`: "the common `chunk.choices[0].delta`
pattern then raises IndexError" on a usage-only `choices: []` chunk. The
non-streaming converters just never got the same guard.

## Fix

Return a valid empty assistant turn (`content: []`, `stop_reason:
"end_turn"`, usage still mapped) when `choices` is empty, before
indexing. The client gets a clean empty response instead of a 500,
matching how the streaming path already tolerates the same shape.
Non-empty responses are 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

- `headroom/backends/litellm.py`: empty-`choices` guard at the top of
`_to_anthropic_response`, returning an empty assistant turn with mapped
usage.
- `headroom/backends/anyllm.py`: same guard in its
`_to_anthropic_response`.
- `tests/test_litellm_nonstream_cache_usage.py`,
`tests/test_backend_anyllm.py`: regressions passing an empty-`choices`
response through each converter and asserting an empty turn instead of
IndexError.

## 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_litellm_nonstream_cache_usage.py::test_to_anthropic_response_empty_choices_returns_empty_turn tests/test_backend_anyllm.py::test_to_anthropic_response_empty_choices_returns_empty_turn -q
2 passed

# with the fix reverted, both fail with
# IndexError: list index out of range

$ uvx ruff@0.15.17 check headroom/backends/litellm.py headroom/backends/anyllm.py tests/test_backend_anyllm.py tests/test_litellm_nonstream_cache_usage.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/backends/litellm.py headroom/backends/anyllm.py
Success: no issues found in 2 source files
```

Note: `tests/test_backend_anyllm.py` has 7 `@pytest.mark.asyncio` tests
that fail locally because pytest-asyncio is not configured in this
environment (`Unknown config option: asyncio_mode`); they are unrelated
to this change and pass in CI. The two new tests here are synchronous
and pass locally.

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, project venv (`uv sync --extra
proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`, pytest in the venv.
- Exact command / steps: built a response stand-in with `choices=[]` and
a usage object, called `LiteLLMBackend._to_anthropic_response` (on a
bare `object.__new__` instance) and
`AnyLLMBackend._to_anthropic_response` (via the file's fake-backend
fixture); then reverted both backend files and re-ran.
- Observed result: with the fix each converter returns `{type: message,
role: assistant, content: [], stop_reason: end_turn, usage: {...}}` with
the input/output token counts mapped; with the fix reverted both raise
`IndexError: list index out of range`. Ran against the actual modules
via the two test files.
- Not tested: a live Azure OpenAI content-filtered response routed
through the backend end to end.

## 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
2026-07-22 06:08:10 -07:00
Abhay Singh
07cf547607
fix(proxy/gemini): tolerate malformed parts on the compression path (#2486)
## Description

Three helpers on the Gemini compression path read a content entry's
`parts` and iterate it without type guards:

```python
# _has_non_text_parts
parts = content.get("parts", [])
for part in parts: ...
# _rebuild_gemini_contents
had_text = any("text" in p for p in content.get("parts", []))
# _gemini_contents_to_messages
parts = content.get("parts", [])
text_parts = [p.get("text", "") for p in parts if "text" in p]
```

`parts` is request-controlled and `.get("parts", [])` only falls back
when the key is absent, so:

- a present-but-null `parts` returns `None`, and `for part in None` /
`any(... for p in None)` raises `TypeError`;
- a list carrying a bare string (a client that treats `parts` as a
string array) makes `p.get("text", "")` raise `AttributeError`, while
`"text" in p` silently does substring matching first;
- a null element in the list crashes the same way.

Any of these 500s the request on the compression path, on data that
parsed as valid JSON.

## Fix

Route all three helpers through a shared `_dict_parts(content)` that
returns the dict entries of `parts`, coercing a non-dict content or a
non-list `parts` to an empty list and dropping non-dict elements.
`_gemini_contents_to_messages` also reads `role` defensively for a
non-dict content entry. Conversion now degrades gracefully (the
malformed part contributes nothing) instead of raising. Well-formed
requests are 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

- `headroom/proxy/handlers/gemini.py`: add `_dict_parts`; use it in
`_has_non_text_parts`, `_rebuild_gemini_contents`, and
`_gemini_contents_to_messages`; read `role` defensively for a non-dict
content entry.
- `tests/test_gemini_function_response_waste.py`: regressions for null
`parts`, bare-string part elements, a null part element,
`_has_non_text_parts` on malformed parts, and a non-dict content entry.

## Testing

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

### Test Output

```text
$ python -m pytest tests/test_gemini_function_response_waste.py -q
16 passed

# with the fix reverted, the new malformed-parts tests fail with
# TypeError: 'NoneType' object is not iterable  (and AttributeError on string parts)

$ uvx ruff@0.15.17 check headroom/proxy/handlers/gemini.py tests/test_gemini_function_response_waste.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/proxy/handlers/gemini.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, project venv (`uv sync --extra
proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`, pytest in the venv.
- Exact command / steps: built a real `HeadroomProxy` and called
`_gemini_contents_to_messages` / `_has_non_text_parts` with contents
carrying `parts: null`, `parts: ["bare string", {text}]`, `parts: [null,
{text}]`, and a non-dict content entry; then reverted `gemini.py` and
re-ran.
- Observed result: with the fix each malformed shape converts without
raising and the valid text part is still emitted (`[{"role": "user",
"content": "kept"}]`); with the fix reverted the null-`parts` and
null-element cases raise `TypeError: 'NoneType' object is not iterable`
and the string-element case raises `AttributeError: 'str' object has no
attribute 'get'`. Ran against the actual module via
`tests/test_gemini_function_response_waste.py`.
- Not tested: a live Gemini request with malformed `parts` routed
through the full proxy compression pipeline end to end.

## 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
2026-07-22 06:07:15 -07:00
Parideboy
f0975b8de0
docs: add troubleshooting entry for uv build cache errors (#2490)
## Description

Adds a troubleshooting section for a uv build error macOS users hit
installing headroom-ai via uv: `src does not appear to be a Python
project` (typically surfacing on `litellm` or `cryptography`) or
`Unknown wheel data type: .DS_Store`. Root cause is uv build/wheel cache
corruption on the user's machine, not a Headroom dependency pin. Also
cross-references the existing `ast-grep-cli>=0.30.0,!=0.44.1` pin, which
already excludes the compromised 0.44.1 build reported in the same
issue.

Closes #2476

## Type of Change

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

## Changes Made

- Added "uv build errors: 'src does not appear to be a Python project'"
subsection under Installation Issues in
`docs/content/docs/troubleshooting.mdx`, with symptom, cause, and `uv
cache clean` fix.
- Cross-referenced the already-shipped `ast-grep-cli` version pin for
the 0.44.1 supply-chain issue.

## 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
N/A — docs-only change, no code paths touched. No pytest/ruff/mypy relevant.
```

## Real Behavior Proof

- Environment: N/A — markdown documentation edit only, no runtime
behavior changed.
- Exact command / steps: Read the modified
`docs/content/docs/troubleshooting.mdx` section against the rendered
structure of adjacent entries (Windows Defender / ast-grep-cli section)
to confirm heading level, code fences, and link formatting match.
- Observed result: New subsection renders consistently with surrounding
Installation Issues entries (same `###` heading depth, Symptom/Cause/Fix
structure, fenced code blocks).
- Not tested: Live docs site build/preview (`cd docs && npm run dev`)
was not run in this environment.

## 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
— N/A, prose docs
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works — N/A, docs-only
- [ ] New and existing unit tests pass locally with my changes — N/A,
docs-only
- [x] I did **not** edit `CHANGELOG.md`

## Screenshots (if applicable)

N/A

## Additional Notes

Docs-only change; no source code touched. `docs && npm run dev` not run
locally in this environment — flagging for maintainer to preview if
desired before merge.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 06:06:51 -07:00
gglucass
2195ba7d91
fix(proxy/openai): don't record Codex WS savings without input accounting (#2493)
## Description

On the Codex WS Responses path (`handle_openai_responses_ws`),
`tokens_saved` accumulates at compression time (our own token count),
while input tokens only arrive with a usage frame on
`response.completed`. A turn that is compressed but never completes —
cancelled mid-response (Esc in Codex), or an upstream error before the
usage frame — records `tokens_saved > 0` with `input_tokens == 0`
through the outcome funnel.

That writes a savings-with-zero-spend checkpoint into the savings
tracker: `compression_savings_usd` advances while `total_input_tokens` /
`total_input_cost_usd` stay flat. `/stats-history` then serves daily
buckets with `compression_savings_usd_delta > 0` and
`total_input_tokens_delta == 0`, which savings dashboards flag as a
data-integrity anomaly ("graph shows compression savings but zero tokens
spent on recent day(s)").

Both WS record sites have the hazard:
- the per-turn metrics closure (`_record_ws_response_metrics`) records
per-field-clamped deltas, so a usage-less turn contributes a
savings-only outcome;
- the session-end residual flush records `residual_tokens_saved` with
`residual_input_tokens` possibly 0.

## Type of Change

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

## Changes Made

- `headroom/proxy/handlers/openai.py`:
- New module-level pure helper `_deferrable_savings_delta(input_delta,
saved_delta)` — returns 0 when `saved_delta > 0` with `input_delta <=
0`, passes everything else through unchanged.
- Per-turn metrics closure: gate `saved_delta` through the helper, and
advance `ws_recorded_tokens_saved_total += saved_delta` (previously `=
tokens_saved`) so deferred savings stay pending and ride along with the
next usage-carrying turn instead of being silently dropped.
- Session-end residual flush: gate `residual_tokens_saved` through the
same helper — savings that never saw a usage frame by session close are
dropped rather than recorded against zero spend (the spend for those
turns is genuinely unknown).
- `tests/test_codex_ws_savings_deferral.py`: truth-table test for the
helper; a bookkeeping walk asserting deferred savings land with the next
usage-carrying turn; and a source-level regression guard for the
closure-internal wiring (same idiom as
`test_codex_ws_compression_scheduler.py`, since the WS closures have no
unit harness yet).

## 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 (real-behavior script below)

### Test Output

```text
$ uv run --frozen --extra dev pytest tests/test_codex_ws_savings_deferral.py tests/test_openai_codex_ws_lifecycle.py tests/test_codex_ws_compression_scheduler.py tests/test_openai_codex_ws_timings.py tests/test_proxy_savings_history.py
83 passed, 1 skipped (pre-existing pending-harness skip)

$ ruff check headroom/proxy/handlers/openai.py tests/test_codex_ws_savings_deferral.py
All checks passed!

$ uv run --frozen --extra dev mypy headroom/proxy/handlers/openai.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: macOS arm64 (Darwin 24.6.0), Python 3.12, this branch
checked out in the repo, run via `uv run --frozen python`.
- Exact command / steps: `uv run --frozen python rbp_demo.py` — a
real-behavior script exercising the REAL `SavingsTracker` (persistence +
`/stats-history` rollup via `history_response()`) and the REAL
`_deferrable_savings_delta` from this branch, no mocks. Scenario: turn 1
compressed (1200 saved) then cancelled before its usage frame (recorded
on day 1), turn 2 compressed (600 more) and completed with
`input_tokens=40000` (day 2); "BEFORE" records what the unfixed handler
emitted, "AFTER" walks the fixed bookkeeping. Additionally, a real
production `~/.headroom/proxy_savings.json` (5000 checkpoints, live
proxy in daily Claude Code + Codex use) was scanned for consecutive
checkpoint pairs where `compression_savings_usd` grew while
`total_input_tokens` stayed flat — one such pair was present
(`provider=openai, model=gpt-5.4-mini`, a Codex WS turn), exactly the
shape this PR removes at the source.
- Observed result: the unfixed recording produces a day-1
`/stats-history` bucket with `compression_savings_usd_delta > 0` and
`total_input_tokens_delta == 0` (the flagged anomaly); the fixed
bookkeeping produces no such bucket and preserves the full 1800 tokens
of savings, paired with the usage-carrying turn. Full output:

```text
BEFORE (unfixed recording): [{'tokens_saved': 1200, 'compression_savings_usd_delta': 0.0009, 'total_input_tokens_delta': 0},
                             {'tokens_saved': 600,  'compression_savings_usd_delta': 0.00045, 'total_input_tokens_delta': 40000}]
AFTER  (fixed recording):   [{'tokens_saved': 1800, 'compression_savings_usd_delta': 0.00135, 'total_input_tokens_delta': 40000}]
desync bucket present before fix: True
desync bucket present after fix:  False
total savings preserved after fix: True
```

- Not tested: a live end-to-end WS session against the real OpenAI
upstream with a mid-response cancel (needs a real Codex client +
billable upstream). The per-turn/residual closure wiring is covered by
the source-level regression guard instead, per the pending-harness note
in `test_codex_ws_compression_scheduler.py`.

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation (N/A —
internal accounting fix, no user-facing docs affected)
- [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 did **not** edit `CHANGELOG.md` — it is generated by
release-please from the Conventional Commit PR title (a CI guard
enforces this)

## Additional Notes

- Sessions that end on a cancelled turn under-report savings slightly
(the deferred savings are dropped at close because their spend is
genuinely unknown). This is the honest trade-off: the alternative —
recording savings against zero spend — is the desync this PR removes.
- `attempted_input_tokens` is intentionally not gated: a cancelled turn
still records its attempted delta, keeping funnel-drop visibility.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 06:06:30 -07:00
Tejas Chopra
5a0a5a79cd
docs: sync Vercel docs with current code and add in-depth proxy config (#2475)
## Description

Bring the published docs (headroom-docs.vercel.app) back in line with
the current codebase. The docs described an older architecture,
advertised user/community statistics the code no longer supports (the
telemetry beacon was removed), shipped code samples that raise on
import, and lacked an in-depth treatment of proxy-mode configuration.

Docs-only change — no `headroom/` source touched.

## Type of Change

- [x] Documentation update

## Changes Made

- **Remove user/community stats.** The anonymous telemetry beacon was
removed from the code and `HEADROOM_TELEMETRY` is now local-only, but
the docs still advertised aggregate "instances worldwide" figures —
which were hardcoded/fabricated. Deleted `community-savings.mdx` (+ nav
entry), the community/live stat widgets and their components
(`community-charts`, `community-stats-header`, `live-stats`, `stats`,
`lib/telemetry`, and a second fabricated `LiveStats` in
`marketing.tsx`), and the `## Production Telemetry` section in
`benchmarks.mdx`. Reframed all telemetry wording as local-only.
- **Correct the architecture docs.** Rewrote `architecture.mdx` to the
real pipeline (interceptor → CacheAligner *off-by-default* →
ContentRouter; Rust `_core`; CCR on by default). Dropped the removed
3-stage / Context Manager / RollingWindow model. Fixed
`how-compression-works.mdx` (3-stage framing, dead LLMLingua reference,
wrong compressor class names) and added an off-by-default note to
`cache-optimization.mdx`.
- **Fix broken code samples** (verified against source):
`TextCompressor`→`TextCrusher` + real `SearchCompressorConfig` fields
(`text-and-logs`), `MemoryCategory`→plain string (`memory`),
`unload_tree_sitter` import path (`code-compression`).
- **In-depth proxy configuration.** Added a "Configuration in depth"
section to `proxy.mdx` (Kompress, CCR/lossless, file-read handling,
reliability, tool-search/MCP, cost-aware routing, observability,
security/networking, performance). Fixed the `HEADROOM_MODE` default
(`token`→`cache`) in three pages and removed a duplicate
`HEADROOM_TELEMETRY` row.
- **Nav + links.** Un-orphaned `crewai`/`autogen` in the sidebar;
normalized `chopratejas`→`headroomlabs-ai` repo/GHCR links (kept the
real HF model id `chopratejas/technique-router`);
`litellm-vertex`→`vertex_ai`.

## Testing

- [ ] Unit tests pass (`pytest`) — N/A, no `headroom/` code changed
- [ ] Linting passes (`ruff check .`) — N/A, no Python changed
- [ ] Type checking passes (`mypy headroom`) — N/A, no Python changed
- [x] Manual testing performed (static docs validation; output below)

### Test Output

```text
-- dangling refs to deleted components/pages (expect empty) --
(none)
-- meta.json valid --
pages: 60 | community-savings present: false | crewai: true | autogen: true
-- Callout balance (open == close) --
docs/content/docs/proxy.mdx open=6 close=6
docs/content/docs/cache-optimization.mdx open=1 close=1
```

## Real Behavior Proof

- Environment: docs are static MDX (Fumadocs/Next.js); no runtime
behavior. Corrections were checked against `headroom/` source.
- Exact command / steps: grepped for references to deleted
components/pages; validated `meta.json` parses and no longer contains
`community-savings`; confirmed `<Callout>` open/close balance and
frontmatter on every edited page; verified every corrected API
name/field/import against the source modules (`text_crusher.py`,
`search_compressor.py`, `memory/__init__.py`, `code_compressor.py`).
- Observed result: no dangling references; nav valid; balanced JSX;
corrected code samples match the real importable API.
- Not tested: full `next build` / `npm run types:check` —
`docs/node_modules` is not installed in this environment. Recommend a
Vercel preview deploy (or `cd docs && npm i && npm run types:check`) as
the merge gate.

## 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
— 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 — N/A (docs-only)
- [x] New and existing unit tests pass locally with my changes — N/A, no
code changed
- [x] I did **not** edit `CHANGELOG.md`

## Additional Notes

- Docs-only; no `headroom/` package code touched, so the
pytest/ruff/mypy items are N/A.
- The full Next.js build was not run locally (deps not installed) — a
Vercel preview is the recommended gate.
- Org normalization assumes `headroomlabs-ai` is canonical (matches
CI/GHCR + the newer docs). If `chopratejas/headroom` is still the
canonical **public** repo, revert the `docs/lib/*.ts` + install/docker
link changes.
- Heads-up: a separate `docs` branch exists on the remote — if the
Vercel docs site deploys from `docs` rather than `main`, retarget this
PR there.
2026-07-21 16:26:56 -07:00
dependabot[bot]
961866ba7c
deps: bump the npm-minor-patch group across 3 directories with 7 updates (#2276)
Bumps the npm-minor-patch group with 6 updates in the /docs directory:

| Package | From | To |
| --- | --- | --- |
| [fumadocs-core](https://github.com/fuma-nama/fumadocs) | `16.11.1` |
`16.11.5` |
| [fumadocs-mdx](https://github.com/fuma-nama/fumadocs) | `15.1.0` |
`15.2.0` |
| [fumadocs-ui](https://github.com/fuma-nama/fumadocs) | `16.11.1` |
`16.11.5` |
|
[@anthropic-ai/sdk](https://github.com/anthropics/anthropic-sdk-typescript)
| `0.106.0` | `0.111.0` |
| [openai](https://github.com/openai/openai-node) | `6.33.0` | `6.47.0`
|
| [postcss](https://github.com/postcss/postcss) | `8.5.16` | `8.5.19` |

Bumps the npm-minor-patch group with 1 update in the /plugins/opencode
directory: @opencode-ai/plugin.
Bumps the npm-minor-patch group with 1 update in the /sdk/typescript
directory:
[@anthropic-ai/sdk](https://github.com/anthropics/anthropic-sdk-typescript).

Updates `fumadocs-core` from 16.11.1 to 16.11.5
<details>
<summary>Commits</summary>
<ul>
<li><a
href="52af6cf292"><code>52af6cf</code></a>
Merge pull request <a
href="https://redirect.github.com/fuma-nama/fumadocs/issues/3416">#3416</a>
from fuma-nama/tegami/version-packages</li>
<li><a
href="efc9d18402"><code>efc9d18</code></a>
chore(mdx): bake passthroughs into runtime module</li>
<li><a
href="368a92e3a2"><code>368a92e</code></a>
feat(mdx): macro collection-level last modified time</li>
<li><a
href="13dfdbc224"><code>13dfdbc</code></a>
feat(mdx): no longer require include for macros</li>
<li><a
href="63623320b5"><code>6362332</code></a>
test macro API on vite</li>
<li><a
href="126a74d995"><code>126a74d</code></a>
fix(ui): fix accessibility of sidebar components</li>
<li><a
href="430254caab"><code>430254c</code></a>
fix(mdx): fix file check</li>
<li><a
href="1862822aa5"><code>1862822</code></a>
feat(mdx): redesign macro infrastructure</li>
<li><a
href="d3710a9614"><code>d3710a9</code></a>
fix(openapi): fix invalid generated request</li>
<li><a
href="ba78b0177b"><code>ba78b01</code></a>
fix(ui): correct prop types</li>
<li>Additional commits viewable in <a
href="https://github.com/fuma-nama/fumadocs/compare/fumadocs@16.11.1...fumadocs@16.11.5">compare
view</a></li>
</ul>
</details>
<br />

Updates `fumadocs-mdx` from 15.1.0 to 15.2.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/fuma-nama/fumadocs/releases">fumadocs-mdx's
releases</a>.</em></p>
<blockquote>
<h2>fumadocs-mdx@15.2.0</h2>
<h3>Support Macro API</h3>
<p>Use <code>fumadocs-mdx/macro</code> to define collections, and enable
the macro-style API from bundler plugin (e.g. <code>createMDX</code>)
using the <code>include</code> option.</p>
<h2>fumadocs-mdx@15.1.1</h2>
<h3>Migrate from <code>js-yaml</code> to <code>yaml</code></h3>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="52af6cf292"><code>52af6cf</code></a>
Merge pull request <a
href="https://redirect.github.com/fuma-nama/fumadocs/issues/3416">#3416</a>
from fuma-nama/tegami/version-packages</li>
<li><a
href="efc9d18402"><code>efc9d18</code></a>
chore(mdx): bake passthroughs into runtime module</li>
<li><a
href="368a92e3a2"><code>368a92e</code></a>
feat(mdx): macro collection-level last modified time</li>
<li><a
href="13dfdbc224"><code>13dfdbc</code></a>
feat(mdx): no longer require include for macros</li>
<li><a
href="63623320b5"><code>6362332</code></a>
test macro API on vite</li>
<li><a
href="126a74d995"><code>126a74d</code></a>
fix(ui): fix accessibility of sidebar components</li>
<li><a
href="430254caab"><code>430254c</code></a>
fix(mdx): fix file check</li>
<li><a
href="1862822aa5"><code>1862822</code></a>
feat(mdx): redesign macro infrastructure</li>
<li><a
href="d3710a9614"><code>d3710a9</code></a>
fix(openapi): fix invalid generated request</li>
<li><a
href="ba78b0177b"><code>ba78b01</code></a>
fix(ui): correct prop types</li>
<li>Additional commits viewable in <a
href="https://github.com/fuma-nama/fumadocs/compare/fumadocs-mdx@15.1.0...fumadocs-mdx@15.2.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `fumadocs-ui` from 16.11.1 to 16.11.5
<details>
<summary>Commits</summary>
<ul>
<li><a
href="52af6cf292"><code>52af6cf</code></a>
Merge pull request <a
href="https://redirect.github.com/fuma-nama/fumadocs/issues/3416">#3416</a>
from fuma-nama/tegami/version-packages</li>
<li><a
href="efc9d18402"><code>efc9d18</code></a>
chore(mdx): bake passthroughs into runtime module</li>
<li><a
href="368a92e3a2"><code>368a92e</code></a>
feat(mdx): macro collection-level last modified time</li>
<li><a
href="13dfdbc224"><code>13dfdbc</code></a>
feat(mdx): no longer require include for macros</li>
<li><a
href="63623320b5"><code>6362332</code></a>
test macro API on vite</li>
<li><a
href="126a74d995"><code>126a74d</code></a>
fix(ui): fix accessibility of sidebar components</li>
<li><a
href="430254caab"><code>430254c</code></a>
fix(mdx): fix file check</li>
<li><a
href="1862822aa5"><code>1862822</code></a>
feat(mdx): redesign macro infrastructure</li>
<li><a
href="d3710a9614"><code>d3710a9</code></a>
fix(openapi): fix invalid generated request</li>
<li><a
href="ba78b0177b"><code>ba78b01</code></a>
fix(ui): correct prop types</li>
<li>Additional commits viewable in <a
href="https://github.com/fuma-nama/fumadocs/compare/fumadocs@16.11.1...fumadocs@16.11.5">compare
view</a></li>
</ul>
</details>
<br />

Updates `@anthropic-ai/sdk` from 0.106.0 to 0.111.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/anthropics/anthropic-sdk-typescript/releases">@​anthropic-ai/sdk's
releases</a>.</em></p>
<blockquote>
<h2>sdk: v0.111.0</h2>
<h2>0.111.0 (2026-07-10)</h2>
<p>Full Changelog: <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.110.0...sdk-v0.111.0">sdk-v0.110.0...sdk-v0.111.0</a></p>
<h3>Features</h3>
<ul>
<li><strong>api:</strong> add support for dreaming (<a
href="77b28a6472">77b28a6</a>)</li>
<li><strong>tools:</strong> gate session tool calls on
evaluated_permission; bound idle by server stop_reason (<a
href="68a6d7b92a">68a6d7b</a>)</li>
</ul>
<h3>Chores</h3>
<ul>
<li><strong>docs:</strong> small updates to field descriptions (<a
href="e25b885eac">e25b885</a>)</li>
<li><strong>docs:</strong> update model example (<a
href="a33f3f0b7c">a33f3f0</a>)</li>
<li><strong>docs:</strong> updates to descriptions and examples (<a
href="eac4bace32">eac4bac</a>)</li>
</ul>
<h2>sdk: v0.110.0</h2>
<h2>0.110.0 (2026-07-02)</h2>
<p>Full Changelog: <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.109.1...sdk-v0.110.0">sdk-v0.109.1...sdk-v0.110.0</a></p>
<h3>Features</h3>
<ul>
<li><strong>api:</strong> add agent-memory-2026-07-22 beta header (<a
href="a470e10aaa">a470e10</a>)</li>
</ul>
<h2>sdk: v0.109.1</h2>
<h2>0.109.1 (2026-07-01)</h2>
<p>Full Changelog: <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.109.0...sdk-v0.109.1">sdk-v0.109.0...sdk-v0.109.1</a></p>
<h3>Chores</h3>
<ul>
<li><strong>api:</strong> remove some nonfunctional types from the SDKs
(<a
href="cc4dd4e257">cc4dd4e</a>)</li>
</ul>
<h2>sdk: v0.109.0</h2>
<h2>0.109.0 (2026-06-30)</h2>
<p>Full Changelog: <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.108.0...sdk-v0.109.0">sdk-v0.108.0...sdk-v0.109.0</a></p>
<h3>Features</h3>
<ul>
<li><strong>api:</strong> add support for Managed Agents event delta
streaming, agent overrides, reverse pagination, vault credential
injection scoping, and agent and deployment webhook events (<a
href="7f3211b488">7f3211b</a>)</li>
</ul>
<h2>sdk: v0.108.0</h2>
<h2>0.108.0 (2026-06-30)</h2>
<p>Full Changelog: <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.107.0...sdk-v0.108.0">sdk-v0.107.0...sdk-v0.108.0</a></p>
<h3>Features</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/anthropics/anthropic-sdk-typescript/blob/main/CHANGELOG.md">@​anthropic-ai/sdk's
changelog</a>.</em></p>
<blockquote>
<h2>0.111.0 (2026-07-10)</h2>
<p>Full Changelog: <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.110.0...sdk-v0.111.0">sdk-v0.110.0...sdk-v0.111.0</a></p>
<h3>Features</h3>
<ul>
<li><strong>api:</strong> add support for dreaming (<a
href="77b28a6472">77b28a6</a>)</li>
<li><strong>tools:</strong> gate session tool calls on
evaluated_permission; bound idle by server stop_reason (<a
href="68a6d7b92a">68a6d7b</a>)</li>
</ul>
<h3>Chores</h3>
<ul>
<li><strong>docs:</strong> small updates to field descriptions (<a
href="e25b885eac">e25b885</a>)</li>
<li><strong>docs:</strong> update model example (<a
href="a33f3f0b7c">a33f3f0</a>)</li>
<li><strong>docs:</strong> updates to descriptions and examples (<a
href="eac4bace32">eac4bac</a>)</li>
</ul>
<h2>0.110.0 (2026-07-02)</h2>
<p>Full Changelog: <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.109.1...sdk-v0.110.0">sdk-v0.109.1...sdk-v0.110.0</a></p>
<h3>Features</h3>
<ul>
<li><strong>api:</strong> add agent-memory-2026-07-22 beta header (<a
href="a470e10aaa">a470e10</a>)</li>
</ul>
<h2>0.109.1 (2026-07-01)</h2>
<p>Full Changelog: <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.109.0...sdk-v0.109.1">sdk-v0.109.0...sdk-v0.109.1</a></p>
<h3>Chores</h3>
<ul>
<li><strong>api:</strong> remove some nonfunctional types from the SDKs
(<a
href="cc4dd4e257">cc4dd4e</a>)</li>
</ul>
<h2>0.109.0 (2026-06-30)</h2>
<p>Full Changelog: <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.108.0...sdk-v0.109.0">sdk-v0.108.0...sdk-v0.109.0</a></p>
<h3>Features</h3>
<ul>
<li><strong>api:</strong> add support for Managed Agents event delta
streaming, agent overrides, reverse pagination, vault credential
injection scoping, and agent and deployment webhook events (<a
href="7f3211b488">7f3211b</a>)</li>
</ul>
<h2>0.108.0 (2026-06-30)</h2>
<p>Full Changelog: <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.107.0...sdk-v0.108.0">sdk-v0.107.0...sdk-v0.108.0</a></p>
<h3>Features</h3>
<ul>
<li><strong>api:</strong> add support for claude-sonnet-5 (<a
href="4588db01ec">4588db0</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="9e46760688"><code>9e46760</code></a>
chore: release main</li>
<li><a
href="8d461ea962"><code>8d461ea</code></a>
feat(api): add support for dreaming</li>
<li><a
href="9436e29159"><code>9436e29</code></a>
codegen metadata</li>
<li><a
href="0aec1e748b"><code>0aec1e7</code></a>
feat(tools): gate session tool calls on evaluated_permission; bound idle
by s...</li>
<li><a
href="ac2fc6779d"><code>ac2fc67</code></a>
chore(docs): update model example</li>
<li><a
href="c8af65d6af"><code>c8af65d</code></a>
chore(docs): updates to descriptions and examples</li>
<li><a
href="2a3a9042ab"><code>2a3a904</code></a>
codegen metadata</li>
<li><a
href="57c56c9172"><code>57c56c9</code></a>
chore(docs): small updates to field descriptions</li>
<li><a
href="4f2eb80719"><code>4f2eb80</code></a>
chore: release main (<a
href="https://redirect.github.com/anthropics/anthropic-sdk-typescript/issues/1107">#1107</a>)</li>
<li><a
href="96d1a991b6"><code>96d1a99</code></a>
chore: release main (<a
href="https://redirect.github.com/anthropics/anthropic-sdk-typescript/issues/1106">#1106</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.106.0...sdk-v0.111.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `openai` from 6.33.0 to 6.47.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/openai/openai-node/releases">openai's
releases</a>.</em></p>
<blockquote>
<h2>v6.47.0</h2>
<h2>6.47.0 (2026-07-14)</h2>
<p>Full Changelog: <a
href="https://github.com/openai/openai-node/compare/v6.46.0...v6.47.0">v6.46.0...v6.47.0</a></p>
<h3>Features</h3>
<ul>
<li>add async event iterators (<a
href="https://redirect.github.com/openai/openai-node/issues/1977">#1977</a>)
(<a
href="2ece8aa848">2ece8aa</a>)</li>
<li>add fromReadableStream to ResponseStream (<a
href="https://redirect.github.com/openai/openai-node/issues/1987">#1987</a>)
(<a
href="5984f442e0">5984f44</a>)</li>
<li><strong>api:</strong> add owner_project_access to APIKeyListParams
(<a
href="7bfce973a1">7bfce97</a>)</li>
<li>pass context to runTools callbacks (<a
href="https://redirect.github.com/openai/openai-node/issues/1973">#1973</a>)
(<a
href="a6f01e53de">a6f01e5</a>)</li>
<li>support streaming file uploads (<a
href="https://redirect.github.com/openai/openai-node/issues/1970">#1970</a>)
(<a
href="a86f1fde30">a86f1fd</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li><strong>assistants:</strong> preserve readable stream deltas (<a
href="https://redirect.github.com/openai/openai-node/issues/1994">#1994</a>)
(<a
href="1cdc0196b4">1cdc019</a>)</li>
<li>avoid deep Deno Zod types (<a
href="https://redirect.github.com/openai/openai-node/issues/1980">#1980</a>)
(<a
href="ae17127eff">ae17127</a>),
closes <a
href="https://redirect.github.com/openai/openai-node/issues/984">#984</a></li>
<li><strong>ci:</strong> bump <code>@​arethetypeswrong/cli</code> to
^0.18.0 and run CI workflows on Node 24 (<a
href="baa0b2ad90">baa0b2a</a>)</li>
<li>emit stream finalization errors (<a
href="https://redirect.github.com/openai/openai-node/issues/1972">#1972</a>)
(<a
href="9555b71ab8">9555b71</a>)</li>
<li>handle Azure filter stream chunks (<a
href="https://redirect.github.com/openai/openai-node/issues/1982">#1982</a>)
(<a
href="c1c5c28267">c1c5c28</a>),
closes <a
href="https://redirect.github.com/openai/openai-node/issues/1015">#1015</a></li>
<li><strong>zod:</strong> support zod v4 mini schemas (<a
href="https://redirect.github.com/openai/openai-node/issues/1985">#1985</a>)
(<a
href="2df10fc19a">2df10fc</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>clarify strict Zod function schemas (<a
href="https://redirect.github.com/openai/openai-node/issues/1988">#1988</a>)
(<a
href="373b08ac3b">373b08a</a>)</li>
</ul>
<h2>v6.46.0</h2>
<h2>6.46.0 (2026-07-09)</h2>
<p>Full Changelog: <a
href="https://github.com/openai/openai-node/compare/v6.45.0...v6.46.0">v6.45.0...v6.46.0</a></p>
<h3>Features</h3>
<ul>
<li><strong>api:</strong> gpt-5.6-sol updates (<a
href="6c397d5d28">6c397d5</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li><strong>assistants:</strong> place array delta entries by index
instead of appending (<a
href="https://redirect.github.com/openai/openai-node/issues/1963">#1963</a>)
(<a
href="0e18d30a31">0e18d30</a>)</li>
<li><strong>runner:</strong> normalize missing tool call IDs (<a
href="https://redirect.github.com/openai/openai-node/issues/1958">#1958</a>)
(<a
href="6371623aad">6371623</a>)</li>
<li>upgrade next to 15.5.16 in examples (<a
href="https://redirect.github.com/openai/openai-node/issues/1967">#1967</a>)
(<a
href="95b54e5894">95b54e5</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>add Azure Assistants example (<a
href="https://redirect.github.com/openai/openai-node/issues/1975">#1975</a>)
(<a
href="90a72e5345">90a72e5</a>),
closes <a
href="https://redirect.github.com/openai/openai-node/issues/701">#701</a></li>
<li>document assistant stream failures (<a
href="https://redirect.github.com/openai/openai-node/issues/1979">#1979</a>)
(<a
href="d93fbe5bc5">d93fbe5</a>),
closes <a
href="https://redirect.github.com/openai/openai-node/issues/959">#959</a></li>
<li>document file search result limits (<a
href="https://redirect.github.com/openai/openai-node/issues/1981">#1981</a>)
(<a
href="e9dc283dce">e9dc283</a>),
closes <a
href="https://redirect.github.com/openai/openai-node/issues/1004">#1004</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/openai/openai-node/blob/main/CHANGELOG.md">openai's
changelog</a>.</em></p>
<blockquote>
<h2>6.47.0 (2026-07-14)</h2>
<p>Full Changelog: <a
href="https://github.com/openai/openai-node/compare/v6.46.0...v6.47.0">v6.46.0...v6.47.0</a></p>
<h3>Features</h3>
<ul>
<li>add async event iterators (<a
href="https://redirect.github.com/openai/openai-node/issues/1977">#1977</a>)
(<a
href="2ece8aa848">2ece8aa</a>)</li>
<li>add fromReadableStream to ResponseStream (<a
href="https://redirect.github.com/openai/openai-node/issues/1987">#1987</a>)
(<a
href="5984f442e0">5984f44</a>)</li>
<li><strong>api:</strong> add owner_project_access to APIKeyListParams
(<a
href="7bfce973a1">7bfce97</a>)</li>
<li>pass context to runTools callbacks (<a
href="https://redirect.github.com/openai/openai-node/issues/1973">#1973</a>)
(<a
href="a6f01e53de">a6f01e5</a>)</li>
<li>support streaming file uploads (<a
href="https://redirect.github.com/openai/openai-node/issues/1970">#1970</a>)
(<a
href="a86f1fde30">a86f1fd</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li><strong>assistants:</strong> preserve readable stream deltas (<a
href="https://redirect.github.com/openai/openai-node/issues/1994">#1994</a>)
(<a
href="1cdc0196b4">1cdc019</a>)</li>
<li>avoid deep Deno Zod types (<a
href="https://redirect.github.com/openai/openai-node/issues/1980">#1980</a>)
(<a
href="ae17127eff">ae17127</a>),
closes <a
href="https://redirect.github.com/openai/openai-node/issues/984">#984</a></li>
<li><strong>ci:</strong> bump <code>@​arethetypeswrong/cli</code> to
^0.18.0 and run CI workflows on Node 24 (<a
href="baa0b2ad90">baa0b2a</a>)</li>
<li>emit stream finalization errors (<a
href="https://redirect.github.com/openai/openai-node/issues/1972">#1972</a>)
(<a
href="9555b71ab8">9555b71</a>)</li>
<li>handle Azure filter stream chunks (<a
href="https://redirect.github.com/openai/openai-node/issues/1982">#1982</a>)
(<a
href="c1c5c28267">c1c5c28</a>),
closes <a
href="https://redirect.github.com/openai/openai-node/issues/1015">#1015</a></li>
<li><strong>zod:</strong> support zod v4 mini schemas (<a
href="https://redirect.github.com/openai/openai-node/issues/1985">#1985</a>)
(<a
href="2df10fc19a">2df10fc</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>clarify strict Zod function schemas (<a
href="https://redirect.github.com/openai/openai-node/issues/1988">#1988</a>)
(<a
href="373b08ac3b">373b08a</a>)</li>
</ul>
<h2>6.46.0 (2026-07-09)</h2>
<p>Full Changelog: <a
href="https://github.com/openai/openai-node/compare/v6.45.0...v6.46.0">v6.45.0...v6.46.0</a></p>
<h3>Features</h3>
<ul>
<li><strong>api:</strong> gpt-5.6-sol updates (<a
href="6c397d5d28">6c397d5</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li><strong>assistants:</strong> place array delta entries by index
instead of appending (<a
href="https://redirect.github.com/openai/openai-node/issues/1963">#1963</a>)
(<a
href="0e18d30a31">0e18d30</a>)</li>
<li><strong>runner:</strong> normalize missing tool call IDs (<a
href="https://redirect.github.com/openai/openai-node/issues/1958">#1958</a>)
(<a
href="6371623aad">6371623</a>)</li>
<li>upgrade next to 15.5.16 in examples (<a
href="https://redirect.github.com/openai/openai-node/issues/1967">#1967</a>)
(<a
href="95b54e5894">95b54e5</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>add Azure Assistants example (<a
href="https://redirect.github.com/openai/openai-node/issues/1975">#1975</a>)
(<a
href="90a72e5345">90a72e5</a>),
closes <a
href="https://redirect.github.com/openai/openai-node/issues/701">#701</a></li>
<li>document assistant stream failures (<a
href="https://redirect.github.com/openai/openai-node/issues/1979">#1979</a>)
(<a
href="d93fbe5bc5">d93fbe5</a>),
closes <a
href="https://redirect.github.com/openai/openai-node/issues/959">#959</a></li>
<li>document file search result limits (<a
href="https://redirect.github.com/openai/openai-node/issues/1981">#1981</a>)
(<a
href="e9dc283dce">e9dc283</a>),
closes <a
href="https://redirect.github.com/openai/openai-node/issues/1004">#1004</a></li>
</ul>
<h2>6.45.0 (2026-06-24)</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="6255405380"><code>6255405</code></a>
release: 6.47.0 (<a
href="https://redirect.github.com/openai/openai-node/issues/1989">#1989</a>)</li>
<li><a
href="1cdc0196b4"><code>1cdc019</code></a>
fix(assistants): preserve readable stream deltas (<a
href="https://redirect.github.com/openai/openai-node/issues/1994">#1994</a>)</li>
<li><a
href="ec2f57fd0d"><code>ec2f57f</code></a>
Preserve snapshots when resuming response streams (<a
href="https://redirect.github.com/openai/openai-node/issues/1984">#1984</a>)</li>
<li><a
href="2df10fc19a"><code>2df10fc</code></a>
fix(zod): support zod v4 mini schemas (<a
href="https://redirect.github.com/openai/openai-node/issues/1985">#1985</a>)</li>
<li><a
href="ebb649811d"><code>ebb6498</code></a>
Fix runTools readable stream round trip tool results (<a
href="https://redirect.github.com/openai/openai-node/issues/1986">#1986</a>)</li>
<li><a
href="5984f442e0"><code>5984f44</code></a>
feat: add fromReadableStream to ResponseStream (<a
href="https://redirect.github.com/openai/openai-node/issues/1987">#1987</a>)</li>
<li><a
href="373b08ac3b"><code>373b08a</code></a>
docs: clarify strict Zod function schemas (<a
href="https://redirect.github.com/openai/openai-node/issues/1988">#1988</a>)</li>
<li><a
href="a6f01e53de"><code>a6f01e5</code></a>
feat: pass context to runTools callbacks (<a
href="https://redirect.github.com/openai/openai-node/issues/1973">#1973</a>)</li>
<li><a
href="53e580efb6"><code>53e580e</code></a>
test: serialize Steady-backed Jest runs (<a
href="https://redirect.github.com/openai/openai-node/issues/1976">#1976</a>)</li>
<li><a
href="a86f1fde30"><code>a86f1fd</code></a>
feat: support streaming file uploads (<a
href="https://redirect.github.com/openai/openai-node/issues/1970">#1970</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/openai/openai-node/compare/v6.33.0...v6.47.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `postcss` from 8.5.16 to 8.5.19
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/postcss/postcss/releases">postcss's
releases</a>.</em></p>
<blockquote>
<h2>8.5.19</h2>
<ul>
<li>Fixed cleaning <code>before</code> for new nodes inserted to
<code>Root</code> (by <a
href="https://github.com/MahinAnowar"><code>@​MahinAnowar</code></a>).</li>
</ul>
<h2>8.5.18</h2>
<ul>
<li>Restricted loading previous source maps file to the
<code>opts.from</code> folder for security reasons (use <code>unsafeMap:
true</code> to disable the check).</li>
</ul>
<h2>8.5.17</h2>
<ul>
<li>Fixed <code>Maximum call stack size exceeded</code> error.</li>
<li>Fixed Prototype hijacking for <code>postcss.fromJSON()</code>.</li>
<li>Fixed <code>Input#origin()</code> for unmapped end position (by <a
href="https://github.com/chatman-media"><code>@​chatman-media</code></a>).</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/postcss/postcss/blob/main/CHANGELOG.md">postcss's
changelog</a>.</em></p>
<blockquote>
<h2>8.5.19</h2>
<ul>
<li>Fixed cleaning <code>before</code> for new nodes inserted to
<code>Root</code> (by <a
href="https://github.com/MahinAnowar"><code>@​MahinAnowar</code></a>).</li>
</ul>
<h2>8.5.18</h2>
<ul>
<li>Restricted loading previous source maps file to the
<code>opts.from</code> folder for security reasons (use <code>unsafeMap:
true</code> to disable the check).</li>
</ul>
<h2>8.5.17</h2>
<ul>
<li>Fixed <code>Maximum call stack size exceeded</code> error.</li>
<li>Fixed Prototype hijacking for <code>postcss.fromJSON()</code>.</li>
<li>Fixed <code>Input#origin()</code> for unmapped end position (by <a
href="https://github.com/chatman-media"><code>@​chatman-media</code></a>).</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="9543b22769"><code>9543b22</code></a>
Release 8.5.19 version</li>
<li><a
href="3d13bf9360"><code>3d13bf9</code></a>
Fix CI on Windows too</li>
<li><a
href="00d0dd2322"><code>00d0dd2</code></a>
Keep explicitly set raws.before when inserting nodes into root (<a
href="https://redirect.github.com/postcss/postcss/issues/2111">#2111</a>)</li>
<li><a
href="7a05b33e7a"><code>7a05b33</code></a>
Temporary fix CI</li>
<li><a
href="4c0d194c13"><code>4c0d194</code></a>
Release 8.5.18 version</li>
<li><a
href="92b4e7891e"><code>92b4e78</code></a>
Update dependencies</li>
<li><a
href="95663d3eb7"><code>95663d3</code></a>
Limit where source map can be loaded for security reasons</li>
<li><a
href="74e25ae9f4"><code>74e25ae</code></a>
Release 8.5.17 version</li>
<li><a
href="d1518afd5a"><code>d1518af</code></a>
Fix Maximum call stack size exceeded error</li>
<li><a
href="2421312ffe"><code>2421312</code></a>
Fix linter</li>
<li>Additional commits viewable in <a
href="https://github.com/postcss/postcss/compare/8.5.16...8.5.19">compare
view</a></li>
</ul>
</details>
<br />

Updates `@opencode-ai/plugin` from 1.17.16 to 1.18.2

Updates `@anthropic-ai/sdk` from 0.110.0 to 0.111.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/anthropics/anthropic-sdk-typescript/releases">@​anthropic-ai/sdk's
releases</a>.</em></p>
<blockquote>
<h2>sdk: v0.111.0</h2>
<h2>0.111.0 (2026-07-10)</h2>
<p>Full Changelog: <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.110.0...sdk-v0.111.0">sdk-v0.110.0...sdk-v0.111.0</a></p>
<h3>Features</h3>
<ul>
<li><strong>api:</strong> add support for dreaming (<a
href="77b28a6472">77b28a6</a>)</li>
<li><strong>tools:</strong> gate session tool calls on
evaluated_permission; bound idle by server stop_reason (<a
href="68a6d7b92a">68a6d7b</a>)</li>
</ul>
<h3>Chores</h3>
<ul>
<li><strong>docs:</strong> small updates to field descriptions (<a
href="e25b885eac">e25b885</a>)</li>
<li><strong>docs:</strong> update model example (<a
href="a33f3f0b7c">a33f3f0</a>)</li>
<li><strong>docs:</strong> updates to descriptions and examples (<a
href="eac4bace32">eac4bac</a>)</li>
</ul>
<h2>sdk: v0.110.0</h2>
<h2>0.110.0 (2026-07-02)</h2>
<p>Full Changelog: <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.109.1...sdk-v0.110.0">sdk-v0.109.1...sdk-v0.110.0</a></p>
<h3>Features</h3>
<ul>
<li><strong>api:</strong> add agent-memory-2026-07-22 beta header (<a
href="a470e10aaa">a470e10</a>)</li>
</ul>
<h2>sdk: v0.109.1</h2>
<h2>0.109.1 (2026-07-01)</h2>
<p>Full Changelog: <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.109.0...sdk-v0.109.1">sdk-v0.109.0...sdk-v0.109.1</a></p>
<h3>Chores</h3>
<ul>
<li><strong>api:</strong> remove some nonfunctional types from the SDKs
(<a
href="cc4dd4e257">cc4dd4e</a>)</li>
</ul>
<h2>sdk: v0.109.0</h2>
<h2>0.109.0 (2026-06-30)</h2>
<p>Full Changelog: <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.108.0...sdk-v0.109.0">sdk-v0.108.0...sdk-v0.109.0</a></p>
<h3>Features</h3>
<ul>
<li><strong>api:</strong> add support for Managed Agents event delta
streaming, agent overrides, reverse pagination, vault credential
injection scoping, and agent and deployment webhook events (<a
href="7f3211b488">7f3211b</a>)</li>
</ul>
<h2>sdk: v0.108.0</h2>
<h2>0.108.0 (2026-06-30)</h2>
<p>Full Changelog: <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.107.0...sdk-v0.108.0">sdk-v0.107.0...sdk-v0.108.0</a></p>
<h3>Features</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/anthropics/anthropic-sdk-typescript/blob/main/CHANGELOG.md">@​anthropic-ai/sdk's
changelog</a>.</em></p>
<blockquote>
<h2>0.111.0 (2026-07-10)</h2>
<p>Full Changelog: <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.110.0...sdk-v0.111.0">sdk-v0.110.0...sdk-v0.111.0</a></p>
<h3>Features</h3>
<ul>
<li><strong>api:</strong> add support for dreaming (<a
href="77b28a6472">77b28a6</a>)</li>
<li><strong>tools:</strong> gate session tool calls on
evaluated_permission; bound idle by server stop_reason (<a
href="68a6d7b92a">68a6d7b</a>)</li>
</ul>
<h3>Chores</h3>
<ul>
<li><strong>docs:</strong> small updates to field descriptions (<a
href="e25b885eac">e25b885</a>)</li>
<li><strong>docs:</strong> update model example (<a
href="a33f3f0b7c">a33f3f0</a>)</li>
<li><strong>docs:</strong> updates to descriptions and examples (<a
href="eac4bace32">eac4bac</a>)</li>
</ul>
<h2>0.110.0 (2026-07-02)</h2>
<p>Full Changelog: <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.109.1...sdk-v0.110.0">sdk-v0.109.1...sdk-v0.110.0</a></p>
<h3>Features</h3>
<ul>
<li><strong>api:</strong> add agent-memory-2026-07-22 beta header (<a
href="a470e10aaa">a470e10</a>)</li>
</ul>
<h2>0.109.1 (2026-07-01)</h2>
<p>Full Changelog: <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.109.0...sdk-v0.109.1">sdk-v0.109.0...sdk-v0.109.1</a></p>
<h3>Chores</h3>
<ul>
<li><strong>api:</strong> remove some nonfunctional types from the SDKs
(<a
href="cc4dd4e257">cc4dd4e</a>)</li>
</ul>
<h2>0.109.0 (2026-06-30)</h2>
<p>Full Changelog: <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.108.0...sdk-v0.109.0">sdk-v0.108.0...sdk-v0.109.0</a></p>
<h3>Features</h3>
<ul>
<li><strong>api:</strong> add support for Managed Agents event delta
streaming, agent overrides, reverse pagination, vault credential
injection scoping, and agent and deployment webhook events (<a
href="7f3211b488">7f3211b</a>)</li>
</ul>
<h2>0.108.0 (2026-06-30)</h2>
<p>Full Changelog: <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.107.0...sdk-v0.108.0">sdk-v0.107.0...sdk-v0.108.0</a></p>
<h3>Features</h3>
<ul>
<li><strong>api:</strong> add support for claude-sonnet-5 (<a
href="4588db01ec">4588db0</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="9e46760688"><code>9e46760</code></a>
chore: release main</li>
<li><a
href="8d461ea962"><code>8d461ea</code></a>
feat(api): add support for dreaming</li>
<li><a
href="9436e29159"><code>9436e29</code></a>
codegen metadata</li>
<li><a
href="0aec1e748b"><code>0aec1e7</code></a>
feat(tools): gate session tool calls on evaluated_permission; bound idle
by s...</li>
<li><a
href="ac2fc6779d"><code>ac2fc67</code></a>
chore(docs): update model example</li>
<li><a
href="c8af65d6af"><code>c8af65d</code></a>
chore(docs): updates to descriptions and examples</li>
<li><a
href="2a3a9042ab"><code>2a3a904</code></a>
codegen metadata</li>
<li><a
href="57c56c9172"><code>57c56c9</code></a>
chore(docs): small updates to field descriptions</li>
<li><a
href="4f2eb80719"><code>4f2eb80</code></a>
chore: release main (<a
href="https://redirect.github.com/anthropics/anthropic-sdk-typescript/issues/1107">#1107</a>)</li>
<li><a
href="96d1a991b6"><code>96d1a99</code></a>
chore: release main (<a
href="https://redirect.github.com/anthropics/anthropic-sdk-typescript/issues/1106">#1106</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.106.0...sdk-v0.111.0">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-21 11:21:14 -05:00
dependabot[bot]
3266ed7641
deps: bump the cargo-minor-patch group with 10 updates (#2284)
Bumps the cargo-minor-patch group with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [clap](https://github.com/clap-rs/clap) | `4.6.1` | `4.6.2` |
| [aws-sigv4](https://github.com/smithy-lang/smithy-rs) | `1.4.5` |
`1.5.1` |
| [aws-config](https://github.com/smithy-lang/smithy-rs) | `1.8.18` |
`1.9.0` |
| [regex](https://github.com/rust-lang/regex) | `1.12.4` | `1.13.1` |
| [toml](https://github.com/toml-rs/toml) | `1.1.2+spec-1.1.0` |
`1.1.3+spec-1.1.0` |
| [fastembed](https://github.com/Anush008/fastembed-rs) | `5.17.2` |
`5.17.3` |
| [uuid](https://github.com/uuid-rs/uuid) | `1.23.4` | `1.24.0` |
| [http-body-util](https://github.com/hyperium/http-body) | `0.1.3` |
`0.1.4` |
| [lru](https://github.com/jeromefroe/lru-rs) | `0.18.0` | `0.18.1` |
| [cc](https://github.com/rust-lang/cc-rs) | `1.2.66` | `1.2.67` |

Updates `clap` from 4.6.1 to 4.6.2
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/clap-rs/clap/releases">clap's
releases</a>.</em></p>
<blockquote>
<h2>v4.6.2</h2>
<h2>[4.6.2] - 2026-07-15</h2>
<h3>Fixes</h3>
<ul>
<li><em>(help)</em> Say <code>alias</code> when there is only one</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/clap-rs/clap/blob/master/CHANGELOG.md">clap's
changelog</a>.</em></p>
<blockquote>
<h2>[4.6.2] - 2026-07-15</h2>
<h3>Fixes</h3>
<ul>
<li><em>(help)</em> Say <code>alias</code> when there is only one</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="0fe0be3027"><code>0fe0be3</code></a>
chore: Release</li>
<li><a
href="480af9d045"><code>480af9d</code></a>
docs: Update changelog</li>
<li><a
href="2b3ddd0294"><code>2b3ddd0</code></a>
Merge pull request <a
href="https://redirect.github.com/clap-rs/clap/issues/6340">#6340</a>
from liskin/fix-completion-escape</li>
<li><a
href="7ffe7399ff"><code>7ffe739</code></a>
fix(complete): Do not suggest options after &quot;--&quot;</li>
<li><a
href="d47fc4f8a5"><code>d47fc4f</code></a>
test(complete): Options suggested after escape (<code>--</code>)</li>
<li>See full diff in <a
href="https://github.com/clap-rs/clap/compare/clap_complete-v4.6.1...clap_complete-v4.6.2">compare
view</a></li>
</ul>
</details>
<br />

Updates `aws-sigv4` from 1.4.5 to 1.5.1
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/smithy-lang/smithy-rs/commits">compare
view</a></li>
</ul>
</details>
<br />

Updates `aws-config` from 1.8.18 to 1.9.0
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/smithy-lang/smithy-rs/commits">compare
view</a></li>
</ul>
</details>
<br />

Updates `regex` from 1.12.4 to 1.13.1
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/rust-lang/regex/blob/master/CHANGELOG.md">regex's
changelog</a>.</em></p>
<blockquote>
<h1>1.13.1 (2026-07-15)</h1>
<p>This is a release that fixes a bug where incorrect regex match
offsets could be
reported. Note that this doesn't impact whether a match occurs or not,
just
where it occurs. The match offsets are still valid for slicing, they
just may
not refer to the correct leftmost-first match. See
<a
href="https://redirect.github.com/rust-lang/regex/pull/1364">#1364</a>
for (many) more details.</p>
<p>Bug fixes:</p>
<ul>
<li><a
href="https://redirect.github.com/rust-lang/regex/issues/1354">#1354</a>:
Fixes previously unsound reverse suffix and inner optimizations.</li>
</ul>
<h1>1.13.0 (2026-07-09)</h1>
<p>This release includes a new API, a <code>regex!</code> macro, for
lazy compilation of
a regex from a string literal. If you use regexes a lot, it's likely
you've
already written one exactly like it. The new macro can be used like
this:</p>
<pre lang="rust"><code>use regex::regex;
<p>fn is_match(line: &amp;str) -&gt; bool {<br />
// The regex will be compiled approximately once and reused
automatically.<br />
// This avoids the footgun of using <code>Regex::new</code> here, which
would<br />
// guarantee that it would be compiled every time this routine is
called.<br />
// This would likely make this routine much slower than it needs to
be.<br />
regex!(r&quot;bar|baz&quot;).is_match(line)<br />
}</p>
<p>let hay = &quot;<br />
path/to/foo:54:Blue Harvest<br />
path/to/bar:90:Something, Something, Something, Dark Side<br />
path/to/baz:3:It's a Trap!<br />
&quot;;</p>
<p>let matches = hay.lines().filter(|line| is_match(line)).count();<br
/>
assert_eq!(matches, 2);<br />
</code></pre></p>
<p>Improvements:</p>
<ul>
<li><a
href="https://redirect.github.com/rust-lang/regex/issues/709">#709</a>:
Add a new <code>regex!</code> macro for efficient and automatic reuse of
a compiled regex.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="2b527599eb"><code>2b52759</code></a>
1.13.1, redux</li>
<li><a
href="40e98238ff"><code>40e9823</code></a>
1.13.1</li>
<li><a
href="75fcb962d6"><code>75fcb96</code></a>
changelog: 1.13.1</li>
<li><a
href="64ad0b618e"><code>64ad0b6</code></a>
automata: fix bug in reverse suffix/inner optimization</li>
<li><a
href="fa91c31a42"><code>fa91c31</code></a>
automata: fix a bug caught by Codex review</li>
<li><a
href="30390ec3e8"><code>30390ec</code></a>
automata: formatting tweaks</li>
<li><a
href="821a8eb1ad"><code>821a8eb</code></a>
automata: refactor reverse suffix/inner search slightly</li>
<li><a
href="10afd704d8"><code>10afd70</code></a>
automata: expose the extracted literals for inner literal
extraction</li>
<li><a
href="8c34f41d3c"><code>8c34f41</code></a>
automata: avoid reverse suffix optimization for non-leftmost-first</li>
<li><a
href="5524f02430"><code>5524f02</code></a>
test: add regression tests for failed reverse suffix/inner
optimizations</li>
<li>Additional commits viewable in <a
href="https://github.com/rust-lang/regex/compare/1.12.4...1.13.1">compare
view</a></li>
</ul>
</details>
<br />

Updates `toml` from 1.1.2+spec-1.1.0 to 1.1.3+spec-1.1.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="eb251609a3"><code>eb25160</code></a>
chore: Release</li>
<li><a
href="f36fb52c37"><code>f36fb52</code></a>
docs: Update changelog</li>
<li><a
href="3adbbb7860"><code>3adbbb7</code></a>
fix(writer): Don't overflow (<a
href="https://redirect.github.com/toml-rs/toml/issues/1189">#1189</a>)</li>
<li><a
href="fb0c1b376d"><code>fb0c1b3</code></a>
fix(writer): Don't overflow</li>
<li><a
href="5e70a7031d"><code>5e70a70</code></a>
test(writer): Add overflow test</li>
<li><a
href="771a975840"><code>771a975</code></a>
chore: Upgrade toml-test (<a
href="https://redirect.github.com/toml-rs/toml/issues/1186">#1186</a>)</li>
<li><a
href="28f6c9cbc8"><code>28f6c9c</code></a>
chore: Upgrade toml-test</li>
<li><a
href="30d75ca443"><code>30d75ca</code></a>
chore(deps): Update Prek to v0.4.9 (<a
href="https://redirect.github.com/toml-rs/toml/issues/1185">#1185</a>)</li>
<li><a
href="17efe57deb"><code>17efe57</code></a>
chore(deps): Update Rust Stable to v1.97 (<a
href="https://redirect.github.com/toml-rs/toml/issues/1184">#1184</a>)</li>
<li><a
href="c9d0d54b21"><code>c9d0d54</code></a>
style: Make clippy happy</li>
<li>Additional commits viewable in <a
href="https://github.com/toml-rs/toml/compare/toml-v1.1.2...toml-v1.1.3">compare
view</a></li>
</ul>
</details>
<br />

Updates `fastembed` from 5.17.2 to 5.17.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/Anush008/fastembed-rs/releases">fastembed's
releases</a>.</em></p>
<blockquote>
<h2>v5.17.3</h2>
<h2><a
href="https://github.com/Anush008/fastembed-rs/compare/v5.17.2...v5.17.3">5.17.3</a>
(2026-07-15)</h2>
<h2>What's Changed</h2>
<ul>
<li>chore(deps): update candle-core requirement from 0.10.2 to 0.11.0 by
<a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/Anush008/fastembed-rs/pull/270">Anush008/fastembed-rs#270</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/Anush008/fastembed-rs/compare/v5.17.2...v5.17.3">https://github.com/Anush008/fastembed-rs/compare/v5.17.2...v5.17.3</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="4e9dc551ba"><code>4e9dc55</code></a>
chore(release): 5.17.3 [skip ci]</li>
<li><a
href="3fd10f984f"><code>3fd10f9</code></a>
chore(deps): update candle-core requirement from 0.10.2 to 0.11.0 (<a
href="https://redirect.github.com/Anush008/fastembed-rs/issues/270">#270</a>)</li>
<li>See full diff in <a
href="https://github.com/Anush008/fastembed-rs/compare/v5.17.2...v5.17.3">compare
view</a></li>
</ul>
</details>
<br />

Updates `uuid` from 1.23.4 to 1.24.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/uuid-rs/uuid/releases">uuid's
releases</a>.</em></p>
<blockquote>
<h2>v1.24.0</h2>
<h2>What's Changed</h2>
<ul>
<li>feat(fmt): support encoding into MaybeUninit buffers by <a
href="https://github.com/weifanglab"><code>@​weifanglab</code></a> in <a
href="https://redirect.github.com/uuid-rs/uuid/pull/892">uuid-rs/uuid#892</a></li>
<li>Prepare for 1.24.0 release by <a
href="https://github.com/KodrAus"><code>@​KodrAus</code></a> in <a
href="https://redirect.github.com/uuid-rs/uuid/pull/896">uuid-rs/uuid#896</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/weifanglab"><code>@​weifanglab</code></a> made
their first contribution in <a
href="https://redirect.github.com/uuid-rs/uuid/pull/892">uuid-rs/uuid#892</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/uuid-rs/uuid/compare/v1.23.5...v1.24.0">https://github.com/uuid-rs/uuid/compare/v1.23.5...v1.24.0</a></p>
<h2>v1.23.5</h2>
<h2>What's Changed</h2>
<ul>
<li>doc: Fix broken link by <a
href="https://github.com/frostyplanet"><code>@​frostyplanet</code></a>
in <a
href="https://redirect.github.com/uuid-rs/uuid/pull/891">uuid-rs/uuid#891</a></li>
<li>perf: Optimize UUID hex parsing and formatting by <a
href="https://github.com/geeknoid"><code>@​geeknoid</code></a> in <a
href="https://redirect.github.com/uuid-rs/uuid/pull/894">uuid-rs/uuid#894</a></li>
<li>Prepare for 1.23.5 release by <a
href="https://github.com/KodrAus"><code>@​KodrAus</code></a> in <a
href="https://redirect.github.com/uuid-rs/uuid/pull/895">uuid-rs/uuid#895</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/geeknoid"><code>@​geeknoid</code></a>
made their first contribution in <a
href="https://redirect.github.com/uuid-rs/uuid/pull/894">uuid-rs/uuid#894</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/uuid-rs/uuid/compare/v1.23.4...v1.23.5">https://github.com/uuid-rs/uuid/compare/v1.23.4...v1.23.5</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="6a8aeab3d0"><code>6a8aeab</code></a>
Merge pull request <a
href="https://redirect.github.com/uuid-rs/uuid/issues/896">#896</a> from
uuid-rs/cargo/v1.24.0</li>
<li><a
href="e6db8ec087"><code>e6db8ec</code></a>
prepare for 1.24.0 release</li>
<li><a
href="606f2365c7"><code>606f236</code></a>
Merge pull request <a
href="https://redirect.github.com/uuid-rs/uuid/issues/892">#892</a> from
weifanglab/main</li>
<li><a
href="ab848dbdf6"><code>ab848db</code></a>
feat(fmt): support encoding into MaybeUninit buffers</li>
<li><a
href="5dc6b3d1a9"><code>5dc6b3d</code></a>
Merge pull request <a
href="https://redirect.github.com/uuid-rs/uuid/issues/895">#895</a> from
uuid-rs/cargo/v1.23.5</li>
<li><a
href="5a7dfe50e2"><code>5a7dfe5</code></a>
prepare for 1.23.5 release</li>
<li><a
href="9b4bfc8fe3"><code>9b4bfc8</code></a>
Merge pull request <a
href="https://redirect.github.com/uuid-rs/uuid/issues/894">#894</a> from
geeknoid/main</li>
<li><a
href="5acc5a550e"><code>5acc5a5</code></a>
perf: Optimize UUID hex parsing and formatting</li>
<li><a
href="6fa1a1e38a"><code>6fa1a1e</code></a>
feat(fmt): support encoding into MaybeUninit buffers</li>
<li><a
href="1e5d867954"><code>1e5d867</code></a>
Merge pull request <a
href="https://redirect.github.com/uuid-rs/uuid/issues/891">#891</a> from
frostyplanet/doc</li>
<li>Additional commits viewable in <a
href="https://github.com/uuid-rs/uuid/compare/v1.23.4...v1.24.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `http-body-util` from 0.1.3 to 0.1.4
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/hyperium/http-body/releases">http-body-util's
releases</a>.</em></p>
<blockquote>
<h2>http-body-util-v0.1.4</h2>
<h2>What's Changed</h2>
<ul>
<li>Add <code>Fused</code> body combinator that always returns
<code>None</code> once completed.</li>
<li>Add <code>BodyExt::into_stream()</code> to convert a body into a
<code>Stream</code>.</li>
<li>Add <code>Full::into_inner()</code> to get the full
<code>Buf</code>.</li>
<li>Add <code>InspectFrame</code> and <code>InspectErr</code>
combinators.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="768504721c"><code>7685047</code></a>
http-body-util v0.1.4</li>
<li><a
href="3396328602"><code>3396328</code></a>
http-body v1.1.0</li>
<li><a
href="2fb78de9c8"><code>2fb78de</code></a>
chore: bump license year (<a
href="https://redirect.github.com/hyperium/http-body/issues/170">#170</a>)</li>
<li><a
href="b16554b604"><code>b16554b</code></a>
chore(ci): bump checkout to v7</li>
<li><a
href="c0c53caee7"><code>c0c53ca</code></a>
chore(ci): use msrv aware update for msrv job</li>
<li><a
href="5ed15d2c3d"><code>5ed15d2</code></a>
tests: fix clippy::double_parens</li>
<li><a
href="c8cb37f9ce"><code>c8cb37f</code></a>
Derive <code>Copy</code> trait to <code>SizeHint</code> struct (<a
href="https://redirect.github.com/hyperium/http-body/issues/164">#164</a>)</li>
<li><a
href="915d6d5cbb"><code>915d6d5</code></a>
feat(util): add <code>InspectErr</code>, <code>InspectFrame</code>
combinators (<a
href="https://redirect.github.com/hyperium/http-body/issues/161">#161</a>)</li>
<li><a
href="0fc0a9415c"><code>0fc0a94</code></a>
docs: fix broken intradoc links (<a
href="https://redirect.github.com/hyperium/http-body/issues/162">#162</a>)</li>
<li><a
href="5a849d49dc"><code>5a849d4</code></a>
chore: add FUNDING.yml</li>
<li>Additional commits viewable in <a
href="https://github.com/hyperium/http-body/compare/http-body-util-v0.1.3...http-body-util-v0.1.4">compare
view</a></li>
</ul>
</details>
<br />

Updates `lru` from 0.18.0 to 0.18.1
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/jeromefroe/lru-rs/blob/master/CHANGELOG.md">lru's
changelog</a>.</em></p>
<blockquote>
<h2><a
href="https://github.com/jeromefroe/lru-rs/tree/0.18.1">v0.18.1</a> -
2026-07-09</h2>
<ul>
<li>Add <code>find_and_promote</code> method.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="c6620d1165"><code>c6620d1</code></a>
Merge pull request <a
href="https://redirect.github.com/jeromefroe/lru-rs/issues/237">#237</a>
from jeromefroe/jerome/prepare-0-18-1-release</li>
<li><a
href="da3c0fc276"><code>da3c0fc</code></a>
Prepare 0.18.1 release</li>
<li><a
href="11662633b9"><code>1166263</code></a>
Merge pull request <a
href="https://redirect.github.com/jeromefroe/lru-rs/issues/236">#236</a>
from pixmaip/find-and-promote</li>
<li><a
href="2daba12567"><code>2daba12</code></a>
Apply suggestions from code review</li>
<li><a
href="2ea00dc674"><code>2ea00dc</code></a>
feat: add find_and_promote API</li>
<li>See full diff in <a
href="https://github.com/jeromefroe/lru-rs/compare/0.18.0...0.18.1">compare
view</a></li>
</ul>
</details>
<br />

Updates `cc` from 1.2.66 to 1.2.67
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/rust-lang/cc-rs/releases">cc's
releases</a>.</em></p>
<blockquote>
<h2>cc-v1.2.67</h2>
<h3>Other</h3>
<ul>
<li>Fix clippy warning (<a
href="https://redirect.github.com/rust-lang/cc-rs/pull/1788">#1788</a>)</li>
<li>Regenerate target info (<a
href="https://redirect.github.com/rust-lang/cc-rs/pull/1785">#1785</a>)</li>
<li>Add support for <code>aarch64-unknown-linux-pauthtest</code> target
(<a
href="https://redirect.github.com/rust-lang/cc-rs/pull/1713">#1713</a>)</li>
<li>Fix nightly compilation error (<a
href="https://redirect.github.com/rust-lang/cc-rs/pull/1783">#1783</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/rust-lang/cc-rs/blob/main/CHANGELOG.md">cc's
changelog</a>.</em></p>
<blockquote>
<h2><a
href="https://github.com/rust-lang/cc-rs/compare/cc-v1.2.66...cc-v1.2.67">1.2.67</a>
- 2026-07-11</h2>
<h3>Other</h3>
<ul>
<li>Fix clippy warning (<a
href="https://redirect.github.com/rust-lang/cc-rs/pull/1788">#1788</a>)</li>
<li>Regenerate target info (<a
href="https://redirect.github.com/rust-lang/cc-rs/pull/1785">#1785</a>)</li>
<li>Add support for <code>aarch64-unknown-linux-pauthtest</code> target
(<a
href="https://redirect.github.com/rust-lang/cc-rs/pull/1713">#1713</a>)</li>
<li>Fix nightly compilation error (<a
href="https://redirect.github.com/rust-lang/cc-rs/pull/1783">#1783</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="fa031a077a"><code>fa031a0</code></a>
chore(cc): release v1.2.67 (<a
href="https://redirect.github.com/rust-lang/cc-rs/issues/1789">#1789</a>)</li>
<li><a
href="842aab16d2"><code>842aab1</code></a>
Bump taiki-e/install-action from 2.81.8 to 2.82.8 (<a
href="https://redirect.github.com/rust-lang/cc-rs/issues/1786">#1786</a>)</li>
<li><a
href="e2f07d0d68"><code>e2f07d0</code></a>
Fix clippy warning (<a
href="https://redirect.github.com/rust-lang/cc-rs/issues/1788">#1788</a>)</li>
<li><a
href="8ced615d2c"><code>8ced615</code></a>
Regenerate target info (<a
href="https://redirect.github.com/rust-lang/cc-rs/issues/1785">#1785</a>)</li>
<li><a
href="2943b52512"><code>2943b52</code></a>
Add missing todo for deprecated API</li>
<li><a
href="43ae1bf3ff"><code>43ae1bf</code></a>
Add support for <code>aarch64-unknown-linux-pauthtest</code> target (<a
href="https://redirect.github.com/rust-lang/cc-rs/issues/1713">#1713</a>)</li>
<li><a
href="a5c584a1fa"><code>a5c584a</code></a>
Fix nightly compilation error (<a
href="https://redirect.github.com/rust-lang/cc-rs/issues/1783">#1783</a>)</li>
<li>See full diff in <a
href="https://github.com/rust-lang/cc-rs/compare/cc-v1.2.66...cc-v1.2.67">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-21 11:20:59 -05:00
Tejas Chopra
1329ed7f1a
feat(proxy): make /v1/compress usable as a gateway/Kong sidecar (#2458)
## Description

Makes the compression-only `POST /v1/compress` endpoint usable as a
**network compression sidecar** behind an API gateway (Kong, LiteLLM,
...), and fixes a latent content-detector hang that silently zeroed
compression on non-Windows hosts.

Motivated by a LiteLLM-sidecar deployment whose team documented five
build-time patches; this ports the ones that belong upstream,
generalized so they cover any aliasing gateway (not just LiteLLM).

Closes #

## Type of Change

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

## Changes Made

- **`lossy_inline` compress mode** (`config.mode="lossy_inline"`, alias
`"lossless_then_lossy"`): lossless byte/data fold first, then Kompress
the folded remainder, with `ccr_inject_marker=False` so every compressor
emits **inline, marker-free** output — no `<<ccr:…>>` markers and no CCR
store write, so the result is safe to forward straight to a provider
with no retrieval round-trip. The mode inherits the deployment's
`enable_kompress`.
- **`HEADROOM_COMPRESS_ALLOW_REMOTE`** opt-in: drops the loopback
dependency on the `/v1/compress` route **only** so an authorized
in-network gateway can reach it. Default is unchanged (loopback-only);
inbound `HEADROOM_PROXY_TOKEN` auth still applies.
- **`HEADROOM_MODEL_ALIAS_MAP`** (gateway-agnostic, fail-soft): one
shared resolver in `pricing/litellm_pricing.py` reduces a
gateway-aliased model name (e.g. `claude-opus`) to a priced
`litellm.model_cost` key, trying the mapped target as-is and with a
`bedrock/` / `vertex_ai/` prefix stripped. `proxy/savings_tracker.py`
now delegates to it, so the live (`/stats`) and persisted
(`/stats-history`) dollar figures price identically.
- **`get_context_limit`**: an operator-configured limit
(`HEADROOM_MODEL_LIMITS` / `~/.headroom/models.json`) now wins
**before** the dynamic LiteLLM lookup, so an aliased name no longer
falls through to the 128K default and skews compression.
- **fix(content_router): first-call detector watchdog on all
platforms.** The native content detector can deadlock on first use
(#575, previously flagged Windows-only). The watchdog was `win32`-only,
so on macOS/Linux a first-use hang was unbounded → `_detect_content`
never returned → the `/v1/compress` executor timeout fired → fail-open →
**`tokens_before=0`, silent zero compression**. Now the native detector
runs under the watchdog on the first call on every platform; once it
returns it is marked verified and the direct fast path is used (zero
steady-state overhead). A hang degrades to pure-Python detection with a
clear warning. `win32` behavior is unchanged.
- Thread `waste_signals` / `pipeline_timing` into the already-present
`/v1/compress` outcome record so the guardrail path populates the
dashboard panels like the forward-proxy paths.

Deliberately **not** ported: the sidecar's LiteLLM-specific `GET
/model/info` HTTP fetch (urllib/ssl/threading/TTL). Kong has no such
endpoint; the static `HEADROOM_MODEL_ALIAS_MAP` covers any gateway with
no network dependency on the pricing path.

## Testing

- [x] Unit tests pass (targeted — see output)
- [x] Linting passes (`ruff check`)
- [x] Type checking passes (`mypy`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ ruff check <changed files>
All checks passed!

$ mypy <changed source files>
Success: no issues found in 6 source files

$ pytest tests/test_gateway_sidecar_ports.py tests/test_proxy_compress_endpoint.py -q
tests/test_gateway_sidecar_ports.py ........                             [ 34%]
tests/test_proxy_compress_endpoint.py ...............                    [100%]
============================= 23 passed in 20.62s ==============================
```

## Real Behavior Proof

- **Environment:** macOS (darwin/arm64), Python 3.12, `.venv`; Kompress
offloaded to a Modal endpoint via `HEADROOM_KOMPRESS_ENDPOINT`.
- **Exact command / steps:** posted typical tool-output payloads to
`POST /v1/compress` (via the FastAPI `TestClient`, loopback) in both
`default` and `lossy_inline` modes; separately reproduced the detector
hang with `faulthandler.dump_traceback_later`.
- **Observed result:**
- Real savings through the endpoint (structural/lossless, Kompress off):
**JSON 150 records 13,982→9,514 (32.0%)**, **logs 314 lines 12,240→9,549
(22.0%)**, **search 200 hits 5,231→3,471 (33.6%)**. `lossy_inline` emits
**zero** CCR markers.
- `faulthandler` pinned the pre-fix hang to
`content_router.py:_detect_content` → native `_rust_detect`. With the
fix, the first call degrades at the 5s watchdog with `"Native content
detector hung … using pure-Python detection"` and compression proceeds
(previously it hung and the endpoint returned `tokens_before=0`).
- Modal Kompress warm latency measured ~0.8s/call; the learned pass
compresses prose further (62→56 words on a sample).
- **Not tested:** full `pytest` suite (ran the two affected test files
only); the native-detector hang was reproduced on a local macOS/arm64
build — the fix's degrade path is verified, but a healthy-native CI
Linux run should confirm the fast (verified) path there.

## Review Readiness

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

## Checklist

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

## Additional Notes

- The five-item context comes from a downstream LiteLLM sidecar's
`PATCHES.md`; item #3 (record an outcome from the guardrail path) was
already upstreamed — this PR only adds the missing
`waste_signals`/`pipeline_timing` threading. Item #2 (observability
read-only exemption when `HEADROOM_PROXY_TOKEN` is set) is not addressed
here.
- All new config is opt-in and fail-soft; with nothing set, behavior is
byte-identical to today.
2026-07-21 00:27:52 -07:00
Ehsan
f4070c44cb
fix(transforms/cross-turn-dedup): don't renumber-fold zero-padded line prefixes (#2369)
## Description

On an HTTP tool-output re-read, `cross_turn_dedup` folds a contiguous
span that
already appeared in an earlier block into a compact pointer, and when
the line
numbers shifted by a constant it carries the offset as a `delta` so the
original
bytes recover as `int(number) + delta`. The module states this renumber
path is
"strictly lossless" for UNPADDED numbers only.

`_LINENO_RE = ^(\d+)(:|\t)(.*)$` does not enforce the "unpadded"
restriction: `\d+`
also matches a LEADING-ZERO prefix. A timestamped log row such as
`08:00:01 ...`
is read as line number `8`, not as data, so a re-read shifted by a
constant (a
later window of the same hourly log) folds under a uniform delta.
Recovery then
renders `str(int("08") + 1)` = `"9"`, not `"09"`: the round-trip is not
byte-exact. This is a lossy (false-positive) fold in a module whose
stated
posture is to prefer false negatives (`CONTRIBUTING.md:129`,
`cross_turn_dedup.py:45-50`).

## Type of Change

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

## Changes Made

- `headroom/transforms/cross_turn_dedup.py`: restrict `_LINENO_RE` to
`[1-9]\d*`
so a leading-zero run stays non-numbered and can fold only on an EXACT
match
(delta 0), never under a lossy renumber. Real `grep -n` / `sed -n` / `rg
-n`
numbers never carry a leading zero, so the intended renumber-fold
feature is
unchanged. Added a comment stating why the character class is
load-bearing.
- `tests/test_cross_turn_dedup.py`: added a delta-aware reconstruction
helper and
three regression tests (the existing `_reconstruct` asserts delta is
absent, so
  it never exercised the numbered path this bug lives on).

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

Three named scenarios, one test each:
1. `test_zero_padded_prefix_not_folded_lossily`: a padded shifted
re-read is left
verbatim (`spans_folded == 0`). This fails on `main` (it folds under a
delta).
2. `test_unpadded_renumber_still_folds_and_recovers_exactly`: an
unpadded `grep -n`
read renumbered by `+5` still folds and reconstructs byte-exact (feature
guard).
3. `test_padded_content_exact_redisplay_still_folds`: the same padded
rows
   re-displayed verbatim still fold with delta 0 (surgical-scope guard).

### 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: 3 new tests on the branch (fixed) ---
3 passed, 14 deselected
--- pytest: revert regex to \d+ (simulate main): the regression test must FAIL ---
1 failed
```

## Real Behavior Proof

- Environment: clean `python:3.12-slim` Docker, `PYTHONPATH` at the
source tree,
  core deps installed by name (tiktoken, pydantic, litellm, click, rich,
  opentelemetry-api, pyyaml, tomlkit), `ruff==0.15.17`, `mypy==1.20.2`.
- Exact command / steps: import the module and print provenance, then
run ruff,
ruff format, mypy on the two changed files, then `pytest` the three new
tests
on the branch, then revert only the regex to `\d+` and re-run the
regression
  test.
- Observed result: module `cross_turn_dedup.py` (sha256
7ff204b65f40617d505895841aa1cfc1ee54bf63ffe6520198f0aa05a850aa8d), regex
now `^([1-9]\d*)(:|\t)(.*)$`; ruff, ruff format, mypy all green; branch
`3 passed`, reverted-regex main `1 failed`. Breakdown:
  - `module: /src/headroom/transforms/cross_turn_dedup.py`
- `sha256:
7ff204b65f40617d505895841aa1cfc1ee54bf63ffe6520198f0aa05a850aa8d`
  - `regex : ^([1-9]\d*)(:|\t)(.*)$`
  - ruff, ruff format, mypy: all green (output above).
- Branch: `3 passed`. Reverted-regex main: the regression test `1
failed`.
- Not tested: the router-level and Rust-backed integration tests in this
file
(`test_apply_*`, `test_dedup_*`) need the compiled `headroom._core`
extension,
which is not built in this lightweight container; they are
`ModuleNotFoundError`
on both `main` and this branch here, so they were not exercised. The
change is a
pure-stdlib regex in a pure-stdlib function; the unit-level
`dedup_blocks` tests
above cover it directly. I also did not measure how often real-world
tool output
  hits the leading-zero shifted shape; the argument is the module's own
  strictly-lossless contract, not observed field frequency.

## 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: no
doc surface)
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`

## Additional Notes

Per `CONTRIBUTING.md` ("Bug or small fix -> Open a PR with repro +
test"), this
goes straight to a PR rather than an issue. One concern only; no
dependency or
generated-file changes.
2026-07-20 17:47:56 -07:00