Commit graph

2630 commits

Author SHA1 Message Date
Abhay Singh
f64aac9733
fix(proxy/gemini): None-guard token counts from usageMetadata (#2347)
## Description

The non-streaming Gemini/Vertex handler reads token counts straight from
the response's `usageMetadata`:

```python
try:
    usage = resp_json.get("usageMetadata", {})
    total_input_tokens = usage.get("promptTokenCount", optimized_tokens)
    output_tokens = usage.get("candidatesTokenCount", 0)
    cache_read_tokens = usage.get("cachedContentTokenCount", 0)
except (...):
    ...
uncached_input_tokens = max(0, total_input_tokens - cache_read_tokens)   # OUTSIDE the try
```

`.get(key, default)` only falls back when the key is **absent**. When
`usageMetadata` carries a key with a **null** value — which Gemini can
do on a safety-blocked turn that produced no candidates — `.get` returns
`None`. That `None` then reaches:

- `max(0, total_input_tokens - cache_read_tokens)` (a `None - int` →
`TypeError`), and
- `RequestOutcome(output_tokens=...)`, whose field is `int` and which
the metrics recorder increments (`tokens_output_total += output_tokens`
→ `TypeError`).

Both run on the success (non-`except`) path, so a single such response
crashes the request and its outcome recording. The Gemini streaming path
already guards these with a `_usage_int` helper; the non-streaming path
(two sites) did not.

## Fix

Coerce the three counts with `int(... or fallback)`, matching the
streaming `_usage_int` guard and the LiteLLM usage mappings:

```python
total_input_tokens = int(usage.get("promptTokenCount", optimized_tokens) or optimized_tokens)
output_tokens = int(usage.get("candidatesTokenCount", 0) or 0)
cache_read_tokens = int(usage.get("cachedContentTokenCount", 0) or 0)
```

No change for a normal integer usage; only a `None` (or absent) value
now becomes the fallback/0. Applied to both non-streaming
usage-extraction sites in `handlers/gemini.py`.

## Type of Change

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

## Changes Made

- `headroom/proxy/handlers/gemini.py`: `int(... or fallback)`-guard
`promptTokenCount` / `candidatesTokenCount` / `cachedContentTokenCount`
at both non-streaming usage sites.
- `tests/test_proxy/test_gemini_savings_profile.py`: add a regression
driving a `generateContent` request whose
`usageMetadata.candidatesTokenCount` is `null`, asserting a 200, an
`int` `output_tokens == 0`, and `uncached_input_tokens == 20` (the
`max(0, …)` no longer raises).
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

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

### Test Output

```text
$ uvx ruff@0.15.17 check headroom/proxy/handlers/gemini.py tests/test_proxy/test_gemini_savings_profile.py
All checks passed!
$ uvx ruff@0.15.17 format --check headroom/proxy/handlers/gemini.py tests/test_proxy/test_gemini_savings_profile.py
2 files already formatted
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/proxy/handlers/gemini.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOMs this box (ML-stack import), so I
reproduced the extraction with a dependency-free script and left the
full pytest to CI.
- Exact command / steps: modeled the OLD (bare `.get`) and NEW (`int(...
or fallback)`) derivations for a blocked response
(`candidatesTokenCount: null`, valid prompt count), a null
`promptTokenCount`, a normal response, and an absent-usage response.
- Observed result: OLD raised `TypeError` at `max(0, None - …)` for a
null prompt count and left `output_tokens = None` (which crashes the
int-typed outcome/metrics recorder) for a null candidate count; NEW
produced `(20, 0)` for the blocked case, `(15, 0)` for the null-prompt
case (the `optimized_tokens` fallback), `(60, 30)` for a normal
response, and the fallbacks for absent usage. The added
`create_app`/`TestClient` test drives the handler end to end and asserts
a 200 with `int` outcome counts.
- Not tested: a live Gemini safety-blocked response; the added test uses
a mocked `_retry_request` returning a `usageMetadata` with a null count,
matching the existing Gemini test harness in this file.

## 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
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

The "unit tests pass locally" box is unchecked because a local pytest
run imports the ML stack and OOMs this box; the added test reuses the
existing `create_app`/`TestClient` + mocked-`_retry_request` harness in
`test_gemini_savings_profile.py` and runs under the normal CI pytest
job, and the behavior is corroborated by the standalone proof above.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-17 12:10:33 -07:00
Abhay Singh
494fb5a60e
fix(security): exclude compromised ast-grep-cli 0.44.1 (supply-chain trojan) (#2342)
## Description

Fixes #2332.

The `ast_grep_cli` **0.44.1** PyPI release was a compromised
supply-chain build: it shipped an info-stealer `sg.exe` (212 KB,
detected as `Trojan:Win64/Lazy!MTB`) alongside the legitimate `ast-grep`
binary as camouflage. `headroom-ai` declares `ast-grep-cli>=0.30.0`, so
a fresh PyPI install — `pip install "headroom-ai[all]"` or `uv tool
install "headroom-ai[all]"` — can resolve the malicious 0.44.1 (the repo
`uv.lock` protects only `uv sync`-from-source, not end users installing
the published package).

## Fix

Exclude exactly the compromised version in the shipped dependency
metadata:

```toml
"ast-grep-cli>=0.30.0,!=0.44.1",
```

`!=0.44.1` removes only the known-bad build, so every other release
stays installable — older safe versions and any future patched release
alike. The committed `uv.lock` already resolves to the safe **0.42.1**,
which still satisfies the new constraint, so no re-resolution is needed;
I updated the lock's `requires-dist` entry to match the new specifier to
keep `uv lock --locked` consistent.

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

- `pyproject.toml`: `ast-grep-cli` constraint is now
`>=0.30.0,!=0.44.1`, with a comment recording why.
- `uv.lock`: update the `ast-grep-cli` `requires-dist` specifier to
match (resolved version unchanged at 0.42.1).

## Testing

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

### Test Output

```text
# Verified the specifier semantics with packaging:
$ python -c "from packaging.specifiers import SpecifierSet; from packaging.version import Version; s=SpecifierSet('>=0.30.0,!=0.44.1'); print(Version('0.44.1') in s, [str(v) for v in ['0.42.1','0.44.0','0.44.2','0.45.0'] if Version(v) in s])"
False ['0.42.1', '0.44.0', '0.44.2', '0.45.0']
# pyproject still parses and carries the new constraint:
$ python -c "import tomllib; print([d for d in tomllib.load(open('pyproject.toml','rb'))['project']['dependencies'] if 'ast-grep' in d])"
['ast-grep-cli>=0.30.0,!=0.44.1']
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12.
- Exact command / steps: evaluated the new
`SpecifierSet('>=0.30.0,!=0.44.1')` against the compromised version and
a range of safe versions, and re-parsed `pyproject.toml`.
- Observed result: `0.44.1` is excluded (`in` -> False); `0.42.1` (the
current lock pin), `0.44.0`, `0.44.2`, `0.45.0`, and `1.0.0` all remain
allowed; the pre-0.30 floor is still enforced. So a resolver can no
longer select the trojaned build, and no legitimate release is blocked.
- Not tested: a full `pip install`/`uv tool install` from a built wheel
on a clean machine; the change is a metadata-only constraint tightening
and the resolver semantics are verified 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
- [ ] 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

## Additional Notes

This is the minimal, high-priority piece of the issue's recommended
actions (pin away from the compromised version). The issue also suggests
an install-docs warning and a `pip-audit` / `uv audit` CI step; those
are worth doing but are separate follow-ups (a CI workflow change I
can't meaningfully validate here), so I left them out to keep this fix
small and obviously correct. No CHANGELOG entry is added since this is a
dependency-metadata security pin, but I'm happy to add one if the
project prefers it here.

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-17 12:10:30 -07:00
Tejas Chopra
0fa337f64f
build(deps): refresh stale uv.lock (reconcile ~30 missing deps) (#2349)
## Description

Refreshes the **stale `uv.lock`** and clears the mcp CVEs in one pass.
main's lock had drifted far from `pyproject.toml` — a full `uv lock`
(with the **CI-matching uv 0.11.29**, what `astral-sh/setup-uv@v5`
installs) reconciles ~30 declared-but-unlocked dependencies and their
transitives. This is the comprehensive counterpart to the minimal #2348.

Closes #

## Type of Change
- [x] Bug fix (security + dependency hygiene)

## Why it's this big
`uv lock --check` **fails on `main`** (the committed lock predates
several pyproject deps). CI hasn't caught it because the pipeline only
ever runs `uv export --frozen` (consume-as-is), never a re-lock — so the
drift accumulated silently. A correct lock is ~1100 lines of
reconciliation.

## Changes Made
- `pyproject.toml`: `mcp>=1.0.0` → `>=1.28.1` (core + `[mcp]` extra) —
carried from the security fix.
- `uv.lock`: full refresh via `uv lock` (uv 0.11.29). `mcp` → **1.28.1**
(clears CVE-2026-52869/52870/59950); ~30 previously-missing deps added;
transitives reconciled.

## Testing
```text
uv 0.11.29 lock --check   -> Resolved 269 packages (up to date, no error)
uv export --frozen ...    -> succeeds (CI pip-audit path)
mcp in refreshed lock     -> 1.28.1
```
The transitive version changes are what `uv lock` produces for the
current `pyproject.toml`; **CI's full test matrix is the validation
gate** for behavior (that's the point of the shards).

## Relationship to #2348
**Superset.** #2348 is the minimal, surgical mcp bump (5-line diff) for
immediate CVE closure with near-zero blast radius. This PR does the same
mcp fix **plus** the full stale-lock reconciliation. Merge **one**:
- Prefer low risk / fast → merge **#2348**, land this refresh separately
afterwards.
- Prefer fixing the lock drift now → merge **this**, close #2348.

No `CHANGELOG.md` edit (release-please owns it).
2026-07-17 04:08:59 -07:00
Tejas Chopra
a90be94e32
fix(deps): bump mcp to 1.28.1 to clear 3 high-severity CVEs (#2348)
## Description

Clears **all 3 open Dependabot alerts** (and the `pip-audit` CI failure)
— every one is `mcp 1.26.0` in `uv.lock`:

| Alert | CVE | Issue | Fix |
|-------|-----|-------|-----|
| #155 | CVE-2026-52870 | experimental task handlers leak cross-session
tasks | 1.27.2 |
| #156 | CVE-2026-52869 | HTTP transports serve session requests without
auth check | 1.27.2 |
| #157 | CVE-2026-59950 | deprecated WebSocket transport lacks
Host/Origin validation | 1.28.1 |

`mcp 1.28.1` satisfies all three.

Closes #

## Type of Change
- [x] Bug fix (security / dependency)

## Changes Made
- `pyproject.toml`: raise the floor `mcp>=1.0.0` → `mcp>=1.28.1` (core
dep **and** the `[mcp]` extra).
- `uv.lock`: bump the `mcp` entry `1.26.0` → `1.28.1` (version +
sdist/wheel URL, sha256, size from PyPI).

**Surgical on purpose.** mcp 1.28.1's resolved dependency set is
unchanged for this project's Python range (1.26 vs 1.28.1 differ only in
`python_version>=3.14` conditionals and an httpx upper bound already
satisfied), so no other locked package changes. Verified: `uv.lock`
parses, `mcp = 1.28.1`, no `mcp-1.26.0` refs remain.

## Testing
```text
python -c "import tomllib; ...; print(pkgs['mcp'])"  -> 1.28.1   (uv.lock valid TOML)
git diff --stat                                       -> pyproject.toml | 4 ; uv.lock | 6
grep -c mcp-1.26.0 uv.lock                            -> 0
```
mcp 1.28.1 ≥ every advisory's fixed-version, so all 3 alerts + pip-audit
clear.

## Real Behavior Proof
- Env: local; hashes fetched from
`https://pypi.org/pypi/mcp/1.28.1/json`.
- Steps: bumped the pyproject floor + the single mcp lock entry;
validated TOML + version + absence of old refs.
- Not tested: full `uv sync` (the lock is separately stale — see note).

## Note (deliberate scoping)
A full `uv lock` refresh churns ~900 lines: the lock is **separately
stale** (missing some declared deps) and local `uv` resolution diverges
(major downgrades of protobuf/posthog/portalocker — likely an env
artifact). That's a pre-existing lock-hygiene problem for its own PR —
**not** bundled into this security fix. No `CHANGELOG.md` edit
(release-please owns it).
2026-07-16 23:57:08 -07:00
Tejas Chopra
4381388d56
chore: release main (#1923)
## Description

Release Please generated the 0.33.0 release PR for main. This updates
release metadata, package versions, and the generated changelog for the
0.33.0 release.

I also aligned the agent-hook plugin manifests, marketplace metadata,
editable lockfile package version, and canonical MCP `server.json`
descriptor to 0.33.0 so all package/plugin/registry version declarations
match the Release Please version bump.

## Type of Change

- [x] Documentation update
- [x] Release / packaging metadata

## Changes Made

- Updated `.release-please-manifest.json`, `pyproject.toml`,
`plugins/openclaw/package.json`, and `sdk/typescript/package.json` to
0.33.0.
- Updated the generated `CHANGELOG.md` release notes for 0.33.0.
- Synced `plugins/headroom-agent-hooks` plugin manifests and marketplace
metadata to 0.33.0.
- Synced `uv.lock` editable `headroom-ai` package version to 0.33.0.
- Regenerated the canonical MCP `server.json` descriptor to 0.33.0.

## Testing

- [x] Version verification passes
- [x] Version-sync tests pass
- [x] MCP server descriptor test passes
- [x] Whitespace check passes

### Test Output

```text
uv run python scripts/verify-versions.py
All versions aligned at 0.33.0

uv run pytest scripts/tests/test_version_sync.py scripts/tests/test_sync_plugin_versions.py -q
14 passed in 0.80s

uv run pytest tests/test_mcp_registry/test_server_json.py -q
4 passed in 0.42s

git diff --check
# no output
```

## Real Behavior Proof

- Environment: Windows 11, local checkout of the Release Please branch.
- Exact command / steps: Ran version verification and MCP descriptor
tests after syncing release metadata, plugin marketplace versions,
lockfile version, and `server.json`.
- Observed result: All package, plugin manifest, marketplace, lockfile,
and MCP descriptor release versions are aligned at 0.33.0.
2026-07-16 21:27:08 -07:00
Tejas Chopra
63945abe3a
chore: sync version state to released 0.31.0 to unblock release-please (v0.32.0) (#2338)
## Description

**Fixes the Release Please pipeline so it emits `v0.32.0`.** pip /
Docker / npm are out of sync because 0.32.0 was never actually released.

### Root cause
Same failure mode as #1916. #2175 (a `fix(deps)` PR) bumped
`pyproject.toml` + `.release-please-manifest.json` to **0.32.0
out-of-band**, so release-please reads 0.32.0 as the *current* version
and computes the next release as **0.33.0** (#1923) — **skipping 0.32.0,
which was never tagged, GitHub-released, or published to PyPI/Docker.**
The last real release is `v0.31.0` (2026-07-09). The plugin/marketplace
manifests were also left at 0.31.0, so version state was split-brained:

```
pyproject.toml / openclaw / sdk-typescript : 0.32.0   <- #2175
plugin.json (x2) / marketplace.json (x2)   : 0.31.0
manifest                                    : 0.32.0
```

### Fix
Realign every version-tracked file **and** the RP manifest to the last
real release, **0.31.0**, via the repo's own `scripts/version-sync.py
--version 0.31.0`. Versions only — no code change.

## What happens after merge
1. Release Please runs on `main`, sees `manifest = 0.31.0` + releasable
commits since `v0.31.0`, and **rewrites its release PR (#1923) to
`chore: release 0.32.0`** (bumping every version file).
2. Merging that PR tags `v0.32.0` and fires `release: published`, which
publishes **PyPI + npm (SDK + openclaw) + Docker** at 0.32.0 in one shot
— bringing all registries back in sync.

## Changes Made
- `.release-please-manifest.json` → `0.31.0`
- `pyproject.toml`, `sdk/typescript/package.json`,
`plugins/openclaw/package.json`, `.releasemetadata` → `0.31.0` (via
`version-sync.py`)
- Plugin/marketplace manifests were already `0.31.0` (unchanged).

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

## Real Behavior Proof
- Environment: local `.venv`.
- Steps: `version-sync.py --version 0.31.0`, reset manifest,
`verify-versions.py`.
- Observed: all 9 version entries aligned at 0.31.0; no CHANGELOG
touched (Changelog Guard passes).
- Not tested: the live release-please recompute (will run on merge —
expected to rewrite #1923 to `chore: release 0.32.0`).

## Note on downstream publish
The release-please workflow only triggers `release.yml`/`docker.yml` if
`RELEASE_PLEASE_TOKEN` (a PAT) is set — with the `GITHUB_TOKEN` fallback
the release is created but downstream publishes don't fire. #1916
shipped 0.31.0 fully via this path, so the PAT was set then; if the
0.32.0 publish doesn't fire on merge, verify that secret still exists.
2026-07-16 21:06:51 -07:00
Tejas Chopra
4726c7343f
ci: repair mypy no-any-return in _win32_pid_alive (#1556 follow-up) (#2336)
## Description
Main lint went red after #1556: `headroom/_subprocess.py:18` returns
`Any` (ctypes `GetLastError()`) from a `-> bool` function → mypy
`no-any-return`. Tests were unaffected.

## Changes Made
- Wrap the comparison in `bool(...)`, matching `pid_alive()`'s existing
style.

## Testing
```text
mypy headroom --ignore-missing-imports  -> Success: no issues found in 504 source files
ruff check headroom/_subprocess.py      -> All checks passed!
ruff format --check                     -> 1 file already formatted
```

## Real Behavior Proof
- Environment: local .venv (ruff 0.15.17 / mypy 1.20.2, CI-pinned).
- Result: `mypy headroom` clean; behavior unchanged (pure typing fix).
2026-07-16 20:55:17 -07:00
Gautam Sharma
793d20fb2a
fix(subscription): read newest transcript tail (#2310)
## Description

Large Claude Code transcript files were capped by reading the first 10
MB of each append-only JSONL file. Because recent entries are appended
at the end, current-window and weighted token usage could silently omit
the newest activity.

Oversized transcripts are now read from EOF. If the capped tail begins
within a JSONL record, only that partial record is discarded. A
  complete record beginning exactly at the boundary remains included.

  ## Type of Change

  - [x] Bug fix
  - [ ] New feature
  - [ ] Breaking change
  - [ ] Documentation-only change
  - [ ] Refactoring

  ## Changes Made

- Read the newest capped transcript bytes instead of the oldest prefix.
  - Determine the tail offset using the opened file handle.
- Inspect the preceding byte to distinguish a partial record from an
exact line boundary.
  - Remove partial bytes before UTF-8 decoding.
  - Preserve existing behavior for transcripts below the 10 MB cap.
- Add direct session-tracking tests for aggregation and boundary
behavior.
  - Add an Unreleased changelog entry.

  ## Testing

  - [x] Added regression tests
  - [x] Focused tests pass
  - [x] Subscription test suite passes
  - [x] Ruff checks pass
  - [x] Mypy passes
  - [x] Changed files pass formatting checks
  - [ ] Entire repository test suite passes without baseline failures

  Commands and results:

- `uv run --extra dev --frozen pytest
tests/test_subscription_session_tracking.py -q`
    - `4 passed`
  - Subscription-focused suite
    - `53 passed`
  - `uv run --extra dev --frozen ruff check .`
    - Passed
  - `uv run --extra dev --frozen mypy headroom --ignore-missing-imports`
    - Success across 504 source files
  - Changed-file Ruff formatting
    - Passed
  - `uv run --extra dev --frozen pytest -q`
    - `9364 passed, 565 skipped, 4 failed`
    - The four existing, unrelated failures are:
      - `test_l2_appends_transform_label`
      - `test_recovery_records_sockets_and_secures_both_backups`
      - `test_dashboard_uses_cached_stats_and_lazy_history_feed_polling`
      - `test_smart_crusher_log_fallback_runs_for_valid_json`

Repository-wide `ruff format --check .` identifies pre-existing
formatting drift only in the untouched
`headroom/proxy/handlers/anthropic.py`.

  ## Real Behavior Proof

A focused reproduction created a 10,485,787-byte transcript with a
marker entry appended after the 10 MB boundary.

  Before the fix:

  ```text
{'file_bytes': 10485787, 'line_count': 1, 'newest_entry_present': False}

  After the fix:

{'file_bytes': 10485787, 'line_count': 1, 'newest_entry_present': True}

  The regression tests additionally verify that:

1. Recent token usage beyond the cap contributes to raw and weighted
totals.
  2. A partial initial JSONL record is discarded.
  3. A complete record exactly at the tail boundary is preserved.
  4. Small transcripts retain their existing behavior.

  Environment: macOS arm64, CPython 3.12.13.

Not tested: mutation of the transcript during the individual file read
by a live Claude Code process. Reads remain bounded to a single recent
  snapshot.

  ## Review Readiness

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

  ## Checklist

- [x] The change follows existing project style and error-handling
conventions.
  - [x] Tests cover the reported failure and relevant boundary cases.
  - [x] The 10 MB memory/read cap remains enforced.
  - [x] No unrelated files or formatting changes are included.
  - [x] No temporary logging or debug code remains.
  - [x] The changelog has been updated.

  ## Additional Notes

The four full-suite failures listed above occur outside the modified
subscription code and are unrelated to this PR. All tests covering
  transcript reading and subscription tracking pass.

---------

Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-16 14:39:10 -07:00
Gautam Sharma
0924755591
fix(memory): serialize MCP backend initialization (#2309)
## Description

The Memory MCP server previously assigned its backend before
asynchronous embedder and vector-index warm-up completed. A tool call
arriving
during the handshake could therefore receive a partially initialized
backend.

Backend initialization is now atomic and shared between concurrent
callers. The backend is published only after warm-up succeeds. Failed
candidates are closed and discarded so later calls can retry with a
fresh backend.

  ## Type of Change

  - [x] Bug fix
  - [ ] New feature
  - [ ] Breaking change
  - [ ] Documentation-only change
  - [ ] Refactoring

  ## Changes Made

- Keep the initializing backend local until warm-up completes
successfully.
- Share one initialization task between handshake and concurrent tool
calls.
  - Await the shared task before exposing the backend to tool handlers.
- Shield shared initialization from cancellation by an individual tool
caller.
  - Close failed or cancelled backend candidates.
  - Clear failed initialization state so subsequent calls can retry.
  - Retrieve and log background initialization failures.
- Add regression tests for handshake races, failure recovery, and
concurrent initialization.
  - Add an Unreleased changelog entry.

  ## Testing

  - [x] Added regression tests
  - [x] Focused test suite passes
  - [x] Ruff checks pass
  - [x] Mypy passes
  - [x] Changed files pass formatting checks
  - [ ] Entire repository test suite passes without baseline failures

  Commands and results:

- `uv run --extra dev --frozen pytest
tests/test_memory/test_mcp_server.py -q`
    - `12 passed`
  - `uv run --extra dev --frozen ruff check .`
    - Passed
- `uv run --extra dev --frozen ruff format --check
headroom/memory/mcp_server.py tests/test_memory/test_mcp_server.py`
    - Passed
  - `uv run --extra dev --frozen mypy headroom --ignore-missing-imports`
    - Success across 504 source files
  - `uv run --extra dev --frozen pytest -q`
    - `9363 passed, 565 skipped, 4 failed`
- The four failures are existing, unrelated failures outside the changed
code:
      - `test_l2_appends_transform_label`
      - `test_recovery_records_sockets_and_secures_both_backups`
      - `test_dashboard_uses_cached_stats_and_lazy_history_feed_polling`
      - `test_smart_crusher_log_fallback_runs_for_valid_json`

Repository-wide `ruff format --check .` also identifies pre-existing
formatting drift in the untouched
`headroom/proxy/handlers/anthropic.py`.

  ## Real Behavior Proof

  The regression tests exercise the affected lifecycle directly:

  1. Start backend initialization through the MCP handshake.
  2. Suspend warm-up before it completes.
  3. Issue a memory tool call and verify its handler is not invoked.
4. Release warm-up and verify the tool receives the initialized backend.
5. Force background initialization to fail and verify the candidate is
closed.
6. Issue another tool call and verify initialization retries with a
fresh backend.
7. Start two tool calls concurrently and verify only one backend is
constructed.

  Observed behavior:

  - Tool calls remain pending while handshake warm-up is incomplete.
  - A partially initialized backend never reaches a tool handler.
  - Failed candidates are closed and discarded.
  - A later tool call successfully retries initialization.
  - Concurrent calls share one initialization task and backend.

  Environment: macOS arm64, CPython 3.12.13.
  
Not tested: a live stdio MCP client using the real ONNX model and
database. The affected initialization lifecycle is covered with
  deterministic asynchronous regression tests.

  ## Review Readiness

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

  ## Checklist

- [x] The implementation follows the repository’s existing style and
error-handling conventions.
- [x] Tests cover the reported race, concurrent initialization, and
failure recovery.
- [x] Failed initialization does not leave a partially published
backend.
  - [x] Failed backend candidates are closed before retry.
  - [x] No unrelated files or formatting changes are included.
  - [x] No temporary logging, debug code, or commented-out code remains.
  - [x] Public behavior changes are documented in the changelog.
- [x] The branch has been rebased from the intended base and is ready
for review.

  ## Additional Notes

The four full-suite failures listed above occur outside the changed
Memory MCP code and are unrelated to this PR. All tests covering the
  modified initialization lifecycle pass.

---------

Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-16 14:38:53 -07:00
Abhay Singh
6decbd1e6e
fix(proxy/streaming): preserve non-standard content-block fields on SSE reconstruction (#2271)
## Description

When the proxy reconstructs a full response from an Anthropic SSE
stream, it silently drops the payload of any content block that isn't
`text` / `tool_use` / `thinking` / `redacted_thinking`.

`_parse_sse_to_response` builds each block on `content_block_start`:

```python
current_block = {"type": btype, "index": block_index}
if btype == "text":
    current_block["text"] = block.get("text", "")
elif btype == "tool_use":
    current_block["id"] = block.get("id")
    current_block["name"] = block.get("name")
    current_block["input"] = {}
elif btype == "thinking":
    ...
elif btype == "redacted_thinking":
    ...
blocks_by_index[block_index] = current_block
```

There's no branch for other block types. A `server_tool_use` or
`web_search_tool_result` block (Anthropic server-side tools) therefore
reconstructs as a bare `{"type": ..., "index": ...}`, losing its `id`,
`name`, `input`, and content.

This reconstructed response is what `has_memory_tool_calls` and the CCR
feedback recorder inspect, so a stream that used a server-side tool
feeds detection a gutted block. The sibling reconstructor
`_reconstruct_anthropic_response` (in
`headroom/ccr/response_handler.py`) already handles this correctly with
`elif btype: current_block = dict(block)` — this path just wasn't
updated.

## Fix

Add an `elif btype:` branch that copies through all of the block's
fields (except `type`, already set), mirroring the sibling:

```python
elif btype:
    for _k, _v in block.items():
        if _k != "type":
            current_block[_k] = _v
```

Standard blocks are untouched; non-standard blocks keep their fields.

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

- `headroom/proxy/handlers/streaming.py`: add the non-standard-block
field copy in `_parse_sse_to_response`'s `content_block_start` handler.
- `tests/test_sse_thinking_blocks.py`: new test asserting a
`server_tool_use` block keeps `id` / `name` / `input`.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

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

### Test Output

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

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOM-kills this box (ML stack import), so I
reproduced the block-construction logic with a dependency-free script
and left the full pytest to CI.
- Exact command / steps: ran a `server_tool_use` content_block_start
through the OLD (special-cases only) and NEW (`elif btype:` copy) logic,
plus a `text` block as a control.
- Observed result: OLD produces `{"type": "server_tool_use", "index":
0}` (id/name/input gone); NEW keeps `id`/`name`/`input`; the `text`
block is identical under both.
- Not tested: a live server-tool stream end-to-end; full local `pytest`
deferred to CI (OOM).

## 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
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

The "unit tests pass locally" box is unchecked because the full suite
imports the ML stack, which I can't run here. The new test reuses the
existing `_Parser(StreamingMixin)` harness in
`tests/test_sse_thinking_blocks.py`, so it runs under the normal CI
pytest job; behaviour is additionally verified by the standalone proof
above.

---------

Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-16 14:38:25 -07:00
Abhay Singh
1612f06a4c
fix(ccr): don't crash tool-call detection on a null function/functionCall (#2269)
## Description

CCR tool-call detection crashes when an upstream response carries a tool
call whose `function` (or `functionCall`) field is explicitly `null`.

`is_ccr_tool_call` and `parse_tool_call` both read the nested name like
this:

```python
tool_call.get("function", {}).get("name")
tool_call.get("functionCall", {}).get("name")
```

`dict.get("function", {})` only substitutes `{}` when the key is
**missing**. When the key is present but `null` — `{"id": "call_1",
"type": "function", "function": null}`, which upstreams (and gateways
like LiteLLM/OpenRouter) emit for a partial or streamed tool call — the
result is `None`, and `None.get("name")` raises `AttributeError`.

These functions run over the untrusted upstream response
(`has_ccr_tool_calls` → `is_ccr_tool_call` for every tool call, and
`parse_tool_call` on the retrieve path), so a single malformed tool call
takes down CCR detection for the whole response. The sibling
`tool_call_id_for_provider` in the same module already guards this shape
(`if isinstance(function_call, dict)`); these two paths just weren't
updated to match.

## Fix

Coalesce with `or {}` so a `null` (or any falsy) value collapses to
`{}`:

```python
(tool_call.get("function") or {}).get("name")
(tool_call.get("functionCall") or {}).get("name")
```

and in `parse_tool_call`:

```python
function = tool_call.get("function") or {}
function_call = tool_call.get("functionCall") or {}
```

A null tool call now reports "not a CCR call" and is passed through as a
normal tool, and real CCR calls are still detected.

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

- `headroom/ccr/tool_calls.py`: `is_ccr_tool_call` coalesces `function`
/ `functionCall` with `or {}`.
- `headroom/ccr/tool_injection.py`: `parse_tool_call` coalesces
`function` (openai) and `functionCall` (google) with `or {}`.
- `tests/test_ccr_tool_calls.py`, `tests/test_ccr_tool_injection.py`:
new tests covering a null-function tool call in detection and parsing.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

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

### Test Output

```text
$ uvx ruff@0.15.17 check headroom/ccr/tool_calls.py headroom/ccr/tool_injection.py tests/test_ccr_tool_calls.py tests/test_ccr_tool_injection.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/ccr/tool_calls.py headroom/ccr/tool_injection.py
Success: no issues found in 2 source files
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOM-kills this box (ML stack import), so I
reproduced the detection logic with a dependency-free script and left
the full pytest to CI.
- Exact command / steps: ran an OpenAI tool call `{"function": null}`
(plus a real CCR call) through the OLD `get("function", {})` form and
the NEW `get("function") or {}` form.
- Observed result: OLD raises `AttributeError` on the null function; NEW
returns `False`/`None` for it and still detects the real CCR call and
both `functionCall`/`name` shapes.
- Not tested: a live upstream emitting a null-function tool call; full
local `pytest` deferred to CI (OOM).

## 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
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

The "unit tests pass locally" box is unchecked because the full suite
imports the ML stack, which I can't run here. The new tests live
alongside the existing CCR tool-call tests so they run under the normal
CI pytest job; behaviour is additionally verified by the standalone
proof above.

---------

Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-16 14:38:09 -07:00
Abhay Singh
8b7e797ed4
fix(proxy/memory): don't crash memory tool-call detection on a null function (#2272)
## Description

`MemoryHandler` crashes when an upstream response carries a tool call
whose `function` field is explicitly `null`.

Three sites read the nested name like `tool_call.get("function",
{}).get("name")`:

- `has_memory_tool_calls` (line ~1043) — over the response's tool calls.
- `handle_tool_calls` (line ~1110/1119) — resolving the tool name and
arguments.
- the memory tool-injection dedup (line ~562) — over the request's
tools.

`dict.get("function", {})` only substitutes `{}` for a *missing* key. A
present-but-null `{"id": "c1", "type": "function", "function": null}` —
a shape upstreams and gateways emit for a partial or streamed tool call
— makes the result `None`, and `None.get("name")` raises
`AttributeError`.

`has_memory_tool_calls` and `handle_tool_calls` both iterate the
untrusted upstream response, so a single malformed tool call takes down
memory tool-call detection and handling for the whole response.

## Fix

Coalesce `function` with `or {}` at all three sites, so a null (or any
falsy) value collapses to `{}`:

```python
name = tc.get("name") or (tc.get("function") or {}).get("name")
args_str = tc.get("arguments") or (tc.get("function") or {}).get("arguments") or "{}"
```

Real tool calls resolve exactly as before.

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

- `headroom/proxy/memory_handler.py`: coalesce `function` with `or {}`
in `has_memory_tool_calls`, `handle_tool_calls`, and the tool-injection
dedup.
- `tests/test_memory_handler_null_function.py`: new tests that a
null-function tool call doesn't crash detection and the real memory call
is still seen.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

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

### Test Output

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

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOM-kills this box (ML stack import), so I
reproduced the name-resolution logic with a dependency-free script and
left the full pytest to CI.
- Exact command / steps: ran a `{"function": null}` tool call (plus a
real `memory_save` call) through the OLD `get("function", {})` and NEW
`get("function") or {}` name resolution.
- Observed result: OLD raises `AttributeError` on the null function; NEW
returns `None` for it and still resolves the real `memory_save` name and
a plain `{"name": "memory"}`.
- Not tested: a live upstream emitting a null-function tool call; full
local `pytest` deferred to CI (OOM).

## 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
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

The "unit tests pass locally" box is unchecked because the full suite
imports the ML stack, which I can't run here. `has_memory_tool_calls`
and `_extract_tool_calls` use no instance state, so the new test
exercises them on a bare instance via `object.__new__` — it runs under
the normal CI pytest job, and the standalone proof above corroborates
it. This is the memory-handler sibling of the same null-`function`
hazard I'm fixing in the CCR tool-call detection and the memory tool
adapter.

---------

Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-16 14:37:41 -07:00
Abhay Singh
ef1e7e403b
fix(proxy/memory): don't crash the memory tool adapter on a null function/arguments (#2270)
## Description

The memory tool adapter crashes when an upstream response carries a tool
call whose `function` or `arguments` field is explicitly `null`.

`_get_tool_name`, `_get_tool_id`, and `_get_tool_input` all read the
nested function like this:

```python
str(tool_call.get("function", {}).get("name", ""))
tool_call.get("function", {}).get("arguments", "{}")
```

Two distinct crashes:

1. **Null `function`** → `AttributeError`. `dict.get("function", {})`
only substitutes `{}` for a *missing* key. A present-but-null `{"id":
"c1", "type": "function", "function": null}` (which upstreams and
gateways emit for partial/streamed tool calls) makes the result `None`,
and `None.get("name")` raises.
2. **Null `arguments`** → `TypeError`. `tool_call.get("function",
{}).get("arguments", "{}")` returns `None` when `arguments` is null, and
`json.loads(None)` raises `TypeError` — which the surrounding `except
json.JSONDecodeError` does **not** catch.

Both parse the untrusted upstream response inside `handle_tool_calls`,
so a single malformed tool call takes down memory tool handling. Notably
`parse_tool_call` in `headroom/ccr/tool_injection.py` already catches
the `json.loads(None)` `TypeError` with an explicit comment, so the
null-arguments hazard is known in the codebase; this path just wasn't
hardened.

## Fix

- Coalesce `function` / `functionCall` with `or {}` so a null value
collapses to `{}`.
- Coalesce the arguments string with `or "{}"` and add `TypeError` to
the `except`, so a null `arguments` yields `{}` instead of crashing.

Real tool calls parse exactly as before.

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

- `headroom/proxy/memory_tool_adapter.py`: coalesce
`function`/`functionCall` (`or {}`) in
`_get_tool_name`/`_get_tool_id`/`_get_tool_input`; coalesce the
arguments string (`or "{}"`) and catch `TypeError`.
- `tests/test_memory_tool_adapter_null_fields.py`: new tests for null
function, null arguments, and that real calls still parse.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

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

### Test Output

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

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOM-kills this box (ML stack import), so I
reproduced the parse helpers with a dependency-free script and left the
full pytest to CI.
- Exact command / steps: ran `{"function": null}` and `{"function":
{"arguments": null}}` (plus a real `memory_save` call) through the OLD
and NEW `_get_tool_name`/`_get_tool_input` logic.
- Observed result: OLD raises `AttributeError` on the null function and
`TypeError` on the null arguments; NEW returns `""`/`{}` for both and
still parses the real call to `{"content": "hi"}`.
- Not tested: a live upstream emitting a null field; full local `pytest`
deferred to CI (OOM).

## 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
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

The "unit tests pass locally" box is unchecked because the full suite
imports the ML stack, which I can't run here. The parse helpers read
only their `tool_call` argument (no instance state), so the new test
exercises them on a bare instance via `object.__new__` — it runs under
the normal CI pytest job, and the standalone proof above corroborates
it.

---------

Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-16 14:37:27 -07:00
Abhay Singh
eed80dd4ba
fix(learn/claude): don't abort the whole scan on a null message line (#2299)
## Description

A single Claude session-log line with an explicit `{"message": null}`
crashes the entire `headroom learn` run.

`ClaudeCodePlugin._scan_session` reads the message object in four
places:

```python
usage = d.get("message", {}).get("usage", {})   # assistant line
...
msg = d.get("message", {})                        # _extract_tool_uses
msg = d.get("message", {})                        # _extract_tool_results
msg = d.get("message", {})                        # _extract_user_events
```

`dict.get("message", {})` only substitutes `{}` for a **missing** key. A
present-but-null `{"type": "assistant", "message": null}` yields `None`,
and `None.get(...)` raises `AttributeError`.

The per-file guard only catches I/O errors:

```python
try:
    with open(jsonl_path, ...) as f:
        for line in f:
            ...
except (OSError, UnicodeDecodeError) as e:
    ...
    return None
```

so the `AttributeError` propagates out of `_scan_session`, past
`scan_project` (which has no try/except around the scan), and aborts the
whole `learn` invocation — every project, not just the one bad line. One
malformed line takes down the entire run.

## Fix

Coalesce the message with `or {}` at all four sites, so a null (or any
falsy) value collapses to `{}`:

```python
usage = (d.get("message") or {}).get("usage", {})
msg = d.get("message") or {}
```

The malformed line is now skipped and scanning continues; valid lines
are parsed exactly as before.

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

- `headroom/learn/plugins/claude.py`: coalesce `d.get("message")` with
`or {}` in `_scan_session` and the three `_extract_*` helpers.
- `tests/test_learn/test_subagent_scanning.py`: new test that a session
containing `{"message": null}` lines scans without crashing and still
parses the valid tool call.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

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

### Test Output

```text
$ uvx ruff@0.15.17 check headroom/learn/plugins/claude.py tests/test_learn/test_subagent_scanning.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/learn/plugins/claude.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOM-kills this box (ML stack import), so I
reproduced the per-line handling with a dependency-free script and left
the full pytest to CI.
- Exact command / steps: ran an assistant line `{"message": null}` (plus
a real assistant line and a missing-message line) through the OLD
`get("message", {})` and NEW `get("message") or {}` logic.
- Observed result: OLD raises `AttributeError` on the null message; NEW
returns `0` for it and still counts `42` input tokens for the real line
and `0` for a missing-message line.
- Not tested: a full `learn` run over a real history containing such a
line; full local `pytest` deferred to CI (OOM).

## 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
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

The "unit tests pass locally" box is unchecked because the full suite
imports the ML stack, which I can't run here. The new test reuses the
existing `ClaudeCodePlugin` scanner harness in
`tests/test_learn/test_subagent_scanning.py`, so it runs under the
normal CI pytest job; behaviour is additionally verified by the
standalone proof above.

---------

Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-16 14:36:11 -07:00
Abhay Singh
ec12e18186
fix(savings): don't fabricate output savings for a free (zero-priced) model (#2298)
## Description

`_estimate_output_savings_usd` reports phantom output-shaping savings
for a model whose output price is legitimately `0.0`.

It reads the per-token output price from litellm and treats a falsy
value as "unavailable":

```python
output_cost_per_token = info.get("output_cost_per_token")
if not output_cost_per_token:
    raise RuntimeError("output cost unavailable")
return float(tokens_saved) * float(output_cost_per_token)
```

`if not output_cost_per_token` is `True` for both a **missing** price
(`None`) *and* a real **`0.0`** (a free / local / vendored-at-zero
model). So for a free model it raises, hits the `except`, and bills the
saved output tokens at `DEFAULT_FALLBACK_OUTPUT_COST_PER_TOKEN` ($15/M)
— fabricating output savings for a model that costs nothing.

This is the exact bug that `_estimate_compression_savings_usd` was
already fixed for (it now uses `if input_cost_per_token is None:`, with
a comment explaining that `if not ...` "treated a real 0.0 as
unavailable and billed the $3/M fallback — phantom savings").
`_estimate_input_cost_usd` carries the same fix.
`_estimate_output_savings_usd` is the one that was missed.

## Fix

Fall back only when the price is truly missing:

```python
if output_cost_per_token is None:
    raise RuntimeError("output cost unavailable")
```

A `0.0` price now correctly yields `$0` output savings; a missing price
still falls back to the estimate; a real price is unchanged.

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

- `headroom/proxy/savings_tracker.py`: `_estimate_output_savings_usd`
falls back on `output_cost_per_token is None` instead of `not
output_cost_per_token`.
- `tests/test_savings_tracker_zero_price.py`: new tests (free → $0,
unknown → fallback, paid → real price), alongside the existing
compression/input-cost zero-price tests.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

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

### Test Output

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

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOM-kills this box (ML stack import), so I
reproduced the estimator with a dependency-free script and left the full
pytest to CI.
- Exact command / steps: ran 1,000,000 saved output tokens through the
OLD `if not ...` and NEW `is None` logic for a free model
(`output_cost_per_token = 0.0`), a paid model, and an unknown model
(`None`).
- Observed result: OLD bills the free model at the $15/M fallback
(`$0.015` phantom savings); NEW returns `$0.00`. The paid model is
unchanged; the unknown model still falls back under both.
- Not tested: a live proxy run pricing a free model end-to-end; full
local `pytest` deferred to CI (OOM).

## 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
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

The "unit tests pass locally" box is unchecked because the full suite
imports the ML stack, which I can't run here. The new tests reuse the
`_fake_litellm` harness in `tests/test_savings_tracker_zero_price.py`
(the same file that pins the compression/input-cost zero-price
behavior), so they run under the normal CI pytest job; behaviour is
additionally verified by the standalone proof above.

---------

Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-16 14:35:57 -07:00
Andrei Boldyrev
6744833afe
fix(proxy): key drift detector on conversations, not credentials; canonicalize drift hashes (#2301)
## Description

The Rust proxy's cache-bust drift detector
(`crates/headroom-proxy/src/cache_stabilization/drift_detector.rs`,
PR-E6) cannot currently tell drift from normal operation on interactive
agentic traffic, so it warns on nearly every turn and a real bust drowns
in the noise. Three compounding defects, all verified against live
Claude Code traffic:

1. `derive_session_key` stops at the credential hash — Claude Code sends
one OAuth bearer for every conversation, so all concurrent conversations
share one LRU slot and every conversation switch logs a false
`cache_drift_observed` (with `drift_dims` computed against the wrong
conversation's baseline).
2. The `early_messages` axis hashes the raw first-3-messages window, so
a lone conversation's normal growth (1 → 3 messages) and the client
relocating its `cache_control` breakpoint to the newest block both fire
a false `early_messages` drift at turn 2–3 of essentially every session.
3. `x-headroom-session-id` — the explicit session identity the Python
proxy honors everywhere session-sticky state exists — is ignored on the
Rust path.

This PR makes the detector's session identity conversation-scoped and
its comparison canonical, the same shape as the merged Python-side fix
for #2085 (`SessionTrackerStore.resolve_tracker` lineage resolution +
`_canonicalize_for_prefix_compare`):

- **`derive_session_key`**: honors `x-headroom-session-id` first
(hashed, like every other key input), then folds a conversation
discriminator into the credential/network arms: a 16-hex-char SHA-256
fingerprint of `(model, canonicalized first message)`. Provider prompt
caches are per-model, so a small-model sidecar call (title generation)
that reuses a conversation's opener stays a separate session instead of
false-drifting on `system`.
- **`canonicalize_for_hash`** on all axes and the discriminator: objects
rebuilt with sorted keys (this workspace enables serde_json
`preserve_order`, so a plain re-serialize would keep client wire order
and leave the hashes key-order sensitive) and `cache_control` stripped
outside opaque tool payloads (`input`/`arguments`/`json`/`input_schema`
— mirroring the Python canonicalizer's `_OPAQUE_PAYLOAD_KEYS`, so a user
field that happens to be *named* `cache_control` still counts as drift).
- **`early_messages`** becomes per-message hashes (`[Option<[u8; 32]>;
3]`) with a prefix-aware comparison: growing into the window is benign;
a settled message changing or disappearing under a stable session key is
still drift. `observe_drift` now gates the warning on drifted dimensions
rather than raw hash inequality.

True positives are preserved (`system`/`tools` changes, in-place history
rewrites under a pinned identity), and the detector remains a pure
observer — no forwarded byte changes, `does_not_mutate_input` still pins
that.

**Documented trade-off** (module doc + `conversation_discriminator`
doc): without the explicit header, a client that rewrites its first
message (history compaction, rolling-window truncation, Responses
chained mode) re-keys to a fresh session — the rewrite surfaces as
`cache_drift_first_request` rather than `cache_drift_observed` against
the old baseline. That is deliberate: the credential-keyed alternative
false-warned on every conversation switch, which buried those same
events anyway. `x-headroom-session-id` pins the identity and reports
rewrites as drift. Byte-identical openers on the same model under one
credential still conflate (rare; documented).

Closes #2300

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

- `derive_session_key`: `x-headroom-session-id` (hashed) wins;
credential/IP arms fold in `conversation_discriminator` — `(model,
canonicalized first message)`, 16 hex chars
- New `canonicalize_for_hash`: sorted-key object rebuild +
`cache_control` stripped outside `OPAQUE_PAYLOAD_KEYS`; applied to the
`system`/`tools`/`early_messages` axes and the discriminator
- `StructuralHash.early_messages`: `[u8; 32]` → `[Option<[u8; 32]>;
EARLY_MESSAGES_WINDOW]` per-message hashes;
`drift_dims`/`early_window_drifted` implement the prefix-aware rule;
`observe_drift` warns on non-empty dims instead of `!=`
- `conversation_messages` shape guard: bare-string message containers
only count for the Responses `input` sugar
- Docs: module header (canonicalization, trade-off, honest cost),
`conversation_discriminator` rationale + blind spots,
`DRIFT_DETECTOR_CAPACITY` cardinality note (per-conversation keys,
163-byte entry), `structural_hash_log_prefix` hex-length fix
- Tests: 13 new unit tests (conversation separation, turn-growth key
stability, explicit header priority, marker relocation + growth not
drift, rewrite/shrink still drift, per-model separation, key-order
neutrality, opaque-payload fields still count, Responses/Chat
discriminator shapes, string-container gating)
- `CHANGELOG.md`: Unreleased → Fixed entry

## Testing

- [x] Unit tests pass (`cargo test -p headroom-proxy` — full crate: lib
+ integration suites)
- [x] Linting passes (`cargo clippy -p headroom-proxy --all-targets` —
zero warnings; `cargo fmt --check` clean)
- [ ] Type checking passes (`mypy headroom`) — n/a, no Python files
touched
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ cargo test -p headroom-proxy --lib drift_detector
test result: ok. 27 passed; 0 failed; 0 ignored; 0 measured; 221 filtered out

$ cargo test -p headroom-proxy
(all suites) test result: ok. 248 passed (lib) + integration suites, 0 failed

$ cargo clippy -p headroom-proxy --all-targets
(no warnings)
```

## Real Behavior Proof

- Environment: macOS 15 (arm64), rustc 1.95.0, repo @ 718c8dc + this
branch
- Exact command / steps: captured two real Claude Code conversations ×
two turns through a local proxy
(`ANTHROPIC_BASE_URL=http://localhost:8791 claude -p …` / `--resume …`),
rebuilt the wire bodies, and replayed them through the real
`derive_session_key` / `compute_structural_hash` / `drift_dims` in a
local `cargo test` harness — before and after this change.
- Observed result: **before** — all four requests share one `auth:` key,
and the raw early-window hash flips between turn 1 and turn 2 of the
*same* conversation (false `early_messages` drift; interleaving also
flips `system`). **After** — turn 1/turn 2 map to one stable key with
`drift_dims == ""`, the two conversations map to distinct keys, and a
rewritten/shrunk settled window still reports `early_messages`.
- Not tested: live OpenAI Chat/Responses traffic (shape-level unit tests
only); log pipeline consumers (event names/fields unchanged).

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

n/a — log-only telemetry change.

## Additional Notes

- `StructuralHash` is `pub`, but the workspace has no external consumers
(checked `sdk/`, `plugins/`, Python, docs) — the field-type change is
contained to `proxy.rs` and the module tests. `[Option<[u8; 32]>; 3]`
keeps `Copy` for the LRU and adds no dependency.
- LRU cardinality: keys moved per-credential → per-conversation;
`DRIFT_DETECTOR_CAPACITY`'s comment now documents the working set, the
~250-byte entry, and the graceful eviction failure mode (repeated
`cache_drift_first_request`, telemetry-only).
- Not in scope, noted for follow-up: keying Responses chained mode
(`previous_response_id`) as a lineage; surfacing mid-history
`role:"system"` insertions on the OpenAI Chat shape (pre-existing blind
spot on all axes).

---------

Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-16 14:34:26 -07:00
Sebastian Schkudlara
517bf992cf
fix(proxy): quarantine compression while timed-out workers run (#2292)
## Description

A request-side `asyncio.wait_for()` timeout stops waiting, but it cannot
preempt an executor thread that already started. The proxy counted those
late workers and still admitted more compression, so repeated slow calls
could consume the whole compression pool and charge every request
another full timeout.

This change tracks running post-timeout workers as timeout debt and
quarantines request-path compression while that debt is non-zero. New
attempts raise `CompressionQuarantinedError` before executor admission,
using an `asyncio.TimeoutError` subclass so Python 3.10 handlers apply
the existing compression-failure policy. Quarantine clears automatically
after all known timed-out workers genuinely exit.

Mitigates #946 and #810. It does not attempt to kill the first running
thread; Python cannot safely preempt it.

## Type of Change

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

## Changes Made

- Track started, finished, timed-out, and debt-recorded state under the
existing compression metrics lock.
- Reject new compression before enqueue while timed-out workers remain;
clear quarantine on the final worker exit.
- Preserve queued-timeout behavior: work cancelled before worker start
does not activate quarantine; a cancellation/start race is
conservatively tracked as running debt.
- Add `/health` and `/stats` runtime fields for quarantine state, worker
debt, activations, and skips.
- Add `headroom_compression_quarantine_total{event="activated|skipped"}`
Prometheus counters.
- Add regression, recovery, queue-race, runtime-payload, export, reset,
and Python 3.10 exception-class coverage.
- Update `CHANGELOG.md`; no dependency or lockfile changes.

## Reproduction

On base commit `718c8dc5`, I applied only the new regression test and
ran:

```bash
.venv/bin/pytest -q \
  tests/test_proxy_compression_executor.py::test_timeout_quarantines_new_work_until_timed_out_worker_finishes
```

The first worker timed out but remained blocked. The second callable
entered the executor instead of being rejected:

```text
FAILED: DID NOT RAISE TimeoutError
```

## Testing

- [x] Affected unit tests pass
- [x] Linting passes (`ruff check .`)
- [x] Changed-file type checking passes
- [x] New tests added for the fix
- [x] Manual testing performed

### Test Output

```text
Python 3.10.20

$ .venv/bin/pytest -q -m 'not slow' \
    tests/test_proxy_compression_executor.py \
    tests/test_prometheus_obs_counters.py \
    tests/test_proxy/test_compression_failure_action.py \
    tests/test_proxy/test_compression_timeout_config.py \
    tests/test_anthropic_pre_upstream_backpressure.py \
    tests/test_openai_codex_ws_lifecycle.py \
    tests/test_codex_ws_compression_scheduler.py \
    tests/test_gemini_compression_offload.py \
    tests/test_proxy_handlers_batch.py \
    tests/test_tokenizer_count_offload.py \
    tests/test_cold_start_fast_pass.py
125 passed, 1 skipped, 1 deselected, 1 warning in 11.06s

$ .venv/bin/ruff check .
All checks passed!

$ .venv/bin/ruff format --check .
1310 files already formatted

$ .venv/bin/mypy headroom/proxy/server.py headroom/proxy/prometheus_metrics.py
Success: no issues found in 2 source files

$ git diff --check
# no output
```

The warning is the existing Starlette `TestClient`/`httpx` deprecation
warning.

## Real Behavior Proof

- Environment: macOS 15.7.4 x86_64, Python 3.10.20,
`compression_max_workers=2`, direct proxy executor path, no external
provider/model.
- Exact command / steps: instantiate the proxy; run a blocking
compression callable with a 50 ms timeout; immediately attempt a second
callable and time the rejection; release the first worker; wait for debt
to reach zero; run the second callable again; export Prometheus metrics.
- Observed result: the first request timed out at 51.182 ms; the second
attempt was rejected in 0.014 ms and its callable never started; debt
was 1 while quarantined, returned to 0 after release, and compression
then resumed normally.

```json
{
  "after_release": {
    "activations_total": 1,
    "leaked_threads_total": 1,
    "quarantine_active": false,
    "skips_total": 1,
    "timed_out_workers": 0
  },
  "bypass_elapsed_ms": 0.014,
  "bypass_error": "compression quarantined: 1 timed-out worker(s) still running",
  "during_quarantine": {
    "quarantine_active": true,
    "timed_out_workers": 1
  },
  "first_timeout_elapsed_ms": 51.182,
  "prometheus": [
    "headroom_compression_quarantine_total{event=\"activated\"} 1",
    "headroom_compression_quarantine_total{event=\"skipped\"} 1"
  ],
  "resumed_result": "resumed",
  "second_callable_started_during_quarantine": false
}
```

- Not tested: a live external model/provider; forced termination of a
permanently wedged native worker; the marked slow native scheduler
benchmark. A broad non-slow run collected 9,859 selected tests but was
stopped at
`tests/test_adversarial_grid.py::TestRunGrid::test_grid_shape_and_schema`
after a macOS process sample showed the pre-existing native
`_core.abi3.so` semaphore stall (`_dispatch_semaphore_wait_slow` →
`semaphore_wait_trap`). The affected executor/handler slice above
completed cleanly.

## 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 hard-to-understand concurrency paths
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove the fix is effective
- [x] New and affected existing unit tests pass locally
- [x] I have updated the CHANGELOG.md

---------

Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-16 14:30:06 -07:00
Tejas Chopra
e7340aee65
ci(changelog): stop the CHANGELOG cascade — release-please owns it (#2329)
## Description

Stops the recurring **CHANGELOG cascade** where several concurrent PRs
all edit `CHANGELOG.md`'s `## Unreleased` section, so the first to merge
turns the rest `DIRTY` — forcing a serial rebase-per-merge grind.

Root cause: **release-please already generates `CHANGELOG.md`** from
Conventional Commit titles (see `.release-please-config.json`
`changelog-sections`), so the hand-written `## Unreleased` entries are
*both redundant with release-please and the sole source of the
conflicts*. The PR template and CONTRIBUTING were actively telling
contributors to keep hand-editing it. `.gitattributes merge=union`
(#2138) does not help — GitHub squash-merge ignores merge drivers.

Fix: make release-please the only author of the changelog and stop
hand-edits at the source.

Closes #

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue) — CI/process

## Changes Made

- **`.github/workflows/changelog-guard.yml`** (new): fails any PR that
modifies `CHANGELOG.md`, except release-please's own release PR (head
branch `release-please--*`). Uses the preinstalled `gh` CLI — no
third-party action to pin.
- **`.github/PULL_REQUEST_TEMPLATE.md`**: flips the "I have updated the
CHANGELOG.md" checkbox to "I did **not** edit CHANGELOG.md —
release-please generates it from my Conventional Commit PR title."
- **`CONTRIBUTING.md`**: replaces step 6 ("Update `CHANGELOG.md`") with
the release-please policy.

## Testing

- [x] `actionlint .github/workflows/changelog-guard.yml` — clean
- [x] YAML parses (`yaml.safe_load`)
- [x] Guard match logic unit-checked locally: fires on root
`CHANGELOG.md`, ignores nested paths (`docs/CHANGELOG.md`) and PRs that
don't touch it.

### Test Output

```text
YAML OK
actionlint OK
root CHANGELOG.md         -> FIRES
nested docs/CHANGELOG.md  -> pass
no changelog              -> pass
```

## Real Behavior Proof

- Environment: local clone + `actionlint`.
- Exact steps: created the workflow, validated with actionlint + a YAML
parse, and exercised the `grep -qx 'CHANGELOG.md'` decision against
representative changed-file lists.
- Observed result: guard fires only on a root-level `CHANGELOG.md` edit;
the release-please branch is exempted via the job-level `if`.
- Not tested: a live PR run in Actions (will exercise on this PR itself
— note this PR does **not** touch `CHANGELOG.md`, so the guard should
pass green here).

## Additional Notes

- To *enforce* (block merge, not just show red), add **Changelog Guard /
no-manual-changelog** to the branch's required status checks.
- Follow-up: the ~10 currently-open PRs that hand-edit `CHANGELOG.md`
will need that edit removed (a clean deletion now, not a conflict
resolution) before they merge — after which the cascade is gone for
good.
2026-07-16 14:28:57 -07:00
Zhenjia ZHOU
844d9caaa1
feat(text-crusher): fold full-width ASCII to half-width in CJK token keys (#2259)
## Description

Real CJK content routinely mixes full-width and half-width forms (`API`
vs `API`, `0` vs `0`, the ideographic space ` ` vs a normal space).
After #1504's ICU tokenization, these width variants produced
*different* token keys, so `API` and `API` didn't dedup or match as the
same term on the CJK relevance and near-duplicate paths.

This folds full-width ASCII (U+FF01–U+FF5E, via the U+FEE0 offset) and
the ideographic space (U+3000 → space) to their half-width forms **when
building the internal token key** inside `tokens_icu`. Only the token
key is normalized — the kept output stays byte-verbatim, so
TextCrusher's extractive / byte-faithful contract is preserved.
CJK-gated (`tokens_icu` is the CJK path); the ASCII path is untouched.

## Type of Change

- [x] Bug fix / enhancement (non-breaking)

## Changes Made

- `crates/headroom-core/src/transforms/text_crusher/crusher.rs`: a
`width_fold(c)` helper applied when building token keys in `tokens_icu`.
- Rust unit tests: full-width ASCII folds to half-width in token keys;
CJK segments split on full-width terminators.

## Testing

- [x] Unit tests pass (`cargo test`)
- [x] Linting passes (`cargo clippy` / `cargo fmt`)
- [x] New tests added

### Test Output

```text
$ cargo test -p headroom-core --lib text_crusher
test result: ok. 13 passed; 0 failed
$ cargo clippy / fmt   # clean
```

## Real Behavior Proof

- Environment: macOS (Darwin), Rust via cargo, branch
`feat/text-crusher-fullwidth-fold` off `main` (rebased after #1504
merged).
- Exact command / steps: `cargo test -p headroom-core --lib
text_crusher`.
- Observed result: `fullwidth_ascii_folds_to_halfwidth` confirms `API`
and `API` now produce the same token key (so they dedup/relevance-match
as one term); `cjk_splits_on_full_width_terminators` confirms full-width
`!`/`?` terminate segments. All 13 text_crusher tests pass; the kept
output is byte-verbatim (only the internal key is folded).
- Not tested: no Python side — TextCrusher is Rust-only, and output
stays byte-verbatim, so no parity fixtures change.

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation — N/A
- [x] My changes generate no new warnings
- [x] I have added tests that prove my change is effective
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md — happy to add an entry if
preferred.

## Additional Notes

- Follow-up to #1504 (CJK-aware TextCrusher); it only touches the
CJK-gated `tokens_icu` path, so English tokenization is unchanged.
2026-07-16 13:51:42 -07:00
GUOHAO LIU
8951a264a2
fix(proxy): preserve content-part array structure in excluded-tool lossless fold write-back (#2261)
## Description

When a tool result is excluded from lossy compression (e.g. grep via
`HEADROOM_EXCLUDE_TOOLS`), the OpenAI Responses adapter performs a
byte-lossless fold on the output text. However, the excluded-tool fold
path joined all content-part text with `_responses_part_text()` and
recorded a `("output", None)` slot, which caused `_set_slot_text` to
replace the entire `output` with a plain string.

For content-part arrays (valid per OpenAI spec: `[{type: output_text,
text: "..."}, {type: input_image, ...}]`), this destroyed the array
structure — non-text parts like images and refusals were silently
dropped.

Closes #2235

## Type of Change

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

## Changes Made

- `headroom/proxy/handlers/openai.py` — In the excluded-tool lossless
fold path, detect list (content-part) outputs and fold each
`input_text`/`output_text` part individually using `("output_part",
index)` slots, matching the eligibility rule already used by
`_slot_texts()` in the normal compression path
- `tests/test_openai_responses_compression_units.py` — Strengthen
existing content-part test to assert output remains a list; add new test
with mixed parts (output_text + input_image + refusal) to verify
structure preservation and byte-identical non-text parts

## Testing

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

### Test Output

```text
$ uv run pytest tests/test_openai_responses_compression_units.py -q --no-header
26 passed in 1.04s

$ uv run pytest tests/test_openai_responses_compression_units.py tests/test_openai_responses_context_compaction.py tests/test_openai_responses_traffic_learner.py -q --no-header
39 passed in 6.43s
```

## Real Behavior Proof

- Environment: Linux 6.8.0-124-generic, Python 3.12.3, headroom main @
eac49656
- Exact command / steps: checkout branch, run `uv run pytest
tests/test_openai_responses_compression_units.py -x -q`, inspect output
structure of excluded-tool items with content-part arrays
- Observed result: All 26 tests pass. For content-part outputs with
mixed types, the compressed output remains a list with the same length
and part types — non-text parts are byte-identical, only
`output_text`/`input_text` parts are updated
- Not tested: Live Codex WS end-to-end (requires Codex Desktop with
content-part tool outputs)

## Review Readiness

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

## Checklist

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

Co-authored-by: lennney <lennney@users.noreply.github.com>
2026-07-16 13:51:34 -07:00
Tanmay Garg
4e2bbfee3f
fix(opencode): Use opencode.jsonc when present (#1590)
## Description

Fix OpenCode proxy injection so it respects user configurations that use
the `.jsonc` extension, preventing Headroom from creating a duplicate
`.json` file that overrides it.

Closes #1588

## 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 `opencode_config_path` in `paths.py` to check for `.jsonc`
- Updated backup creation in `config.py` to preserve the original
extension

## 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
N/A
```

## Real Behavior Proof

- Environment: local headroom dev
- Exact command / steps: creating a dummy
`.config/opencode/opencode.jsonc` and running `headroom wrap opencode`.
- Observed result: Headroom successfully injects into `.jsonc` and
creates a backup named `opencode.jsonc.headroom-backup`.
- Not tested: N/A

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

## Additional Notes
2026-07-16 13:51:21 -07:00
guyoron1
f42ce4a239
fix: harden fd lifecycle and SystemError handling in runtime and proxy kill (#1556)
## Description

Make `pid_alive()` safe on Windows even when `psutil` is not installed,
and harden `_kill_proxy_by_pid` exception handling for stale PIDs.

### Problem

`headroom._subprocess.pid_alive()` falls back to `os.kill(pid, 0)` when
`psutil` cannot be imported. On Windows, CPython routes `os.kill(pid,
0)`
through `TerminateProcess` — a destructive call that **kills the target
process**. Since `psutil` is not a declared runtime dependency in
`pyproject.toml`, a normal lightweight install can hit that fallback,
meaning `runtime_status()` can silently terminate a live proxy.

### Fix

- **`headroom/_subprocess.py`**: On `win32`, bypass `os.kill` entirely
and probe via `kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)`.
  If `ctypes` also fails, return `True` conservatively (assume alive)
  to prevent false-negative liveness from causing callers to kill a
  running process.
- **`headroom/cli/wrap.py`**: Widen `_kill_proxy_by_pid` exception
  handlers on both SIGTERM and SIGKILL paths to catch `OSError` and
  `SystemError` (Windows `WinError 87`), preventing crashes from
  stale/invalid PIDs.

## Type of Change

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

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom/_subprocess.py`)

### New Tests

- `test_pid_alive_win32_no_psutil_never_calls_os_kill` — simulates
  `win32` + broken `psutil`, asserts `os.kill` is never called and
  the `kernel32.OpenProcess` path is used instead
- `test_pid_alive_win32_no_psutil_no_ctypes_returns_conservative` —
  simulates `win32` + broken `psutil` + broken `ctypes`, asserts
  `os.kill` is never called and `True` is returned conservatively
2026-07-16 13:51:03 -07:00
Gautam Sharma
5279c33b19
fix(memory): preserve semantically similar memories (#2303)
## Description

Prevent memory_save from automatically deleting semantically similar but
distinct memories. The previous fire-and-forget deduplication path
deleted existing memories at cosine similarity scores of 0.92 or higher
after the save had already returned success. Similarity remains
available as a consolidation hint, while supersession now requires an
explicit memory_update or memory_delete operation.

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

  - Removed the automatic background deletion scheduled by memory_save.
- Removed the automatic-dedup threshold and background coroutine that
were no longer needed.
  - Preserved the existing similarity search and consolidation hint.
  - Kept explicit memory_update and memory_delete behavior unchanged.
- Added a regression test proving that distinct memories survive even at
0.99 simulated similarity.
  - Added an Unreleased changelog entry.

  ## Testing

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

  ### Test Output

$ uv run --extra dev --frozen pytest
tests/test_memory_handler_native_ops.py
  33 passed

  $ uv run --extra dev --frozen ruff check .
  All checks passed!

$ uv run --extra dev --frozen ruff format --check
headroom/proxy/memory_handler.py tests/test_memory_handler_native_ops.py
  2 files already formatted

  $ uv run --extra dev --frozen mypy headroom --ignore-missing-imports
  Success: no issues found in 504 source files

  $ uv run --extra dev --frozen pytest
  9361 passed, 565 skipped, 4 failed

The four full-suite failures are unrelated to this diff: the Anthropic
compaction test passed in isolation; the Codex recovery test exceeded
the macOS AF_UNIX path limit; the dashboard test expects text absent
from the existing implementation; and the content-router test expects a
  fallback absent from the existing strategy chain.

The repository-wide format check also flags pre-existing formatting in
the untouched headroom/proxy/handlers/anthropic.py.

  ## Real Behavior Proof

- Environment: macOS on Apple Silicon, CPython 3.12.13, real
LocalBackend, temporary SQLite database, and the local
sentence-transformers
    embedding backend; no external provider or model API.

- Exact command / steps: Ran uv run --extra dev --frozen python with a
temporary database, saved User's primary backend framework at work is
FastAPI., queried its similarity to User's primary backend framework at
home is FastAPI., saved the second fact through
    MemoryHandler._execute_save, and listed the user's memories.

- Observed result: The real embedding similarity was 0.9387, above the
former 0.92 deletion threshold. The second save returned saved,
included the consolidation hint, retained the original memory, and left
both distinct facts in the database (memory_count: 2).

- Not tested: Live OpenAI or Anthropic provider calls, a deployed proxy
or MCP client session, and Qdrant or Neo4j memory backends. These
paths share the handler policy changed here; backend-specific explicit
update and delete behavior is unchanged.

  ## Review Readiness

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

  ## Checklist

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

  ## Additional Notes

The documentation and code-comment checklist items are not applicable
because this change removes unsafe behavior without introducing a new
public interface or complex implementation. The full-suite checkbox
remains unchecked because four unrelated tests failed locally, as
  documented above.
2026-07-16 11:32:38 -07:00
Rod Boev
26b43f64d6
fix(proxy): keep anthropic ccr compression active across deferred injection (#2291) (#2297)
## Description

Large native Claude Code requests on the Anthropic path can still
forward with zero request compression after CCR tool injection is
deferred on a frozen prefix. The stale skip branch treats deferred
injection as a reason to bypass request compression entirely, even
though the later sticky CCR path already knows when new markers actually
require the tool. This removes that stale bypass so compression still
runs while the reversible CCR path stays intact. Closes #2291.

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

- Remove the stale `should_skip_ccr_request_compression` branch from
`headroom/proxy/handlers/anthropic.py`, so deferred CCR tool injection
no longer bypasses request compression in token, non-cache, or cache
mode.
- Keep the existing sticky CCR injection path as the only place that
decides whether historical markers need the retrieval tool reintroduced.
- Update `tests/test_proxy/test_anthropic_ccr_deferred_injection.py` to
cover the two broken zero-compression cases and preserve the
already-reversible frozen-prefix path.

## Testing

- [x] Unit tests pass (`uv run pytest
tests/test_proxy/test_anthropic_ccr_deferred_injection.py::test_cache_mode_compresses_delta_but_replays_cached_prefix_when_markers_are_historical
tests/test_proxy/test_anthropic_ccr_deferred_injection.py::test_non_token_non_cache_mode_keeps_compression_and_injects_tool_for_new_markers
tests/test_proxy/test_anthropic_ccr_deferred_injection.py::test_existing_retrieve_tool_keeps_reversible_ccr_path_when_prefix_is_frozen
tests/test_openai_tool_search_deferral.py
tests/test_openai_responses_compression_units.py -q`)
- [x] Linting passes (`uv run ruff check .`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed

### Test Output

```text
======================= 53 passed, 1 warning in 12.71s ========================
All checks passed!
1310 files already formatted
```

## Real Behavior Proof

- Environment: synced branch and base worktrees on a local Windows proxy
test host
- Exact command / steps: run the updated Anthropic deferred-injection
regressions directly against the base package tree and the branch
package tree, then run the focused branch pytest suite above
- Observed result: the base package tree fails the two updated
zero-compression regressions (`FAIL
test_cache_mode_compresses_delta_but_replays_cached_prefix_when_markers_are_historical`,
`FAIL
test_non_token_non_cache_mode_keeps_compression_and_injects_tool_for_new_markers`)
while preserving the already-reversible path; the branch package tree
prints three `PASS` lines for the same trio and keeps the neighboring
OpenAI suites green in the 53-test focused run
- Not tested: a live upstream Claude Code request with the reporter's
exact provider/model credentials

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

`CHANGELOG.md` is not applicable here because Headroom's release
pipeline derives it from conventional commits.

Scope is limited to the Anthropic CCR request-compression seam that
current #2291 evidence exercises. OpenAI tool-search deferral is
untouched because the current live issue is a native Claude Code path
and the concrete stale skip branch on `origin/main` is in
`anthropic.py`.

Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-16 09:32:27 -07:00
Tejas Chopra
1d79e70f95
fix(tests): repair three main-branch test failures (#2306)
## Description

`main` CI is red on three independent test failures. All three are
**test-side** bugs (stale cache, semantic merge conflict, stale mock) —
no product code regressed. Each test passed in isolation but failed on
`main`, and each also blocks the `chore: release main` PR (#1923).

Closes #

## Type of Change

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

## Changes Made

- **`test_l2_appends_transform_label`** — `tool_desc_max_chars()`
memoises into a module global. An earlier test in shard 1 reads it with
the env unset, pinning the cache to `0`, so this test's
`setenv("HEADROOM_TOOL_DESC_MAX_CHARS=20")` was swallowed (`assert 0 ==
20`). Reset the cache before reading and after, mirroring the sibling
`test_l2_skips_label_when_disabled`.
- **`test_dashboard_uses_cached_stats_and_lazy_history_feed_polling`** —
semantic merge conflict: #2198 (persist lifetime metrics) intentionally
retired the session-card `Filtered (lifetime)` row and moved
CLI-filtering lifetime into the history tab as `Lifetime Saved`, while
the assertion from #1433 still checked the old string. Assert the
current `Lifetime Saved` label.
- **`test_smart_crusher_log_fallback_runs_for_valid_json`** — stale
mock: #1857 made token counting whitespace-aware, so the router now
rates the JSON above the naive `len(content.split())==8` the no-op
kompress mock reported, making it look like a saving and
short-circuiting before the Log fallback. Mock now reports
`_estimate_tokens(content)` to match the router.

## Testing

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

### Test Output

```text
$ pytest tests/test_anthropic_compaction_transforms.py \
         tests/test_proxy_dashboard_stats_cache.py \
         tests/test_transforms_content_router.py -q
78 passed, 1 skipped in 12.14s

$ ruff check <the three files>
All checks passed!
$ ruff format --check <the three files>
3 files already formatted
```

## Real Behavior Proof

- Environment: local `.venv`, Python 3.12.6, pytest 9.0.2 (same three
tests that fail on the `main` CI shards 1/3/4).
- Exact command / steps: ran the three previously-failing tests by node
id — all pass. Reproduced the shard-isolation failure for #1 by calling
`tool_desc_max_chars()` with the env unset (cache → 0) before the test,
confirmed the reset makes it pass.
- Observed result: 3/3 target tests pass; 78 passed / 1 skipped across
the three full files.
- Not tested: full suite (unchanged product code); CI shards will re-run
on this PR.

## Review Readiness

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

## Additional Notes

`mypy headroom` (the CI-enforced scope) is unaffected — these edits
touch only `tests/`, which CI does not type-check. Once this lands on
`main`, the `chore: release main` PR (#1923) drops to just the
`test_root_server_json_matches_builder` failure, which is the release
version-bump `server.json` regen (not a code bug).
2026-07-16 09:21:41 -07:00
Tejas Chopra
718c8dc559
fix(proxy): repair main lint (ruff-format drift + mypy host_header) (#2268)
## Description

`main`'s `lint` CI job is currently **red** (latest main `eac49656` →
`lint: failure`), which blocks every open PR. Two causes, both from
recent merges that were green in isolation but combined into a red
`main`:

- **ruff-format drift** on 7 files — committed with formatter output
that ruff `0.15.17` (the CI-pinned version) rewrites.
- **mypy error** in `server.py`:
`_request_has_same_origin_or_no_provenance(request, host_header)` —
`host_header` is `request.headers.get("host")` (`str | None`) but the
function requires `str`.

These passed per-PR because each PR's checks ran against an older base;
the serialized `main` state is what went red — a logical-merge /
tool-version gap that per-PR CI doesn't catch without a strict merge
queue.

## Type of Change

- [x] Bug fix (CI/lint repair)

## Changes Made

- `ruff format` (0.15.17) the 7 drifted files — formatting only, no
logic changes: `cli/proxy.py`, `proxy/forwarded_headers.py`,
`proxy/savings_tracker.py`, `proxy/server.py`, `tests/conftest.py`,
`tests/test_persistent_metrics_persistence.py`,
`tests/test_proxy_loopback_gating.py`.
- Add `assert host_header is not None` after the
`is_ip_literal_host_header()` guard (which already rejects a missing
Host), narrowing the type for the same-origin check.

## Testing

- [x] `ruff check .` — clean
- [x] `ruff format --check .` — clean (tracked)
- [x] `mypy headroom --ignore-missing-imports` — clean

### Test Output

```text
$ mypy headroom --ignore-missing-imports  → Success: no issues found in 504 source files
$ ruff check .                            → All checks passed (tracked)
$ ruff format --check .                   → clean (tracked)
```

## Real Behavior Proof

- Environment: branch off current `main` (`eac49656`), ruff 0.15.17 +
mypy 1.20.2 (CI-pinned).
- Confirmed `lint: failure` on main's latest CI run; after this change
all three lint steps pass locally.
- Not tested: full pytest suite — formatting + a type-narrowing `assert`
only, no behavior change.

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines (this *is* the
style fix)
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
- [ ] Tests added (N/A — no behavior change)
- [x] New and existing unit tests pass locally
- [ ] CHANGELOG (N/A)

## Additional Notes

The 7 files were touched by recent merges (#2198, #2247) whose local
ruff differed from the pinned `0.15.17`. Merging this unblocks the
`lint` gate for all open PRs (including #2207).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-15 23:18:50 -07:00
kaz
eac49656a1
feat(wrap): add headroom wrap kimi for Kimi CLI (#1426)
## Description

Adds `headroom wrap kimi`, routing Kimi CLI through the Headroom proxy.

Kimi CLI speaks an OpenAI-compatible `/chat/completions` API (its
`kosong` backend wraps `AsyncOpenAI`) and lets the base URL be
overridden via `KIMI_BASE_URL`. This wrapper points it at the local
proxy. Kimi's own OAuth bearer is forwarded upstream unchanged, so —
unlike the Copilot subscription path — no extra login or token exchange
is needed.

## Type of Change

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

## Changes Made

- `headroom/providers/kimi/`: new slice; `build_launch_env` sets
`KIMI_BASE_URL` with the per-project base-URL prefix, mirroring the
aider/vibe slices.
- `headroom/cli/wrap.py`: `kimi` subcommand; falls back to the
`kimi-cli` binary when `kimi` is not on `PATH`; `--kimi-api-url`
overrides the upstream coding endpoint (default
`https://api.kimi.com/coding/v1`).
- `tests/test_cli/test_wrap_kimi.py`: 8 tests for the wrap command.
- `README.md`: Kimi CLI row in the agent-compatibility matrix.

## 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_cli/test_wrap_kimi.py -q
........                                                                 [100%]
8 passed in 0.36s

$ ruff check headroom/providers/kimi headroom/cli/wrap.py tests/test_cli/test_wrap_kimi.py
All checks passed!

$ ruff format --check headroom/providers/kimi headroom/cli/wrap.py tests/test_cli/test_wrap_kimi.py
4 files already formatted
```

## Real Behavior Proof

- Environment: macOS; Kimi CLI (`kimi` / `kimi-cli`); `headroom proxy`
started with `--openai-api-url https://api.kimi.com/coding/v1`.
- Exact command / steps: start `headroom proxy --port 8787
--openai-api-url https://api.kimi.com/coding/v1`, then `curl -s
http://localhost:8787/v1/chat/completions` with the Kimi OAuth bearer
and a one-line `kimi-for-coding` chat request (`"Reply with exactly:
PONG"`).
- Observed result: `HTTP 200`; `choices[0].message.content == "PONG"`
from `kimi-for-coding`; the OAuth bearer was forwarded and accepted
upstream; the per-project path `/p/<name>/v1/chat/completions` also
returned `HTTP 200`.
- Not tested: Windows/Linux PATH discovery; the `--learn` / `--memory`
live paths beyond flag wiring.

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

## Additional Notes

- `ruff check` and `ruff format --check` pass locally; `mypy` was run on
the new `headroom/providers/kimi` slice only (clean), so the full-tree
`mypy headroom` box is left unchecked and is left to CI.
- The slice deliberately reuses `codex.proxy_base_url` and
`with_project_prefix`, identical to the aider/vibe wrappers, so
per-project savings attribution works without Kimi sending custom
headers.
- Kimi's separate search/fetch services are out of scope for
`KIMI_BASE_URL` and continue to hit Kimi directly; only the LLM
`/chat/completions` traffic is compressed.

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 21:40:58 +00:00
Ashish
46d4378cf7
feat(evals): weekly HotpotQA answer-recall report on the prose path (#1188)
## Description

Follow-up to **#1187** (the offline fidelity gate). That gate is
hermetic and **structured-only** (JSON tool outputs via Rust
compressors) so it can block every PR with zero setup. This PR adds the
genuinely-uncovered piece: **prose answer-recall on a real dataset
(HotpotQA)** in the **model-allowed weekly job**, where compression
routes through Kompress (ModernBERT).

> **Stacked on #1187.** Until that merges, this PR's diff shows its
commit too; it reduces to just `c71cc0cb` once #1187 lands. Please
review/merge #1187 first.

Closes #

## Type of Change

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

## Changes Made

- **`CompressionOnlyRunner.evaluate_dataset_recall(suite)`**: for each
QA case, compress the supporting `context` via the production routing
path (`ContentRouter`) and check the `ground_truth` answer survives
(`compute_information_recall`). Counts only **probeable** cases — answer
literally present in the context and non-trivial (skips `yes/no`,
too-short) — so the aggregate is meaningful rather than inflated by
un-measurable cases.
- **`.github/workflows/eval.yml`**: a non-blocking step in the existing
`weekly-suite` job (schedule/manual only) drives it with
`load_hotpotqa(n=50)`. Defensive: a dataset download or model failure
emits `:⚠️:` and `|| true`, never failing the job.
- **Hermetic unit test** (`tests/test_dataset_recall_runner.py`):
exercises the method with synthetic JSON-array contexts (SmartCrusher /
Rust — no model, no network), so it runs in the standard `[dev]` shard.

### Scope notes

- **Prose path only.** BFCL / tool-schema integrity is already covered
by the existing `evaluate_tool_schema_compaction` eval (which runs in
the PR smoke-test), so this targets the previously-uncovered prose
recall path. NQ is an easy further extension using the same method +
`load_natural_questions`.
- **Why weekly, not per-PR.** Real datasets need a network download +
the ModernBERT model. The `weekly-suite` job already installs `[all]`
and genuinely runs every Monday (verified: 5 consecutive successful
scheduled runs), so it's the correct home — keeping PR CI fast and
hermetic.

## 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
$ HF_HUB_OFFLINE=1 python -m pytest tests/test_dataset_recall_runner.py -q
..                                                                       [100%]
2 passed in 0.20s
```

## Real Behavior Proof

- Environment: local checkout of `feat/weekly-dataset-recall`, `pip
install -e ".[dev]"`, `HF_HUB_OFFLINE=1` (proves the unit tests need no
model/network)
- Exact command / steps: `HF_HUB_OFFLINE=1 python -m pytest
tests/test_dataset_recall_runner.py -q` -> `6 passed in 0.36s`; coverage
JSON confirms the runner's per-case exception handler and both
`warm_kompress_model` outcomes are exercised
- Observed result: with a synthetic suite of 3 cases (one probeable
answer in an error row, one trivial `yes`, one absent answer),
`evaluate_dataset_recall` counts only the 1 probeable case (`passed=1`,
`accuracy_rate=1.0`, `benchmark="dataset_recall:synthetic"`); a
monkeypatched compressor crash records the error and counts the case
failed instead of aborting; the new weekly-suite YAML step parses via
`yaml.safe_load` and sits under the `schedule || workflow_dispatch`
guard
- Not tested: the live HotpotQA download + ModernBERT compression --
exercised only by the weekly job (or `workflow_dispatch`), by design

## Review Readiness

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

## Checklist

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

## Additional Notes

- CHANGELOG/version intentionally untouched: repo uses
**release-please**.
- The weekly job can be triggered on demand via **workflow_dispatch** to
see the HotpotQA recall numbers without waiting for Monday.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-07-15 21:40:55 +00:00
Semianchuk Vitalii
63f74aa3e6
fix: replace computer_call_output with apply_patch_call_output in output_shaper (#2250)
The _RESPONSES_TOOL_OUTPUT_TYPES frozenset in output_shaper.py had
computer_call_output instead of apply_patch_call_output, making it
inconsistent with the canonical definitions in handlers/openai.py and
output_turn_policy.py. This caused apply_patch_call_output items to be
misclassified, preventing effort routing optimization for those turns.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 21:33:25 +00:00
AxelRay
81d40a6437
fix(proxy): record Prometheus metrics for POST /v1/compress (#2247)
## Description

`POST /v1/compress` compressed messages correctly but never recorded
Prometheus business metrics. Standalone compress microservice
deployments (including LiteLLM `guardrail: headroom`) left
`headroom_requests_total`, token counters, latency, and
by_model/by_provider families at zero.

This wires the existing request-outcome funnel into `handle_compress` so
success and timeout paths update the same counters as reverse-proxy
handlers, and hard failures call `record_failed`.

Closes #2244

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

- On successful compression, record a `RequestOutcome` with
`provider="compress"`, model, token before/after/saved, latency,
transforms, tags, and client.
- On compression timeout (fail-open), record zero-savings outcome plus
`record_compression_failed("timeout")`.
- On hard compression errors (503), call
`metrics.record_failed(provider="compress")`.
- Leave bypass header and empty-message early returns unrecorded (no
real compression work).
- Response schemas and status codes unchanged.
- Add regression tests for success, timeout, and hard-failure metric
recording.

## 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 pytest tests/test_proxy_compress_endpoint.py -q
# 16 passed

uv run ruff check headroom/proxy/handlers/openai.py tests/test_proxy_compress_endpoint.py
# passed
```

## Real Behavior Proof

- Environment: local checkout of this branch; FastAPI TestClient
fixtures for `/v1/compress` (loopback client)
- Exact command / steps:
  - `uv run pytest tests/test_proxy_compress_endpoint.py -q`
- `uv run ruff check headroom/proxy/handlers/openai.py
tests/test_proxy_compress_endpoint.py`
- Observed result:
- Success path awaits `_record_request_outcome` with `tokens_saved =
max(0, before-after)` and `provider="compress"`
- Timeout path records zero-savings outcome and
`record_compression_failed("timeout")`
- Hard failure path awaits `record_failed(provider="compress")` and
still returns 503
- Not tested: live multi-process scrape of `GET /metrics` while a real
headroom process handles LiteLLM guardrail POSTs

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A

## Additional Notes

- Focused compress endpoint suite only; full-repo mypy was not run.
- No public API or response-schema changes.
2026-07-15 21:27:59 +00:00
Dávid Balatoni
5424e99a65
Clarify uv tool install path on macOS (#1196)
## Description

Clarifies the recommended install path for the Headroom CLI on macOS
Apple Silicon and Linux. The docs now prefer `uv tool install --python
3.13 "headroom-ai[all]"` for host-level CLI use, keep `pip install`
scoped to Python project environments, and call out absolute executable
paths for MCP clients that do not inherit interactive shell `PATH`.

## Type of Change

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

## Changes Made

- Added `uv tool install --python 3.13` guidance to the README, docs
install page, quickstarts, and wiki install pages.
- Documented `uv tool update-shell` for shells that cannot find the
installed `headroom` command.
- Clarified absolute MCP server command paths for clients that do not
inherit the interactive shell `PATH`.
- Pointed Intel macOS users at the Docker-native install path until
native wheel support lands.

## Testing

Describe the tests you ran to verify your changes:

- [ ] Unit tests pass (`pytest`) - not run; docs-only change.
- [ ] Linting passes (`ruff check .`) - not run; docs-only change.
- [ ] Type checking passes (`mypy headroom`) - not run; docs-only
change.
- [ ] New tests added for new functionality - not applicable.
- [x] Manual testing performed
- [x] `git diff --check upstream/main...HEAD`

## Real Behavior Proof

```bash
$ git diff --check upstream/main...HEAD
# exits 0; no whitespace errors
```

`npm --prefix docs run types:check` was also attempted. It regenerated
MDX and route types successfully, then failed in existing docs app code
because `@/lib/...` imports cannot resolve from files such as
`app/(home)/layout.tsx`, `app/api/search/route.ts`, and
`components/button.tsx`. This PR only changes `README.md`,
`docs/content/docs/installation.mdx`,
`docs/content/docs/quickstart.mdx`, and `wiki/*.md` files.

## Review Readiness

- [x] Draft PR; docs wording and install-path accuracy are ready for
review.
- [x] No code or runtime files changed.
- [x] Known docs type-check blocker is documented above.

## Test Output

```bash
$ git diff --check upstream/main...HEAD
# no output
```

```text
$ npm --prefix docs run types:check
[MDX] generated files
✓ Types generated successfully
app/(home)/layout.tsx(2,29): error TS2307: Cannot find module @/lib/layout.shared or its corresponding type declarations.
...
components/button.tsx(4,20): error TS2307: Cannot find module @/lib/cn or its corresponding type declarations.
```

## 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
- not applicable; docs-only change.
- [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 - not applicable; docs-only change.
- [ ] New and existing unit tests pass locally with my changes - not
run; docs-only change.
- [ ] I have updated the CHANGELOG.md if applicable - not applicable.

## Screenshots (if applicable)

Not applicable.

## Additional Notes

The PR remains a draft while docs verification is limited by the
existing docs app `@/lib/*` resolution issue.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 21:06:30 +00:00
akothari-godaddy
7ddcbcb616
perf: surface optimization overhead diagnostics (#1212)
## Summary
- add overhead diagnostics to perf JSON output
- report optimization p50/p95/p99, slow request percentage, per-stage
totals/percentiles, and top slow requests
- update text report and recommendations to point at the slowest stage
and HEADROOM_COMPRESSION_TIMEOUT_SECONDS when optimization is
consistently slow

## Verification
- python -m py_compile headroom/perf/analyzer.py
tests/test_cli_perf_format.py
- pytest tests/test_cli_perf_format.py could not run locally because
pytest is not installed in this Python environment
2026-07-15 21:04:21 +00:00
TUTU244
412db40a0b
fix(code): pin tree-sitter-language-pack <1.0.0 in [code] extra (#1219)
## 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: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 20:54:25 +00:00
John Xu
1c50eca8b3
fix(proxy): skip Responses memory tools for ChatGPT auth (#1579)
## Description

Fix ChatGPT/Codex session-auth Responses proxy handling so the ChatGPT
backend always receives an explicit `store=false`, while keeping
Responses memory tools limited to the regular API-key path where stored
responses are supported.

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

- Detect ChatGPT auth before Responses memory-tool injection and force
`store=false` for ChatGPT-auth Responses payloads.
- Skip Responses memory tools and transparent memory-tool continuation
handling for ChatGPT auth across HTTP, WebSocket first frames, WebSocket
follow-up `response.create` frames, and WS-to-HTTP fallback.
- Preserve API-key behavior after the current main merge: API-key
requests that explicitly set `store=false` skip Responses memory tools,
while API-key requests that receive injected memory tools are forced to
`store=true` for continuation support.
- Address Copilot formatter comments by making
`_allow_responses_memory_tools` call sites formatter-stable.

## 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
$ uv run --extra dev ruff format --check headroom/proxy/handlers/openai.py
1 file already formatted

$ uv run --extra dev ruff check headroom/proxy/handlers/openai.py tests/test_openai_codex_routing.py tests/test_openai_codex_ws_timings.py tests/test_ws_http_fallback.py
All checks passed!

$ uv run --extra dev python -m pytest -q tests/test_openai_codex_routing.py tests/test_openai_codex_ws_timings.py tests/test_ws_http_fallback.py
37 passed in 0.34s
```

## Real Behavior Proof

- Environment: Local checkout of `fix/codex-store-false-memory-tools`
using `uv run --extra dev`.
- Exact command / steps: Ran the focused formatter, lint, and pytest
commands listed in `Testing`.
- Observed result: Formatting is stable, lint passes, and the focused
OpenAI/Codex routing and fallback tests pass.
- Not tested: Full test suite, `mypy headroom`, and a fresh live ChatGPT
backend probe after the formatter-only follow-up. The original PR
validation recorded that valid ChatGPT subscription backend requests
return `200` with `store=false`, while identical `store=true` or omitted
`store` requests return `400 Store must be set to false`.

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A.

## Additional Notes

- Post-deploy monitoring terms: `Responses: forced store=false for
ChatGPT auth`, `WS Responses: forced store=false for ChatGPT auth`,
`chatgpt_store_false`, `Memory: forced store=true for Responses memory
tool continuation`, and upstream 400s containing `Store must be set to
false`.
- Expected healthy signals: ChatGPT-auth Responses requests keep
`store=false` and no longer fail with `Store must be set to false`;
API-key memory-tool flows still inject memory tools and can continue via
`previous_response_id`.
- Rollback trigger: any increase in ChatGPT-auth 400s, API-key
memory-tool continuation failures, or missing memory tool injection on
API-key Responses requests.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 20:52:01 +00:00
Aashish Tamsya
420dc9077b
feat(grok-build): add Grok Build wrap command and MCP integration (#1629)
## Description

Adds first-class Grok Build support to Headroom so Grok CLI sessions can
route through the local proxy for context compression and savings
tracking.

This PR introduces `headroom wrap grok-build` / `headroom unwrap
grok-build`, a `grok_build` provider slice, Grok MCP registrar support,
and install/telemetry wiring so Grok traffic is attributed correctly in
the proxy and dashboard.

Review follow-up (`9368c413`): when users already own
`[model.grok-build]` in `~/.grok/config.toml`, wrap rewrites `base_url`
in that table in place instead of appending a duplicate header (invalid
TOML).

## Type of Change

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

## Changes Made

- Added `headroom/providers/grok_build/` with runtime helpers,
reversible `~/.grok/config.toml` injection, and install env builders.
- Added `headroom wrap grok-build` and `headroom unwrap grok-build` CLI
commands.
- Added `GrokRegistrar` for Headroom MCP registration in Grok config.
- Wired `grok_build` into install planner/registry, agent savings,
telemetry, and proxy client detection (`grok/` user agent).
- **Review fix:** rewrite `base_url` inside an existing user-owned
`[model.grok-build]` table in place (`# was: …` metadata).
- Added regression tests + docs (`grok-build.mdx`, `proxy.mdx`) and
CHANGELOG entry.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ pytest -q tests/test_provider_grok_build.py tests/test_mcp_registry/test_grok_registrar.py
============================== 12 passed in 1.13s ==============================
```

See **Screenshots** below for terminal captures (pytest, review-fix
in-place rewrite, proxy `/readyz`, unwrap).

## Real Behavior Proof

- Environment: macOS, Python 3.11.12 venv, feat/grok-build @ `9368c413`,
isolated `GROK_HOME` temp dirs, proxy port 8799
- Exact command / steps: see screenshot evidence (wrap/unwrap, in-place
table rewrite, `/readyz`)
- Observed result: see screenshots — 12 tests pass; single
`[model.grok-build]` table after wrap on pre-existing config; proxy
healthy; unwrap restores backup
- Not tested: Live interactive Grok chat with xAI auth through the proxy

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

Terminal captures from local verification (`9368c413`). Assets hosted on
fork prerelease only — **not** in the source tree.

**1. Pytest — 12 passed (incl. review-fix regression)**

![pytest 12
passed](https://github.com/aashishtamsya/headroom/releases/download/pr-1629-evidence/01-pytest.png)

**2. Review fix — in-place `[model.grok-build]` rewrite (single table,
`# was:` metadata)**

![review fix in-place
rewrite](https://github.com/aashishtamsya/headroom/releases/download/pr-1629-evidence/02-review-fix-in-place.png)

**3. Proxy health — `/readyz` healthy on port 8799**

![proxy readyz
healthy](https://github.com/aashishtamsya/headroom/releases/download/pr-1629-evidence/03-proxy-health.png)

**4. Unwrap — restores pre-wrap backup**

![unwrap restores
backup](https://github.com/aashishtamsya/headroom/releases/download/pr-1629-evidence/04-unwrap.png)

## Additional Notes

Screenshot assets:
https://github.com/aashishtamsya/headroom/releases/tag/pr-1629-evidence
(temporary prerelease; safe to delete after merge).

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 20:51:52 +00:00
EC0RP
ecf086f1f8
docs(compose): document the memory-stack docker-compose services (#1242)
## What

Adds explanatory comments throughout `docker-compose.yml` so the "memory
stack" is self-documenting for new users.

Covers:
- The **headroom-proxy** service — OpenAI-compatible endpoint, why it
binds to `0.0.0.0`, the `/readyz` healthcheck, and the `depends_on`
start-order caveat.
- **Qdrant** (vector search) and **Neo4j** (relationship graph) — their
roles, exposed ports, and named volumes for persistence.
- A header block with quick-start steps, the full list of host-exposed
ports, and a note that the proxy can run standalone without the
datastores.
- A callout that the `NEO4J_AUTH` default is **local-dev only** and must
be overridden before any non-local use.

## Why

The compose file previously had only minimal inline comments, making it
unclear which services are optional and which port maps to what. These
are documentation-only changes — no behavior, image, or configuration
values changed.

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

Co-authored-by: Cason Clark <casonclark@Casons-MacBook-Pro.local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 20:38:55 +00:00
panamarob30-jpg
3f5067022f
[codex] Simulate Codex read maturation risk (#1395)
## Summary
- add Codex support to `audit-reads --codex --simulate-maturation`
- classify Codex edit targets from `apply_patch`, `sed -i`, `tee`, and
shell redirects so maturation risk can count edits
- include focused tests for Codex maturation metrics, CLI JSON/text
output, and edit-risk buckets
- refresh `uv.lock` to match the current `pyproject.toml` version/extras

## Validation
- `uv run ruff check headroom/audit/codex.py
headroom/audit/maturation.py headroom/audit/__init__.py
headroom/cli/audit.py tests/test_audit_codex.py`
- `uv run pytest tests/test_audit_codex.py tests/test_audit_reads.py
tests/test_read_maturation.py
tests/test_read_maturation_handler_nobust.py -q`
- live local run: `uv run headroom audit-reads --codex --path
/home/robert-briscoe/.codex/sessions --simulate-maturation`

Co-authored-by: Robert Briscoe <robert@briscoe.dev>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 20:36:34 +00:00
yaowei
ac7ee4e0bf
fix(proxy): support Codex WS compatible gateways (#1281)
Adds opt-in compatibility for OpenAI-compatible WebSocket gateways used
behind Codex /v1/responses.

- HEADROOM_OPENAI_WS_FLATTEN_RESPONSE_CREATE=1 flattens Codex
response.create frames before upstream send.
- HEADROOM_OPENAI_WS_PROPAGATE_UPSTREAM_CLOSE=1 propagates upstream
close code/reason back to the client.
- Default behavior is unchanged.

Tested:
python -m pytest tests/test_openai_codex_ws_lifecycle.py -q
18 passed

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 20:36:27 +00:00
monkeygold
02c77640a9
fix(transforms): guard Log fallback against invalid JSON + fix MIXED false-positive on source code (#1347)
## Summary

Three related fixes in the content router/detector, addressing data-loss
and misrouting bugs found via chaotic audit:

- **SMART_CRUSHER → Log fallback guard (#1306):** Truncated/invalid JSON
tool outputs were tagged `json_array` by the native magika detector
(classifies by shape, not parseability), routed to SmartCrusher (no-op),
Kompress (no-op), then collapsed by LogCompressor to a single
CCR-retrieval marker — **99.9% data loss** when CCR retrieval isn't
configured. A JSON-validity guard (`_content_is_valid_json`) now skips
the Log fallback for content that fails `json.loads`; valid JSON arrays
still reach it (LogCompressor is a no-op on them).
- **MIXED false-positive on source code:** `is_mixed_content` regex
heuristics misclassify Python with dict/list literals (`{`, `[` at line
start → `has_json_blocks`) + docstrings (`has_prose`) as MIXED, wasting
1–1.4s latency with 0% compression. When the native detector confidently
says `SOURCE_CODE` (confidence ≥ 0.8), `_determine_strategy` now trusts
it over the regex heuristics.
- **PASSTHROUGH for code when CodeAware disabled:** When
`prefer_code_aware_for_code=False` (default), source code now uses
`PASSTHROUGH` instead of `KOMPRESS`, honouring the config's "let code
pass through unmangled" intent. KOMPRESS can destroy code semantics (98%
compression, 11% fact recall on large blobs).
- **RecursionError hardening:** Caught in both `_try_detect_json` and
`_content_is_valid_json` so deeply nested JSON (`[[[[...]]]]` with 10k+
levels) no longer crashes the detector/router — also serves as a DoS
mitigation.

#### Test plan
- [x] `tests/test_transforms_content_router.py` — 36 passed (8 new
tests)
- [x] `tests/test_transforms_content_detection.py` — 9 passed
- [x] `tests/test_cache_aligner_detector_only.py` — 22 passed
- [x] `tests/test_compression_decision.py`,
`test_compression_policy.py`, `test_compress_api.py`,
`test_compression_safety_rails.py` — 137 passed, 5 skipped
- [x] `ruff check` on changed files — all checks passed
- [x] `mypy` on changed files — no issues found

New tests cover:
- Invalid JSON skips Log fallback (content preserved verbatim)
- Valid JSON arrays still reach Log fallback
- MIXED false-positive overridden by high-confidence SOURCE_CODE
detection
- Low-confidence SOURCE_CODE does NOT override MIXED (safety)
- Genuine mixed content (PLAIN_TEXT detection) still uses MIXED
- PASSTHROUGH preserves code verbatim, never invokes Kompress
- CodeAware explicitly enabled still uses CODE_AWARE

#### Risks / rollback
- Behaviour change: code blobs previously routed through MIXED→KOMPRESS
now use PASSTHROUGH. This is the documented intent of
`prefer_code_aware_for_code=False`; if a deployment relied on the
accidental KOMPRESS compression of code, set
`prefer_code_aware_for_code=True` to restore CODE_AWARE.
- The JSON-validity guard adds one `json.loads` call in the narrow "no
savings" fallback path only — negligible overhead.
- Revert is a single-commit revert; no schema/migration changes.

Generated with [Devin](https://devin.ai)

Co-authored-by: monkeygold <monkeygold@users.noreply.github.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 20:36:20 +00:00
Peter Lodri
a51bbfb6a5
fix(opencode): use type=local + environment field for MCP config (#1380) (#1388)
## Summary

Fixes #1380 — OpenCode MCP config was written with the wrong schema in
both `mcp install` and `wrap opencode`.

### Root Cause

`_spec_to_entry` and `build_opencode_config_content` both generated:
```json
{
  "type": "remote",
  "url": "http://127.0.0.1:<port>/mcp",
  "env": {...}
}
```

OpenCode's local-stdio MCP schema requires:
```json
{
  "type": "local",
  "command": ["headroom", "mcp", "serve"],
  "environment": {...}
}
```

The proxy does not expose `/mcp`; it returns 404. The `env` field is
also the wrong key — OpenCode expects `environment`.

### Changes

- **`headroom/mcp_registry/opencode.py`** — `_spec_to_entry`:
`type=local`, remove `url`, command always a list, env vars under
`environment`; `_entry_to_spec`: read `environment` first, fall back to
legacy `env` for existing configs
- **`headroom/providers/opencode/runtime.py`** —
`build_opencode_config_content`: local stdio entry with
`HEADROOM_PROXY_URL` env var pointing to the proxy port (headroom mcp
serve picks it up at startup)
- **Tests** — updated two assertions to match corrected schema; added 3
regression tests for `type=local`, `environment` field, and legacy `env`
fallback

## Test Plan

- [x] `pytest tests/test_mcp_registry_opencode.py` — 64 passed
- [x] `pytest tests/test_cli/test_wrap_opencode.py` — 64 passed
- [x] All previously-passing tests remain green

## Remaining items from #1380

- `--no-mcp` still writes persistent MCP via
`inject_opencode_provider_config()` — tracked in the issue, separate PR
- `mcp uninstall/status` symmetry — tracked in the issue, larger scope
- `--target opencode` CLI addition — tracked in the issue

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

## Real behavior proof

**Setup:** macOS 14, Python 3.12, headroom-ai 0.27.0-dev, OpenCode 0.1.x

**Steps after patch:**
```bash
headroom mcp install --agent opencode
cat ~/.config/opencode/opencode.json | python3 -m json.tool
```

**After-fix evidence — written config:**
```json
{
  "mcp": {
    "headroom": {
      "type": "local",
      "command": ["headroom", "mcp", "serve"],
      "enabled": true
    }
  }
}
```
Before fix: `type: "remote"`, `url: "http://127.0.0.1:8787/mcp"` (404 on
proxy), `env` key (wrong field name). OpenCode failed to start headroom
MCP server.

After fix: `type: "local"` — OpenCode launches the MCP server as a
subprocess and the MCP session connects.

**What I did not test:** Windows config paths, `OPENCODE_HOME` env
override on Linux.

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 20:36:14 +00:00
JerrettDavis
d50c73f2a1 test: align savings schema assertions 2026-07-15 15:15:24 -05:00
Sneha Roy
e8bff1cfe3
feat: add CrewAI and AutoGen tool compression integrations (#1384)
## Description

Add CrewAI and AutoGen tool compression integrations, following the same
patterns as the existing LangChain agent integration
(`HeadroomToolWrapper` / `wrap_tools_with_headroom`). Both delegate
compression to `compress_tool_result()` from the MCP integration, with
per-tool metrics tracking via `ToolCompressionMetrics` /
`ToolMetricsCollector`.

Closes #1379

## Type of Change

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

## Changes Made

- Add `headroom/integrations/crewai/` — `HeadroomToolWrapper` subclasses
CrewAI `BaseTool`, wraps `_run()` with compression
- Add `headroom/integrations/autogen/` — `HeadroomToolWrapper` wraps
AutoGen `FunctionTool` (sync and async) with compression
- Wire both into `headroom/integrations/__init__.py` with aliased
re-exports (avoids name collision with LangChain's
`HeadroomToolWrapper`)
- Add `[crewai]` and `[autogen]` optional dependency extras to
`pyproject.toml`
- Add 24 unit tests (12 per framework) under `tests/test_integrations/`
- Add `.mdx` doc pages for both frameworks under `docs/content/docs/`
- Update `CHANGELOG.md` with entries under `### Added`

## Testing

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

### Test Output

```text
$ ruff check headroom/integrations/crewai headroom/integrations/autogen tests/test_integrations/crewai tests/test_integrations/autogen
All checks passed!

$ pytest tests/test_integrations/autogen -v
12 passed

$ pytest tests/test_integrations/crewai -v
12 passed
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.11, crewai 1.14.7, autogen-agentchat
0.7.5
- Exact command / steps: Ran standalone adapter demos and benchmark
runner across 4 task types
- Observed result:

| Task | Tokens (raw) | Tokens (compressed) | Savings |
|------|-------------|-------------------|---------|
| Inventory JSON (80 items) | 5,044 | 1,532 | 69.6% |
| Server logs (150 lines) | 8,712 | 314 | 96.4% |
| Analytics query (100 rows) | 10,762 | 10,762 | 0% |
| API docs (20 endpoints) | 8,043 | 8,043 | 0% |

Compression results are identical across CrewAI and AutoGen — expected
since both route through the same `compress_tool_result()` pipeline.

- Not tested: Full end-to-end with a live LLM agent loop (demos test the
compression pipeline standalone). LangGraph not included — headroom
already has `headroom/integrations/langchain/langgraph.py`.

## Review Readiness

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

## Checklist

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

## Additional Notes

- LangGraph integration is intentionally excluded — headroom already has
one at `headroom/integrations/langchain/langgraph.py`
- Re-exports in `__init__.py` are aliased (`CrewAIToolWrapper`,
`AutoGenToolWrapper`) to avoid collision with the existing LangChain
`HeadroomToolWrapper`
- Both integrations follow the exact same conventions as the existing
LangChain agents module: optional dep guard, `compress_tool_result()`
delegation, metrics with 1000-entry cap, Google-style docstrings
- `mypy` not checked due to Rust build dependency (`maturin`) that
requires Application Control policy changes on this machine

---------

Co-authored-by: Sneha27feb <sroy27.ai@gmail.com>
2026-07-15 19:58:54 +00:00
Gen Li
3dd9660d91
feat: 3-layer context compression pipeline (L1+L2+L3) (#1405)
## Description

> **Default behavior is unchanged:** only L1 (annotation-key stripping)
is on by default. L2 (description truncation) and L3 (system-prompt
compression) are **opt-in** via `HEADROOM_TOOL_DESC_MAX_CHARS` and
`HEADROOM_SYSTEM_COMPACT=1` respectively — instruction-level compression
never runs unless an operator explicitly enables it. Verified in
`system_compact.py`: `system_compact_enabled()` returns `False` when the
env var is unset.

Reduces MCP-injected context overhead (~40K tokens / 20% of a 200K
window) through a progressive 3-layer compression pipeline. Each layer
is independently controlled, fail-safe, and additive — operators can
enable L1 only (default) or opt into L2/L3 for deeper savings.

### Layer 1: Tool Schema Annotation Key Stripping (default on)
- Strip JSON Schema annotation keys (`$schema`, `title`, `examples`,
`deprecated`, `default`, `readOnly`, `writeOnly`) from tool definitions
- Normalise whitespace in `description` fields
- Zero risk — removes only non-constraint metadata that models ignore
- ~8% savings on tool schema size

### Layer 2: Tool Description Truncation (opt-in:
`HEADROOM_TOOL_DESC_MAX_CHARS`)
- Truncate verbose tool/parameter descriptions to configurable length
- Preserves first complete sentence (critical for model tool selection)
- Optionally appends second sentence within 1.5× budget
- Hard-truncates with `...` if a single sentence exceeds limit
- Recursively processes nested `description` fields in
`input_schema`/`parameters`
- ~43% savings on description text (estimated ~17K tokens)

### Layer 3: System Prompt CCR Compression (opt-in:
`HEADROOM_SYSTEM_COMPACT`)
- Compress `system[]` content blocks using existing
`ContentRouter.compress()`
- Only compresses blocks exceeding `HEADROOM_SYSTEM_COMPACT_MIN_CHARS`
(default 500)
- Preserves `cache_control` markers and non-text blocks
- Fail-safe: leaves block unchanged if compression fails or doesn't save
size
- ~14.5% savings on system prompt (estimated ~3.5K tokens)

**Combined savings (all 3 layers enabled): ~40K → ~17K tokens (~58%
reduction)**

## Type of Change

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

## Changes Made

- `headroom/proxy/tool_schema_compaction.py` — New shared module: L1
annotation stripping + L2 description truncation with
`strip_annotation_keys()` and `truncate_descriptions()`
- `headroom/proxy/system_compaction.py` — New module: L3 system prompt
CCR compression with `compact_system_blocks()`
- `headroom/proxy/handlers/anthropic.py` — Add L1+L2+L3 call sites
(after tool assembly, before PRE_SEND)
- `headroom/proxy/handlers/openai.py` — Add L1+L2+L3 call sites
(parallel to Anthropic handler)
- `tests/test_tool_schema_compaction.py` — 42 unit tests covering edge
cases, nested schemas, fail-safe behavior
- `tests/test_system_compaction.py` — Tests for L3 compression,
cache_control preservation, min-chars gating

## Testing

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

### Test Output

```text
$ uv run pytest tests/test_tool_schema_compaction.py tests/test_system_compaction.py tests/test_anthropic_compaction_transforms.py -v
===== 49 passed in 4.96s =====

$ uv run ruff check <changed files>
All checks passed!

$ uv run mypy headroom/proxy/tool_schema_compaction.py headroom/proxy/system_compaction.py headroom/proxy/handlers/anthropic.py headroom/proxy/handlers/openai.py
Success: no issues found in 4 source files

# Manual verification with HEADROOM_TOOL_DESC_MAX_CHARS=120
# Single tool schema: 548→434 bytes (L1, 20.8% saved) → 315 bytes (L2, 27.4% saved)
# Combined: 548→315, 42.5% saved
# Full request with proxy: orig=39179 opt=31988 saved=7191 (18.4% compression)
```

## Real Behavior Proof

- Environment: macOS, Python 3.13, headroom proxy v0.28.0, Claude Code
CLI
- Exact command / steps:
1. Start proxy with `HEADROOM_TOOL_DESC_MAX_CHARS=120
HEADROOM_SYSTEM_COMPACT=1 headroom proxy`
  2. Route Claude Code traffic through proxy
  3. Check `/stats` endpoint for `transforms_applied` and byte savings
- Observed result: L1/L2/L3 transforms applied correctly, ~58% token
reduction on MCP-heavy context
- Not tested: Windows, production deployment

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

- L2 and L3 are **opt-in** via env vars. Default behavior is unchanged
(only L1 active).
- All layers have fail-safe fallbacks — if compaction fails or doesn't
reduce size, the original payload passes through unchanged.
- The Anthropic handler now appends `anthropic:tool_schema_compaction`
(L1), `anthropic:tool_desc_compaction` (L2), and
`anthropic:system_compact` (L3) to `transforms_applied`, so `/stats` and
transformation accounting are no longer blind to compression that
changed the request. Covered by handler-level e2e regression in
`tests/test_anthropic_compaction_transforms.py` (positive + negative
cases). The earlier follow-up #1423 is superseded — no longer needed.

---------

Signed-off-by: lg320531124 <lg320531124@users.noreply.github.com>
Co-authored-by: lg320531124 <lg320531124@users.noreply.github.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 19:58:51 +00:00
Zhenjia ZHOU
4035c04187
feat(text-crusher): CJK-aware segmentation + relevance via ICU (#1504)
## Description

`TextCrusher` (the native extractive prose compressor added in #1171)
only handled ASCII: `split_segments` split on `.!?`+whitespace and
`tokens` split on whitespace/alphanumeric runs. CJK
(Chinese/Japanese/Korean) has neither spaces nor ASCII terminators, so a
whole CJK paragraph collapsed into **one segment / one token** — it
passed through at ~0% compression, and BM25 relevance + salience scored
zero terms.

This makes `TextCrusher` CJK-aware. CJK-bearing content takes an ICU
(`icu_segmenter`, UAX#29 sentence + dictionary word) segmentation path,
with a length fallback for terminator-sparse runs, a local BM25 over the
ICU word tokens, and ICU-token salience. Dispatch is on **content
only**, so pure-ASCII text is byte-identical to before — the shared
`BM25Scorer` and the ASCII path are untouched.

It also adds a committed, reproducible answer-retention eval
(`benchmarks/i18n_compression_eval.py`) with a deterministic zh/ja/ko CI
regression gate, so the improvement below is permanently verifiable
rather than a one-off measurement.

Extends #1171.

## Type of Change

- [x] Bug fix (CJK passed through near-uncompressed)
- [x] New feature (CJK segmentation / relevance support)
- [x] Performance improvement (CJK now compresses; ICU segmenters
cached, not rebuilt per call)

## Changes Made

- `is_cjk` predicate gates a CJK path (ideographs, kana, Hangul, CJK
punctuation, full/half-width forms).
- `split_segments` → ICU `SentenceSegmenter` for CJK + a mandatory
length fallback (whitespace / CJK punctuation / hard cap) for
terminator-sparse runs; ASCII path unchanged.
- `tokens` → ICU `WordSegmenter` (dictionary) for CJK; ASCII path
unchanged.
- `relevance_cjk`: a local BM25 over ICU word tokens — the shared ASCII
`BM25Scorer` scores zero terms for CJK and is parity-locked, so this is
an intentional separate scorer (documented in code).
- CJK salience uses ICU tokens (whitespace-split gave one giant "word" →
zero salience).
- `count_tokens`: CJK-aware so `compression_ratio` isn't nonsense for
space-free text.
- ICU segmenters resolved once in `static LazyLock` (compiled_data is
static) instead of rebuilt per call.
- New dep `icu_segmenter` 2.2, `compiled_data` only (see Dependency
below).
- `benchmarks/i18n_compression_eval.py` +
`tests/test_transforms/test_text_crusher_cjk_eval.py`: a zh/ja/ko
answer-retention eval — a deterministic needle CI gate (always-runs, no
external data), real-transcript fidelity with CJK-aware salient, and
optional `multi-wiki-qa` natural-data retention (loaded via the
`[evals]` `datasets` extra, skipped if absent; data never vendored —
CC-BY-NC-SA).

## Testing

- [x] Unit tests pass (`pytest` + `cargo test`)
- [x] Linting passes (`ruff check`/`format` on the new eval + test —
clean)
- [ ] Type checking passes (`mypy headroom`) — N/A, the only Python
added is a benchmark + test, not `headroom/` source
- [x] New tests added for new functionality
- [x] Manual testing performed (see Real Behavior Proof)

### Test Output

```text
$ cargo test -p headroom-core --lib text_crusher
running 12 tests
test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 841 filtered out

$ .venv/bin/python -m pytest tests/test_transforms/test_text_crusher*.py
15 passed

$ .venv/bin/python -m pytest tests/test_transforms/test_text_crusher_cjk_eval.py
6 passed   # deterministic zh/ja/ko needle CI gate

$ cargo clippy -p headroom-core && ruff check benchmarks/i18n_compression_eval.py   # both clean
```

## Real Behavior Proof

- Environment: macOS (Darwin 25.3.0), Python in a uv venv,
`headroom-core` built via `uv pip install -e .` (maturin), branch
`feat/cjk-text-compression`.
- Exact command / steps: built `_core`, then ran a mixed
Chinese+Japanese doc (no spaces, `。` terminators) through
`TextCrusher().compress(doc, "认证令牌缓存策略", 0.3)`; separately evaluated
answer-retention on the public CMRC2018 Chinese QA dev set (bury the
gold-answer paragraph among 25 distractors, query = the question,
compress to 30%, check the gold answer survives), and end-to-end through
`ContentRouter`.
- Observed result: a mixed Chinese+Japanese doc compressed 189 → 78
tokens (ratio 0.41, kept 3/8 segments) with the query-relevant sentence
surviving — before this change the same doc was a single segment → 100%
passthrough. On the public CMRC2018 Chinese QA dev set, answer-retention
under 30% compression rose 34% → 93% (multiple seeds). End-to-end
through `ContentRouter` on real CJK content, aggregate savings rose 16%
→ 40%. Pure-ASCII (English) output stayed byte-identical (the English
parity fixtures did not move). Demo terminal output:

    ```text
    ORIGINAL  tokens= 189  chars=189
    COMPRESS  tokens=  78  ratio=0.41  segments kept 3/8
    QUERY-RELEVANT sentence survived: True
    --- compressed output (verbatim kept CJK sentences) ---
    认证令牌的缓存策略采用最近最少使用淘汰算法来管理过期条目。
    请求重试使用指数退避并设置最大次数上限。
    数据备份每天凌晨执行并保留最近三十天的快照。
    ```
The committed eval now demonstrates this across all three CJK languages.
The deterministic needle gate (in CI via
`tests/test_transforms/test_text_crusher_cjk_eval.py`, 6 passed) has
TextCrusher keep the query-relevant needle while truncate/random drop it
in zh, ja, and ko. On real `multi-wiki-qa` natural data (n=80/lang),
query-aware answer-retention is **zh 74% / ja 70% / ko 50%** vs
**25–41%** for the truncate/random baselines:

    ```text
=== Part A: multi-wiki-qa answer-retention (n=80/lang, target_ratio=0.3)
===
      lang    text_crusher  truncate  random
      zh-cn           74%       25%     38%
      ja              70%       31%     39%
      ko              50%       26%     41%
    ```
Korean is measurably weaker (ICU has no Korean dictionary and falls back
to UAX#29 word-breaking) — still well above baselines, and scoped as a
follow-up.
- Not tested: the live proxy HTTP path (validated at the `ContentRouter`
/ `TextCrusher` layer, not via a running proxy); no-space Korean
(standard Korean is space-delimited and is covered); non-CJK SE-Asian
scripts (out of scope).

## Dependency (per CONTRIBUTING supply-chain policy)

`icu_segmenter` 2.2 (ICU4X), `features = ["compiled_data"]`:

- **Why this package (vs. ourselves / existing deps):** CJK needs
dictionary/UAX#29 segmentation. A hand-rolled char-bigram scored
slightly worse on real data (CMRC2018 answer-retention: 92.5% ICU vs 91%
bigram, 4 seeds); jieba/lindera are ZH-only or 13–207 MB dicts. ICU4X
covers zh/ja/ko in one crate. The existing `unicode-segmentation` does
UAX#29 only (no CJK dictionary), so it can't word-segment space-free
CJK.
- **Who maintains it:** the official `unicode-org` ICU4X project; active
release cadence (2.2 in 2025); no known CVEs.
- **Install surface:** ~13 new pure-Rust crates, no build scripts, no
native code, no build/runtime network. `compiled_data` bundles locale
data at compile time (hermetic). `auto`/`lstm` deliberately NOT enabled
— LSTM covers SE-Asian scripts (Thai/Lao), not CJK, and would pull in
`libm` for nothing.
- **Why this version:** 2.x is the stabilized ICU4X API (1.x used a
different data-provider model); floored at 2.2 (Cargo.lock pins the
patch) since segmenter boundaries are observable in output and bumps
should be deliberate.

## 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 (CHANGELOG)
- [x] My changes generate no new warnings (clippy + fmt clean)
- [x] I have added tests that prove my feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md

## Additional Notes

- **Parity:** the shared `BM25Scorer` (byte-exact parity-locked with
`headroom/relevance/bm25.py`) is untouched. `relevance_cjk` is a
separate local scorer because the shared one's tokenizer is ASCII-only.
The whole CJK path lives in Rust (`text_crusher.py` is a thin wrapper
over `_core`), so there is no Python mirror to keep in sync; the parity
fixtures stay green (only the CJK `unicode` fixture was re-recorded,
intentionally; English fixtures unchanged).
- **Known by-design gap (not a bug):** CJK content + a pure-ASCII query
yields no token overlap, so relevance falls back to recency + salience
(cross-script query matching is unsupported).
- The Python added is a benchmark
(`benchmarks/i18n_compression_eval.py`) plus its test, not `headroom/`
runtime source — both are `ruff`-clean; `mypy headroom` is unaffected.
- **License:** the optional Part A pulls `alexandrainst/multi-wiki-qa`
(CC-BY-NC-SA-4.0) at run time via the `[evals]` extra and is skipped if
absent — the dataset is never vendored into the repo, and the always-run
CI gate (Part C) uses only our own deterministic data.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 19:58:48 +00:00
mstattma
3757a7cef3
fix(core): avoid unidiff panic on bash xtrace (#1506)
## Summary
- preflight unified diff inputs before calling the Rust unidiff parser
- catch parser panics so malformed-but-diff-looking input falls back to
non-diff
- add regressions for Bash xtrace lines like `+++ test.sh` and `+++
dirname test.sh`

## Repro
`detect_content_type("+++ test.sh")` could panic through the Rust
detector because unidiff treated the lone `+++` line as a target header
without a preceding source header.

## Tests
- `cargo fmt --all --check`
- `cargo test -p headroom-core --lib
transforms::unidiff_detector::tests`
- `cargo test -p headroom-core --lib`

Co-authored-by: Michael Stattmann <mstattma@users.noreply.github.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 19:58:45 +00:00
vscunha
05932d7165
fix(proxy): compress OpenCode tool schemas and embedded JSON (#1535)
## Description

Fixes two remaining OpenCode/OpenAI Chat compression gaps after `main`
incorporated the original savings-profile threading and user
content-block work from this PR.

OpenCode requests can still report very low savings when most input
tokens live in verbose `tools` schemas rather than messages. They can
also route poorly when a short instruction wraps a valid JSON block but
does not satisfy the existing long-prose heuristic.

Closes #1534

## 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 causes existing functionality
to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Compact OpenAI Chat Completions `tools` schemas whenever request
compression is active, reusing the existing OpenAI Responses schema
compactor. The outbound tool invocation shape is preserved while
non-semantic annotations such as `$schema`, `title`, and `examples` are
removed.
- Include the tool-schema token delta in Headroom's savings accounting
and expose `openai:chat:tool_schema_compaction` in the applied
transforms.
- Detect valid JSON blocks surrounded by prose or log text as mixed
content, so short OpenCode instructions route through mixed/SmartCrusher
handling instead of falling through or producing a no-op.
- Adapt the mixed-content change to the new
`headroom.transforms.mixed_content` module introduced on `main` by
#1939.

## Why the Focus Changed

The original headline fix—threading savings-profile kwargs into
`/v1/chat/completions`—is now already present on `main`, as is the user
content-block opt-in behavior. Those duplicate changes were removed
during the merge.

The branch also no longer changes developer/system role protection or
forced-Kompress semantics. It follows `main` for both, so the earlier
instruction-role safety concern is outside the current diff.

The resulting PR is limited to two OpenCode-specific compression gaps
that remain reproducible on current `main`.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check` on changed files)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for the fixed behavior
- [ ] Manual live-upstream testing performed after the latest rebase

### Test Output

```text
59 passed, 1 warning in 83.53s
All checks passed!  # ruff check
4 files already formatted  # ruff format --check
python -m py_compile: passed
git diff --check: passed
```

Focused test coverage includes:

- OpenAI Chat tool-schema compaction, transform reporting, outbound
schema shape, and positive token savings.
- Embedded JSON mixed-content detection, SmartCrusher routing, positive
savings, and preservation of a critical sentinel value.
- Current `main` regressions for savings-profile threading, user content
blocks, turn hooks, and forced-Kompress behavior.

## Real Behavior Proof

- Environment: Linux ARM64, Python 3.13.12, current `main` at `9bacf481`
merged into the branch.
- Exact command / steps: focused pytest run across the OpenAI
cache-stability, content-router, mixed-content, savings-profile,
user-block, turn-hook, and forced-Kompress suites.
- Observed result: 59 tests passed; the chat request test forwarded
compacted tools and reported positive savings, while the embedded-JSON
fixture used mixed routing and preserved `CRITICAL_NEEDLE_42`.
- Not tested: full repository suite and a live external OpenCode request
after the latest merge; those remain for CI/live follow-up.

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented the non-obvious behavior
- [ ] I have made corresponding documentation changes — N/A; internal
routing behavior only
- [x] My changes generate no new warnings
- [x] I have added tests that prove the fixes are effective
- [x] New and existing focused tests pass locally
- [ ] I have updated the changelog — N/A; release automation handles fix
entries

## Screenshots

N/A — proxy/transform behavior only.

## Additional Notes

- Current diff versus `main`: 4 files, 172 insertions, no role-policy or
forced-Kompress changes.
- The mixed-content conflict was resolved by extending the new isolated
parser module rather than reintroducing parsing code into
`ContentRouter`.
2026-07-15 19:58:42 +00:00
Manmit Singh
942e916368
feat(cli): add headroom inspect to view original vs compressed content (#1595)
## Description

Headroom exposes plenty of *quantitative* compression telemetry (token
counts, ratios, `headroom perf`, `/metrics`) but no way to actually
**see what the compressor changed** in the content. That makes it hard
to trust compression or debug a quality regression ("did it drop
something I cared about?").

This adds a `headroom inspect` command (the issue's Option 1). It reads
the proxy's existing loopback `/transformations/feed` endpoint — which
already carries the pre/post-compression message snapshots when the
proxy runs with `--log-messages` — and renders, per request, the
original vs compressed content for each message with the changed
segments highlighted. No new dependencies (stdlib `difflib`).

```
headroom inspect                 # inspect the most recent request
headroom inspect --last 5        # the 5 most recent
headroom inspect --full          # include unchanged messages
headroom inspect --format json   # raw feed for offline tooling
```

Per request it shows the model, per-request token counts + savings, the
transforms applied, and a colorized unified diff of each changed message
(red = removed, green = added). Clear errors when no proxy is reachable
or when the proxy wasn't started with `--log-messages`.

Side-by-side / interactive rendering (the fuller form of Option 1) can
follow as a polish pass; this lands the core "see what changed"
capability on data Headroom already captures.

Closes #1267

## Type of Change

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

## Changes Made

- `headroom/cli/inspect.py`: new `inspect` command +
content-flattening/diff-render helpers.
- `headroom/cli/__init__.py`, `headroom/cli/main.py`: register the
command.
- `tests/test_cli_inspect.py`: unit tests (content extraction, the
no-proxy / no-`--log-messages` / empty-feed paths, text render, json
output).
- `wiki/cli.md`: document the command + options.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [x] New tests added

### Test Output

```text
$ pytest tests/test_cli_inspect.py -q
7 passed

$ ruff check headroom/cli/ tests/test_cli_inspect.py
All checks passed!
```

## Real Behavior Proof

- Environment: repo main @ HEAD, local venv
- Exact command / steps: invoked the `inspect` command against a mocked
`/transformations/feed` payload (one request, a user message with a line
removed by SmartCrusher).
- Observed result: header shows `req-1 gpt-4o`, `tokens 100 → 40 (saved
60, 60.0%)`, `transforms: SmartCrusher`, and a unified diff with the
removed line on the original side; no-proxy and missing-`--log-messages`
cases raise actionable errors; `--format json` emits the raw feed.
- Not tested: live end-to-end against a real proxy with `--log-messages`
(the data source — the feed endpoint — is exercised via the mocked
payload that mirrors its shape).

## 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 made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove the feature works
2026-07-15 19:58:38 +00:00
Manmit Singh
4cbd5da673
feat(proxy): opt-in compression for catch-all passthrough routes (#1699)
## Description

Requests whose path doesn't match a built-in API route fall through to
`handle_passthrough`, which forwarded the body verbatim — bypassing
ContentRouter/Kompress/TOIN entirely. Wrapper-proxy setups that front
Headroom on custom paths (e.g. `/api/codex-proxy/<key>/v1/responses`)
got zero compression on coding-agent traffic and hit context-limit 400s
in long sessions. This adds an opt-in flag that routes OpenAI
Responses-shaped passthrough bodies through the same compression path
the native `/v1/responses` handler uses.

Closes #1546

## Type of Change

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

## Changes Made

- Added `ProxyConfig.compress_passthrough` (default `False`) +
`--compress-passthrough` CLI flag + `HEADROOM_COMPRESS_PASSTHROUGH=1`
env.
- `handle_passthrough`: when enabled, POST requests whose path ends in
`/responses` with an OpenAI Responses-shaped body are compressed via the
existing `_compress_openai_responses_payload_in_executor` before
forwarding; stale `Content-Length` is dropped so httpx recomputes it.
- New `_maybe_compress_passthrough_responses` helper — fail-open:
non-JSON, non-Responses payloads, unmodified results, and any compressor
error forward the original body unchanged.
- Documented the flag in `docs/content/docs/proxy.mdx`.

## 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
$ .venv/bin/python -m pytest tests/test_compress_passthrough.py -q
collected 6 items
tests/test_compress_passthrough.py ......                                [100%]
============================== 6 passed in 0.35s ===============================

$ .venv/bin/ruff check headroom/proxy/handlers/openai.py headroom/proxy/models.py headroom/proxy/server.py tests/test_compress_passthrough.py
All checks passed!
```

## Real Behavior Proof

- Environment: macOS arm64, Python 3.14, repo `.venv`.
- Exact command / steps: `.venv/bin/python -m pytest
tests/test_compress_passthrough.py -q` — covers a Responses-shaped body
being compressed, non-JSON passthrough, non-Responses (`messages`)
payload untouched, unmodified-result short-circuit, compressor-error
fail-open, and `ProxyConfig().compress_passthrough is False` default.
Plus import smoke: `ProxyConfig(compress_passthrough=True)`,
server/handler modules import, helper present.
- Observed result: 6 passed; flag defaults off; enabled path reuses the
native Responses compressor and never raises out to the request.
- Not tested: live end-to-end through a real second proxy to a real
upstream (no external wrapper proxy / upstream credentials in sandbox);
the compression call is the same one `/v1/responses` already exercises
in CI.

## Review Readiness

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

## Checklist

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

## Additional Notes

Scoped to OpenAI Responses-shaped bodies (the reporter's exact case).
Anthropic `/messages` and OpenAI `/chat/completions` passthrough
compression are natural follow-ups — deliberately left out to keep this
change focused and fail-safe. CHANGELOG is release-managed, left
unchecked.
2026-07-15 19:58:34 +00:00
Ashish
6469fcd018
feat(transforms): language-aware stack-trace collapse for Go/Rust/.NET/Java/Node (#1791)
## Description

Stack-trace handling covered only Python tracebacks and a generic ` at
symbol(` pattern. Go panics and Rust panics flowed to prose compression;
.NET traces were unrecognized; Java chained exceptions split into
separate traces at every `Caused by:` (so later chain heads fell off the
`max_stack_traces` cliff); and oversized traces were blindly
head-truncated — keeping runtime scheduler noise while dropping the app
frames and chain heads an agent actually needs.

This PR adds language-aware trace flavors (Go, Rust, .NET, Java chains,
Node async) to the Rust core and both Python mirrors, and replaces blind
truncation with a runtime-frame collapse: message lines, chain heads,
the trace head, and app-code frames survive; contiguous runtime/stdlib
frames fold into `[... N frames collapsed]` markers. A 147-line Go panic
dump compresses to 19 lines with the panic message, signal line, and app
frame intact.

Note one intentional behavior change: now that Go/Rust panics are
*detected*, panics ≤8KB in tool outputs gain the existing error-output
protection (`protect_error_outputs`) they previously missed — small
panics stay verbatim, exactly like small Python tracebacks already do.

## Type of Change

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

## Changes Made

- `crates/headroom-core/src/transforms/log_compressor.rs`:
- New `TraceFlavor::GoPanic` (`panic:` / `fatal error:` / `goroutine N
[state]:` openers, tab-indented `.go:` frame lines, `created by` /
call-line continuation, blank-separated goroutine blocks) and
`TraceFlavor::DotNet` (`Unhandled exception.`, `at … ) in file:line N`
frames — checked before Java since those frames also satisfy the Java
shape — plus `--->` inner-exception heads and `--- End of` separators).
- Renamed the misnamed `Go` flavor to `RustBacktrace` (its `is_go_frame`
matched `N: 0x<hex>` — the Rust backtrace shape) and gave it real
openers (`thread '…' panicked at`, `stack backtrace:`); the free-text
panic-message line after the opener stays in the trace (`terminates` now
receives `lines_so_far`).
- Java: continues across `Caused by:` / `Suppressed:` / `... N more`,
and `is_java_at_frame` admits `/` so JPMS module frames (`at
java.base/…`) pass the opener re-check — without this, modern JDK traces
fragmented at the parse cap into ≤20-line groups.
- Frame collapse (`collapse_trace_frames`): for traces over
`stack_trace_max_lines`, keeps message/chain-head lines, first
`trace_head_frames` frames, up to `trace_app_frames` app frames; runtime
frames (prefix + path marker tables per language) fold into `[... N
frames collapsed]` markers that occupy the run's first line slot and
carry score 0.8 so the global cap doesn't drop them first. Collapsed
frame indices are excluded from the context-line pass (otherwise ±3
context re-added them), and the parse cap re-opens on continuation lines
so selection sees one contiguous trace. New config:
`collapse_runtime_frames=true`, `trace_head_frames=3`,
`trace_app_frames=5`; new sidecar stat `runtime_frames_collapsed`.
- `crates/headroom-py/src/lib.rs`: the three new knobs on the
`LogCompressorConfig` PyO3 signature.
- `headroom/transforms/log_compressor.py`: dataclass fields +
constructor pass-through; `_parse_lines` opener patterns mirrored per
the documented contract.
- `headroom/transforms/content_detector.py`: `_LOG_PATTERNS` additions
(Go panic/goroutine/frame lines, Rust panic/backtrace/numbered frames,
.NET, Java chain heads, Node `at async`); JS/Java `at` pattern admits
JPMS module paths.
- `tests/test_transforms_stack_traces.py` (new, 10 tests) + 7 new Rust
unit tests (flavor open/continue/terminate, chain grouping, collapse
keeps chain heads/app frames, collapse-off comparison, small traces
untouched).

## Testing

- [x] Added new tests for the changes
- [x] All existing tests pass

### Test Output

```
$ cargo test -p headroom-core
928 passed; 3 ignored

$ python -m pytest tests/test_transforms_stack_traces.py tests/test_log_compressor.py \
    tests/test_transforms_log_compressor.py tests/test_transforms_content_detection.py \
    tests/test_transforms_content_router.py tests/test_transforms/test_content_router.py \
    tests/test_lossless_mode.py tests/test_compression_fidelity_regression.py -q
191 passed
```

## Real Behavior Proof

- Environment: macOS arm64, Python 3.13, repo main @ e8151f05,
`headroom._core` rebuilt via scripts/build_rust_extension.sh
- Exact command / steps: fed a 147-line Go panic + 24-goroutine dump and
a 66-line Java chained exception through
`LogCompressor(LogCompressorConfig(enable_ccr=False)).compress(...)`
- Observed result: Go dump 147 → 19 lines with `panic: runtime error…`,
`[signal SIGSEGV…]`, and the `main.handler` app frame kept, scheduler
frames as `[... 4 frames collapsed]` per goroutine; Java output keeps
`Caused by: java.io.IOException`, `com.example.Disk.read`, and `... 17
more` — all three were lost under blind truncation (verified by the
collapse-off comparison test)
- Not tested: Windows; PHP/Ruby traces (out of scope); interplay with
Kompress relevance-split on mixed log+trace payloads beyond the existing
suite

## Review Readiness

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 19:58:30 +00:00
Ashish
737b332129
feat(config): fold TOML array-of-tables to csv-schema via SmartCrusher (#1799)
## Description

Adds a schema-fold tier to the structured-config compressor (introduced
in #1784). TOML files containing an `[[array-of-tables]]` are parsed
with the stdlib `tomllib` reference parser and bridged to SmartCrusher's
lossless `csv-schema` renderer, which folds the repeated per-record keys
into a single schema over the rows. On lockfiles and override-lists —
where the repeated keys dominate the byte count — this is a large win.

**Stacked on #1784 — review only the top commit** (`feat(config): fold
TOML array-of-tables to csv-schema via SmartCrusher`). The base commit
is #1784's config-compressor PR; this PR will collapse to the single new
commit once #1784 merges.

Faithfulness is guaranteed by construction, not by a heuristic:
- `tomllib` is the reference TOML parser, so the extracted records are
ground-truth.
- `csv-schema` is a lossless JSON renderer (`smart_crusher.py` documents
it as such), so the model reads a faithful, reformatted view of the
exact parsed data.
- Byte-exact recovery rides the existing CCR path — the original is
persisted to the `CompressionStore` and a `Retrieve original: hash=…`
marker is emitted. The fold is only emitted when that store write
succeeds, so nothing is ever unrecoverable.

Scope is deliberately **TOML-only**: `tomllib` is stdlib, whereas PyYAML
is only a *transitive* dependency (not declared in `pyproject.toml`),
and INI record-sections would need a bespoke dict-of-dicts→records
transform. Those flavors can follow in a separate PR with an explicit
dependency decision.

## Type of Change

- [x] New feature (non-breaking change which adds functionality)

## Changes Made

- `headroom/transforms/config_compressor.py`: added Tier 3
(`_schema_fold`) — TOML `[[array-of-tables]]` → `tomllib` → JSON →
`SmartCrusher(csv-schema)`. New `enable_schema_fold` config flag
(default on; auto-off in lossless mode since it rides `enable_ccr`). The
fold competes with the reversible text tiers and is adopted only when
strictly smaller. Added `_load_toml` (stdlib parser with tomli backport)
and `_json_default` (TOML date/time → ISO; bail on any other
non-serializable value).
- Recovery reuses the existing `CompressionStore` + `Retrieve original:
hash=` marker; no new CCR plumbing.
- `tests/test_transforms_config_compressor.py`: 14 new tests covering
the fold, big-win assertion, byte-exact CCR round-trip, lossless-mode
disable, flag-off, non-TOML skip, no-array skip, small-array
`passthrough` decline, store-failure fallback, savings-floor rejection,
unparseable/non-serializable bails, `_load_toml`/`_json_default` units,
and a datetime-valued fold.

## Testing

- [x] New and existing unit tests pass locally
- [x] New tests added for the new behavior

### Test Output

```
tests/test_transforms_config_compressor.py ............................. [ 59%]
headroom/transforms/config_compressor.py     127      0     36      0   100%
============================== 49 passed in 0.61s ==============================
```

Must-stay-green suites (`test_lossless_mode`,
`test_lossless_excluded_compaction`,
`test_transforms_content_detection`,
`test_compression_fidelity_regression`) — 48 passed.
Router/tabular/smart_crusher regression — 73 + 82 passed. `mypy
--strict` clean on the changed module.

## Real Behavior Proof

- Environment: local, Python 3.11.0, macOS (darwin), `HF_HUB_OFFLINE=1`
- Exact command / steps: parsed a 25-record `[[tool.mypy.overrides]]`
TOML through
`ConfigCompressor(ConfigCompressorConfig(enable_ccr=True)).compress()`,
then retrieved the CCR hash from the `CompressionStore`.
- Observed result: `strategy=config_schema_fold`, 2765 → 840 chars (30%
of original); the marker hash resolved to the byte-exact original
(`recovered == original` True); with `enable_ccr=False` (lossless mode)
the fold did not run and no marker was emitted; a 3-record long-valued
`[[package]]` array correctly declined (SmartCrusher `passthrough`).
- Not tested: the live proxy end-to-end path and non-TOML flavors
(YAML/INI schema folding is intentionally out of scope for this PR).

## Review Readiness

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 19:58:27 +00:00