Commit graph

2630 commits

Author SHA1 Message Date
JD Davis
4210d6e609
refactor(pricing): isolate litellm model resolution (#1936)
## Description
Extracts LiteLLM model-name resolution rules into a pure pricing-domain
module. `litellm_pricing.py` now acts as the adapter that asks LiteLLM
whether candidate keys exist, while `litellm_model_resolution.py` owns
prefix rules, alias rules, lookup candidate ordering, and deterministic
resolution.

Closes #

## Type of Change
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to not work as expected)
- [ ] Documentation update
- [ ] Performance improvement
- [x] Code refactoring (no functional changes)

## Changes Made
- Added `headroom.pricing.litellm_model_resolution` with explicit prefix
rules, alias rules, pricing lookup candidates, and a pure resolver
function.
- Simplified `headroom.pricing.litellm_pricing` to delegate model-name
selection to the pure resolver while keeping its public API and cache
behavior intact.
- Added focused tests for candidate ordering, case-insensitive MiniMax
matching, aliases, and unknown-model fallback.

## 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_pricing_litellm_model_resolution.py tests/test_pricing_litellm.py tests/test_proxy_streaming_resilience.py::TestModelResolutionCaching -q
============================= 22 passed in 2.18s =============================

python -m ruff check headroom/pricing/litellm_model_resolution.py headroom/pricing/litellm_pricing.py tests/test_pricing_litellm_model_resolution.py tests/test_pricing_litellm.py tests/test_proxy_streaming_resilience.py
All checks passed!

python -m mypy headroom/pricing/litellm_model_resolution.py headroom/pricing/litellm_pricing.py
Success: no issues found in 2 source files

python -m compileall -q headroom\pricing\litellm_model_resolution.py headroom\pricing\litellm_pricing.py
# no output; exited 0

git commit -m "refactor(pricing): isolate litellm model resolution"
Sync plugin versions.....................................................Passed
check for merge conflicts................................................Passed
ruff.....................................................................Passed
ruff-format..............................................................Passed
mypy.....................................................................Passed
```

## Real Behavior Proof
- Environment: Windows PowerShell, Python 3.13.13, branch
`jd/pricing-model-resolution` based on `headroomlabs/main`.
- Exact command / steps: Ran pure resolver tests, LiteLLM pricing
adapter tests, model-resolution caching tests, focused ruff, targeted
mypy, compileall, and commit hooks.
- Observed result: Existing pricing behavior and cache behavior passed
while model resolution is now isolated and directly testable.
- Not tested: Full pytest suite and live LiteLLM network or package
update behavior beyond the local installed dependency/fakes.

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

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

## Screenshots (if applicable)
N/A

## Additional Notes
Documentation and CHANGELOG updates are N/A for this internal refactor.
Full pytest was not run; validation is focused on
pricing/model-resolution behavior touched by this slice.
2026-07-10 17:35:31 -05:00
JD Davis
1f3696a3d0
refactor(proxy): isolate body forwarding policy (#1935)
## Description
Extracts the byte-faithful Python forwarder policy out of the broad
proxy helpers module into a dedicated `headroom.proxy.body_forwarding`
domain. The new module owns the outbound body algebra: passthrough
original bytes, canonical JSON bytes for mutated bodies, and explicit
legacy JSON rollback mode.

Closes #

## Type of Change
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to not work as expected)
- [ ] Documentation update
- [ ] Performance improvement
- [x] Code refactoring (no functional changes)

## Changes Made
- Added `headroom.proxy.body_forwarding` with `OutboundBody`,
`OutboundBodySource`, `BodyMutationTracker`, mode resolution, canonical
serialization, and body selection helpers.
- Kept `headroom.proxy.helpers` compatibility exports for existing
callers.
- Updated Python forwarder call sites to import body-forwarding policy
from the dedicated module.
- Added tests for the new value object and compatibility exports.

## 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_proxy_byte_faithful_forwarding.py -q
============================= 40 passed in 3.61s =============================

python -m ruff check headroom/proxy/body_forwarding.py headroom/proxy/helpers.py headroom/proxy/server.py headroom/proxy/handlers/streaming.py headroom/proxy/handlers/openai.py headroom/proxy/handlers/anthropic.py headroom/proxy/handlers/batch.py tests/test_proxy_byte_faithful_forwarding.py
All checks passed!

python -m mypy headroom/proxy/body_forwarding.py
Success: no issues found in 1 source file

python -m compileall -q headroom\proxy\body_forwarding.py headroom\proxy\helpers.py headroom\proxy\server.py headroom\proxy\handlers\streaming.py headroom\proxy\handlers\openai.py headroom\proxy\handlers\anthropic.py headroom\proxy\handlers\batch.py
# no output; exited 0

git commit -m "refactor(proxy): isolate body forwarding policy"
Sync plugin versions.....................................................Passed
check for merge conflicts................................................Passed
ruff.....................................................................Passed
ruff-format..............................................................Passed
mypy.....................................................................Passed
```

## Real Behavior Proof
- Environment: Windows PowerShell, Python 3.13.13, branch
`jd/architecture-slice-2` based on `headroomlabs/main`.
- Exact command / steps: Ran the focused byte-faithful forwarding suite,
focused ruff command, targeted mypy, compileall over touched modules,
and commit hooks.
- Observed result: Forwarding behavior stayed byte-faithful;
compatibility exports remain intact; lint, formatting, and mypy passed.
- Not tested: Full pytest suite, live upstream proxy traffic, and manual
end-to-end clients.

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

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

## Screenshots (if applicable)
N/A

## Additional Notes
Documentation and CHANGELOG updates are N/A for this internal refactor.
The full pytest suite was not run; validation is focused on the
body-forwarding domain and existing byte-faithful forwarding coverage.
2026-07-10 17:27:26 -05:00
Fabien Culpo
d2170b1922
fix(learn): parse fenced JSON even with a prose preamble (#1988)
## Description

`_strip_fenced_json` only stripped a markdown fence when the string
*started with* ```` ``` ````. When the model prefixed prose before the
fence (e.g. `Here is the JSON:\n\n```json ...`) despite being told to
return JSON only, the guard was skipped and `json.loads` ran on the
prose, raising `JSONDecodeError`. The claude-cli streaming path surfaced
this as `returned unparseable output`, and `headroom learn` silently
discarded the LLM analysis, degrading to "No actionable patterns found".
This is the parsing-side cousin of the silent-degradation issue fixed in
#373.

Closes #1989. Related: #373.

## 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/learn/analyzer.py`: rewrote `_strip_fenced_json` to locate
the fenced block wherever it appears, then fall back to the whole text,
then to a first-`{` / last-`}` slice, only re-raising `JSONDecodeError`
if nothing parses as a JSON object. Preserves the prior "first opening /
last closing fence" behaviour and triple-backtick content inside the
payload. Fixes all three call sites (non-streaming CLI, claude-cli
streaming, litellm).
- `tests/test_learn/test_analyzer.py`: added regression cases to
`TestStripFencedJson` for preamble-before-fence, prose around a bare
object, and triple-backticks inside the payload.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`) — scoped to the changed
module (see Additional Notes)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ pytest tests/test_learn/test_analyzer.py -q
........................................................................ [ 86%]
...........                                                              [100%]
83 passed, 1 warning in 2.18s

$ ruff check headroom/learn/analyzer.py tests/test_learn/test_analyzer.py
All checks passed!
$ ruff format --check headroom/learn/analyzer.py tests/test_learn/test_analyzer.py
2 files already formatted

$ mypy --ignore-missing-imports --follow-imports=silent headroom/learn/analyzer.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: macOS, Python 3.13.12, headroom-ai at this branch
(runtime deps from an installed 0.30.0 env).
- Exact command / steps: ran the old vs new `_strip_fenced_json` on the
exact failing model output (a prose preamble followed by a ```json
fence), then applied the fix over an installed 0.30.0 and re-ran the
previously failing `headroom learn --apply`. Input sample: `'The JSON is
my deliverable for this analysis task. Here it
is:\n\n```json\n{"context_file_rules": [], "memory_file_rules":
[]}\n```'`
- Observed result: OLD raised `JSONDecodeError: Expecting value: line 1
column 1 (char 0)`; NEW returned `{'context_file_rules': [],
'memory_file_rules': []}`. The real `headroom learn --apply` run that
had been failing with `returned unparseable output` then completed and
consumed the LLM analysis instead of dropping it. Full transcript:
  ```text
  OLD: JSONDecodeError -> Expecting value: line 1 column 1 (char 0)
  NEW: {'context_file_rules': [], 'memory_file_rules': []}
  ```
- Not tested: full end-to-end `headroom learn --apply` was not re-run
inside CI here (it shells out to a live `claude` CLI); the parser is
exercised deterministically by the added unit tests and the before/after
repro above.

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation (N/A —
updated the function docstring only; no external 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
- [ ] I have updated the CHANGELOG.md if applicable (N/A — no CHANGELOG
entry convention observed for this fix; happy to add if maintainers
prefer)

## Additional Notes

- `mypy` was run against the changed module in isolation
(`--ignore-missing-imports --follow-imports=silent`) rather than the
full project, because I validated in an ad-hoc environment; the change
keeps the existing `-> dict` signature and annotations, so it is
type-neutral.
- Not addressed here (possible follow-up): the failure is swallowed as a
warning in `analyze()`, so users only see "No actionable patterns found"
with no signal the LLM pass produced nothing — the same
silent-degradation class as #373, on the parsing side.
2026-07-10 11:15:36 -07:00
Abhay Singh
5e14b8c0f2
fix(memory/sync): don't clobber memories sharing a first line (#1976)
## Description

`ClaudeCodeAdapter.write_memories`
(`headroom/memory/sync_adapters/claude_code.py`) picks
each memory's file name from the **first line of its content only**:

```python
first_line = content.split("\n")[0][:60].strip()
slug = _sanitize_for_filename(first_line)
filename = f"headroom_{slug}.md"
```

So two *distinct* DB memories whose first lines slugify to the same
value map to the same
file. The existing "already on disk?" guard only skips when the on-disk
content hash equals
this memory's hash — for a genuine collision (same slug, different body)
it falls through and
`target.write_text(...)` overwrites the other memory. Data loss.

It also never converges. The overwritten memory never lands on disk, so
on the next
`sync_export` the adapter reads back the agent's files, doesn't find
that memory's hash in
`agent_hashes`, and re-exports it — overwriting the other one this time.
The pair ping-pongs
on every sync, and each round appends a fresh line to `MEMORY.md`.

This is realistic for headed/structured memories (e.g. several entries
that begin
`# Project conventions` or `The user prefers …`).

Closes: no issue filed — found while auditing the memory sync adapters.

## Fix

When the slug is already taken by a **different** memory (a distinct
`headroom_id` in the
existing file's frontmatter), disambiguate the file name with a short
content-hash suffix so
both survive. A matching `headroom_id` means it's an update of the same
memory, so the plain
slug file is rewritten as before — existing file names don't change, so
there's no migration
churn for the common (no-collision) case:

```python
existing_id = existing_fm.get("headroom_id", "")
if headroom_id and existing_id and existing_id != headroom_id:
    suffix = (content_hash or hashlib.sha256(content.encode()).hexdigest()[:16])[:8]
    filename = f"headroom_{slug}_{suffix}.md"
    ...
```

## Type of Change

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

## Changes Made

- `headroom/memory/sync_adapters/claude_code.py`: in `write_memories`,
disambiguate the file name with a content-hash suffix when the slug
already belongs to a different `headroom_id`; same-id updates still
rewrite the slug file in place.
- `tests/test_memory_sync.py`: add
`test_write_distinct_memories_sharing_first_line_do_not_clobber` (two
files survive) and `test_write_same_memory_updates_in_place` (no
duplicate on update).

## Testing

- [x] New regression tests added (`tests/test_memory_sync.py`)
- [x] Linting passes (`ruff check`) and formatting is clean (`ruff
format --check`)
- [ ] Full `pytest` deferred to CI (local-OOM reason below).

```text
$ uv run ruff check headroom/memory/sync_adapters/claude_code.py tests/test_memory_sync.py
All checks passed!
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.10, headroom from this branch.
Importing `headroom` pulls in the torch/transformers stack and a full
`pytest` gets OOM-killed on this box, so I verified the write logic with
a dependency-free script (replicating `_sanitize_for_filename` /
`_parse_frontmatter` / the write loop against a real temp dir) and left
the full pytest to CI.
- Exact command / steps: wrote two memories that share the first line `#
Project conventions` but differ in body (distinct `headroom_id`),
through both the old and new logic, then wrote a same-id update.
- Observed result: the old logic reports `written=2` but leaves **one**
file (the first memory's body is gone); the new logic keeps both, and a
same-id update rewrites in place instead of duplicating:

```text
OLD: written=2 files=1 tabs=False fridays=True
NEW: written=2 files=2 tabs=True fridays=True
UPDATE: files=1 second=True
MEMORY COLLISION FIX VERIFIED
```

- Not tested: a full `sync_export`/`sync_import` round-trip through the
DB backend (needs the heavy stack). The fix is confined to the
file-naming decision in `write_memories`, and the new tests drive that
method directly. Full local `pytest` deferred to CI (OOM, per above).

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes — ran
lint + a standalone logic check; full pytest deferred to CI (local OOM,
disclosed above)
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

- No new dependencies; a small, migration-safe naming guard plus tests.
- @JerrettDavis tagging you — this one is a quiet data-loss path in the
Claude memory sync (a collision drops one memory and then thrashes on
every sync), so it may be worth a look when you get a chance.
2026-07-10 11:47:31 -04:00
Abhay Singh
4cb33cd9e3
fix(proxy): strip inbound Content-Encoding on messages/chat forward (#1970)
## Description

The Anthropic `/v1/messages` and OpenAI `/v1/chat/completions` handlers
decode the
inbound request body before forwarding it upstream.
`read_request_json_with_bytes`
(helpers.py) inflates `zstd`/`gzip`/`deflate`/`br` bodies, and the
handler forwards
the resulting plain JSON. But both handlers build the upstream-bound
header dict and
pop only `host`, `content-length`, and `accept-encoding` — they leave
the original
`Content-Encoding` header in place:

```python
headers = dict(request.headers.items())
headers.pop("host", None)
headers.pop("content-length", None)
headers.pop("accept-encoding", None)   # content-encoding NOT popped
```

So when a client — or an edge proxy like a Cloudflare Worker — sends a
request with
`Content-Encoding: gzip` (or `zstd`/`br`/`deflate`) and a compressed
body, Headroom
decompresses it, then forwards plain JSON that still advertises
`content-encoding: gzip`.
The upstream provider tries to gunzip already-decoded JSON and rejects
the request with
HTTP 400. Every such request fails.

This is a known class of bug: the `/v1/responses` handler already fixes
exactly this at
`openai.py` with the comment *"Leaving a stale content-encoding header
makes the upstream
try to decompress already-decoded JSON and reject it with HTTP 400
(#1542)."* That fix
landed only on the `/responses` path — the messages and chat paths were
missed.

Closes: no issue filed — found while auditing request-header forwarding
across the handlers.

## Fix

Pop `content-encoding` and `transfer-encoding` in both handlers, right
after the existing
`content-length` pop, mirroring the `/v1/responses` handler:

```python
headers.pop("content-encoding", None)
headers.pop("transfer-encoding", None)
```

## Type of Change

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

## Changes Made

- `headroom/proxy/handlers/anthropic.py`: strip
`content-encoding`/`transfer-encoding` from the upstream-bound request
headers in `handle_anthropic_messages`.
- `headroom/proxy/handlers/openai.py`: same strip in the
`/v1/chat/completions` handler.
- `tests/test_proxy_compression_headers.py`: add
`TestRequestContentEncodingStripping` covering gzip/zstd/deflate/br,
`transfer-encoding`, and the absent-header (plain curl) case.

## Testing

- [x] New regression tests added
(`tests/test_proxy_compression_headers.py`)
- [x] Linting passes (`ruff check`) and formatting is clean (`ruff
format --check`)
- [ ] Full `pytest` deferred to CI (local-OOM reason below).

```text
$ uv run ruff check headroom/proxy/handlers/anthropic.py headroom/proxy/handlers/openai.py tests/test_proxy_compression_headers.py
All checks passed!
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.10, headroom from this branch.
Importing `headroom` pulls in the torch/transformers stack and a full
`pytest` gets OOM-killed on this box, so I verified the header logic
with a dependency-free script (the same pure-dict pattern the existing
tests in this file use) and left the full pytest to CI.
- Exact command / steps: replicated the handler's request-header
stripping (old vs new) in a standalone script and ran a
`gzip`/`zstd`/`deflate`/`br` request through both.
- Observed result: the old logic keeps `content-encoding` (which is what
makes the upstream 400); the new logic strips it while preserving
`authorization` and `content-type`:

```text
OK gzip: old leaks 'gzip' -> upstream 400 ; new strips it
OK zstd: old leaks 'zstd' -> upstream 400 ; new strips it
OK deflate: old leaks 'deflate' -> upstream 400 ; new strips it
OK br: old leaks 'br' -> upstream 400 ; new strips it
OK transfer-encoding stripped
OK safe when absent
CONTENT-ENCODING STRIP VERIFIED
```

- Not tested: an end-to-end POST of a real gzip body through a booted
proxy to a live upstream (needs the heavy stack + a provider key). The
header now matches the byte-faithful forwarding the `/responses` path
already does, and the new tests exercise the exact strip logic. Full
local `pytest` deferred to CI (OOM, per above).

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes — ran
lint + a standalone logic check; full pytest deferred to CI (local OOM,
disclosed above)
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

- Small, contained parity fix — two `pop()` calls plus tests, no new
dependencies.
- @JerrettDavis tagging you since you've been triaging the
proxy-forwarding fixes (this is the sibling of the #1542 `/responses`
fix) — should be a quick one if you have a moment.
2026-07-10 11:45:58 -04:00
Tejas Chopra
10e4829201
fix(dedup): shorter fold pointer + MIN_LINES=3, dedup Anthropic list-… (#1932)
…content tool_results, line-number reanchoring

cross_turn_dedup:
- Compact the fold pointer (~35c vs ~100c) and lower MIN_LINES=3 /
MIN_CHARS=40 so the abundant short (2-4 line) re-read repeats pay off
(~4.3% -> ~6% lossless observation-byte reduction on Opus SWE-bench,
measured on 73 real trajectories).
- Add uniform-offset line-number reanchoring: a re-read whose line
numbers all shifted by a constant after an edit now still folds.
Matching keys on the number-stripped content; the offset is carried in
the pointer so recovery of the original numbered bytes stays exact
(unpadded grep/sed/rg -n form only). Cache-safe (prefix-monotonic) and
information-preserving (earliest copy kept verbatim). Small real-data
gain, but closes the renumbering blind spot.

content_router:
- Dedup Anthropic list-content tool_result blocks (the single
{"type":"text"} sub-block), which the string-only extraction path
previously skipped entirely, so cross-turn dedup never fired on
Anthropic-native tool results.

## Description

<!-- Briefly explain the change and why it is needed. -->

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)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- 

## Testing

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

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

### Test Output

```text
# Paste relevant command output or artifact links here
```

## Real Behavior Proof

- Environment:
- Exact command / steps:
- Observed result:
- Not tested:

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

Add screenshots to help explain your changes.

## Additional Notes

<!-- Mention any N/A checklist items, tradeoffs, follow-ups, or
maintainer context. -->

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 11:38:08 -04:00
Tejas Chopra
7dbb9c3810
chore: extract agent-evals into standalone headroom-bench repo (#1967)
## Description

`agent-evals` was a self-contained nested project (a coding-agent
accuracy A/B framework: run trusted coding benchmarks WITH vs WITHOUT
Headroom). It has no runtime coupling to the `headroom` wheel and was
never wired into `make ci-precheck`. It has been extracted into its own
repo (`headroom-bench`) so its heavy benchmark deps (swebench,
mini-swe-agent, modal) never touch headroom and it can iterate on its
own cadence.

This PR removes the 33 nested files. Full history is preserved in the
extracted repo.

Closes #

## Type of Change

- [x] Code refactoring (no functional changes)

## Changes Made

- Remove `agent-evals/` (33 files) — extracted to the standalone
`headroom-bench` repo.

## Testing

`agent-evals` was never imported by the headroom package and never part
of `make ci-precheck`, so headroom's build/lint/type/test surface is
unaffected by this pure deletion.

### Test Output

```text
# No headroom code touched. Verification that the removal is self-contained:
$ git grep -Ei 'agent[-_]evals' -- ':!agent-evals/' ':!*.lock'
CHANGELOG.md:270:* **agent-evals:** Phase 0 ...   # historical changelog entry only (kept)
# -> zero code / CI / import references

$ git diff --name-only upstream/main..HEAD | wc -l
33
$ git diff --name-only upstream/main..HEAD | grep -vc '^agent-evals/'
0    # every changed file is under agent-evals/
```

## Real Behavior Proof

- Environment: `headroom` @ branch `chore/extract-agent-evals` (1 commit
over `upstream/main`; fork in sync, 0 drift).
- Exact command / steps: `git subtree split --prefix=agent-evals` ->
seeded the new repo `headroom-bench` (history preserved); `git rm -r
agent-evals` here.
- Observed result: 33-file deletion, all under `agent-evals/`; no
dangling references in code, `Makefile`, or `.github/workflows/`. The
extracted repo is intact and its suite passes (78 passed, 3 skipped).
- Not tested: nothing runtime in headroom changes (agent-evals was never
imported by the wheel).

## 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] New and existing unit tests pass locally with my changes

## Screenshots (if applicable)

n/a
2026-07-10 06:44:29 -07:00
JD Davis
88e41b65a1
Extract request log redaction policy (#1968)
## Description

Extracts the pure image-base64 request-log redaction decision/transform
logic from `request_logger.py` into a dedicated policy module.
`RequestLogger` remains the owner of the Prometheus-facing redaction
counter and existing request_logger constants remain available for
compatibility.

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)
- [ ] Documentation update
- [ ] Performance improvement
- [x] Code refactoring (no functional changes)

## Changes Made

- Added `headroom.proxy.request_log_redaction_policy` with a pure
`RedactionResult` outcome.
- Kept global redaction metrics/counter side effects in
`request_logger.py`.
- Added direct policy tests for count reporting, nested image paths, and
data URL threshold behavior.
- Carried forward the LiteLLM callback compatibility shim needed for
current mypy on `main`.

## 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_request_log_redaction_policy.py tests\test_image_log_redaction.py
20 passed in 0.31s

python -m ruff check .
All checks passed!

python -m ruff format --check .
1095 files already formatted

python -m mypy headroom --ignore-missing-imports
Success: no issues found in 409 source files

gitleaks protect --staged --no-banner --redact
no leaks found
```

## Real Behavior Proof

- Environment: Windows, Python 3.13.13, branch
`jd/architecture-slice-23`.
- Exact command / steps: ran targeted request-log redaction tests, ruff,
ruff format check, mypy, and staged gitleaks scan.
- Observed result: redaction behavior remains covered through existing
logger tests and new pure policy tests; local lint/type/security checks
pass.
- Not tested: full proxy runtime; this slice only moves pure redaction
policy and keeps the logger entry point intact.

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A

## Additional Notes

Documentation and changelog updates are N/A for this internal
architecture-only refactor. The push reported existing default-branch
Dependabot alerts; no staged secret leaks were found for this PR.
2026-07-10 06:43:04 -07:00
JD Davis
1d2b76e72e
fix: harden persistent install startup (#1851)
## Description

Hardens persistent install startup and proxy compression behavior for
issue #1843. Repeated `headroom install start` / scheduled ensure calls
no longer spawn duplicate runtimes by default, and `/v1/compress` now
fails open on compression timeout instead of returning a 503. The PR
also adds a machine-readable platform feature matrix and app-level
stabilization tests for health, compression functionality, timeout
behavior, and matrix evidence.

Refs #1843

## 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
- [x] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Wrapped direct persistent deployment starts with the existing
profile-local runtime start lock.
- Made `headroom install start` idempotent when the deployment is
already healthy.
- Added wedged-runtime handling: if a PID is running but `/readyz` does
not recover inside the grace window, stop it before starting again.
- Kept `install agent ensure` inside the already-held lock while
delegating to the shared start helper.
- Changed `/v1/compress` timeout behavior from `503 compression_timeout`
to fail-open `200` with original messages, `compression_skipped: true`,
and `skip_reason: compression_timeout`.
- Added `tests/test_platform_stabilization_functional.py` covering real
FastAPI health/compression routes, successful compression metrics,
timeout fail-open speed, and a real JSON tool payload that reduces
tokens.
- Added `docs/platform-feature-matrix.json` and
`docs/platform-stabilization.md` for Linux/macOS/Windows hardening
coverage and known gaps.
- Strengthened matrix tests so cited local test/workflow paths must
exist.

## 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
# Clean source tree without copied Rust extension: functional module is skipped locally, as CI copies _core from the built wheel.
> python -m pytest tests/test_install tests/test_cli/test_install_cli.py tests/test_platform_feature_matrix.py tests/test_platform_stabilization_functional.py -q
collected 120 items / 1 skipped
119 passed, 2 skipped in 17.08s

> python -m ruff check headroom/proxy/handlers/openai.py tests/test_platform_stabilization_functional.py tests/test_platform_feature_matrix.py
All checks passed!

# Local Windows compiled-core proof:
> python -m maturin build --profile ci --out dist-local
Built wheel for abi3 Python >= 3.10 to dist-local\headroom_ai-0.29.0-cp310-abi3-win_amd64.whl

# Copied _core.pyd from the wheel into headroom/ for local route execution, then:
> python -m pytest tests/test_platform_stabilization_functional.py -q
collected 4 items
4 passed in 6.71s

> python -m pytest tests/test_install tests/test_cli/test_install_cli.py tests/test_platform_feature_matrix.py -q
collected 120 items
119 passed, 1 skipped in 17.16s

Commit hooks:
Sync plugin versions.....................................................Passed
check for merge conflicts................................................Passed
ruff.....................................................................Passed
ruff-format..............................................................Passed
mypy.....................................................................Passed
```

## Real Behavior Proof

- Environment: Windows 11, PowerShell, Python 3.13.13, worktree
`C:\git\headroom-stabilization` on branch
`jd/cross-platform-stabilization`.
- Exact command / steps: built the Windows wheel with `maturin`,
extracted `_core.pyd`, ran the new FastAPI route tests and
install/matrix tests listed above, then removed generated artifacts
before committing.
- Observed result: direct start paths now no-op when healthy, skip
spawning when the start lock is contended, and stop a wedged runtime
before restart. `/v1/compress` now returns original messages quickly on
timeout instead of a 503. The real JSON tool-payload smoke test returns
`tokens_before > tokens_after`, `tokens_saved > 0`, `compression_ratio <
1.0`, and non-empty transforms through the public route.
- Not tested: full native Windows persistent process e2e remains blocked
by the upstream CRT/wheel issue already documented in workflows and in
the matrix. No real OS service was installed locally; service manager
behavior is covered by argument-level unit tests.

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A

## Additional Notes

CHANGELOG is not updated because this is an unreleased
hardening/test/documentation pass. The platform matrix intentionally
records partial/blocked Windows/macOS e2e gaps instead of claiming full
coverage where the repo cannot currently run it.
2026-07-10 00:40:34 -04:00
OrbisAI Security
28ca61fc9d
fix: patch nltk vulnerability (CVE-2026-54293) (#1929)
## Description

Updates the locked `nltk` package from 3.9.4 to 3.10.0 to address
CVE-2026-54293, reported by OrbisAI Security as an information
disclosure/path traversal issue in `nltk.data.load()`.

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

- Updated the `nltk` lockfile entry from 3.9.4 to 3.10.0.
- Added the new locked `defusedxml` dependency required by `nltk`
3.10.0.
- Added an explicit `nltk>=3.10.0` uv constraint so future lock
refreshes cannot regress below the fixed version.
- Updated the benchmark-extra comment now that the nltk CVE has an
upstream fixed release.

## Testing

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

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

### Test Output

```text
uv lock --locked
Resolved 257 packages in 1ms

uv run --extra benchmark python -c "import importlib.metadata as md; print('lm-eval', md.version('lm-eval')); print('rouge-score', md.version('rouge-score')); print('nltk', md.version('nltk'))"
lm-eval 0.4.10
rouge-score 0.1.2
nltk 3.10.0
```

## Real Behavior Proof

- Environment: GitHub pull request diff for
headroomlabs-ai/headroom#1929.
- Exact command / steps: Reviewed the PR diff and ran the focused uv
lock/import checks listed above.
- Observed result: The lockfile now points at nltk 3.10.0 artifacts,
includes the new defusedxml dependency, and records the nltk>=3.10.0
resolver constraint.
- Not tested: Full local test suite was not run for this lockfile-only
security update.

## 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
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A.

## Additional Notes

Original automated security context from OrbisAI Security:

- CVE: CVE-2026-54293
- Severity: HIGH
- Scanner: trivy
- Rule: `CVE-2026-54293`
- File: `uv.lock`
- Assessment: Likely exploitable
- Description: nltk information disclosure via path traversal in
`nltk.data.load()`.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-09 21:20:13 -07:00
panamarob30-jpg
abc557a5dc
[codex] Document local LLM prefill benchmarking (#1396)
## Summary
- add a Local LLM Prefill Benchmark docs page for baseline-vs-optimized
proxy testing
- document the `--no-optimize` baseline, optimized rerun, dashboard
comparison, and optional `--learn` condition
- link the workflow from the proxy and benchmarks docs

## Context
This captures the local-inference workflow shown in Joe Maddalone's June
2026 Headroom demo: Headroom can improve local model prompt-processing
time by sending fewer prompt tokens, even when token cost is not the
main concern.

## Validation
- `npm --prefix docs run types:check`
- `npm --prefix docs run build`

## Notes
- This PR is independent from #1395, which covers Codex audit/maturation
evidence.

Co-authored-by: Robert Briscoe <robert@briscoe.dev>
2026-07-09 21:47:59 -05:00
Matt Van Horn
d05802b620
fix: emit unknown Anthropic content blocks verbatim in buffered-to-SSE conversion (#1825)
## Description

Unknown Anthropic content block types are now emitted verbatim inside
`content_block_start` during buffered-to-SSE conversion instead of
raising `ValueError`. The block-start loop in
`StreamingMixin._response_to_sse`
(`headroom/proxy/handlers/streaming.py`) previously handled only `text`,
`tool_use`, `thinking`, and `redacted_thinking`; any other type fell
through to a hard raise, which turned a fully-generated upstream
response into an HTTP 502.

Closes #1806

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

- Emit unknown content block types, including `server_tool_use`,
`server_tool_result`, `mcp_tool_use`, and future Anthropic block types,
verbatim in `content_block_start` with no delta.
- Preserve main's explicit `server_tool_use` support and newer buffered
CCR/thinking regression coverage after merging current main.
- Keep `content_block_delta` generation gated on known delta-capable
block types, so unknown blocks do not produce spurious deltas.

## Testing

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

### Test Output

```text
uv run --with pytest python -m pytest tests/test_sse_thinking_blocks.py -q
12 passed, 1 warning

uv run --with ruff==0.15.17 ruff check headroom/proxy/handlers/streaming.py tests/test_sse_thinking_blocks.py
All checks passed!
```

## Real Behavior Proof

- Environment: Windows worktree `C:\git\headroom-governance-main`, PR
head `45934b94`.
- Exact command / steps: Merged current `headroomlabs/main`, then ran
the targeted SSE pytest command and Ruff check shown above.
- Observed result: Targeted SSE tests passed with 12 tests, and
unknown/server_tool_use content blocks round-trip verbatim in
`content_block_start`; before this change the same input raised
`ValueError: Unsupported Anthropic content block type for SSE
conversion: 'server_tool_use'` after the full generation had already
been buffered, surfacing to the client as a 502 and a full multi-minute
retry.
- Not tested: End-to-end against a live upstream that emits server-side
tool blocks.

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A.

## Additional Notes

The prior `test_response_to_sse_rejects_unknown_content_block` is
replaced by `test_response_to_sse_emits_unknown_content_block_verbatim`;
current main's newer buffered CCR/thinking tests are preserved after the
merge from main.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-09 21:45:40 -05:00
Rod Boev
d2a86b5909
fix(proxy): strip duplicated upstream server headers (#1828)
## Description

Fixes duplicated upstream server headers emitted by the proxy when
forwarding responses.

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

- Adjust proxy response forwarding so upstream server headers are not
duplicated.
- Preserve the intended response-header behavior while avoiding repeated
header values.
- Keep the change scoped to proxy/header handling.

## Testing

- [x] Unit tests pass
- [x] New tests added for new functionality when applicable
- [x] Manual testing performed

### Test Output

```text
Focused tests/review were completed before this governance body cleanup. The current branch is conflicted and still needs merge resolution before final merge readiness.
```

## Real Behavior Proof

- Environment: Headroom development/review context.
- Exact command / steps: Reviewed the proxy response-header behavior and
existing focused coverage for duplicate upstream server headers.
- Observed result: The PR implementation prevents duplicated upstream
server headers while preserving proxy forwarding behavior.
- Not tested: Current conflicted branch after merge resolution;
conflicts still need to be resolved before merge.

## Review Readiness

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

## Additional Notes

This body was normalized by a maintainer after approval so the
governance parser reflects the already-reviewed PR state. The PR remains
blocked by merge conflicts.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-09 21:43:17 -05:00
JD Davis
98ff203f98
ci: allow PyPI deps during CPU torch install (#1930)
## Description

Fixes the CI failure exposed on the `8527b910` push run:

- Run:
https://github.com/headroomlabs-ai/headroom/actions/runs/29063619700
- Failed job: `test (3)` / job `86271743483`
- Failed step: `Install (CPU torch + prebuilt wheel + dev deps, no cargo
rebuild)`

The failing command used the PyTorch CPU index as the only package
index:

```bash
pip install torch --index-url https://download.pytorch.org/whl/cpu
```

That index does not provide transitive dependencies such as
`typing-extensions`, so pip backtracked across torch CPU wheels and
failed before tests ran. This PR keeps the PyTorch CPU index primary for
`torch` while allowing PyPI for dependencies.

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

- Adds `--extra-index-url https://pypi.org/simple` to each CI `pip
install torch --index-url https://download.pytorch.org/whl/cpu` command.
- Leaves the CPU torch index as the primary index so CI still installs
CPU torch wheels.

## 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
git diff --check
# passed

rg -n "pip install torch" .github/workflows/ci.yml
# all four torch install commands now include --extra-index-url https://pypi.org/simple
```

## Real Behavior Proof

- Environment: Local worktree `C:\git\headroom-ci-fix`, branch
`jd/fix-ci-torch-install-index`.
- Exact command / steps: Inspected failed GitHub Actions log for run
`29063619700` job `86271743483`, then updated matching torch install
commands in `.github/workflows/ci.yml`.
- Observed result: The workflow no longer uses the PyTorch CPU index as
the only index for torch installs; PyPI is available for transitive
dependencies such as `typing-extensions`.
- Not tested: Full CI run locally; GitHub Actions is the authoritative
verification for the affected install 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
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A.

## Additional Notes

This is a fix-forward PR. Reverting `8527b910` would reintroduce the
Ruff failure it fixed; the failed job did not reach tests and failed
only during dependency installation.
2026-07-09 19:36:26 -07:00
JerrettDavis
8527b910dc test(litellm): remove unused pytest import 2026-07-09 20:59:12 -05:00
JerrettDavis
2d418335a1 ci: preserve merge labels while state is unknown 2026-07-09 19:51:41 -05:00
JerrettDavis
595b709a5b ci: keep ready label off changes-requested PRs 2026-07-09 19:49:55 -05:00
ahhdammm
1deb947ac1
fix(proxy): hoist ccr_workspace_key default so /v1/messages survives CCR-inject off (#1096)
## Summary

`handle_anthropic_messages` only assigns `ccr_workspace_key` /
`ccr_workspace_label` **inside** the
`if (ccr_inject_tool or ccr_inject_system_instructions) and not
_bypass:` block (around `headroom/proxy/handlers/anthropic.py:1302`),
but references `ccr_workspace_key` **unconditionally** in the
proactive-expansion gate at
`headroom/proxy/handlers/anthropic.py:1394-1397`:

```python
if (
    self.ccr_context_tracker
    and self.config.ccr_proactive_expansion
    and ccr_workspace_key      # <-- unbound when the inject block was skipped
):
```

Running the proxy with `--no-ccr-inject-tool` and the default
`ccr_inject_system_instructions=False` (a real, supported configuration)
skips the assignment. With `ccr_context_tracking=True` and
`ccr_proactive_expansion=True` (both defaulting to `True`), the gate is
reached and raises `UnboundLocalError`, which FastAPI surfaces as HTTP
500 on **every** `/v1/messages` request. The Claude Code SDK retries ~10
times (`type=system/api_retry`) and then emits the upstream error as the
assistant reply (`API Error: 500 Internal Server Error`), which looked
exactly like an Anthropic outage from the agent side.

Fix: hoist `ccr_workspace_key, ccr_workspace_label = None, None` to
before the gated block. The downstream uses already treat a falsy key as
"workspace unresolved" — `track_compression` short-circuits to the
existing `elif self.ccr_context_tracker and not ccr_workspace_key:` log
line, and the proactive-expansion gate stays closed via short-circuit
`and`. Behavior with CCR inject enabled is byte-identical.

The bug appears to have been introduced by #500 (workspace scoping). I
traced it after my NanoClaw containers started returning `API Error: 500
Internal Server Error` for every scheduled run — `journalctl --user -u
headroom` showed the traceback.

## Reproduction

Failing test in `tests/test_anthropic_ccr_workspace_unbound.py` mirrors
the deployment config:

```python
config = ProxyConfig(
    ccr_inject_tool=False,                  # user passed --no-ccr-inject-tool
    ccr_inject_system_instructions=False,   # default
    ccr_context_tracking=True,              # default — installs the tracker
    ccr_proactive_expansion=True,           # default — reaches the gate
    ...
)
```

Before the fix:
```
headroom/proxy/handlers/anthropic.py:1397: in handle_anthropic_messages
    and ccr_workspace_key
        ^^^^^^^^^^^^^^^^^
E   UnboundLocalError: cannot access local variable 'ccr_workspace_key' where it is not associated with a value
FAILED tests/test_anthropic_ccr_workspace_unbound.py::test_proactive_expansion_does_not_raise_when_ccr_inject_disabled
```

After the fix:
```
tests/test_anthropic_ccr_workspace_unbound.py .                          [100%]
1 passed
```

## Real behavior proof

**Setup tested on:** Ubuntu 24.04 on WSL2 (NUC15CRH), Python 3.12.3,
`headroom-ai==0.25.0` venv at `/home/adam/headroom-env/`, service
started by user-level systemd unit:

```
headroom proxy --host 0.0.0.0 --port 8787 --mode token \
  --no-ccr-inject-tool --no-ccr-marker --no-telemetry --code-aware
```

Provider: Anthropic via direct `CLAUDE_CODE_OAUTH_TOKEN` injection from
the calling container (NanoClaw / Claude Agent SDK on
`claude-opus-4-8`).

**Before the patch** — every request through the proxy 500ed:
```
$ curl -sS -m 5 -o /tmp/r -w "HTTP %{http_code}\n" -X POST http://localhost:8787/v1/messages \
    -H "x-api-key: placeholder" -H "anthropic-version: 2023-06-01" \
    -H "content-type: application/json" \
    -d '{"model":"claude-opus-4-8","max_tokens":10,"messages":[{"role":"user","content":"hi"}]}'
HTTP 500
$ head -c 40 /tmp/r
Internal Server Error

$ journalctl --user -u headroom -n 50 --no-pager | grep -A1 ccr_workspace_key | head
    and ccr_workspace_key
        ^^^^^^^^^^^^^^^^^
UnboundLocalError: cannot access local variable 'ccr_workspace_key' where it is not associated with a value
```

NanoClaw container logs showed the SDK's 10 `system/api_retry` events
then surfacing `API Error: 500 Internal Server Error` as the assistant
result.

**After the patch** (applied in place to the installed file, service
restarted):
```
$ systemctl --user restart headroom
$ TOKEN=$(jq -r .claudeAiOauth.accessToken ~/.claude/.credentials.json)
$ curl -sS -m 30 -o /tmp/r -w "HTTP %{http_code}\n" -X POST http://localhost:8787/v1/messages \
    -H "Authorization: Bearer $TOKEN" -H "anthropic-version: 2023-06-01" \
    -H "content-type: application/json" \
    -d '{"model":"claude-opus-4-8","max_tokens":20,"messages":[{"role":"user","content":"reply with just the word pong"}]}'
HTTP 429
$ cat /tmp/r
{"type":"error","error":{"type":"rate_limit_error","message":"Error"},"request_id":"req_011Cc9PSZHi4QssEKLhZX5uq"}
```

The local 500 is gone — the proxy now forwards cleanly and surfaces
upstream's real response (here a 429 because the retry storm had been
hammering the account for hours; the shape of the response, and the
presence of an `anthropic-request_id`, confirms the proxy is no longer
crashing on its own code path).

Then `journalctl --user -u headroom --since "5 min ago" | grep -iE
'unbound|traceback'` returned no new occurrences after the restart at
12:30 PDT.

**What I did *not* test:**
- The `_bypass=True` path (same fix protects it, but I did not exercise
it end-to-end).
- The CCR-inject-on path — relied on the existing
`tests/test_proxy_anthropic_cache_stability.py` and
`tests/test_proxy_system_prompt_immutable.py` suites passing (they do;
ran `uv run pytest tests/test_proxy_anthropic_cache_stability.py
tests/test_anthropic_pre_upstream_backpressure.py
tests/test_anthropic_stage_timings.py
tests/test_provider_proxy_routes.py
tests/test_proxy_system_prompt_immutable.py` → 68 passed).

## Test plan
- [x] `uv run pytest tests/test_anthropic_ccr_workspace_unbound.py` —
fails on `main`, passes on this branch.
- [x] `uv run pytest tests/test_proxy_anthropic_cache_stability.py
tests/test_anthropic_pre_upstream_backpressure.py
tests/test_anthropic_stage_timings.py
tests/test_provider_proxy_routes.py
tests/test_proxy_system_prompt_immutable.py` — 68 passed.
- [x] `uv run ruff check` / `uv run ruff format --check` on modified
files — clean.
- [x] Live proxy verified against the configuration that reproduced the
bug.

Co-authored-by: Adam Barnum <adamleebarnum@gmail.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-09 17:29:40 -05:00
Sepuri Sai Krishna
772adc93b2
fix(scripts): rename .releaseetadata to .releasemetadata (#1246)
## Description
  
Fixes a typo in the release metadata filename written by
`scripts/version-sync.py`.
The file was being created as `.releaseetadata` (double `e`) instead of
`.releasemetadata`.
Any downstream tooling or developer looking for the artifact by its
correct name would not find it.

  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

- `scripts/version-sync.py`: corrected the filename in
`write_release_metadata()` — both the docstring and the `metadata_path`
assignment.
- `scripts/tests/test_version_sync.py`: updated 3 test assertions to
reference `.releasemetadata`.

  ## Testing

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

  ### Test Output

  ```text   
  $ uv run python -m pytest scripts/tests/test_version_sync.py -q
============================= test session starts
==============================
  platform linux -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
  rootdir: /home/sepurisaikrishna/Documents/calude-here/headroom
  configfile: pyproject.toml
  collected 6 items
  
scripts/tests/test_version_sync.py ...... [100%]

=============================== warnings summary
===============================
  PytestConfigWarning: Unknown config option: asyncio_mode

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 6 passed, 1 warning in 1.00s
=========================

  $ git diff --check origin/main..HEAD
  # no output; command exited 0
 ```

  ## Real Behavior Proof

- Environment: Linux, Python 3.14.0, uv-managed .venv, branch based on
current origin/main.
- Exact command / steps: grep -r "releaseetadata" scripts/ before the
fix returns hits; after the fix returns nothing. Confirmed
.releasemetadata is written correctly by
  test_release_metadata_written.
- Observed result: all 6 test_version_sync.py tests pass with the
corrected filename.
- Not tested: full repository pytest, ruff, and mypy — this is a
one-line spelling fix with no logic changes.

  ## Review Readiness

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

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

  ## Screenshots (if applicable)   

  N/A.

  ## Additional Notes

- The typo was consistent across implementation and tests, so all tests
passed before this fix with the wrong name. The fix corrects both the
code and the test expectations together.
- No production behaviour changes the file is written but not yet
consumed by any workflow step.
2026-07-09 17:29:17 -05:00
Peter Lodri
9be727de68
fix(litellm): inherit CustomLogger so future hooks don't crash proxy (#1114) (#1391)
## Summary

Fixes #1114 — LiteLLM 1.89.x added `async_post_call_success_hook` and
started calling it after every successful completion. `HeadroomCallback`
was a plain `object` subclass with no such method, causing:

```
AttributeError: type object 'HeadroomCallback' has no attribute 'async_post_call_success_hook'
```

This crashed the LiteLLM proxy on every successful API call.

### Root cause

```python
class HeadroomCallback:   # plain object — no-op hooks not inherited
    ...
```

### Fix

Inherit from `litellm.integrations.custom_logger.CustomLogger` which
provides no-op defaults for every hook it defines. Future additions to
`CustomLogger` will be covered automatically.

```python
try:
    from litellm.integrations.custom_logger import CustomLogger as _CustomLogger
except ImportError:
    _CustomLogger = object  # litellm not installed — graceful fallback

class HeadroomCallback(_CustomLogger):
    ...
    def __init__(self, ...):
        super().__init__()
        ...
```

## Type of Change

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

## Changes Made

- `headroom/integrations/litellm_callback.py` — inherit `CustomLogger`;
add `super().__init__()`
- `tests/test_litellm_callback.py` — 7 tests: instantiation,
`async_post_call_success_hook` present + callable + no-op, all current
hooks present, pre-call hook still works

## Real behavior proof

**Setup:** Python 3.13, litellm 1.89.1, headroom-ai 0.27.0-dev  
**Steps after patch:**  
```bash
python3 -c "
from headroom.integrations.litellm_callback import HeadroomCallback
import asyncio
cb = HeadroomCallback()
# Simulate what litellm proxy calls on success
asyncio.run(cb.async_post_call_success_hook(data={}, user_api_key_dict={}, response=None))
print('OK — no AttributeError')
"
```
**After-fix evidence:** Runs without exception. Before fix:
`AttributeError: type object 'HeadroomCallback' has no attribute
'async_post_call_success_hook'`.

**What I did not test:** Live LiteLLM proxy with YAML config (no LiteLLM
proxy running in test env); tested via unit tests and direct Python
instantiation.

## Test Results

```
tests/test_litellm_callback.py  7/7 passed
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-09 17:09:33 -05:00
Rocker Zhang
7836aea2be
fix(proxy): preserve upstream 5xx status on retry exhaustion (#1570)
## What

When the upstream returns a retryable 5xx (529 Overloaded, 503),
`_retry_request` retried up to the cap and then raised, which the caller
collapsed into a generic 502. That hides the retryable signal: clients
see a 502 and give up instead of applying their own overload backoff.

On exhaustion, return the last upstream response (preserving its status
and body) when one is available. Connection and timeout errors still
raise — only an `HTTPStatusError` carrying a real upstream response is
surfaced.

## Why this scope

`_retry_request` is provider-agnostic, so this fix applies uniformly to
all providers (no per-handler change needed). It is purely a
returned-status correctness fix and does not touch request accounting —
a separate change handles counting an exhausted 5xx as a failed request
across all provider handlers.

## Verification

`tests/test_retry_preserve_upstream_status.py`: 529/503 status+body
preservation, 4xx no-retry, connect-error still raises, success
passthrough. Against unpatched main the 503-preservation test fails
(collapses to 502); with the fix all pass.


Addresses #1568.
2026-07-09 17:07:26 -05:00
Rocker Zhang
e365ad7152
fix(proxy): count exhausted upstream 5xx as failed across all providers (#1571)
## What

A companion to the retry-exhaustion change that returns the real
upstream 5xx (e.g. 529 Overloaded) instead of a synthetic 502. Once the
real 5xx is returned, it flows through the success funnel and is
recorded via `record_request`, feeding the savings/cost stats and
inflating the save-rate.

`RequestOutcome` now carries the upstream `status_code` (default 200).
In `emit_request_outcome`, a `status_code >= 500` records a failed
request and returns before the savings/cost/log success path. 4xx stay
on the normal funnel (client errors the proxy still served).

The real status is threaded onto the retry-fed `RequestOutcome` at every
provider site: Anthropic (message, batch, passthrough), OpenAI (chat,
responses, passthrough), Gemini (generateContent, all-non-text path,
countTokens). Sites that cannot carry a 5xx keep the default 200: local
cache hits, backend-routed paths that early-return on error, websocket
units (no HTTP status), and streaming generators that early-return on
`>= 400`.

## Scope notes

- **429**: an exhausted 429 (rate limit) currently stays on the success
funnel since it is < 500. Extending the failed-accounting to
exhausted-429 is a separate follow-up, kept out of this 5xx-scoped
change.
- **Streaming**: streaming responses return before the retry-exhaustion
logic, so they do not receive an exhausted 5xx through this path;
`from_stream` is unchanged.

## Verification

`tests/test_outcome_records_5xx_as_failed.py` exercises the `>= 500`
funnel guard; `tests/test_5xx_accounting_all_providers.py` pins the
per-provider contract for each wired site. Against unpatched main the
guard test fails (no `status_code` field); with the change all pass.


Addresses #1568. Builds on #1570 (preserve-5xx-status): the 503
accounting takes effect once that lands; the 429/529 accounting is
independent.
2026-07-09 17:07:08 -05:00
dependabot[bot]
75fff43eca
deps: bump @types/node from 25.5.2 to 26.1.1 in /docs (#1683)
Bumps
[@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node)
from 25.5.2 to 26.1.1.
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-09 17:02:59 -05:00
dependabot[bot]
e8b66a27e1
deps: bump fumadocs-typescript from 4.0.14 to 5.3.0 in /docs (#1684)
Bumps [fumadocs-typescript](https://github.com/fuma-nama/fumadocs) from
4.0.14 to 5.3.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/fuma-nama/fumadocs/releases">fumadocs-typescript's
releases</a>.</em></p>
<blockquote>
<h2>fumadocs-typescript@5.3.0</h2>
<h3>Default to Base UI</h3>
<p>Internal packages &amp; templates now use Base UI rather than Radix
UI.</p>
<h2>fumadocs-typescript@5.2.7</h2>
<h3>Migrate to <code>cnfast</code></h3>
<p>Drop <code>tailwind-merge</code>.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="9a269030df"><code>9a26903</code></a>
Version Packages</li>
<li><a
href="3e33f4362f"><code>3e33f43</code></a>
perf(satteri): reduce clones</li>
<li><a
href="3597c9d1e6"><code>3597c9d</code></a>
perf(satteri): persist results</li>
<li><a
href="0f389cf3de"><code>0f389cf</code></a>
feat(satteri): decouple imports/exports from <code>compile()</code></li>
<li><a
href="4611f97d49"><code>4611f97</code></a>
feat(satteri): full rehype-toc functionality</li>
<li><a
href="d095300760"><code>d095300</code></a>
fix(satteri): workaround common issues</li>
<li><a
href="0297e25477"><code>0297e25</code></a>
configure pretrust</li>
<li><a
href="02c242b0da"><code>02c242b</code></a>
chore(satteri): clean code</li>
<li><a
href="3d80b8b242"><code>3d80b8b</code></a>
fix(mdx): ensure satteri integration is optional</li>
<li><a
href="0ec19af868"><code>0ec19af</code></a>
feat(satteri): more tests &amp; move remark-include</li>
<li>Additional commits viewable in <a
href="https://github.com/fuma-nama/fumadocs/compare/fumadocs-typescript@4.0.14...fumadocs-typescript@5.3.0">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-09 17:02:44 -05:00
dependabot[bot]
350daeba73
deps: bump @types/node from 22.19.15 to 26.1.1 in /plugins/openclaw (#1685)
Bumps
[@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node)
from 22.19.15 to 26.1.1.
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node">compare
view</a></li>
</ul>
</details>
<br />

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-09 17:02:22 -05:00
dependabot[bot]
87151952ee
deps: bump @types/node from 22.20.0 to 26.1.1 in /plugins/opencode (#1688)
Bumps
[@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node)
from 22.20.0 to 26.1.1.
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node">compare
view</a></li>
</ul>
</details>
<br />

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-09 17:02:04 -05:00
Zhenjia ZHOU
8879c50dbe
fix(adaptive-sizer): char bigrams for spaceless CJK items (#1748)
## Description

`compute_unique_bigram_curve` — the adaptive sizer's coverage-curve
builder, mirrored in Rust and Python — word-splits each item on
whitespace to form word bigrams. A spaceless CJK item has no whitespace,
so it collapsed into one `(whole_string, "")` pseudo-bigram: the
coverage curve then grew ~1 per item, the kneedle knee detector found no
knee, and CJK lists under-compressed.

Spaceless CJK items now use character bigrams, producing a real coverage
curve. Mirrored byte-exactly in Rust and Python (identical
reference-test curve values). Non-CJK items — anything
whitespace-bearing or spaceless-ASCII — are byte-identical to before, so
the `smart_crusher` parity fixtures are unchanged.

## Type of Change

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

## Changes Made

- `crates/headroom-core/src/transforms/adaptive_sizer.rs` +
`headroom/transforms/adaptive_sizer.py`: add
`is_cjk_char`/`_is_cjk_char` (identical code-point ranges) and a
spaceless-CJK character-bigram branch in `compute_unique_bigram_curve`.
- Rust unit tests + `tests/test_adaptive_sizer.py`: CJK curve,
single-char CJK, ASCII-unchanged, empty-item — the Rust and Python
reference values are identical.

## Testing

- [x] Unit tests pass (`cargo test` + `pytest`)
- [x] Linting passes (`cargo clippy` / `cargo fmt` / `ruff` / `mypy`)
- [x] New tests added for new functionality
- [x] Manual testing performed (see Real Behavior Proof)

### Test Output

```text
$ cargo test -p headroom-core --lib adaptive_sizer
test result: ok. 35 passed; 0 failed

$ .venv/bin/python -m pytest tests/test_adaptive_sizer.py
20 passed

$ .venv/bin/python -m pytest -k "smart_crusher and parity"
18 passed, 6 skipped   # non-CJK fixtures unchanged
```

## Real Behavior Proof

- Environment: macOS (Darwin), Rust via cargo, Python in a uv venv,
branch `feat/adaptive-sizer-cjk` off `main`.
- Exact command / steps: called `compute_unique_bigram_curve` on a CJK
list and on ASCII lists, in both implementations.
- Observed result: `compute_unique_bigram_curve(["数据库连接失败", "数据库连接成功"])`
returns `[6, 8]` in **both** Rust and Python (before: ~`[1, 2]` — one
pseudo-bigram per item, no coverage signal). ASCII curves are unchanged:
`["the cat", "the dog", "a fish"]` → `[1, 2, 3]`. The `smart_crusher`
parity suite (all-ASCII fixtures) stays green, confirming non-CJK output
is byte-identical.
- Byte-exact parity: the Rust reference test (`vec![6, 8]`) and the
Python test (`[6, 8]`) use the same inputs and the same expected values,
so the two implementations are pinned to agree.

## 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 sizing heuristic)
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md — N/A: internal sizing-heuristic
fix, no user-facing surface change

## Additional Notes

- This is a parity-locked function (Rust and Python must agree
byte-for-byte). The fix is CJK-gated, so non-CJK output is
byte-identical and the `smart_crusher` parity fixtures need no
re-recording.
2026-07-09 17:01:29 -05:00
Zhenjia ZHOU
985621d60e
fix(search-compressor): CJK-aware relevance + harden Rust/Python parity (#1749)
## Description

The search compressor's relevance scorer (`score_matches`, present in
both the Rust runtime path and the Python legacy mirror) split the query
on whitespace. A spaceless CJK query therefore matched a result line
only when the WHOLE query was a literal substring of that line — partial
overlaps never boosted relevant lines, so correct matches got dropped
when the result set was over budget.

This adds CJK character bigrams to the query match set, so a longer CJK
query boosts lines that share a substring. It also fixes two latent
Rust/Python parity divergences the ASCII-only fixtures had masked:

- **Length filter**: Rust counted word length in BYTES (`w.len()`),
Python in codepoints (`len(w)`), so a CJK word crossed the `> 2`
threshold differently. Rust now uses `chars().count()`.
- **Dedup**: Rust collected words into a `Vec` (no dedup), Python into a
`set`, so a repeated query word double-counted in Rust. Rust now uses a
`BTreeSet`.

Both scorers are byte-exact now; non-CJK output is unchanged (the 53
existing tests and the parity fixtures stay green).

## Type of Change

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

## Changes Made

- `crates/headroom-core/src/transforms/search_compressor.rs` +
`headroom/transforms/search_compressor.py`: add
`is_cjk_char`/`_is_cjk_char` and `cjk_bigrams`/`_cjk_bigrams` (identical
ranges + logic), union CJK bigrams into the query match set, and align
the Rust word set to Python (`chars().count()` length, `BTreeSet`
dedup).
- `tests/test_search_compressor_cjk.py` + a Rust unit test: CJK bigram
extraction (same input/expected in both languages) and a CJK query
boosting a partially-overlapping line.
- Corrected a stale `_score_matches` docstring that referenced a
non-existent parity assertion; it now states honestly how the two sides
are pinned (test-equal for word-overlap + CJK bigrams; a few error-boost
keywords still diverge, fixed only Rust-side).

## Testing

- [x] Unit tests pass (`cargo test` + `pytest`)
- [x] Linting passes (`cargo clippy` / `cargo fmt` / `ruff` / `mypy`)
- [x] New tests added for new functionality
- [x] Manual testing performed (see Real Behavior Proof)

### Test Output

```text
$ cargo test -p headroom-core --lib search_compressor
test result: ok. 16 passed; 0 failed

$ .venv/bin/python -m pytest tests/test_search_compressor_cjk.py \
    tests/test_transforms_search_compressor.py tests/test_search_compressor.py
55 passed   # 2 new CJK tests + 53 existing (no regression)
```

## Real Behavior Proof

- Environment: macOS (Darwin), Rust via cargo, Python in a uv venv
(`_core` rebuilt on this branch), branch `feat/search-compressor-cjk`
off `main`.
- Exact command / steps: scored a CJK content line against a longer CJK
query whose whole form is not a substring of the line.
- Observed result: for content `src/a.py:10:认证令牌已过期需要重新登录` and query
`认证令牌缓存淘汰策略` (the whole query is NOT a substring of the line, but its
bigrams are), the line now scores `> 0` (bigrams 认证 / 证令 / 令牌 match);
before, it scored `0`. An ASCII-only line still scores `0`. All 53
existing search-compressor tests are unchanged. `cjk_bigrams("认证令牌")`
returns `{认证, 证令, 令牌}` in **both** Rust and Python.

## 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 relevance scoring)
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md — N/A: internal relevance-scoring
fix, no user-facing surface change

## Additional Notes

- The two parity divergences (byte-vs-codepoint length, `Vec`-vs-`set`
dedup) were pre-existing and only reachable with non-ASCII or
repeated-word queries — the all-ASCII fixtures never exercised them.
This PR brings both sides back to byte-exact for the word-overlap +
CJK-bigram scoring. The remaining error-boost keyword divergence is
pre-existing (fixed only Rust-side in the 3e.1 port) and is now
documented in the code rather than glossed over.
2026-07-09 17:00:30 -05:00
Sakamoto, Kazunori
c85731dc23
fix(mcp): correct default Claude Code config path in ClaudeRegistrar (#1859)
## Description

<!-- Briefly explain the change and why it is needed. -->

`ClaudeRegistrar` originally assumed Claude Code's modern per-user MCP
config lives at `~/.claude/.claude.json`. On a real Claude Code 2.1.202
install with `CLAUDE_CONFIG_DIR` unset, the actual file is
`~/.claude.json`, directly under the home directory. Whenever `claude
mcp add` failed for any transient reason and the registrar fell back to
writing the config file directly, it wrote to a path Claude Code never
reads — registration reported success with no error, but the server
silently never became available, and once that wrong file existed,
`get_server()` kept reading it back as already-registered, so the
registrar never retried.

While fixing the path, several related correctness and test-isolation
issues in the same file were found and fixed:

- Three tests instantiated `ClaudeRegistrar(claude_cli=None)` without
`home_dir`, so the legacy config path resolved to the developer's real
`~/.claude/mcp.json` — one test was actually deleting a `headroom` entry
from it.
- `detect()` only checked the legacy `~/.claude` directory, so installs
where the `claude` CLI is absent from `PATH` and only the modern
`~/.claude.json` exists were treated as not detected, skipping
registration entirely.
- `unregister_server()` returned early on CLI success without cleaning
the legacy config file, so a stale legacy entry could survive a
successful `claude mcp remove` and `get_server()` would keep reporting
the server as registered.
- `_read_server_entry`, `_remove_from_file`, and `_register_via_file`
assumed `mcpServers` was always a dict once present; a hand-edited or
corrupted config with `mcpServers` set to `null`, a list, or a string
crashed with an unhandled `AttributeError`/`TypeError`.

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

- `_resolve_claude_config_dir` now defaults to `home` (the modern config
is `~/.claude.json`), keeping the `CLAUDE_CONFIG_DIR` and explicit
`config_dir` overrides intact; the legacy `.claude` directory is pinned
to `home / ".claude"` independently of where the modern config resolves.
- `detect()` also recognizes an install via
`self._modern_config.exists()`, not just the legacy directory.
- `unregister_server()` always cleans both the modern and legacy config
files, even after a successful CLI removal.
- `_read_server_entry`, `_remove_from_file`, and `_register_via_file`
now validate `mcpServers` is a dict before indexing into it, degrading
gracefully instead of crashing on malformed config.
- Module and constructor docstrings now state the current config-path
facts plainly (paths, and what `CLAUDE_CONFIG_DIR` relocates); the
constructor docstring also clarifies that `home_dir`/`config_dir`
isolate file-based reads/writes but not CLI subprocess calls.
- `tests/test_mcp_registry/test_claude_registrar.py`: corrected path
expectations, isolated the three previously-unisolated tests from the
real home directory, and added coverage for the modern-config-only
detect case, CLI-success-with-stale-legacy-entry, and non-dict
`mcpServers` values.
- Filed #1861 for a related, currently-unexercised gap: CLI subprocess
calls don't honor `home_dir`/`config_dir` overrides.

## Testing

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

### Test Output

```text
$ pytest tests/test_mcp_registry/test_claude_registrar.py
============================== 35 passed in 0.08s ==============================

$ ruff check headroom/mcp_registry/claude.py tests/test_mcp_registry/test_claude_registrar.py
All checks passed!

$ mypy headroom/mcp_registry/claude.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Claude Code 2.1.202 (macOS, Darwin 25.5.0),
`CLAUDE_CONFIG_DIR` unset.
- Exact command / steps: Injected a uniquely-named probe server into
**only** `~/.claude/.claude.json` (old assumed path) and ran `claude mcp
list`; then injected a different probe into **only** `~/.claude.json`
(corrected path) and ran `claude mcp list` again; restored both files
afterward.
1. `ZZZ_nested_probe` written to `~/.claude/.claude.json` only → `claude
mcp list`.
2. `ZZZ_flat_probe` written to `~/.claude.json` only → `claude mcp
list`.
- Observed result: The nested-path probe (`ZZZ_nested_probe`) was
**not** recognized by `claude mcp list` — Claude Code ignores
`~/.claude/.claude.json`. The flat-path probe (`ZZZ_flat_probe`) **was**
recognized and listed. This confirms `~/.claude.json` is the file Claude
Code actually reads. Both config files were restored to their original
state after the test.
- Not tested: older Claude Code versions (< 2.1.202); Windows/Linux path
resolution (logic is platform-agnostic via `pathlib`, but only macOS was
exercised); the CLI-subprocess env-isolation gap tracked in #1861.

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A — CLI/config-path change with no UI surface.

## Additional Notes

- The `CLAUDE_CONFIG_DIR=~/.claude` configuration still correctly
resolves the modern config to `~/.claude/.claude.json` via the unchanged
env override — only the default (env unset) changed.
- Any machine that hit the original bug may have a stale
`~/.claude/.claude.json` written by the old fallback; it is harmless and
can be deleted.
- #1861 tracks a related gap (CLI subprocess env isolation) that isn't
exercised by any current production call site.
2026-07-09 16:58:20 -05:00
Kenneth Wong
33c7f6cd3a
fix(bedrock): resolve global.* inference profiles + pin per-user app-profile ARNs (#1795)
## What & why

Bedrock model resolution failed on accounts whose inference profiles use
the newer `global.` cross-region prefix and undated version suffixes. On
such an account, `list_inference_profiles` returns current-gen models as
`global.anthropic.claude-opus-4-8`, `global.anthropic.claude-sonnet-5`,
`global.anthropic.claude-opus-4-6-v1`,
`global.anthropic.claude-fable-5`, etc.

`_normalize_bedrock_profile_id` only stripped `us.`/`eu.`/`apac.`/`au.`
and only matched a dated `-vN:M` suffix, so every `global.`-prefixed
profile was silently dropped from the discovered model map. Requests
then fell through to the fabricated fallback id and Bedrock rejected
them:

```
litellm.BadRequestError: BedrockException - {"message":"The provided model identifier is invalid."}
```

On the affected account the discovered-profile count went from 5 → 14
after the fix.

## Changes

1. **`_normalize_bedrock_profile_id`** — strip the `global.` prefix in
addition to the region prefixes, and match undated version suffixes
(`-v1`, or none at all) alongside the legacy dated `-vN:M`.

2. **`HEADROOM_BEDROCK_MODEL_MAP` operator override** (read from the
process environment). AWS discovery keys the model map by normalized
model name, so it cannot disambiguate application inference profiles
that share one underlying model — e.g. a team where
`claude-sonnet-5-alice` and `claude-sonnet-5-bob` both resolve to
`claude-sonnet-5`. When you need requests billed to a *specific*
application profile (per-user cost attribution), pin it explicitly:

   ```

HEADROOM_BEDROCK_MODEL_MAP="claude-sonnet-5=arn:aws:bedrock:REGION:ACCT:application-inference-profile/abc123,claude-opus-4-8=arn:...:application-inference-profile/def456"
   ```

The plain model name (kept plain so a client's tool-search deferral
stays on) resolves to the pinned ARN, routed via the converse endpoint.
The override wins over discovery; when unset, discovery-only behaviour
is unchanged.

## Tests

`tests/test_bedrock_region.py` (43 passing): `global.`-prefixed
normalization across dated / bare-`-v1` / no-suffix shapes; override-map
parsing (empty, single, multi, whitespace, malformed-skip); and
`map_model_id` override routing (pinned name → app-profile ARN via
converse, wins over discovery; unpinned name falls through).

No behavioural change for accounts already on system-defined
region-prefixed profiles.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-09 16:56:23 -05:00
Tejas Chopra
49a1a1405b
ci(opencode): compile + test the OpenCode plugin on changes (#1750)
The OpenCode plugin (`plugins/opencode`) is the routing shim that
carries `headroom wrap opencode` traffic through the proxy — but **no CI
job ever compiled it**. It has `typecheck`/`build`/`test` scripts that
only ran locally, so:
- TypeScript 6.x (#1687) and @types/node 26 (#1688) major-bump PRs had
**zero build evidence** (why they're held).
- Any source edit to the plugin could silently break the build.

This adds a path-gated workflow that runs `npm ci → typecheck → build →
test` whenever `plugins/opencode/**` (or this workflow) changes. Matches
repo conventions (`setup-node@v6`, node 20, npm cache).

**Verified green locally on main:** `tsc --noEmit` clean, `tsup` build
ok, 13 vitest tests pass.

Unblocks safe evaluation of the held dependency-bump PRs and protects
the routing plugin going forward.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-09 16:55:51 -05:00
Rod Boev
372d6c8cd4
fix(wrap): preserve custom Codex provider base_url during proxy injection (#1894)
## Description

Refs #1614 (Bug 2 only; Bug 1's config-mutation ordering is covered by a
separate PR).

`headroom wrap codex` unconditionally pointed the proxy's upstream
OpenAI route at `api.openai.com`, even when the user's Codex config
already declared a custom OpenAI-compatible provider such as
`freemodel.dev`, LiteLLM, or vLLM under `[model_providers.<name>]`. The
proxy then silently rerouted traffic to OpenAI, which rejected the
user's gateway API key, and Codex interpreted the resulting auth
failures as an invalid session.

## Type of Change

- [x] Bug fix

## Changes Made

- `_detect_custom_codex_upstream_base_url` and
`_codex_custom_provider_base_urls` in `headroom/cli/wrap.py` scan the
existing `config.toml` for a user-declared custom `[model_providers.*]`
table, excluding Codex built-ins and Headroom's own table, and return
its `base_url` when the selection is unambiguous: either the top-level
`model_provider` names it directly, or a prior wrap left the original
provider in the `# was: <original>` comment from
`_redirect_existing_top_level_keys`.
- The detector falls back to the sole custom provider when exactly one
candidate exists and no matching top-level selection is present, which
covers the issue repro where the custom table exists without a static
top-level provider pin.
- `_inject_codex_provider_config` now detects that custom upstream
before building the injected provider block. When found, it adds
`X-Headroom-Base-Url` to `env_http_headers`, mapped to
`HEADROOM_CODEX_UPSTREAM_BASE_URL`, matching Codex's env-var-based
header contract.
- `codex()` exports the detected value into
`HEADROOM_CODEX_UPSTREAM_BASE_URL` for the launched Codex process unless
the user already set it. The proxy's OpenAI HTTP handlers already honor
`X-Headroom-Base-Url`, so HTTP `/v1/chat/completions` and
`/v1/responses` requests forward to the preserved gateway instead of the
default OpenAI upstream.

This is scoped to the HTTP request path. Codex's WebSocket transport for
`/v1/responses` resolves its upstream from a separate header-independent
path and keeps the existing behavior.

## Testing

- [x] Focused Codex wrap tests passed locally before PR review: `pytest
tests/test_cli/test_wrap_codex.py -q`
- [x] Broader Codex CLI test selection passed locally before PR review:
`pytest tests/test_cli/ -k codex -q`
- [x] CI lint, format, and type checks passed on PR head `fb769349`.
- [x] CI build, test shards, native wrapper, Docker init, Docker wrap,
security, merge-conflict, and governance jobs passed on PR head
`fb769349`.
- [ ] Live `headroom wrap codex` against a real `freemodel.dev` account
was not run for this PR.

## Test Output

Previously reported local focused test output:

```text
pytest tests/test_cli/test_wrap_codex.py -q
83 passed

pytest tests/test_cli/ -k codex -q
113 passed, 426 deselected
```

Previously reported local static checks:

```text
ruff check
ruff format --check
mypy
```

CI evidence on PR head `fb769349`: GitHub Actions run `28987811482`
completed successfully for CI, including `lint` with `ruff check`, `ruff
format --check`, and `mypy`; `build`; `build-wheel`; four test shards;
`test-agno`; `test-extras`; `test-dashboard-ui`;
`windows-native-wrapper`; `macos-native-wrapper`; and
`docker-native-e2e`. PR Governance run `28987811437` completed
successfully.

## Real Behavior Proof

- Environment: Unit-level Codex config injection using pytest tmp home,
PR head `fb769349`, and GitHub Actions CI run `28987811482`.
- Exact command / steps: With a Codex config containing
`[model_providers.freemodel]`, `base_url = "https://api.freemodel.dev"`,
and `wire_api = "responses"`, call
`_inject_codex_provider_config(8787)`.
- Observed result: The injector returns `https://api.freemodel.dev`; the
injected `[model_providers.headroom]` table contains `env_http_headers =
{ "X-Headroom-Project" = "HEADROOM_PROJECT", "X-Headroom-Base-Url" =
"HEADROOM_CODEX_UPSTREAM_BASE_URL" }`; the user's
`[model_providers.freemodel]` table remains unchanged; and re-running
`_inject_codex_provider_config(9999)` preserves the same upstream while
updating the proxy port.
- Not tested: Live external traffic through a real `freemodel.dev` key,
WebSocket custom-upstream routing, and Bug 1's
dependency-check-before-config-mutation path.

## Review Readiness

- [x] Scope is limited to #1614 Bug 2, custom provider `base_url`
preservation for Codex wrap.
- [x] Bug 1 remains out of scope and is called out separately.
- [x] The changed code uses the existing proxy `X-Headroom-Base-Url`
contract instead of adding a new proxy route.
- [x] Ambiguous multiple custom providers keep prior fallback behavior
instead of guessing.
- [x] A collaborator reviewed and approved the current head after
running the focused Codex wrap tests locally.
- [x] This PR is ready for human review.

## Checklist

- [x] I have performed a self-review.
- [x] Focused tests added and passing.
- [x] Lint, format, and type-check clean on CI.
- [x] No unrelated files changed.
- [x] This PR is ready for human review.
2026-07-09 16:55:00 -05:00
Mohammad Hussian
662b7bc00e
fix(release): sync all package versions to v0.31.0 (#1882)
## Description

Current `main` has advanced the core package versions to `0.31.0`, but
the plugin marketplace manifests and hook plugin manifests were still
left at `0.30.0`. This PR now keeps the original version-sync intent
while updating the remaining metadata to the current release line.

It also preserves the previously-added `lxml-html-clean>=0.4.5` security
floor in `pyproject.toml` / `uv.lock` so the security audit remains
unblocked.

Closes #1872

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

- Synced `pyproject.toml`, SDK/package manifests, plugin manifests,
marketplaces, `.release-please-manifest.json`, and editable package lock
metadata to `0.31.0`.
- Updated the OpenClaw plugin dependency on `headroom-ai` to `^0.31.0`.
- Merged current `main` and resolved the version metadata conflicts in
favor of current `0.31.0` alignment.

## Testing

- [x] Unit tests pass (`pytest tests/test_plugin_manifests.py
scripts/tests/test_version_sync.py -q`)
- [ ] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [ ] New tests added for new functionality
- [x] Manual testing performed (`python scripts/verify-versions.py`)

### Test Output

```text
python scripts/verify-versions.py
All versions aligned at 0.31.0

pytest tests/test_plugin_manifests.py scripts/tests/test_version_sync.py -q
10 passed in 0.42s
```

## Real Behavior Proof

- Environment: Windows, Python 3.13.13, PR head after merging current
`main`.
- Exact command / steps: Ran `python scripts/verify-versions.py` and
`pytest tests/test_plugin_manifests.py
scripts/tests/test_version_sync.py -q`.
- Observed result: Version verification exits successfully with `All
versions aligned at 0.31.0`; focused manifest/version-sync tests pass.
- Not tested: Full wheel/build matrix; this is metadata-only version
alignment and CI will cover the broader matrix.

## 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] New and existing focused tests pass locally with my changes
- [ ] I have made corresponding changes to the documentation
- [ ] I have updated the CHANGELOG.md if applicable

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-09 16:53:55 -05:00
Leoy
42bdf23d24
fix(install): write deployment manifest atomically and tolerate corrupt manifests (#1303)
## Description

Make deployment-manifest persistence in `headroom/install/state.py`
crash-safe by writing the manifest **atomically**. `save_manifest` used
a plain `path.write_text(...)` (truncate-then-write), so an interrupted
save (Ctrl-C, system restart, container OOM/SIGKILL) could leave a
truncated `manifest.json` on disk.

> **Note (rebased onto current `main`):** since this PR was opened,
#1491 hardened `load_manifest` to raise a typed `ManifestError` on a
corrupt manifest. I've rebased and **dropped my original `load_manifest
→ return None` change in favour of that deliberate typed-error design**,
so this PR now scopes down to the still-missing piece: the **atomic
write** (upstream `save_manifest` is still a plain `write_text`), plus a
regression test for the `ManifestError` path that `main` added without
test coverage.

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

- Add `_atomic_write_text(path, data)`: write to a same-directory temp
file → `flush()` + `os.fsync()` → `os.replace()` (atomic rename on POSIX
and Windows); the temp file is cleaned up if anything fails.
- `save_manifest` now persists via `_atomic_write_text` instead of
`path.write_text(...)`, so a crash between truncate and full write
leaves either the previous file or the complete new one — never a
truncated manifest.
- `load_manifest` is left exactly as `main` has it (raises
`ManifestError` on a corrupt payload) — no behavioural change from me
there.
- Tests: add `test_save_manifest_writes_atomically` (no leftover temp
file; manifest round-trips) and
`test_load_manifest_raises_manifest_error_on_corrupt_payload` (covers
the `ManifestError` path #1491 introduced but did not test).

## Testing

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

### Test Output

```text
$ pytest tests/test_install/test_state.py -q
.....                                                                    [100%]
5 passed in 0.11s

$ ruff check headroom/install/state.py tests/test_install/test_state.py
All checks passed!

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

$ mypy headroom/install/state.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- **Environment:** macOS (Darwin), Python 3.13, rebased onto current
`main`.
- **Exact command / steps:** `save_manifest(manifest)` then inspect the
profile dir and reload.
- **Observed result:** after a save the profile directory contains only
`manifest.json` (no leftover `.manifest.json.*.tmp`), and
`load_manifest("default")` round-trips the persisted manifest. A
deliberately-corrupt `manifest.json` (`"{not json"`) makes
`load_manifest` raise `ManifestError` (typed), not a raw
`JSONDecodeError`.
- **Not tested:** the physical-crash-mid-write window is reasoned about
via `os.replace()` atomicity, not fault-injected.

## Review Readiness

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

## Checklist

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

## Additional Notes

Documentation / CHANGELOG boxes are unchecked as N/A — this is an
internal persistence-hardening fix with no user-facing surface. The diff
is now small (atomic write + two tests); the corrupt-manifest handling
itself lives in `main` via #1491.
2026-07-09 14:07:05 -05:00
Shengbo_Wang
1de35e775f
fix(code): parse-probe tree-sitter availability in code_handler (#1231) (#1300)
## Description

`_check_tree_sitter()` in
`headroom/compression/handlers/code_handler.py` only verified that
`tree_sitter_language_pack` could be imported. When tree-sitter core and
the language pack are built against different ABIs, the import succeeds
but `parser.language = get_language(...)` raises at request time,
silently falling back to the generic text compressor — with no warning,
while the banner still reports code-aware as enabled.

#1299 fixed the same class of bug in `transforms/code_compressor.py`.
This PR is the defensive follow-up tracked by #1231: it applies the same
parse probe to the compression **structure handler** so both code-aware
paths are consistent.

Closes #1231

## Type of Change

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

## Changes Made

- Replace the import-only probe in `code_handler._check_tree_sitter()`
with a real parse probe: construct a `Parser`, assign a Python language,
and parse `b"x = 1\n"` — if any step fails, mark unavailable
- Log a WARNING when import succeeds but parsing fails, so the downgrade
is visible
- Add `TestAvailabilityProbe` covering the simulated ABI mismatch (->
False) and healthy install (-> True) cases

> Note: an earlier revision of this PR also touched
`transforms/code_compressor.py`, but that fix landed independently via
#1299. After rebasing onto current `main`, this PR is scoped to the
remaining `code_handler.py` gap only.

## Testing

- [x] Unit tests pass (`pytest
tests/test_compression/test_code_handler.py` -> 22 passed, 8 skipped)
- [x] Linting / formatting pass (`ruff check .`, `ruff format --check
.`)
- [x] New tests added for new functionality

### Real Behavior Proof

- Setup: Windows 11 (GBK locale), Python 3.10, tree-sitter NOT installed
- Before fix: `_check_tree_sitter()` returns `True` on a
partial/ABI-mismatched install (import succeeds), then silently degrades
to the text compressor at request time with no warning
- After fix: the probe parses a trivial snippet; an ABI mismatch is
caught at probe time, `_check_tree_sitter()` returns `False`, and a
WARNING is logged.
`TestAvailabilityProbe::test_abi_mismatch_returns_false` reproduces this
with a fake Parser whose `language` setter raises.

Signed-off-by: RTCartist <wangshengb@buaa.edu.cn>
2026-07-09 14:06:29 -05:00
Sujit Patil
85804043ff
fix(proxy): record cache metrics for non-streaming backend paths (#1271)
## Description

Fixes missing cache metric propagation in backend-routed non-streaming
request paths.

The streaming implementations already populate cache usage metrics
(`cache_read`, `cache_write`, cache hit percentage) in `RequestOutcome`,
but the equivalent non-streaming paths were left incomplete after the P0
proxy pipeline audit:

- `anthropic.py` (Bedrock / Vertex non-streaming): extracted only
`output_tokens` from the backend usage block — `cache_read_input_tokens`
and `cache_creation_input_tokens` were never read. A comment in the code
explicitly acknowledged this: *"Cache metrics aren't extracted from the
backend response here yet — that's a follow-up."*
- `openai.py` (OpenAI backend non-streaming): extracted cache metrics
and fed them to `openai_prefix_tracker`, but never forwarded them into
`RequestOutcome`. The values were computed then silently dropped.

As a result, all non-streaming backend-routed requests reported:

```text
cache_read=0 cache_write=0 cache_hit_pct=0
```

even when upstream usage data contained valid cache counters.

## Type of Change

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

## Changes Made

- `headroom/proxy/handlers/anthropic.py`: Extract
`cache_read_input_tokens`, `cache_creation_input_tokens`, and TTL bucket
splits (`cache_write_5m_tokens`, `cache_write_1h_tokens`) from the
Bedrock non-streaming usage block. Compute `uncached_input_tokens`. Pass
all five fields to `RequestOutcome`.
- `headroom/proxy/handlers/openai.py`: Compute `uncached_input_tokens`
and forward the already-extracted `cache_read_tokens`,
`cache_write_tokens`, and `uncached_input_tokens` into `RequestOutcome`
in the backend non-streaming path.

## Testing

- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
# Existing regression suite that specifically targets this omission:
# tests/test_backend_nonstreaming_cache_metrics.py
#
# Module docstring from the file explicitly documents the bug class:
#
#   "The **non-streaming** backend paths were left behind — the same bug class
#    on the parallel code path: anthropic.py extracted only output_tokens;
#    openai.py extracted cache fields but never threaded them into RequestOutcome."
#
# Four tests cover both handlers and both the positive (cache data present)
# and zero (no cache data in upstream response) cases:
#
#   test_openai_backend_nonstreaming_emits_perf_with_cache_read_and_inferred_write
#   test_openai_backend_nonstreaming_perf_zeros_when_upstream_omits_cache_usage
#   test_anthropic_backend_nonstreaming_emits_perf_with_cache_read_and_write
#   test_anthropic_backend_nonstreaming_perf_zeros_when_upstream_omits_cache_usage
#
# Tests were written to fail on main before this fix (intentional regression tests).
# Local test execution is blocked by a missing MSVC toolchain (maturin/headroom._core
# Rust extension cannot compile on this machine without VS Build Tools).
```

## Real Behavior Proof

- **Environment:** Windows, Python 3.13, headroom main branch (commit
`b70fccbe`)
- **Exact steps:** Inspected the `RequestOutcome` construction in both
non-streaming backend branches. Confirmed that `cache_read_tokens` and
`cache_write_tokens` defaulted to `0` in both paths because the
constructor calls omitted them.
- **Observed result (pre-fix):** `PERF` log line emitted `cache_read=0
cache_write=0 cache_hit_pct=0` for every non-streaming Bedrock/backend
request, even when the upstream response body contained
`cache_read_input_tokens: 500, cache_creation_input_tokens: 200`.
- **Observed result (post-fix):** `RequestOutcome` now receives the
extracted values; the funnel passes them through to Prometheus, the cost
tracker, `RequestLog`, and the `PERF` line — matching the existing
streaming path behavior.
- **Not tested:** Live Bedrock / Vertex endpoint (no credentials on this
machine). The fix is a pure pass-through of values already present in
the parsed response body.

## Review Readiness

- [x] I have performed a self-review before requesting human 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 or that my
feature works

## Additional Notes

The regression test file
`tests/test_backend_nonstreaming_cache_metrics.py` was intentionally
written to expose this exact omission (it was not added after the fix).
The streaming sibling fix was tracked as issue #327; this PR closes the
parallel non-streaming gap. The fix is a pure observability change — no
request or response payloads are modified.

---------

Co-authored-by: Sujit <sujit@example.com>
2026-07-09 14:06:05 -05:00
dependabot[bot]
ebd23152d5
ci: bump actions/cache from 5 to 6 (#1413)
Bumps [actions/cache](https://github.com/actions/cache) from 5 to 6.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/cache/releases">actions/cache's
releases</a>.</em></p>
<blockquote>
<h2>v6.0.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Update packages, migrate to ESM by <a
href="https://github.com/Samirat"><code>@​Samirat</code></a> in <a
href="https://redirect.github.com/actions/cache/pull/1760">actions/cache#1760</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/cache/compare/v5...v6.0.0">https://github.com/actions/cache/compare/v5...v6.0.0</a></p>
<h2>v5.1.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Bump <code>@​actions/cache</code> to v5.1.0 - handle read-only cache
access by <a
href="https://github.com/jasongin"><code>@​jasongin</code></a> in <a
href="https://redirect.github.com/actions/cache/pull/1775">actions/cache#1775</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/cache/compare/v5...v5.1.0">https://github.com/actions/cache/compare/v5...v5.1.0</a></p>
<h2>v5.0.5</h2>
<h2>What's Changed</h2>
<ul>
<li>Update ts-http-runtime dependency by <a
href="https://github.com/yacaovsnc"><code>@​yacaovsnc</code></a> in <a
href="https://redirect.github.com/actions/cache/pull/1747">actions/cache#1747</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/cache/compare/v5...v5.0.5">https://github.com/actions/cache/compare/v5...v5.0.5</a></p>
<h2>v5.0.4</h2>
<h2>What's Changed</h2>
<ul>
<li>Add release instructions and update maintainer docs by <a
href="https://github.com/Link"><code>@​Link</code></a>- in <a
href="https://redirect.github.com/actions/cache/pull/1696">actions/cache#1696</a></li>
<li>Potential fix for code scanning alert no. 52: Workflow does not
contain permissions by <a
href="https://github.com/Link"><code>@​Link</code></a>- in <a
href="https://redirect.github.com/actions/cache/pull/1697">actions/cache#1697</a></li>
<li>Fix workflow permissions and cleanup workflow names / formatting by
<a href="https://github.com/Link"><code>@​Link</code></a>- in <a
href="https://redirect.github.com/actions/cache/pull/1699">actions/cache#1699</a></li>
<li>docs: Update examples to use the latest version by <a
href="https://github.com/XZTDean"><code>@​XZTDean</code></a> in <a
href="https://redirect.github.com/actions/cache/pull/1690">actions/cache#1690</a></li>
<li>Fix proxy integration tests by <a
href="https://github.com/Link"><code>@​Link</code></a>- in <a
href="https://redirect.github.com/actions/cache/pull/1701">actions/cache#1701</a></li>
<li>Fix cache key in examples.md for bun.lock by <a
href="https://github.com/RyPeck"><code>@​RyPeck</code></a> in <a
href="https://redirect.github.com/actions/cache/pull/1722">actions/cache#1722</a></li>
<li>Update dependencies &amp; patch security vulnerabilities by <a
href="https://github.com/Link"><code>@​Link</code></a>- in <a
href="https://redirect.github.com/actions/cache/pull/1738">actions/cache#1738</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/XZTDean"><code>@​XZTDean</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/cache/pull/1690">actions/cache#1690</a></li>
<li><a href="https://github.com/RyPeck"><code>@​RyPeck</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/cache/pull/1722">actions/cache#1722</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/cache/compare/v5...v5.0.4">https://github.com/actions/cache/compare/v5...v5.0.4</a></p>
<h2>v5.0.3</h2>
<h2>What's Changed</h2>
<ul>
<li>Bump <code>@actions/cache</code> to v5.0.5 (Resolves: <a
href="https://github.com/actions/cache/security/dependabot/33">https://github.com/actions/cache/security/dependabot/33</a>)</li>
<li>Bump <code>@actions/core</code> to v2.0.3</li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/cache/compare/v5...v5.0.3">https://github.com/actions/cache/compare/v5...v5.0.3</a></p>
<h2>v.5.0.2</h2>
<h1>v5.0.2</h1>
<h2>What's Changed</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/actions/cache/blob/main/RELEASES.md">actions/cache's
changelog</a>.</em></p>
<blockquote>
<h1>Releases</h1>
<h2>How to prepare a release</h2>
<blockquote>
<p>[!NOTE]
Relevant for maintainers with write access only.</p>
</blockquote>
<ol>
<li>Switch to a new branch from <code>main</code>.</li>
<li>Run <code>npm test</code> to ensure all tests are passing.</li>
<li>Update the version in <a
href="https://github.com/actions/cache/blob/main/package.json"><code>https://github.com/actions/cache/blob/main/package.json</code></a>.</li>
<li>Run <code>npm run build</code> to update the compiled files.</li>
<li>Update this <a
href="https://github.com/actions/cache/blob/main/RELEASES.md"><code>https://github.com/actions/cache/blob/main/RELEASES.md</code></a>
with the new version and changes in the <code>## Changelog</code>
section.</li>
<li>Run <code>licensed cache</code> to update the license report.</li>
<li>Run <code>licensed status</code> and resolve any warnings by
updating the <a
href="https://github.com/actions/cache/blob/main/.licensed.yml"><code>https://github.com/actions/cache/blob/main/.licensed.yml</code></a>
file with the exceptions.</li>
<li>Commit your changes and push your branch upstream.</li>
<li>Open a pull request against <code>main</code> and get it reviewed
and merged.</li>
<li>Draft a new release <a
href="https://github.com/actions/cache/releases">https://github.com/actions/cache/releases</a>
use the same version number used in <code>package.json</code>
<ol>
<li>Create a new tag with the version number.</li>
<li>Auto generate release notes and update them to match the changes you
made in <code>RELEASES.md</code>.</li>
<li>Toggle the set as the latest release option.</li>
<li>Publish the release.</li>
</ol>
</li>
<li>Navigate to <a
href="https://github.com/actions/cache/actions/workflows/release-new-action-version.yml">https://github.com/actions/cache/actions/workflows/release-new-action-version.yml</a>
<ol>
<li>There should be a workflow run queued with the same version
number.</li>
<li>Approve the run to publish the new version and update the major tags
for this action.</li>
</ol>
</li>
</ol>
<h2>Changelog</h2>
<h3>6.1.0</h3>
<ul>
<li>Bump <code>@actions/cache</code> to v6.1.0 to pick up <a
href="https://redirect.github.com/actions/toolkit/pull/2435">actions/toolkit#2435
Handle cache write error due to read-only token</a></li>
<li>Switch redundant &quot;Cache save failed&quot; warning to debug log
in save-only</li>
</ul>
<h3>6.0.0</h3>
<ul>
<li>Updated <code>@actions/cache</code> to ^6.0.1,
<code>@actions/core</code> to ^3.0.1, <code>@actions/exec</code> to
^3.0.0, <code>@actions/io</code> to ^3.0.2</li>
<li>Migrated to ESM module system</li>
<li>Upgraded Jest to v30 and test infrastructure to be ESM
compatible</li>
</ul>
<h3>5.0.4</h3>
<ul>
<li>Bump <code>minimatch</code> to v3.1.5 (fixes ReDoS via globstar
patterns)</li>
<li>Bump <code>undici</code> to v6.24.1 (WebSocket decompression bomb
protection, header validation fixes)</li>
<li>Bump <code>fast-xml-parser</code> to v5.5.6</li>
</ul>
<h3>5.0.3</h3>
<ul>
<li>Bump <code>@actions/cache</code> to v5.0.5 (Resolves: <a
href="https://github.com/actions/cache/security/dependabot/33">https://github.com/actions/cache/security/dependabot/33</a>)</li>
<li>Bump <code>@actions/core</code> to v2.0.3</li>
</ul>
<h3>5.0.2</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="55cc834586"><code>55cc834</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/cache/issues/1768">#1768</a>
from jasongin/readonly-cache</li>
<li><a
href="d8cd72f230"><code>d8cd72f</code></a>
Bump <code>@​actions/cache</code> to v6.1.0 - handle cache write error
due to RO token</li>
<li><a
href="2c8a9bd745"><code>2c8a9bd</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/cache/issues/1760">#1760</a>
from actions/samirat/esm_migration_and_package_update</li>
<li><a
href="e9b91fdc3f"><code>e9b91fd</code></a>
Prettier fixes</li>
<li><a
href="e4884b8ff7"><code>e4884b8</code></a>
Rebuild dist</li>
<li><a
href="10baf0191a"><code>10baf01</code></a>
Fixed licenses</li>
<li><a
href="e39b386c90"><code>e39b386</code></a>
Fix test mock return order</li>
<li><a
href="b692820337"><code>b692820</code></a>
PR feedback</li>
<li><a
href="60749128a4"><code>6074912</code></a>
Rebuild dist bundles as ESM to match type:module</li>
<li><a
href="5a912e8b4a"><code>5a912e8</code></a>
Fix lint and jest issues</li>
<li>Additional commits viewable in <a
href="https://github.com/actions/cache/compare/v5...v6">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-09 13:32:20 -05:00
Hafiz Ismail
adf8fed9bd
fix(code): stop TS export duplication + comment displacement (#1906)
## Description

`CodeAwareCompressor` (AST-based code compression,
`headroom/transforms/code_compressor.py`) had two bugs in its
structure-reassembly path, found while investigating a reported Go
brace-duplication issue (the Go bug itself — `statement_list` row-range
swallowing a block's closing brace — was already fixed on `main` in
#1668; this PR fixes what was *actually* still broken):

1. **TS/JS `export` keyword duplication.** `export function foo() {}` /
`export class Foo {}` compressed to `export export function foo() {}` —
invalid syntax, silently discarded by `_verify_syntax`'s fallback (the
caller never sees an error, compression just quietly no-ops). Root
cause: `_compress_function_ast` / `_compress_class_ast` slice a node's
source by **line**, not by byte offset, deliberately — to preserve
leading indentation for definitions nested inside classes. But when a
node shares its *first* line with a preceding sibling (the `export`
keyword is a sibling of the function inside tree-sitter's
`export_statement` node, not part of the function node itself), that
line-based slice pulled the sibling's text in too. The
`export_statement` handler then re-prepended the same `export` text on
top, producing the duplicate.
2. **Doc-comment displacement (all languages).** A `/** ... */` or `//`
doc comment directly above a top-level function/class/type got detached
from its declaration during AST extraction and re-emitted in one cluster
at the very end of the compressed output, instead of staying attached to
what it documents. Root cause: doc comments are top-level *siblings* of
the declaration they document, not children of it — the extractor didn't
attach them to anything, so they fell through to a "leftover top-level
code" bucket that gets flushed as a single block after all functions.

Also tightens `test_actual_go_compression`, which — per its own comment
— was written to *tolerate* the Go bug (`compression_ratio may be 1.0 if
compression produces invalid syntax`) rather than catch it. Since the
underlying Go bug is already fixed on `main`, this now asserts real
compression (`compression_ratio < 1.0`), matching its JS/Python
siblings.

Closes #1905

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

Two commits: the fix itself, then the tests that prove it — bisectable
independently, both pass the full suite on their own.

**Commit 1 — `fix(code):`**
- `headroom/transforms/code_compressor.py`: add `_get_node_lines()` —
line-based node slicing that still preserves indentation, but trims a
preceding sibling's text from the first line when that prefix isn't pure
whitespace (i.e. an `export` keyword sharing the line), so callers that
re-add the sibling text themselves don't get a duplicate; used by
`_compress_function_ast` and `_compress_class_ast`.
- `headroom/transforms/code_compressor.py`: add
`_get_leading_comment_text()` — walks a node's `prev_sibling` chain to
collect contiguous doc-comment nodes immediately above it (no blank line
in between) and returns them for the caller to prepend, also marking
their byte ranges as captured so they aren't independently swept into
the leftover top-level-code bucket; wired into every capture branch in
`_extract_structure` (package, import, export statement, decorator,
function, class, type).
- `CHANGELOG.md`: added an entry under `### Fixed`.

**Commit 2 — `test(code):`**
- `tests/test_transforms/test_code_compressor.py`:
`test_actual_go_compression` now asserts `compression_ratio < 1.0`
instead of tolerating a 1.0 fallback.
- `tests/test_code_aware_brace_comment_regressions.py` (new): 4
regression tests — TS `export` not duplicated + valid syntax, TS doc
comments stay attached, Go doc comments stay attached, and a
real-TS-compression parity test matching the existing JS/Python/Go
"actual compression" tests.

## Testing

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

### Test Output

```text
$ ruff check headroom/transforms/code_compressor.py tests/test_transforms/test_code_compressor.py tests/test_code_aware_brace_comment_regressions.py
All checks passed!

$ ruff format --check headroom/transforms/code_compressor.py tests/test_transforms/test_code_compressor.py tests/test_code_aware_brace_comment_regressions.py
3 files already formatted

$ pytest tests/test_transforms/test_code_compressor.py tests/test_code_aware_regressions.py tests/test_code_aware_brace_comment_regressions.py -q
83 passed in 6.23s

$ pytest -q   # full suite
7912 passed, 5 failed, 442 skipped in 417.43s (0:06:57)
# The 5 failures are pre-existing and unrelated: confirmed to fail identically
# with this PR's changes stashed out (clean upstream/main checkout).
#   - test_wrap_marker_is_stale_when_pid_reused (PID-reuse detection, env-specific)
#   - test_read_cached_oauth_token_falls_back_to_gh_cli (leaks real local `gh` credentials)
#   - test_rtk_reader_returns_none_on_nonzero_exit / test_lean_ctx_reader_returns_none_on_failure_and_logs
#     (pass in isolation; fail only in full-suite order — pre-existing test-pollution, unrelated to code_compressor.py)
#   - test_parser_usable_in_thread_pool (test itself passes a str to parser.parse(),
#     which tree-sitter's binding has always required as bytes — a pre-existing test
#     bug unrelated to this change; separate fix in progress on another branch)

$ mypy headroom
Success: no issues found in 408 source files
```

## Real Behavior Proof

- Environment: macOS (Darwin 24.6.0), Python 3.14.5, headroom-ai dev
checkout built via `uv sync --extra dev` + `maturin develop -m
crates/headroom-py/Cargo.toml` (real `headroom._core` build, not
mocked), `tree-sitter==0.25.2` / `tree-sitter-language-pack` per the
pinned `[code]` extra.
- Exact command / steps: ran
`CodeAwareCompressor(CodeCompressorConfig(min_tokens_for_compression=10,
enable_ccr=False)).compress(open("sdk/typescript/src/client.ts").read(),
language="typescript")` identically against `git stash`-ed (pre-fix) and
current (post-fix) trees; full snippet and additional samples below.
- Observed result: `client.ts` (real 20KB SDK file in this repo) went
from `compression_ratio=1.0` with a silent fallback (`export export
class HeadroomClient` in the raw AST attempt, invalid syntax) to
`compression_ratio=0.942`, `syntax_valid=True` — real compression, no
duplication; full before/after table below.
- Not tested: real-world repos beyond this repo's own SDK sample and the
bundled benchmark fixture — broader corpus testing may follow as a
comment on this PR.

**Exact command, full snippet:**

```python
from headroom.transforms.code_compressor import CodeAwareCompressor, CodeCompressorConfig
compressor = CodeAwareCompressor(CodeCompressorConfig(min_tokens_for_compression=10, enable_ccr=False))
with open("sdk/typescript/src/client.ts") as f:
    code = f.read()
result = compressor.compress(code, language="typescript")
```

**Observed result, before vs. after, real code:**

| Sample | Before (main) | After (this fix) |
|---|---|---|
| `sdk/typescript/src/client.ts` (real 20KB SDK file, this repo) |
`compression_ratio=1.0`, silent fallback — `export export class
HeadroomClient` in the raw AST attempt, invalid syntax |
`compression_ratio=0.942`, `syntax_valid=True` — real compression, no
duplication |
| TS fixture exercising both bugs (exported fn/class + doc comments) |
`compression_ratio=1.0`, silent fallback | `compression_ratio=0.993`,
`syntax_valid=True` |
| `middleware/ratelimit.go` (bundled benchmark sample) |
`compression_ratio=0.862`, `syntax_valid=True` — unaffected (Go bug
already fixed on `main` by #1668) | `compression_ratio=0.862`,
`syntax_valid=True` — unchanged, confirms no regression |
| `generate_go_code(3)` (existing test fixture) |
`compression_ratio=0.498` | `compression_ratio=0.498` — unchanged,
confirms no regression |

On code shaped to actually exercise elision (function bodies long enough
to exceed `max_body_lines=5`), TypeScript compresses in line with other
languages once the correctness bug stops blocking it entirely:

| Language | Compression savings (synthetic fixture, ~10-line function
bodies) |
|---|---|
| Python | 64.4% |
| Go | 52.3% |
| TypeScript | 49.0% |
| JavaScript | 42.8% |

(`client.ts`'s real-world 5.8% savings is lower than the synthetic
TypeScript number above because most of its methods are ≤5 lines — under
the elision threshold regardless of language — not because of a
language-specific limitation.)

## Review Readiness

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

## Checklist

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

## Additional Notes

The Go brace-duplication bug that motivated this investigation was
already fixed on `main` (#1668, merged before this branch was based) —
confirmed via the minimal repro and `ratelimit.go`, both compress
cleanly with no duplicated braces. This PR fixes what was still actually
broken: the TS/JS `export`-duplication bug and the doc-comment
displacement bug (both present across languages), found empirically
while verifying the original bug report against the current `main`.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-07-09 12:51:32 -05:00
Rod Boev
e3b45e402b
fix(learn): handle Windows UTF-8, drive-letter paths, and CLI shim fallback (#1895)
## Description

`headroom learn --verbosity` is broken on Windows in three related ways:

- Transcript/profile reads can use the platform default codec, so
non-ASCII content can raise `UnicodeDecodeError` and collapse learning
signals to empty output.
- `--project <path>` can miss real Claude project directories because
Windows profile junctions can raise `PermissionError` during directory
walks, and escaped Claude project folder names cannot always distinguish
`vibe-remote` from `vibe\remote`.
- `headroom learn --agent codex` can fail with `` `claude` not found in
PATH `` even when the npm-installed CLI exists, because Windows `.cmd`
shims require `PATHEXT` resolution.

Refs https://github.com/headroomlabs-ai/headroom/issues/1624 for the
Windows learn failures. The dashboard-hint UX and
third-party-provider-auth items in that issue are unrelated and out of
scope for this PR.

## 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/learn/verbosity.py`: read and write verbosity
transcripts/profiles with `encoding="utf-8"` so non-ASCII content works
regardless of the Windows locale codec.
- `headroom/learn/plugins/claude.py`: skip inaccessible siblings one
entry at a time during greedy project path decoding, so one Windows
junction no longer hides valid project directories.
- `headroom/learn/plugins/claude.py`: prefer a valid `cwd` found in
Claude session JSONL when discovering project paths, which resolves
ambiguous escaped folder names such as `vibe-remote` versus
`vibe\remote`.
- `headroom/learn/analyzer.py`: resolve Windows CLI shim paths through
`shutil.which()` after `FileNotFoundError`, then retry once for
streaming and non-streaming CLI calls.
- `CHANGELOG.md`: document the Windows learn fixes under `Unreleased`.

## Testing

- [x] Unit tests pass (`uv run pytest
tests/test_verbosity_learn.py::TestWindowsEncoding
tests/test_learn/test_scanner.py::TestGreedyPathDecode::test_permission_denied_sibling_does_not_abort_the_walk
tests/test_learn/test_analyzer.py::TestWindowsCliShimFallback
tests/test_learn/test_scanner.py::TestDecodeProjectPath::test_discover_project_prefers_session_cwd_over_ambiguous_folder_name
-q`)
- [x] Linting passes (`uv run ruff check
headroom/learn/plugins/claude.py tests/test_learn/test_scanner.py`)
- [x] Formatting passes (`uv run ruff format --check
headroom/learn/plugins/claude.py tests/test_learn/test_scanner.py`)
- [ ] Type checking passes (`uv run mypy headroom`) not run; no new
public type surface
- [x] New tests added for the Windows `cwd` disambiguation regression
- [x] Manual testing performed

### Test Output

```text
uv run ruff format headroom/learn/plugins/claude.py
1 file reformatted

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

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

uv run pytest tests/test_verbosity_learn.py::TestWindowsEncoding tests/test_learn/test_scanner.py::TestGreedyPathDecode::test_permission_denied_sibling_does_not_abort_the_walk tests/test_learn/test_analyzer.py::TestWindowsCliShimFallback tests/test_learn/test_scanner.py::TestDecodeProjectPath::test_discover_project_prefers_session_cwd_over_ambiguous_folder_name -q
9 passed in 0.25s
```

CI on current head `c6dbac40` is green. A prior `test (1)` run hit an
unrelated timing-sensitive scheduler assertion; GitHub did not permit
direct rerun without admin rights, so the empty commit `c6dbac40`
retriggered CI and the shard passed.

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, real filesystem for the
path-decoding reproduction.
- Exact command / steps: Ran `uv run pytest
tests/test_verbosity_learn.py::TestWindowsEncoding
tests/test_learn/test_scanner.py::TestGreedyPathDecode::test_permission_denied_sibling_does_not_abort_the_walk
tests/test_learn/test_analyzer.py::TestWindowsCliShimFallback
tests/test_learn/test_scanner.py::TestDecodeProjectPath::test_discover_project_prefers_session_cwd_over_ambiguous_folder_name
-q`, `uv run ruff check headroom/learn/plugins/claude.py
tests/test_learn/test_scanner.py`, and `uv run ruff format --check
headroom/learn/plugins/claude.py tests/test_learn/test_scanner.py`; the
path tests create real Windows-style project directories, inaccessible
siblings, ambiguous `vibe\remote` versus `vibe-remote` folders, and
Claude session JSONL with `cwd` pointing at the intended project.
- Observed result: The focused test command returned `9 passed in
0.25s`, Ruff check passed, and Ruff format check passed. The decoder
skips the inaccessible sibling and reaches `vibe-remote`; the
session-`cwd` test returns `vibe-remote` instead of trusting the
ambiguous escaped folder name; the UTF-8 tests round-trip non-ASCII
transcript/profile content under a non-UTF-8 Windows-style codec; the
CLI shim tests retry once through `shutil.which()` after
`FileNotFoundError`.
- Not tested: real npm-installed `claude`/`codex` CLI shims,
dashboard-hint UX, and third-party-provider auth.

## Review Readiness

- [x] I have performed a self-review
- [x] Retrospective review completed after opening; it found one missing
`cwd` disambiguation case, now fixed in this PR
- [x] This PR is ready for human review

## Checklist

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

## Additional Notes

- The post-open retrospective review concluded this needed targeted
rework rather than only a retrospective sign-off.
- The `cwd` recovery commit is `ee720bb1`; `0905be7b` contains the
required formatter cleanup; current head `c6dbac40` is an empty CI-rerun
commit after GitHub denied direct rerun without admin rights.

---------

Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-07-09 12:49:38 -05:00
yehsuf
f52ca19db1
fix(copilot-auth): stop discarding the caller's valid Copilot auth token (#1879)
## Description

`apply_copilot_api_auth()` treats any incoming
`gho_`/`ghs_`/`ghp_`/`github_pat_`-prefixed GitHub OAuth bearer token as
"not a suitable Copilot API token" (`_is_copilot_api_token()`) and
silently replaces it with a token independently fetched/exchanged by
Headroom itself, even when the caller already sent its own valid,
correctly-entitled Copilot credential.

This closes headroomlabs-ai/headroom#1813, which reports the exact same
defect from OpenCode's native GitHub Copilot integration: Headroom
replaces its native `gho_` bearer token, changing the effective
client/integrator lane Copilot's backend sees and breaking model
discovery/inference parity with native (non-proxied) behavior.

I hit the same root cause independently, through a different trigger
path, while building a new dedicated GitHub Copilot CLI redirect
integration (so Copilot CLI traffic gets the same full Headroom pipeline
treatment Claude Code already gets): a live Copilot CLI session's own
`gho_`-prefixed token worked end-to-end for model `claude-sonnet-5` when
sent directly to Copilot's real API, but the exact same request got `400
model_not_supported` once routed through Headroom, because Headroom
silently swapped in a different, independently-exchanged token with
different (lesser) model entitlements.

Closes #1813

## Type of Change

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

## Changes Made

- Added `_is_forwardable_copilot_bearer_token()` in
`headroom/copilot_auth.py`: a new, narrowly-scoped helper used only by
`apply_copilot_api_auth()` (the chat-completion/inference auth path)
that accepts both short-lived Copilot API tokens (`tid_`) AND GitHub
OAuth tokens (`gho_`/`ghs_`/`ghp_`/`github_pat_`) as forwardable, since
live evidence (this PR + #1813) shows both are valid, correctly-entitled
Copilot bearer credentials when supplied directly by a Copilot-aware
client.
- **Deliberately did NOT touch `_is_copilot_api_token()`** — that helper
is used by a separate, unrelated code path
(`resolve_subscription_bearer_token_details()`, for Copilot
subscription/user-info API resolution) with different endpoint
requirements. Broadening it in place would have changed
subscription-resolution behavior as an unintended side effect; the new
helper keeps that concern isolated.
- `apply_copilot_api_auth()` now calls
`_is_forwardable_copilot_bearer_token()` instead of
`_is_copilot_api_token()` when deciding whether to forward the caller's
existing bearer token unchanged. Non-Copilot-shaped or
blank/whitespace-only tokens still fall through to Headroom's own
fetch/exchange, unchanged.
- Fixed a companion `/v1/responses` WebSocket ordering bug in
`headroom/proxy/handlers/openai.py` (filed as
headroomlabs-ai/headroom#1880): the `OPENAI_API_KEY` fallback used a
stale `_lower_headers` snapshot computed before
`apply_copilot_api_auth()` ran, and ran *before* it. Once the upstream
is known, Copilot auth is now resolved first, and the fallback checks
live header state instead — this matters more now that a
correctly-forwarded Copilot bearer token must not be shadowed by an
unrelated OpenAI-key fallback injected earlier in the flow.

## Testing

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

### Test Output

```text
$ pytest tests/test_copilot_auth.py tests/test_proxy_copilot_auth_hooks.py tests/test_provider_copilot_wrap.py -k "not falls_back_to_gh_cli" -q
============================= test session starts ==============================
platform darwin -- Python 3.12.12, pytest-9.0.3, pluggy-1.6.0
collected 95 items / 1 deselected / 94 selected

tests/test_copilot_auth.py ............................................. [ 47%]
............                                                             [ 60%]
tests/test_proxy_copilot_auth_hooks.py ...                               [ 63%]
tests/test_provider_copilot_wrap.py ..................................   [100%]

======================= 94 passed, 1 deselected in 0.52s =======================

# The 1 deselected test (test_read_cached_oauth_token_falls_back_to_gh_cli) is a
# pre-existing, environment-dependent failure unrelated to this change: it leaks a
# real gho_ token from this machine's own authenticated `gh` CLI keychain instead of
# using the mocked one. Confirmed identical on main before this change.

$ pytest tests/test_openai_codex_ws_lifecycle.py tests/test_openai_codex_routing.py tests/test_proxy_openai_responses_integration.py tests/test_codex_ws_compression_scheduler.py tests/test_openai_codex_ws_timings.py -q
================== 53 passed, 15 skipped, 1 warning in 16.19s ==================
# (15 skips are pre-existing, environment-gated, unrelated to this change)

$ ruff check headroom/copilot_auth.py headroom/proxy/handlers/openai.py tests/test_copilot_auth.py
All checks passed!
```

## Real Behavior Proof

- Environment: real GitHub Copilot CLI session (`gho_`-prefixed OAuth
token), routed through a dedicated Headroom redirect-proxy integration
(new, currently opt-in/staging-only work, not part of this PR) that
forwards Copilot CLI's `/v1/messages` and `/chat/completions` traffic
through Headroom.
- Exact command / steps: live Copilot CLI request for model
`claude-sonnet-5`, sent once directly to
`api.business.githubcopilot.com` and once routed through Headroom with
this fix applied.
- Observed result: both paths return `200 OK` with identical token/cost
usage. Before this fix, the proxied path returned `400
model_not_supported` for the exact same request, because Headroom
substituted a different, less-entitled token for the client's own valid
`gho_` token.
- Not tested: the companion WS-ordering fix (`/v1/responses` fallback
ordering) was validated via the existing WS lifecycle/routing/timing
unit test suites (see Test Output) rather than a live WebSocket session
— I don't have a live client that exercises that exact
`OPENAI_API_KEY`-fallback-into-Copilot-upstream path 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

## Screenshots (if applicable)

N/A

## Additional Notes

This PR was rebased onto current `main` after it turned out
`_is_copilot_api_token()` had already been introduced upstream (#557,
merged before this PR was opened) with the opposite assumption — that
`gho_`/`ghs_`/`ghp_`/`github_pat_` tokens always need exchanging and
must not be forwarded. That assumption is exactly what #1813 reports as
broken. Rather than reverting that classification wholesale (my original
approach in this branch), this version keeps it intact for its original
caller and adds a second, narrowly-scoped helper for the
inference/chat-completion auth path specifically — see "Changes Made"
above for the reasoning.

mypy is left unchecked above only because I didn't have it
configured/runnable in my local environment for this pass — happy to run
it if a maintainer flags a concern, or CI will catch it.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-09 12:46:35 -05:00
Dima Solodukha
d604e86904
fix(litellm): surface Bedrock cache token usage in non-streaming responses (#1848)
## Description

Non-streaming `complete_message()` builds the Anthropic-shape usage from
`prompt_tokens`/`completion_tokens` only. LiteLLM's `prompt_tokens`
includes cached tokens, so when Bedrock prompt caching is active a
non-streaming client sees `input_tokens` equal to the full prompt and no
cache fields. That looks identical to the cache being broken (#1345),
and the savings tracker never credits the hits. The streaming and OpenAI
paths already map these fields.

Related: #1390 — that PR makes the markers reach Bedrock; this one makes
the result visible in non-streaming responses.

Closes # (contributes to #1345 together with #1390; not closing it
alone)

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

- Extract `_anthropic_usage_from_litellm()` in
`headroom/backends/litellm.py`: maps `cache_read_input_tokens` /
`cache_creation_input_tokens` (with `prompt_tokens_details` fallback)
into the Anthropic-shape usage and reports `input_tokens` without the
cached portion, matching what Anthropic returns.
- Use it in `complete_message()` instead of the inline
`prompt_tokens`/`completion_tokens` dict.
- Add `tests/test_litellm_nonstream_cache_usage.py` (5 cases: plain
usage, cache read, cache write, `prompt_tokens_details` fallback,
negative clamp).

## Testing

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

### Test Output

```text
$ pytest tests/test_litellm_nonstream_cache_usage.py -q
5 passed, 1 warning in 2.13s

$ ruff check headroom/backends/litellm.py tests/test_litellm_nonstream_cache_usage.py
All checks passed!
```

mypy not run locally: my environment fails on unrelated numpy stubs
(`numpy/__init__.pyi: Type statement is only supported in Python
3.12+`); relying on CI for the mypy gate.

## Real Behavior Proof

- Environment: real AWS Bedrock, us-east-1,
`us.anthropic.claude-sonnet-4-5-20250929-v1:0`, headroom-ai 0.30.0 with
this patch, Python 3.13.
- Exact command / steps: `headroom proxy --backend bedrock
--bedrock-region us-east-1 --mode cache --port 8787`, then three
identical non-streaming `POST /v1/messages` with a 1,226-token system
block marked `cache_control: {"type": "ephemeral"}` (fresh salted
prefix), with the conversion fix from #1390 applied so markers reach
Bedrock.
- Observed result: before this patch usage reported `input_tokens=1213`
with no cache fields on every call; after — call 1: `input_tokens=11,
cache_creation_input_tokens=1226`; calls 2–3: `input_tokens=11,
cache_read_input_tokens=1226`. Matches a direct-to-Bedrock baseline
(boto3 `invoke_model` with the same payload).
- Not tested: streaming path (unchanged by this PR), non-Bedrock LiteLLM
providers (mapping is provider-agnostic: fields are absent → behavior
identical to before).

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A — token counts are in Real Behavior Proof above.

## Additional Notes

Documentation and CHANGELOG unchecked: single-function bugfix, no
user-facing docs describe the non-streaming usage fields; happy to add a
CHANGELOG entry if maintainers want one.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-09 12:44:36 -05:00
Adryan Eka Vandra
bb2acf700a
fix(proxy): honor x-headroom-base-url on /v1/messages route (#1763)
## Description

The Anthropic Messages route (`POST /v1/messages`) ignored the
`x-headroom-base-url` per-request upstream override and unconditionally
forwarded to `api.anthropic.com`. `handle_anthropic_messages` already
accepts `upstream_base_url` (it builds the upstream URL via
`build_copilot_upstream_url`), but the route never passed it. Clients
that speak the Anthropic Messages wire format while authenticating
against a non-Anthropic gateway (e.g. OpenCode Zen's "Go" tier) were
forwarded to the real Anthropic API, which rejected the gateway key with
`401 invalid x-api-key`.

The route now reads and trims `x-headroom-base-url` and passes it
through as `upstream_base_url`, mirroring the OpenAI-compatible routes
and the generic passthrough route (`proxy_routes.py:996`).

Closes #1760

## Type of Change

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

## Changes Made

- `headroom/providers/proxy_routes.py`: the `/v1/messages` route reads
`x-headroom-base-url`; when present it strips whitespace and a trailing
slash and passes the value as `upstream_base_url` to
`handle_anthropic_messages`. Absent or whitespace-only headers keep the
previous default (`api.anthropic.com`).
- `tests/test_proxy/test_anthropic_upstream_header.py`: new test module
pinning the route contract (header present, absent, empty,
whitespace-only, trimming + trailing-slash stripping).
- `docs/content/docs/configuration.mdx`: new "Proxy upstream override
(`x-headroom-base-url`)" subsection under Per-Request Overrides
documenting the header across the OpenAI, Anthropic Messages, and
passthrough routes.
- `CHANGELOG.md`: `Unreleased > Fixed` entry for the `/v1/messages`
override.

## 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_proxy/ -k "anthropic or passthrough or bedrock"
collected 140 items / 91 deselected / 49 selected
tests/test_proxy/test_anthropic_upstream_header.py ....                  [ 65%]
...
49 passed, 91 deselected, 1 warning in 79.68s

$ ruff check headroom/providers/proxy_routes.py tests/test_proxy/test_anthropic_upstream_header.py
All checks passed!

$ mypy headroom/providers/proxy_routes.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

Ran the actual `headroom proxy` against a local mock upstream (a tiny
HTTP server on `127.0.0.1:9911` that logs the path it receives) to
reproduce the issue's before/after.

- Environment: local, macOS, Python 3.12; ran `headroom proxy --port
8799` against a local mock upstream (a tiny HTTP server on
`127.0.0.1:9911` that logs the path it receives).
- Exact command / steps: started the proxy and the mock upstream, then
sent one `POST /v1/messages` **with** the override header and one
**without** it (negative control), using these two `curl` commands.

  ```bash
# WITH the override header — expect routing to the mock at
127.0.0.1:9911
  curl http://127.0.0.1:8799/v1/messages \
-H "content-type: application/json" -H "anthropic-version: 2023-06-01" \
    -H "x-headroom-base-url: http://127.0.0.1:9911" \
    -H "x-api-key: zen-test-key" \
-d
'{"model":"glm-5.2","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}'

# WITHOUT the override header — expect routing to the real
api.anthropic.com
  curl http://127.0.0.1:8799/v1/messages \
-H "content-type: application/json" -H "anthropic-version: 2023-06-01" \
    -H "x-api-key: sk-ant-fake" \
-d
'{"model":"claude-3-5-sonnet-20241022","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}'
  ```

- Observed result: with the header, the mock upstream logged `HIT
path=/v1/messages x-api-key=zen-test-key` and the proxy returned `HTTP
200`, confirming the request was routed to
`<x-headroom-base-url>/v1/messages` carrying the gateway key. Without
the header, the request went to the real `api.anthropic.com` (returned
`HTTP 401` with a genuine `request_id` and
`{"type":"authentication_error","message":"invalid x-api-key"}`) and the
mock received no additional hit — matching the pre-fix behavior in the
issue. Also verified by TDD: the two override unit cases failed before
the route change (`assert None == 'https://opencode.ai/zen/go'`) and
passed after it; all 4 new cases and 49 related proxy tests are green.
- Not tested: a request against the real OpenCode Zen gateway (no
credentials); the gateway path is verified with a local mock upstream
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] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

- Manual testing against the real OpenCode Zen gateway is N/A (no
credentials); a local mock upstream is used instead to prove the routing
(see Real Behavior Proof).
- Scope is limited to `/v1/messages`. The related
`/v1/messages/count_tokens` route uses a fixed passthrough target and is
out of scope for this issue.
2026-07-09 12:43:40 -05:00
Abhay Singh
7fd0c42ced
fix(memory/sync): make Codex AGENTS.md adapter additive (stop wiping memories) (#1674)
## Description

`sync_export` (in `headroom/memory/sync.py`) hands each adapter only the
**delta** — the memories the agent doesn't already have. It reads the
agent's
current memories, builds `agent_hashes`, and only puts a memory in
`to_export`
if its hash isn't already there:

```python
agent_hashes = {am.content_hash for am in await adapter.read_memories()}
for mem in existing_memories:
    if content_hash in agent_hashes:
        continue          # skip: agent already has it
    to_export.append(...)
exported = await adapter.write_memories(to_export)   # ← delta only
```

The `ClaudeCodeAdapter` is additive (a file per memory + index append),
so a
delta is correct for it. But `CodexAdapter.write_memories` rebuilt its
**entire**
`<!-- headroom:memory --> … <!-- /… -->` section from just the passed
delta and
spliced it back with `_MARKER_PATTERN.sub`. So every export
**overwrote** the
section with only the new items.

Concrete thrash:
- DB has A, B → first sync exports `[A, B]` → section = A, B 
- Add C → next sync's delta is `[C]` → section becomes **just C** (A, B
erased)
- Now the agent only has C → next sync's delta is `[A, B]` → section
becomes
  **A, B** (C erased) …

The file bounces between disjoint subsets and never holds the full set —
silent
memory loss on every sync.

Closes: no issue filed — found while auditing the memory sync adapters.

## Fix

Make `CodexAdapter.write_memories` additive, matching the adapter
contract the
ClaudeCode adapter already follows: read the facts already in the
managed
section, merge the incoming delta into them (dedup by rendered
first-line), and
write the union. Return the count actually added. The function-based
`re.sub`
is kept so literal backslashes / `\u` in a memory aren't treated as
regex
escapes.

## Type of Change

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

## Changes Made

- `headroom/memory/sync_adapters/codex_agent.py`: `write_memories` now
merges the delta into the existing section instead of replacing the
whole section.
- `tests/test_memory_sync.py`: **two existing tests asserted the old
replace-the-whole-section behavior — i.e. they codified this bug.**
Updated them to the additive semantics (an existing managed fact is
preserved) and added `test_write_accumulates_across_syncs` covering the
delta-export-across-syncs scenario.
- `CHANGELOG.md`: Bug Fixes entry under Unreleased.

## Testing

- [x] New regression test added; two behavior-codifying tests corrected
- [x] Linting passes (`ruff check`) and formatting is clean (`ruff
format --check`)
- [ ] Full `pytest` deferred to CI (local-OOM reason below).

```text
$ uv run ruff check headroom/memory/sync_adapters/codex_agent.py tests/test_memory_sync.py
All checks passed!
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12.11, headroom from this branch.
Importing `headroom` loads the torch/transformers stack and a full
`pytest` gets OOM-killed on this box, so I verified the merge logic with
a dependency-free script (only stdlib) and left the full pytest to CI.
- Exact command / steps: replicated `write_memories` (read existing
section bullets → merge delta → splice) against real temp files, then
ran the multi-sync scenario: export `[A, B]`, then export the delta
`[C]`, then re-export an existing fact; plus a literal-backslash memory
and a no-marker file.
- Observed result: after the delta export of C, A and B are still
present (no wipe); re-exporting an existing fact adds nothing;
backslashes land literally; a file with no marker keeps its surrounding
content:

```text
OK: A,B preserved after delta-export of C (no wipe)
OK: re-writing existing fact -> added 0, others intact
OK: literal backslashes preserved
OK: no-marker file -> section appended, existing preserved
CODEX MERGE LOGIC VERIFIED
```

- Not tested: a full DB→adapter `sync_export` run end-to-end (needs a
memory backend/embedder = the heavy stack); the delta contract is
confirmed by reading `sync.py`, and the adapter merge is covered by the
unit tests. Full local `pytest` deferred to CI (OOM, per above).

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes — ran
lint + a standalone logic check; full pytest deferred to CI (local OOM,
disclosed above)
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

- The most reviewer-sensitive part is that I changed two existing tests.
They were asserting `"old fact" not in content` after a write — i.e.
they locked in the replace-the-whole-section behavior that causes the
wipe. Given `sync_export` only ever passes the delta, that behavior is
the bug; the updated tests assert the fact is preserved. Happy to
discuss if you'd rather fix this on the `sync_export` side instead (e.g.
pass the full set to replace-style adapters), but making the adapter
additive matches the existing ClaudeCode adapter and keeps the contract
uniform.
- @JerrettDavis tagging you — flagging the test change up front so it's
not a surprise in the diff.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-09 12:43:01 -05:00
dependabot[bot]
bfe4b4d21f
ci: bump actions/checkout from 4 to 7 (#1414)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to
7.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/checkout/releases">actions/checkout's
releases</a>.</em></p>
<blockquote>
<h2>v7.0.0</h2>
<h2>What's Changed</h2>
<ul>
<li>block checking out fork pr for pull_request_target and workflow_run
by <a href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2454">actions/checkout#2454</a></li>
<li>Bump actions/publish-immutable-action from 0.0.3 to 0.0.4 in the
minor-actions-dependencies group across 1 directory by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/actions/checkout/pull/2458">actions/checkout#2458</a></li>
<li>Bump flatted from 3.3.1 to 3.4.2 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/actions/checkout/pull/2460">actions/checkout#2460</a></li>
<li>Bump js-yaml from 4.1.0 to 4.2.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/actions/checkout/pull/2461">actions/checkout#2461</a></li>
<li>Bump <code>@​actions/core</code> and
<code>@​actions/tool-cache</code> and Remove uuid by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/actions/checkout/pull/2459">actions/checkout#2459</a></li>
<li>upgrade module to esm and update dependencies by <a
href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2463">actions/checkout#2463</a></li>
<li>Bump the minor-npm-dependencies group across 1 directory with 3
updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/actions/checkout/pull/2462">actions/checkout#2462</a></li>
<li>getting ready for checkout v7 release by <a
href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2464">actions/checkout#2464</a></li>
<li>update error wording by <a
href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2467">actions/checkout#2467</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/checkout/pull/2454">actions/checkout#2454</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/checkout/compare/v6.0.3...v7.0.0">https://github.com/actions/checkout/compare/v6.0.3...v7.0.0</a></p>
<h2>v6.0.3</h2>
<h2>What's Changed</h2>
<ul>
<li>Update changelog by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2357">actions/checkout#2357</a></li>
<li>fix: expand merge commit SHA regex and add SHA-256 test cases by <a
href="https://github.com/yaananth"><code>@​yaananth</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2414">actions/checkout#2414</a></li>
<li>Fix checkout init for SHA-256 repositories by <a
href="https://github.com/yaananth"><code>@​yaananth</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2439">actions/checkout#2439</a></li>
<li>Update changelog for v6.0.3 by <a
href="https://github.com/yaananth"><code>@​yaananth</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2446">actions/checkout#2446</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/yaananth"><code>@​yaananth</code></a>
made their first contribution in <a
href="https://redirect.github.com/actions/checkout/pull/2414">actions/checkout#2414</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/checkout/compare/v6...v6.0.3">https://github.com/actions/checkout/compare/v6...v6.0.3</a></p>
<h2>v6.0.2</h2>
<h2>What's Changed</h2>
<ul>
<li>Add orchestration_id to git user-agent when ACTIONS_ORCHESTRATION_ID
is set by <a
href="https://github.com/TingluoHuang"><code>@​TingluoHuang</code></a>
in <a
href="https://redirect.github.com/actions/checkout/pull/2355">actions/checkout#2355</a></li>
<li>Fix tag handling: preserve annotations and explicit fetch-tags by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2356">actions/checkout#2356</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/checkout/compare/v6.0.1...v6.0.2">https://github.com/actions/checkout/compare/v6.0.1...v6.0.2</a></p>
<h2>v6.0.1</h2>
<h2>What's Changed</h2>
<ul>
<li>Update all references from v5 and v4 to v6 by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2314">actions/checkout#2314</a></li>
<li>Add worktree support for persist-credentials includeIf by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2327">actions/checkout#2327</a></li>
<li>Clarify v6 README by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2328">actions/checkout#2328</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/checkout/compare/v6...v6.0.1">https://github.com/actions/checkout/compare/v6...v6.0.1</a></p>
<h2>v6.0.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Update README to include Node.js 24 support details and requirements
by <a href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a>
in <a
href="https://redirect.github.com/actions/checkout/pull/2248">actions/checkout#2248</a></li>
<li>Persist creds to a separate file by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2286">actions/checkout#2286</a></li>
<li>v6-beta by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2298">actions/checkout#2298</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/actions/checkout/blob/main/CHANGELOG.md">actions/checkout's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<h2>v7.0.0</h2>
<ul>
<li>Block checking out fork PR for pull_request_target and workflow_run
by <a href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2454">actions/checkout#2454</a></li>
<li>Bump actions/publish-immutable-action from 0.0.3 to 0.0.4 in the
minor-actions-dependencies group across 1 directory by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/actions/checkout/pull/2458">actions/checkout#2458</a></li>
<li>Bump flatted from 3.3.1 to 3.4.2 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/actions/checkout/pull/2460">actions/checkout#2460</a></li>
<li>Bump js-yaml from 4.1.0 to 4.2.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/actions/checkout/pull/2461">actions/checkout#2461</a></li>
<li>Bump <code>@​actions/core</code> and
<code>@​actions/tool-cache</code> and Remove uuid by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/actions/checkout/pull/2459">actions/checkout#2459</a></li>
<li>upgrade module to esm and update dependencies by <a
href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2463">actions/checkout#2463</a></li>
<li>Bump the minor-npm-dependencies group across 1 directory with 3
updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/actions/checkout/pull/2462">actions/checkout#2462</a></li>
</ul>
<h2>v6.0.3</h2>
<ul>
<li>Fix checkout init for SHA-256 repositories by <a
href="https://github.com/yaananth"><code>@​yaananth</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2439">actions/checkout#2439</a></li>
<li>fix: expand merge commit SHA regex and add SHA-256 test cases by <a
href="https://github.com/yaananth"><code>@​yaananth</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2414">actions/checkout#2414</a></li>
</ul>
<h2>v6.0.2</h2>
<ul>
<li>Fix tag handling: preserve annotations and explicit fetch-tags by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2356">actions/checkout#2356</a></li>
</ul>
<h2>v6.0.1</h2>
<ul>
<li>Add worktree support for persist-credentials includeIf by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2327">actions/checkout#2327</a></li>
</ul>
<h2>v6.0.0</h2>
<ul>
<li>Persist creds to a separate file by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2286">actions/checkout#2286</a></li>
<li>Update README to include Node.js 24 support details and requirements
by <a href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a>
in <a
href="https://redirect.github.com/actions/checkout/pull/2248">actions/checkout#2248</a></li>
</ul>
<h2>v5.0.1</h2>
<ul>
<li>Port v6 cleanup to v5 by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2301">actions/checkout#2301</a></li>
</ul>
<h2>v5.0.0</h2>
<ul>
<li>Update actions checkout to use node 24 by <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2226">actions/checkout#2226</a></li>
</ul>
<h2>v4.3.1</h2>
<ul>
<li>Port v6 cleanup to v4 by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2305">actions/checkout#2305</a></li>
</ul>
<h2>v4.3.0</h2>
<ul>
<li>docs: update README.md by <a
href="https://github.com/motss"><code>@​motss</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1971">actions/checkout#1971</a></li>
<li>Add internal repos for checking out multiple repositories by <a
href="https://github.com/mouismail"><code>@​mouismail</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1977">actions/checkout#1977</a></li>
<li>Documentation update - add recommended permissions to Readme by <a
href="https://github.com/benwells"><code>@​benwells</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2043">actions/checkout#2043</a></li>
<li>Adjust positioning of user email note and permissions heading by <a
href="https://github.com/joshmgross"><code>@​joshmgross</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2044">actions/checkout#2044</a></li>
<li>Update README.md by <a
href="https://github.com/nebuk89"><code>@​nebuk89</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2194">actions/checkout#2194</a></li>
<li>Update CODEOWNERS for actions by <a
href="https://github.com/TingluoHuang"><code>@​TingluoHuang</code></a>
in <a
href="https://redirect.github.com/actions/checkout/pull/2224">actions/checkout#2224</a></li>
<li>Update package dependencies by <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2236">actions/checkout#2236</a></li>
</ul>
<h2>v4.2.2</h2>
<ul>
<li><code>url-helper.ts</code> now leverages well-known environment
variables by <a href="https://github.com/jww3"><code>@​jww3</code></a>
in <a
href="https://redirect.github.com/actions/checkout/pull/1941">actions/checkout#1941</a></li>
<li>Expand unit test coverage for <code>isGhes</code> by <a
href="https://github.com/jww3"><code>@​jww3</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1946">actions/checkout#1946</a></li>
</ul>
<h2>v4.2.1</h2>
<ul>
<li>Check out other refs/* by commit if provided, fall back to ref by <a
href="https://github.com/orhantoy"><code>@​orhantoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1924">actions/checkout#1924</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="9c091bb21b"><code>9c091bb</code></a>
update error wording (<a
href="https://redirect.github.com/actions/checkout/issues/2467">#2467</a>)</li>
<li><a
href="1044a6dea9"><code>1044a6d</code></a>
getting ready for checkout v7 release (<a
href="https://redirect.github.com/actions/checkout/issues/2464">#2464</a>)</li>
<li><a
href="f0282184c7"><code>f028218</code></a>
Bump the minor-npm-dependencies group across 1 directory with 3 updates
(<a
href="https://redirect.github.com/actions/checkout/issues/2462">#2462</a>)</li>
<li><a
href="d914b262ff"><code>d914b26</code></a>
upgrade module to esm and update dependencies (<a
href="https://redirect.github.com/actions/checkout/issues/2463">#2463</a>)</li>
<li><a
href="537c7ef99c"><code>537c7ef</code></a>
Bump <code>@​actions/core</code> and <code>@​actions/tool-cache</code>
and Remove uuid (<a
href="https://redirect.github.com/actions/checkout/issues/2459">#2459</a>)</li>
<li><a
href="130a169078"><code>130a169</code></a>
Bump js-yaml from 4.1.0 to 4.2.0 (<a
href="https://redirect.github.com/actions/checkout/issues/2461">#2461</a>)</li>
<li><a
href="7d09575332"><code>7d09575</code></a>
Bump flatted from 3.3.1 to 3.4.2 (<a
href="https://redirect.github.com/actions/checkout/issues/2460">#2460</a>)</li>
<li><a
href="0f9f3aa320"><code>0f9f3aa</code></a>
Bump actions/publish-immutable-action (<a
href="https://redirect.github.com/actions/checkout/issues/2458">#2458</a>)</li>
<li><a
href="f9e715a95f"><code>f9e715a</code></a>
block checking out fork pr for pull_request_target and workflow_run (<a
href="https://redirect.github.com/actions/checkout/issues/2454">#2454</a>)</li>
<li><a
href="df4cb1c069"><code>df4cb1c</code></a>
Update changelog for v6.0.3 (<a
href="https://redirect.github.com/actions/checkout/issues/2446">#2446</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/actions/checkout/compare/v4...v7">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-09 11:09:14 -05:00
Tejas Chopra
807e5e3e6a
ci(release-please): use a PAT so releases trigger the publish workflows (#1920)
## Description

Makes the release pipeline hands-off by fixing the token release-please
uses.

### Why the current setup silently breaks publishing
release-please authenticates with `secrets.GITHUB_TOKEN`. **A
release/tag created by `GITHUB_TOKEN` does not emit events that trigger
other workflows** — this is GitHub's built-in recursion guard. So
`release.yml` (PyPI + npm) and `docker.yml`, which both fire on
`release: published`, **never ran off a bot-created release**. The
result: releases had to be cut by hand (`gh release create`, which runs
as a real user and *does* trigger them), and pip / npm / Docker drifted
out of sync (pip 0.30 vs Docker 0.27).

Proof: creating v0.31.0 manually (my user token) immediately kicked off
both `Release: v0.31.0` and `Docker: v0.31.0`; a `GITHUB_TOKEN`-created
release would not have.

### Change
Use `RELEASE_PLEASE_TOKEN` (a fine-grained PAT with `contents: write` +
`pull-requests: write`, treated by GitHub as a real user):
```yaml
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
```
- The release the bot creates now **does** trigger `release.yml` /
`docker.yml` → PyPI + npm + Docker publish automatically on merge of the
release PR.
- The PAT can also tag past branch/tag protection.
- Falls back to `GITHUB_TOKEN` if the secret is ever unset — the release
PR still opens; it just won't trigger downstream publishes (i.e. no
worse than today).

The `RELEASE_PLEASE_TOKEN` secret is already configured in repo
settings.

## Type of Change

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

## Changes Made

- `.github/workflows/release-please.yml`: swap `token: ${{
secrets.GITHUB_TOKEN }}` for `${{ secrets.RELEASE_PLEASE_TOKEN ||
secrets.GITHUB_TOKEN }}`, with a comment explaining the recursion-guard
reason.

## Testing

- [x] Manual testing performed (YAML validated; token expression
resolves)

### Test Output

```text
$ python -c "import yaml; ... token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}"
parse OK
```

## Real Behavior Proof

- Environment: local macOS; CI workflow YAML change only.
- Exact command / steps: changed the `token:` input on the
`release-please-action` step to the PAT (with GITHUB_TOKEN fallback);
validated the workflow YAML parses and the token expression is correct.
- Observed result: the workflow now authenticates release-please as a
real user via `RELEASE_PLEASE_TOKEN`, so releases it creates will emit
`release: published` and trigger `release.yml` + `docker.yml`. Verified
out-of-band that a user-token release does trigger those two workflows
(v0.31.0), whereas the bot token does not.
- Not tested: a full bot-driven release cycle end-to-end (only
observable when the next release PR merges with this token in place);
this PR is the enabling change for that.

## 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
2026-07-09 09:07:19 -07:00
JD Davis
55efb1c77d
fix(proxy): keep OpenAI tool observations mutable in cache mode (#1884)
## Description

Diagnoses and fixes the low-savings OpenAI-compatible cache-mode path
reported in #1696.

OpenAI-compatible tool-calling clients can end a turn with `role:
"tool"` (or legacy `role: "function"`) rather than `role: "user"`. The
OpenAI chat handler's cache-mode freeze boundary treated those tails as
non-mutable, and because `HeadroomProxy` resolves
`_strict_previous_turn_frozen_count` from the Anthropic mixin first, the
OpenAI-specific helper was not used in production. That froze the entire
conversation before `ContentRouter` ran, leaving no live tool
observation to compress and producing near-pass-through savings on long
coding sessions.

This PR keeps final OpenAI tool/function observations mutable in cache
mode, explicitly calls the OpenAI helper to avoid the mixin-name
collision, and clamps negative token-savings artifacts at the
metrics/cost aggregation boundary so stats cannot under-report actual
forwarded savings.

Closes #1696

## 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 final OpenAI `user`, `tool`, and `function` messages as the
mutable cache-mode live zone.
- Route OpenAI cache-boundary calls through
`OpenAIHandlerMixin._strict_previous_turn_frozen_count` explicitly so
the Anthropic mixin method cannot shadow it in `HeadroomProxy`'s MRO.
- Preserve cache-mode live-tail boundaries even when compression-cache
state would otherwise freeze the whole request.
- Clamp negative `tokens_saved` artifacts in `CostTracker.record_tokens`
and `PrometheusMetrics.record_request`.
- Add regression coverage for OpenAI final `tool`/`function` tails,
over-frozen tracker state, and non-negative savings aggregation.

## Testing

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

### Test Output

```text
$ maturin build --profile ci --out dist --interpreter python
Built wheel for abi3 Python >= 3.10 to dist\headroom_ai-0.29.0-cp310-abi3-win_amd64.whl

$ python -m pytest tests\test_proxy_handler_helpers.py tests\test_proxy_openai_cache_stability.py tests\test_observability_metrics.py tests\test_cost_tracker_counterfactual.py
49 passed in 10.27s

$ python -m ruff check .
All checks passed!

$ python -m mypy headroom
Success: no issues found in 407 source files

$ python -m pytest
53 failed, 7703 passed, 488 skipped, 5893 warnings, 131 errors in 595.18s (0:09:55)
```

Full-suite note: the full local `pytest` run was attempted on
Windows/Python 3.13 after building `headroom._core`. It did not complete
green due to broad pre-existing/local-environment failures outside this
change area, dominated by SQLite/memory persistence permission/path
errors plus unrelated adapter/cache/tool tests. The focused regression
suite for this PR passes, and repo-level lint/type gates pass.

## Real Behavior Proof

- Environment: Windows, Python 3.13.13, Rust/Cargo available, local
`headroom._core` wheel built with `maturin build --profile ci`.
- Exact command / steps: ran the OpenAI cache-stability tests with final
`role: "tool"` and `role: "function"` chat tails.
- Observed result:
`test_openai_cache_mode_keeps_final_tool_observation_mutable[tool]` and
`[function]` pass, proving the pipeline receives `frozen_message_count
== 2` for a 3-message request instead of freezing all 3 messages.
- Not tested: live Lemonade/KiloCode upstream session; no local Lemonade
Server was available.

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A

## Additional Notes

Docs and CHANGELOG are N/A for this narrow proxy bug fix. The broad
local `pytest` checkbox is intentionally left unchecked because the full
suite had unrelated local-environment failures; see the test output
above. Focused regression tests, `ruff check .`, and `mypy headroom` are
green.
2026-07-09 07:51:01 -07:00
Tejas Chopra
68676daa50
feat: ship the coding profile as Headroom's out-of-box default posture (#1893)
Make a bare `headroom proxy` (and the uvicorn factory / argparse main)
default to the cache-mode coding posture instead of requiring users to
set a dozen env vars.

Profile (agent_savings.py):
* "coding" is now the DEFAULT profile (DEFAULT_PROFILE), and it is
rewritten for cache mode: proxy_mode="cache" and
compress_user_messages=True (cache mode compresses the newest
OBSERVATION delta — a user/tool turn — so compress_user must be on or
there is nothing to compress; prefix stability is preserved by the delta
engine, not by refusing to touch user turns).
* AgentSavingsProfile carries the standalone router/handler toggles too
(tool_search, cross_turn_dedup, lossless_then_lossy, protect_reads,
code_aware, effort_router, lossless, min_chars_for_block); proxy_env()
emits them. Defaults preserve current behavior for the other profiles.
* coding sets: tool_search=1, dedupe=1, lossless_then_lossy=1,
protect_reads=1, code_aware=1, effort_router=0, lossless=0,
min_chars_for_block=25. CCR stays ON (no HEADROOM_NO_CCR) so any lossy
loss is recoverable.
* apply_agent_savings_env_defaults() now honors an explicit
HEADROOM_SAVINGS_PROFILE already in the env before falling back to the
default.

Delivery (pollution-free by construction):
* MODE and savings_profile default via INLINE defaults in the config
builders (cache / coding) — no global env mutation, so unit tests that
build config directly keep clean defaults.
* The request-time toggles are seeded into os.environ (setdefault) via
seed_proxy_env_defaults() ONLY at the executable/deployment entries —
run_server() (before serving) and create_app_from_env() (uvicorn
factory) — NOT in the CLI command or any library builder, so CliRunner
tests never leak coding defaults into os.environ across tests.
* CLI code_aware now defaults ON, matching the argparse server path
(degrades to a no-op without tree-sitter).

All explicit user env vars / CLI flags still win (setdefault + `or`
fallbacks). HEADROOM_LOSSLESS was already 0 by default; unchanged.

Tests: coding-profile + CLI-proxy-env tests updated to the new defaults;
1047 passed across the touched areas (only pre-existing memory/env
failures remain).

## Description

<!-- Briefly explain the change and why it is needed. -->

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)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- 

## Testing

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

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

### Test Output

```text
# Paste relevant command output or artifact links here
```

## Real Behavior Proof

- Environment:
- Exact command / steps:
- Observed result:
- Not tested:

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

Add screenshots to help explain your changes.

## Additional Notes

<!-- Mention any N/A checklist items, tradeoffs, follow-ups, or
maintainer context. -->

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 07:49:54 -07:00
Tejas Chopra
c9217856d3
Tejas/turn hooks extension (#1903)
## Description

<!-- Briefly explain the change and why it is needed. -->

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)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- 

## Testing

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

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

### Test Output

```text
# Paste relevant command output or artifact links here
```

## Real Behavior Proof

- Environment:
- Exact command / steps:
- Observed result:
- Not tested:

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

Add screenshots to help explain your changes.

## Additional Notes

<!-- Mention any N/A checklist items, tradeoffs, follow-ups, or
maintainer context. -->
2026-07-09 07:49:06 -07:00
github-actions[bot]
a9515155c7
chore: release main (#1918)
🤖 I have created a release *beep* *boop*
---


<details><summary>0.31.0</summary>

##
[0.31.0](https://github.com/headroomlabs-ai/headroom/compare/v0.30.0...v0.31.0)
(2026-07-09)


### Features

* **cache:** provider-agnostic cache-mode delta + cc-agnostic prefix
comparison
([#1868](https://github.com/headroomlabs-ai/headroom/issues/1868))
([7c2f0ea](7c2f0ea079))
* **ccr:** wire retrieve-tool interception into OpenAI Responses handler
([#1898](https://github.com/headroomlabs-ai/headroom/issues/1898))
([62cd307](62cd3072a2))
* **compression:** add audit-safe mode with protected pattern matching
([#1899](https://github.com/headroomlabs-ai/headroom/issues/1899))
([bb112dd](bb112dd176))
* **content-router:** accept any real compression (remove min-savings
floor)
([#1771](https://github.com/headroomlabs-ai/headroom/issues/1771))
([6c31db9](6c31db97fb))
* **content-router:** lossless-first dispatch, cross-turn dedup, and A7
lossy-after-fold
([#1818](https://github.com/headroomlabs-ai/headroom/issues/1818))
([60af15f](60af15f96f))
* **proxy:** add provider-only HTTP proxy
([#1807](https://github.com/headroomlabs-ai/headroom/issues/1807))
([ebe0a3b](ebe0a3bd7b))
* **proxy:** add turn-hook extension point for buffered model turns
([#1891](https://github.com/headroomlabs-ai/headroom/issues/1891))
([ec950f7](ec950f7ef1))


### Bug Fixes

* **build:** enable Intel macOS pip installs via ort-load-dynamic
([#1538](https://github.com/headroomlabs-ai/headroom/issues/1538))
([32ce99e](32ce99e4b4))
* **cache:** avoid fallback session collisions
([#1827](https://github.com/headroomlabs-ai/headroom/issues/1827))
([0f606b6](0f606b6281))
* **ccr:** make expired retrieve misses terminal
([#1781](https://github.com/headroomlabs-ai/headroom/issues/1781))
([9cbdba4](9cbdba4dc1))
* **ccr:** preserve Anthropic re-stream shape
([#1854](https://github.com/headroomlabs-ai/headroom/issues/1854))
([f663894](f663894f60))
* **ccr:** preserve thinking blocks in buffered stream re-synthesis
([#1897](https://github.com/headroomlabs-ai/headroom/issues/1897))
([ede085c](ede085cc11))
* **cli/proxy:** preserve explicit HEADROOM_MIN_TOKENS=0 / MAX_ITEMS=0
([#1886](https://github.com/headroomlabs-ai/headroom/issues/1886))
([3a33af1](3a33af1af3))
* **code-compressor:** CJK-aware relevance-query symbol matching
([#1747](https://github.com/headroomlabs-ai/headroom/issues/1747))
([b38315c](b38315cf72))
* **codex:** discover updated Codex state stores
([#1889](https://github.com/headroomlabs-ai/headroom/issues/1889))
([9d42eba](9d42ebaa1a))
* **codex:** OpenCode Zen telemetry attribution
([#1648](https://github.com/headroomlabs-ai/headroom/issues/1648))
([f18c6bd](f18c6bd896))
* **content-detector:** detect and compress space-separated JSON objects
([#1742](https://github.com/headroomlabs-ai/headroom/issues/1742))
([5194bdc](5194bdc5a6))
* **content-router:** token-measure lossless folds at the acceptance
gate ([#1772](https://github.com/headroomlabs-ai/headroom/issues/1772))
([c5493ea](c5493ea93b))
* **copilot:** normalize subscription routing host
([#1836](https://github.com/headroomlabs-ai/headroom/issues/1836))
([afd9cbd](afd9cbdfaf))
* **copilot:** route mixed-model requests per model
([#1785](https://github.com/headroomlabs-ai/headroom/issues/1785))
([5af5e22](5af5e22862))
* **dashboard:** deduplicate repeated savings metrics
([#1804](https://github.com/headroomlabs-ai/headroom/issues/1804))
([88f935a](88f935a1eb))
* **dashboard:** distinguish unavailable RTK from zero stats in Docker
([#1900](https://github.com/headroomlabs-ai/headroom/issues/1900))
([87f6e93](87f6e93c14))
* **dashboard:** distinguish unavailable RTK from zero stats in Docker
([#1901](https://github.com/headroomlabs-ai/headroom/issues/1901))
([361adcd](361adcd1a0))
* **dashboard:** price proxy savings without litellm
([#1728](https://github.com/headroomlabs-ai/headroom/issues/1728))
([188e382](188e382b44))
* detect and clear stale ANTHROPIC_BASE_URL from crashed wrap sessions
([#1768](https://github.com/headroomlabs-ai/headroom/issues/1768))
([#1837](https://github.com/headroomlabs-ai/headroom/issues/1837))
([84509a4](84509a4b89))
* **docker:** persist headroom workspace in compose
([#1839](https://github.com/headroomlabs-ai/headroom/issues/1839))
([5e29c06](5e29c06aaf))
* **docker:** report source build version
([#1862](https://github.com/headroomlabs-ai/headroom/issues/1862))
([3807488](38074888ac))
* **evals:** default unparseable judge scores below pass threshold
([#1892](https://github.com/headroomlabs-ai/headroom/issues/1892))
([42ebbc6](42ebbc6cce))
* **install:** pass sc.exe create as raw command line so binPath=
quoting survives
([#1654](https://github.com/headroomlabs-ai/headroom/issues/1654))
([#1702](https://github.com/headroomlabs-ai/headroom/issues/1702))
([d6e0710](d6e0710228))
* **install:** persist --no-http2 override through install apply
([#1676](https://github.com/headroomlabs-ai/headroom/issues/1676))
([6fb5f3b](6fb5f3bc3d))
* **mcp:** isolate ClaudeRegistrar CLI config env
([#1888](https://github.com/headroomlabs-ai/headroom/issues/1888))
([1c947b1](1c947b1103))
* **mcp:** surface dead proxy state
([#1786](https://github.com/headroomlabs-ai/headroom/issues/1786))
([931eed8](931eed879d))
* **memory:** resolve Trae cwd metadata from user reminders
([#1737](https://github.com/headroomlabs-ai/headroom/issues/1737))
([#1887](https://github.com/headroomlabs-ai/headroom/issues/1887))
([3e85eb1](3e85eb1880))
* **opencode:** use local MCP config
([#1383](https://github.com/headroomlabs-ai/headroom/issues/1383))
([4bd3ddf](4bd3ddfaa5))
* **proxy/openai:** thread savings-profile kwargs into chat completions
([#1606](https://github.com/headroomlabs-ai/headroom/issues/1606))
([7ff842d](7ff842da17))
* **proxy/openai:** translate max_tokens -&gt; max_completion_tokens on
chat path
([#1774](https://github.com/headroomlabs-ai/headroom/issues/1774))
([285808b](285808b90e))
* **proxy:** bound Codex WS compression fallback latency
([#1802](https://github.com/headroomlabs-ai/headroom/issues/1802))
([d24a3f8](d24a3f8425))
* **proxy:** bound HF tokenizer load and offload token counting off
event loop
([#1738](https://github.com/headroomlabs-ai/headroom/issues/1738))
([46d5d68](46d5d685d9))
* **proxy:** cancel retry backoff on shutdown
([#1834](https://github.com/headroomlabs-ai/headroom/issues/1834))
([da2d8dc](da2d8dc9db))
* **proxy:** compress Anthropic user text blocks when enabled
([#1875](https://github.com/headroomlabs-ai/headroom/issues/1875))
([e36439a](e36439a941))
* **proxy:** freeze must forward cached (compressed) prefix
byte-identical — stop token-mode cache busting
([#1850](https://github.com/headroomlabs-ai/headroom/issues/1850))
([248ae0f](248ae0f3e0))
* **proxy:** fsync savings dir after atomic rename
([#1764](https://github.com/headroomlabs-ai/headroom/issues/1764))
([7de2c1e](7de2c1e4c2))
* **proxy:** keep cache_control bounded + stable so the freeze overlay
stops busting
([#1852](https://github.com/headroomlabs-ai/headroom/issues/1852))
([4820134](48201345be))
* **proxy:** persist lifetime cache-read savings across restarts
([#1665](https://github.com/headroomlabs-ai/headroom/issues/1665))
([908997e](908997ef61))
* **proxy:** preserve streaming passthrough beta headers
([#1783](https://github.com/headroomlabs-ai/headroom/issues/1783))
([0f553a8](0f553a8ebb))
* **proxy:** release _active_streams session lock on setup-phase errors
([#1864](https://github.com/headroomlabs-ai/headroom/issues/1864))
([2ccd831](2ccd831032))
* **proxy:** retry HTTP/2 stream resets instead of 502ing
([#1645](https://github.com/headroomlabs-ai/headroom/issues/1645))
([2ce19c2](2ce19c2c55))
* **proxy:** retry passthrough on transient upstream connection close
([#1513](https://github.com/headroomlabs-ai/headroom/issues/1513))
([5d14080](5d14080c94))
* **proxy:** route Foundry Anthropic messages
([#1878](https://github.com/headroomlabs-ai/headroom/issues/1878))
([739f654](739f654bbd))
* **proxy:** serve /favicon.ico locally instead of tunneling upstream
([#1787](https://github.com/headroomlabs-ai/headroom/issues/1787))
([#1847](https://github.com/headroomlabs-ai/headroom/issues/1847))
([3076e32](3076e32172))
* **proxy:** stop rtk stat failures from corrupting session baseline
([#1693](https://github.com/headroomlabs-ai/headroom/issues/1693))
([681b9a8](681b9a8c1a))
* **proxy:** strip 1m model suffix before upstream forwarding
([#1840](https://github.com/headroomlabs-ai/headroom/issues/1840))
([e22d745](e22d7453d4))
* **proxy:** subtract cache write premiums from net savings
([#1800](https://github.com/headroomlabs-ai/headroom/issues/1800))
([53a465b](53a465b121))
* **router:** honor MCP aliases in excluded tools
([#1822](https://github.com/headroomlabs-ai/headroom/issues/1822))
([#1863](https://github.com/headroomlabs-ai/headroom/issues/1863))
([140d6e4](140d6e4f96))
* **rtk:** link managed rtk onto PATH instead of mutating the hook
([#1698](https://github.com/headroomlabs-ai/headroom/issues/1698))
([140cb05](140cb05fbc))
* **streaming:** preserve server_tool_use sse blocks
([#1826](https://github.com/headroomlabs-ai/headroom/issues/1826))
([4ac5493](4ac54934cb))
* **toin:** publish skip compression recommendations
([#1782](https://github.com/headroomlabs-ai/headroom/issues/1782))
([be51008](be51008c70))
* **transforms:** normalize diff compressor context
([#1801](https://github.com/headroomlabs-ai/headroom/issues/1801))
([838c523](838c5234a8))
* **transforms:** pass through ragged tables instead of misaligning
columns
([#1713](https://github.com/headroomlabs-ai/headroom/issues/1713))
([c7665ca](c7665ca088))
* use rtk native Cursor hook instead of injecting .cursorrules
([#756](https://github.com/headroomlabs-ai/headroom/issues/756))
([#1846](https://github.com/headroomlabs-ai/headroom/issues/1846))
([1573f1f](1573f1fd07))
* **wrap:** replace stale-proxy detection with Vite-style port fallback
([#1406](https://github.com/headroomlabs-ai/headroom/issues/1406))
([b4205c6](b4205c68e6))


### Performance Improvements

* **proxy:** cap compression workers to CPU count
([#1803](https://github.com/headroomlabs-ai/headroom/issues/1803))
([0a3851b](0a3851b240))
* **savings:** batch tracker persistence off the request hot path
([#1817](https://github.com/headroomlabs-ai/headroom/issues/1817))
([451b9f0](451b9f0867))


### Dependencies

* bump the cargo-minor-patch group across 1 directory with 7 updates
([#1909](https://github.com/headroomlabs-ai/headroom/issues/1909))
([45601d9](45601d93bc))
* bump the npm-minor-patch group across 4 directories with 18 updates
([#1907](https://github.com/headroomlabs-ai/headroom/issues/1907))
([8872bbc](8872bbc6a2))
</details>

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

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-09 07:47:54 -07:00
Tejas Chopra
904fa30586
ci(devcontainers): free runner disk on the default variant too (#1917)
## Description

The **Dev Containers → `validate (default)`** job failed on `main` with:

```
error: Failed to install: numpy-2.4.1-...whl
  Caused by: ... No space left on device (os error 28)
postCreateCommand from devcontainer.json failed with exit code 2
```

The failing step was `Start default` → the devcontainer's
`post-create.sh` runs `uv sync`, which fills the runner's disk
installing the ML wheel set (numpy et al.).

The workflow already has a `Free runner disk` step (reclaims ~14 GB),
but it was gated `if: matrix.name == 'memory-stack'` — so the
**default** variant had no disk cushion and tipped over. This drops the
gate so **both** variants free disk before the container build.

Closes #

## Type of Change

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

## Changes Made

- Remove the `matrix.name == 'memory-stack'` gate on the `Free runner
disk` step in `.github/workflows/devcontainers.yml` so it runs for the
`default` variant too; reword the comment to note the default variant's
`uv sync` disk exhaustion.

## Testing

- [x] Linting passes (`ruff check .`) — n/a (workflow YAML)
- [x] Manual testing performed (YAML validated; step now ungated)

### Test Output

```text
$ python -c "import yaml; ... 'Free runner disk' steps: 1 | has if-gate: False"
Free runner disk steps: 1 | has if-gate: False
```

## Real Behavior Proof

- Environment: local macOS; edited CI workflow only.
- Exact command / steps: removed the `if: matrix.name == 'memory-stack'`
gate on the `Free runner disk` step; validated the YAML parses and the
step is now ungated.
- Observed result: `Free runner disk` runs for every `validate` matrix
variant, reclaiming ~14 GB before the devcontainer build so `uv sync` no
longer exhausts the disk on the default variant.
- Not tested: the live CI run (the fix's effect is only observable when
the Dev Containers workflow next runs on this PR / main). The `default`
failure was a runner disk-exhaustion flake, not a code issue.

## 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
2026-07-09 07:44:48 -07:00