mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
1991 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b699bedf95
|
fix(models): version-boundary longest-prefix match in ModelRegistry.get (#1658)
## Description
`ModelRegistry.get()` has a prefix fallback for versioned model ids. It
accepted
**any** registered name as a bare `str.startswith` prefix and returned
the
**first** match in dict-insertion order:
```python
for name, info in _MODELS.items():
if model_lower.startswith(name):
return info
```
Two concrete failures fall out of that:
- `gpt-4` is registered before `gpt-4-32k`, so `get("gpt-4-32k-0613")`
matches
`gpt-4` first and returns an **8192**-token window instead of
`gpt-4-32k`'s
**32768**.
- `gpt-4.1` / `gpt-4.5-preview` aren't registered, so they also match
`gpt-4`
and inherit its **8192**-token window — even though they're much larger,
distinct models.
`get_context_limit()` reads straight from `get()` (no LiteLLM fallback),
so both
cases make the proxy believe a nearly-empty context is almost full and
compress
far too aggressively — or reject — on requests that are actually small.
This is
silent: no error, just a wrong number driving every downstream
compression
decision for those models.
## Fix
The fallback now:
1. Only matches when the registered name ends at a **version boundary**
in the
query — the next character must be a separator (`-`, `/`, `:`, `@`, `_`)
— so
`gpt-4.1`'s `.` no longer matches `gpt-4` (it falls through to the
caller's
default instead of a wrong 8192).
2. Picks the **longest** qualifying name, so `gpt-4-32k-0613` →
`gpt-4-32k`.
Exact and alias lookups are unchanged, and boundary-separated variants
like
`gpt-4o-new-version` still resolve to `gpt-4o`.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- `headroom/models/registry.py`: replace the first-match `startswith`
prefix loop in `ModelRegistry.get` with a
longest-prefix-at-a-version-boundary match.
- `tests/test_models.py`: add regression tests — `gpt-4-32k-0613` →
`gpt-4-32k` (32768), and `gpt-4.1`/`gpt-4.5-preview` no longer resolve
to gpt-4's 8192 window.
- `CHANGELOG.md`: Bug Fixes entry under Unreleased.
## Testing
- [x] New tests added for the fixed behavior (`tests/test_models.py`)
- [x] Linting passes (`ruff check`) and formatting is clean (`ruff
format --check`)
- [ ] Full `pytest` run deferred to CI — see Real Behavior Proof for why
I verify the logic with a dependency-free script locally.
```text
$ uv run ruff check headroom/models/registry.py tests/test_models.py
All checks passed!
$ uv run ruff format --check headroom/models/registry.py tests/test_models.py
2 files already formatted
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.12.11, headroom built from this
branch (`uv sync --extra dev`). Importing `headroom` pulls in the
torch/transformers stack; a full `pytest` run exhausts memory and gets
OOM-killed on this box, so I verify the matching logic with a
dependency-free script (only stdlib) and leave the full pytest to CI.
- Exact command / steps: replicated the relevant `_MODELS` registration
order (`gpt-4o`, `gpt-4-turbo`, `gpt-4`, `gpt-4-32k`) and the new
longest-prefix-with-boundary loop in a standalone script (no `headroom`
import), then asserted the resolved context windows.
- Observed result: `gpt-4-32k-0613` resolves to 32768 (was 8192 under
first-match), `gpt-4.1`/`gpt-4.5-preview` fall through to the caller
default (no longer 8192), and `gpt-4o-new-version` / `gpt-4` /
`gpt-4-0613` resolve exactly as before:
```text
OK: gpt-4-32k-0613 -> 32768 (was 8192 under old first-prefix-wins)
OK: gpt-4.1 / gpt-4.5-preview -> default (not 8192)
OK: gpt-4o-new-version, gpt-4, gpt-4-0613 still resolve as before
REGISTRY LOGIC VERIFIED
```
- Not tested: I did not add explicit registry entries for
`gpt-4.1`/`gpt-4.5` (their real windows) — that's a data addition,
separate from this matching-logic fix; today they fall back to the
caller's default, which is honest for an unregistered model and strictly
better than the previous wrong 8192. Full local `pytest` deferred to CI
(OOM, per above).
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes — ran
lint + a standalone logic check; full pytest deferred to CI (local OOM,
disclosed above)
- [x] I have updated the CHANGELOG.md if applicable
## Additional Notes
- No new dependencies; pure logic change in one function plus tests.
- Found via a read-through of the registry while looking at how context
limits drive compression decisions.
|
||
|
|
48f06caca7
|
ci: add Windows wheel build job (win_amd64) (#1086)
### Summary
Adds `build-wheel-windows` job to the CI pipeline that compiles the Rust
extension on `windows-latest` and uploads the resulting `.whl` as a
separate artifact (`headroom-wheel-windows`).
This addresses the long-standing missing Windows wheel.
### Changes
- New job `build-wheel-windows`: mirrors the existing `build-wheel`
(Linux) job
- Uses `dtolnay/rust-toolchain@stable` for Rust setup on Windows
- Uses `Swatinem/rust-cache` for dependency caching
- Builds with CI cargo profile for speed
- 45-minute timeout (Windows Rust builds are slower)
- Uploads wheel as `headroom-wheel-windows` artifact
### Testing
✅ **Local compilation verified**: built v0.26.0 from source on Windows
10 (Python 3.12.10, Rust 1.96.0, MSVC Build Tools 2022). The wheel
installed and ran successfully.
### Notes
Only the CI-profile build is added here. The release-wheel publish
(`release.yml`) can be updated in a follow-up PR once this basic Windows
build is proven in CI.
Co-authored-by: Win He <win-he@users.noreply.github.com>
|
||
|
|
d1db00ab86
|
fix(proxy): keep PRE_SEND from reintroducing empty tool arrays (#2015)
## Description The direct body-write fix for empty `tools: []` already landed, but the later OpenAI PRE_SEND write-back path still reintroduces the empty array. This aligns that guard with the existing direct-assignment contract so tools-free requests stay tools-free while explicit client `tools: []` stays preserved. Anthropic's current-main PRE_SEND path already had the equivalent empty-tools protection and needed no code change. Closes #1983 ## 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 - Mirror the direct `tools or _original_tools is not None` guard in the OpenAI PRE_SEND write-back path. - Leave Anthropic unchanged because current `main` already protects the empty-tools case there. - Extend the focused #728 regression file with PRE_SEND-specific coverage. - Add a changelog note for providers that reject empty `tools` arrays. ## Testing - [x] Unit tests pass - [x] Linting passes - [ ] Type checking passes - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text uv run pytest tests/test_issue_728_empty_tools_injection.py -q 11 passed uv run ruff check headroom/proxy/handlers/openai.py tests/test_issue_728_empty_tools_injection.py All checks passed uv run ruff format --check headroom/proxy/handlers/openai.py tests/test_issue_728_empty_tools_injection.py 2 files already formatted ``` ## Real Behavior Proof - Environment: OpenAI-compatible provider that rejects empty `tools` arrays - Exact command / steps: send a request without `tools`, then repeat with explicit `tools: []` - Observed result: the OpenAI PRE_SEND path now skips `tools: []` when the client omitted tools, while the focused regression still preserves explicit client `tools: []` and deliberate clearing of a previously present tool list - Not tested: live provider run on this host - Scope: PRE_SEND request-body write-back ## 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 ## Additional Notes The change is intentionally narrow. It only brings PRE_SEND write-back into parity with the direct-assignment guard that already exists. |
||
|
|
9bacf4810f
|
refactor(transforms): isolate mixed content parsing (#1939)
## Description Extracts mixed-content parsing out of the large `ContentRouter` module into a pure transform-domain module. The router still exports the existing compatibility names, but section typing, mixed-content indicators, section splitting, and JSON block extraction now live in a focused domain object/function layer. Closes # ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made - Added `headroom.transforms.mixed_content` with `ContentSection`, `mixed_content_indicators`, `is_mixed_content`, `split_into_sections`, and JSON block extraction. - Updated `ContentRouter` to delegate mixed-content debug indicators and parsing to the new module while preserving legacy imports from `content_router.py`. - Added direct unit coverage for mixed-content detection, section boundaries, and JSON delimiters inside string literals. - Included the LiteLLM callback signature compatibility shim needed for repo-wide mypy while the earlier architecture PRs are still open. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text python -m pytest tests/test_mixed_content_sections.py tests/test_transforms_content_router.py tests/test_litellm_callback.py tests/test_compress_api.py::TestLiteLLMCallback -q 50 passed in 6.82s python -m ruff check . All checks passed! python -m ruff format --check . 1095 files already formatted python -m mypy headroom --ignore-missing-imports headroom\proxy\server.py:1457: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] headroom\proxy\server.py:1468: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] Success: no issues found in 409 source files ``` ## Real Behavior Proof - Environment: Windows, Python 3.13.13, clean worktree `C:\git\headroom-pr-slice6` - Exact command / steps: ran the pytest, Ruff, format, and mypy commands listed above. - Observed result: mixed-content parsing behavior remains covered through existing router tests and new direct tests; repo-wide lint/type checks pass. - Not tested: full pytest suite and Docker/native CI jobs are left to GitHub Actions. ## 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 - Documentation, changelog, and screenshots are N/A for this internal refactor. - Manual UI testing is N/A; this is pure transform parsing logic. - Comment checklist is unchecked because the extracted functions are small and covered by direct tests. |
||
|
|
5a7265daa8
|
refactor(proxy): isolate auth classification policy (#1945)
## Description Extract auth-mode and client-harness classification rules into `headroom.proxy.auth_policy`, leaving `auth_mode` as the header-reading/logging adapter. This gives the proxy a pure `AuthSignals` value object and deterministic policy functions for auth mode, client classification, and Codex Responses stamping. Closes # ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made - Added `AuthSignals` as the normalized input model for pure auth/client policy. - Moved `AuthMode`, subscription UA prefixes, client UA map, Codex Responses path, auth-mode classification, client classification, and Codex stamping rules into `headroom.proxy.auth_policy`. - Kept `headroom.proxy.auth_mode` public API stable by adapting headers into `AuthSignals` and delegating to policy functions. - Added direct pure-policy tests for subscription precedence, OAuth/PAYG token shapes, explicit client override, and Codex Responses stamping. - Included the LiteLLM callback hook compatibility shim needed for repo-wide mypy on branches based on `main`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text python -m pytest tests/test_auth_policy.py tests/test_auth_mode.py tests/test_codex_client_stamp.py tests/test_litellm_callback.py tests/test_compress_api.py::TestLiteLLMCallback -q 48 passed in 6.63s python -m ruff check . All checks passed! python -m ruff format --check . 1095 files already formatted python -m mypy headroom --ignore-missing-imports Success: no issues found in 409 source files ``` ## Real Behavior Proof - Environment: Windows, Python 3.13.13, local worktree `C:\git\headroom-pr-slice9`. - Exact command / steps: Ran the focused pytest suite plus repo-wide Ruff, format check, and mypy commands listed above. - Observed result: Existing adapter behavior remains covered by `tests/test_auth_mode.py` and `tests/test_codex_client_stamp.py`, while the extracted pure policy is covered by `tests/test_auth_policy.py`. - Not tested: Full test suite locally; CI will run the full matrix. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes Documentation and changelog updates are not applicable for this internal refactor. The LiteLLM shim is repeated here because this branch is intentionally independent from the other open architecture slices and must stay green against current `main`. |
||
|
|
b5aa8a358e
|
refactor(cache): isolate compression strategy outcomes (#1938)
## Description Extracts local compression strategy accounting out of `CompressionFeedback` into a pure cache-domain object. This keeps strategy counters, retrieval-rate math, pruning, and best-strategy selection independently testable while preserving the existing `LocalToolPattern` public API. Closes # ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made - Added `CompressionStrategyOutcomes` as the strategy-outcome domain for compression/retrieval counters, pruning, retrieval rates, and recommendation selection. - Updated `LocalToolPattern` and `CompressionFeedback` to delegate strategy accounting to that domain while keeping existing fields and methods intact. - Added direct unit coverage for strategy outcome math and bounded pruning behavior. - Updated the LiteLLM callback hook signature to remain compatible with current LiteLLM typing and the existing three-argument call shape. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text python -m ruff check . All checks passed! python -m ruff format --check . 1095 files already formatted python -m mypy headroom --ignore-missing-imports headroom\proxy\server.py:1457: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] headroom\proxy\server.py:1468: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] Success: no issues found in 409 source files python -m pytest tests/test_compression_strategy_outcomes.py tests/test_ccr_feedback.py tests/test_toin_fixes.py tests/test_litellm_callback.py tests/test_compress_api.py::TestLiteLLMCallback -q collected 54 items 46 passed, 8 skipped in 6.58s ``` ## Real Behavior Proof - Environment: Windows, Python 3.13.13, clean worktree `C:\git\headroom-pr-slice5` - Exact command / steps: ran the lint, format, type-check, and focused pytest commands listed above. - Observed result: strategy outcome tests and existing feedback/TOIN/LiteLLM compatibility tests pass; repo-wide lint/type validation passes. - Not tested: full pytest suite and Docker/native CI jobs are left to GitHub Actions. ## 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 - Documentation and changelog are N/A for this internal refactor. - Manual UI testing is N/A; this is cache feedback and integration callback logic. - Comment checklist is unchecked because the extracted object is intentionally straightforward and covered by tests. |
||
|
|
41af39d769
|
fix(proxy): preserve terminal tool on Codex Responses (#2000)
## Description Cache-mode optimization can make a client-defined Responses function named `terminal` invalid by treating it as a deferrable tool. On supported models with a large tool set, Headroom adds `defer_loading` and tool search; the Codex endpoint then rejects the request as `terminal.terminal` in a reserved namespace. This keeps the exact `terminal` function resident in the OpenAI Responses deferral helper. Other non-core functions and MCP tools remain eligible for deferral, and unsupported models or small tool sets keep their existing no-op behavior. Closes #1946 ## 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 - Keep the exact OpenAI Responses function name `terminal` resident during server-side tool-search deferral. - Preserve deferral for adjacent and unrelated function names, MCP tools, and the existing model and tool-count gates. - Add issue-shaped regression and negative-space coverage. - Document the user-visible fix in `CHANGELOG.md`. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_openai_tool_search_deferral.py -q`) - [x] Linting passes (`uv run ruff check headroom/proxy/helpers.py tests/test_openai_tool_search_deferral.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text uv sync --extra dev OK uv run pytest tests/test_openai_tool_search_deferral.py -q 25 passed uv run ruff check headroom/proxy/helpers.py tests/test_openai_tool_search_deferral.py All checks passed uv run ruff format --check headroom/proxy/helpers.py tests/test_openai_tool_search_deferral.py 2 files already formatted ``` ## Real Behavior Proof - Environment: credentialed Codex Responses endpoint, `gpt-5.6-terra`, Headroom cache mode with lossless compression - Exact command / steps: start `headroom proxy --mode cache --lossless`, then send a Responses request with at least 12 tools including the bare client-defined `terminal` function - Observed result: local proof now locks the emitted request shape, `terminal` stays resident, adjacent names such as `terminal_helper` still defer, and the input remains unchanged; live upstream acceptance on `gpt-5.6-terra` still needs a credentialed run - Not tested: live upstream acceptance on a credentialed `gpt-5.6-terra` Responses request with the exact issue-shaped tool set. - Scope: OpenAI Responses tool-search deferral in the optimized request path ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [ ] I have commented my code, particularly in hard-to-understand areas - [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 ## Additional Notes The change is scoped to the exact `terminal` function name in OpenAI Responses tool-search deferral. It does not change ContentRouter policy, Anthropic tool deferral, tool schema compaction, or unrelated function names. Live endpoint acceptance is still an external proof item and is called out in Real Behavior Proof. |
||
|
|
75d786117a
|
fix(proxy): cache_savings_usd silently zeroes when litellm is unavailable (#2005)
## Description
On any install where `litellm` cannot be imported, `SavingsTracker`'s
`lifetime.cache_savings_usd` and `display_session.cache_savings_usd`
stay pinned at exactly `0.0` forever — while `cache_read_tokens`
accumulates correctly and `total_input_cost_usd` stays nonzero, so the
tracker looks alive and the zero is easy to miss.
This hits every Python 3.14 install out of the box: the project's own
dependency spec is `litellm>=1.86.2,<2.0 ; python_full_version <
'3.14'`, so on 3.14 `LITELLM_AVAILABLE` is `False` and cache savings
silently read as $0. Observed in the wild with 45.8M lifetime
`cache_read_tokens` and `cache_savings_usd: 0.0` in
`proxy_savings.json`.
Root cause: `_estimate_cache_savings_usd` is the only one of the three
USD estimators with no fallback when litellm is missing —
`_estimate_input_cost_usd` and `_estimate_compression_savings_usd` both
fall back to `DEFAULT_FALLBACK_INPUT_COST_PER_TOKEN`, while
`_estimate_cache_savings_usd` returns `0.0`.
## 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`: when litellm is unavailable,
`_estimate_cache_savings_usd` now estimates at
`DEFAULT_FALLBACK_INPUT_COST_PER_TOKEN` per cache-read token — mirroring
the fallback its two sibling estimators already use (approximate over
zero). Unknown-model behaviour with litellm present is unchanged (still
fails open to `0.0`).
- `tests/test_proxy_savings_history.py`: new regression test
`test_cache_savings_usd_falls_back_when_litellm_unavailable` (unit +
through `SavingsTracker.record_request`);
`test_cache_savings_edge_cases_zero_and_unpriced` now pins a fake
litellm price table so it keeps testing the unpriced-model path on every
environment (without litellm installed it would otherwise exercise the
fallback path instead).
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
$ pytest tests/test_proxy_savings_history.py -k "cache_savings" -q
========================= 3 passed, 1 warning in 0.34s =========================
$ ruff check headroom/proxy/savings_tracker.py tests/test_proxy_savings_history.py
All checks passed!
$ ruff format --check headroom/proxy/savings_tracker.py tests/test_proxy_savings_history.py
2 files already formatted
$ mypy headroom/proxy/savings_tracker.py
Success: no issues found in 1 source file
```
## Real Behavior Proof
- Environment: macOS, Python 3.14.6 venv, headroom installed editable —
litellm absent (excluded by the project's own `python_full_version <
'3.14'` marker), i.e. the exact environment the bug ships in.
- Exact command / steps: ran the one-liner below twice in that venv —
once with `headroom/proxy/savings_tracker.py` checked out from main
(`d2170b19`), once from this branch — output pasted verbatim:
```text
$ python -c 'import headroom.proxy.savings_tracker as st;
print("litellm importable:", st._get_litellm_module() is not None);
print("cache_savings_usd for 1M cache-read tokens:",
st._estimate_cache_savings_usd("claude-sonnet-4-6", 1_000_000))'
# on main (
|
||
|
|
cb38f79377
|
refactor(proxy): isolate forwarded header policy (#1942)
## Description Extract the trusted forwarded-header trust policy into `headroom.proxy.forwarded_policy`, leaving `forwarded_headers` as the FastAPI/request-state adapter. This makes CIDR parsing, peer trust, leftmost forwarded-for handling, and rejection decisions deterministic and directly testable without request/logging side effects. Closes # ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made - Added `ForwardedHeaderInputs` and `ForwardedHeaderResolution` as pure policy value objects. - Moved CIDR parsing, IP normalization, trust membership, header splitting, and forwarded-header resolution into `headroom.proxy.forwarded_policy`. - Kept `headroom.proxy.forwarded_headers` as the request adapter with the same public API and compatibility helper names. - Added direct tests for trusted, rejected, direct-client, IPv4-mapped IPv6, and leftmost `X-Forwarded-For` policy behavior. - Included the LiteLLM callback hook compatibility shim needed for repo-wide mypy on branches based on `main`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text python -m pytest tests/test_forwarded_policy.py tests/test_forwarded_headers.py tests/test_litellm_callback.py tests/test_compress_api.py::TestLiteLLMCallback -q 51 passed in 6.36s python -m ruff check . All checks passed! python -m ruff format --check . 1095 files already formatted python -m mypy headroom --ignore-missing-imports Success: no issues found in 409 source files ``` ## Real Behavior Proof - Environment: Windows, Python 3.13.13, local worktree `C:\git\headroom-pr-slice8`. - Exact command / steps: Ran the focused pytest suite plus repo-wide Ruff, format check, and mypy commands listed above. - Observed result: The existing request-facing forwarded-header behavior remains covered by `tests/test_forwarded_headers.py`, while the extracted pure policy is covered by `tests/test_forwarded_policy.py`. - Not tested: Full test suite locally; CI will run the full matrix. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes Documentation and changelog updates are not applicable for this internal refactor. The LiteLLM shim is repeated here because this branch is intentionally independent from the other open architecture slices and must stay green against current `main`. |
||
|
|
0ce09fb63f
|
refactor(output): isolate verbosity steering (#1940)
## Description Extract byte-stable output verbosity steering into `headroom.proxy.output_steering` so `output_shaper` can focus on turn classification and effort routing while preserving the existing public import surface. Closes # ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made - Added `headroom.proxy.output_steering` for Anthropic system steering and OpenAI Responses instruction steering. - Kept existing `headroom.proxy.output_shaper` imports compatible by re-exporting the moved helpers. - Added direct tests for replacement, cache-prefix preservation, and idempotent OpenAI Responses steering. - Included the LiteLLM callback hook compatibility shim needed for repo-wide mypy on branches based on `main`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text python -m pytest tests/test_output_steering.py tests/test_output_shaper.py tests/test_litellm_callback.py tests/test_compress_api.py::TestLiteLLMCallback -q 57 passed in 6.17s python -m ruff check . All checks passed! python -m ruff format --check . 1095 files already formatted python -m mypy headroom --ignore-missing-imports Success: no issues found in 409 source files ``` ## Real Behavior Proof - Environment: Windows, Python 3.13.13, local worktree `C:\git\headroom-pr-slice7`. - Exact command / steps: Ran the focused pytest suite plus repo-wide Ruff, format check, and mypy commands listed above. - Observed result: Steering behavior remains covered through the existing `output_shaper` tests and the new direct `output_steering` tests. - Not tested: Full test suite locally; CI will run the full matrix. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes Documentation and changelog updates are not applicable for this internal refactor. The LiteLLM shim is repeated here because this branch is intentionally independent from the other open architecture slices and must stay green against current `main`. |
||
|
|
fd5b9e75ad
|
refactor(ccr): isolate tool call classification (#1937)
## Description Extracts provider-shaped CCR tool-call extraction and classification into `headroom.ccr.tool_calls`. `CCRResponseHandler` now delegates detection/parsing to a pure domain module and stays focused on retrieval execution and continuation orchestration. Closes # ## Type of Change - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made - Added `headroom.ccr.tool_calls` with provider-native extraction, CCR detection, provider-specific tool result IDs, and CCR/other-tool splitting. - Re-exported the pure CCR tool-call helpers from `headroom.ccr`. - Kept `CCRResponseHandler` private compatibility methods while delegating to the new module. - Added focused tests for Anthropic, OpenAI, Google, and OpenAI Responses tool-call shapes. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text python -m pytest tests/test_ccr_tool_calls.py tests/test_ccr_response_handler.py tests/test_ccr_response_handler_extra.py tests/test_ccr_response_handler_openai_responses.py tests/test_ccr_batch_processor.py::TestCCRToolCallDetectionInBatch -q ============================= 64 passed in 0.57s ============================= python -m ruff check headroom/ccr/tool_calls.py headroom/ccr/response_handler.py headroom/ccr/__init__.py tests/test_ccr_tool_calls.py tests/test_ccr_response_handler.py tests/test_ccr_response_handler_extra.py tests/test_ccr_response_handler_openai_responses.py tests/test_ccr_batch_processor.py All checks passed! python -m mypy headroom/ccr/tool_calls.py headroom/ccr/response_handler.py Success: no issues found in 2 source files python -m compileall -q headroom\ccr\tool_calls.py headroom\ccr\response_handler.py headroom\ccr\__init__.py # no output; exited 0 git commit -m "refactor(ccr): isolate tool call classification" Sync plugin versions.....................................................Passed check for merge conflicts................................................Passed ruff.....................................................................Passed ruff-format..............................................................Passed mypy.....................................................................Passed ``` ## Real Behavior Proof - Environment: Windows PowerShell, Python 3.13.13, branch `jd/architecture-slice-4` based on `headroomlabs/main`. - Exact command / steps: Ran CCR tool-call tests, existing CCR response handler tests, OpenAI Responses CCR tests, CCR batch detection tests, focused ruff, targeted mypy, compileall, and commit hooks. - Observed result: Existing handler behavior remains covered while provider-shaped CCR classification is now directly testable as a pure module. - Not tested: Full pytest suite, live upstream provider traffic, and manual streaming clients. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes Documentation and CHANGELOG updates are N/A for this internal refactor. Full pytest was not run; validation is focused on CCR tool-call detection/parsing and response-handler compatibility. |
||
|
|
4210d6e609
|
refactor(pricing): isolate litellm model resolution (#1936)
## Description Extracts LiteLLM model-name resolution rules into a pure pricing-domain module. `litellm_pricing.py` now acts as the adapter that asks LiteLLM whether candidate keys exist, while `litellm_model_resolution.py` owns prefix rules, alias rules, lookup candidate ordering, and deterministic resolution. Closes # ## Type of Change - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made - Added `headroom.pricing.litellm_model_resolution` with explicit prefix rules, alias rules, pricing lookup candidates, and a pure resolver function. - Simplified `headroom.pricing.litellm_pricing` to delegate model-name selection to the pure resolver while keeping its public API and cache behavior intact. - Added focused tests for candidate ordering, case-insensitive MiniMax matching, aliases, and unknown-model fallback. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text python -m pytest tests/test_pricing_litellm_model_resolution.py tests/test_pricing_litellm.py tests/test_proxy_streaming_resilience.py::TestModelResolutionCaching -q ============================= 22 passed in 2.18s ============================= python -m ruff check headroom/pricing/litellm_model_resolution.py headroom/pricing/litellm_pricing.py tests/test_pricing_litellm_model_resolution.py tests/test_pricing_litellm.py tests/test_proxy_streaming_resilience.py All checks passed! python -m mypy headroom/pricing/litellm_model_resolution.py headroom/pricing/litellm_pricing.py Success: no issues found in 2 source files python -m compileall -q headroom\pricing\litellm_model_resolution.py headroom\pricing\litellm_pricing.py # no output; exited 0 git commit -m "refactor(pricing): isolate litellm model resolution" Sync plugin versions.....................................................Passed check for merge conflicts................................................Passed ruff.....................................................................Passed ruff-format..............................................................Passed mypy.....................................................................Passed ``` ## Real Behavior Proof - Environment: Windows PowerShell, Python 3.13.13, branch `jd/pricing-model-resolution` based on `headroomlabs/main`. - Exact command / steps: Ran pure resolver tests, LiteLLM pricing adapter tests, model-resolution caching tests, focused ruff, targeted mypy, compileall, and commit hooks. - Observed result: Existing pricing behavior and cache behavior passed while model resolution is now isolated and directly testable. - Not tested: Full pytest suite and live LiteLLM network or package update behavior beyond the local installed dependency/fakes. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes Documentation and CHANGELOG updates are N/A for this internal refactor. Full pytest was not run; validation is focused on pricing/model-resolution behavior touched by this slice. |
||
|
|
1f3696a3d0
|
refactor(proxy): isolate body forwarding policy (#1935)
## Description Extracts the byte-faithful Python forwarder policy out of the broad proxy helpers module into a dedicated `headroom.proxy.body_forwarding` domain. The new module owns the outbound body algebra: passthrough original bytes, canonical JSON bytes for mutated bodies, and explicit legacy JSON rollback mode. Closes # ## Type of Change - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made - Added `headroom.proxy.body_forwarding` with `OutboundBody`, `OutboundBodySource`, `BodyMutationTracker`, mode resolution, canonical serialization, and body selection helpers. - Kept `headroom.proxy.helpers` compatibility exports for existing callers. - Updated Python forwarder call sites to import body-forwarding policy from the dedicated module. - Added tests for the new value object and compatibility exports. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text python -m pytest tests/test_proxy_byte_faithful_forwarding.py -q ============================= 40 passed in 3.61s ============================= python -m ruff check headroom/proxy/body_forwarding.py headroom/proxy/helpers.py headroom/proxy/server.py headroom/proxy/handlers/streaming.py headroom/proxy/handlers/openai.py headroom/proxy/handlers/anthropic.py headroom/proxy/handlers/batch.py tests/test_proxy_byte_faithful_forwarding.py All checks passed! python -m mypy headroom/proxy/body_forwarding.py Success: no issues found in 1 source file python -m compileall -q headroom\proxy\body_forwarding.py headroom\proxy\helpers.py headroom\proxy\server.py headroom\proxy\handlers\streaming.py headroom\proxy\handlers\openai.py headroom\proxy\handlers\anthropic.py headroom\proxy\handlers\batch.py # no output; exited 0 git commit -m "refactor(proxy): isolate body forwarding policy" Sync plugin versions.....................................................Passed check for merge conflicts................................................Passed ruff.....................................................................Passed ruff-format..............................................................Passed mypy.....................................................................Passed ``` ## Real Behavior Proof - Environment: Windows PowerShell, Python 3.13.13, branch `jd/architecture-slice-2` based on `headroomlabs/main`. - Exact command / steps: Ran the focused byte-faithful forwarding suite, focused ruff command, targeted mypy, compileall over touched modules, and commit hooks. - Observed result: Forwarding behavior stayed byte-faithful; compatibility exports remain intact; lint, formatting, and mypy passed. - Not tested: Full pytest suite, live upstream proxy traffic, and manual end-to-end clients. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes Documentation and CHANGELOG updates are N/A for this internal refactor. The full pytest suite was not run; validation is focused on the body-forwarding domain and existing byte-faithful forwarding coverage. |
||
|
|
d2170b1922
|
fix(learn): parse fenced JSON even with a prose preamble (#1988)
## Description `_strip_fenced_json` only stripped a markdown fence when the string *started with* ```` ``` ````. When the model prefixed prose before the fence (e.g. `Here is the JSON:\n\n```json ...`) despite being told to return JSON only, the guard was skipped and `json.loads` ran on the prose, raising `JSONDecodeError`. The claude-cli streaming path surfaced this as `returned unparseable output`, and `headroom learn` silently discarded the LLM analysis, degrading to "No actionable patterns found". This is the parsing-side cousin of the silent-degradation issue fixed in #373. Closes #1989. Related: #373. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/learn/analyzer.py`: rewrote `_strip_fenced_json` to locate the fenced block wherever it appears, then fall back to the whole text, then to a first-`{` / last-`}` slice, only re-raising `JSONDecodeError` if nothing parses as a JSON object. Preserves the prior "first opening / last closing fence" behaviour and triple-backtick content inside the payload. Fixes all three call sites (non-streaming CLI, claude-cli streaming, litellm). - `tests/test_learn/test_analyzer.py`: added regression cases to `TestStripFencedJson` for preamble-before-fence, prose around a bare object, and triple-backticks inside the payload. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) — scoped to the changed module (see Additional Notes) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_learn/test_analyzer.py -q ........................................................................ [ 86%] ........... [100%] 83 passed, 1 warning in 2.18s $ ruff check headroom/learn/analyzer.py tests/test_learn/test_analyzer.py All checks passed! $ ruff format --check headroom/learn/analyzer.py tests/test_learn/test_analyzer.py 2 files already formatted $ mypy --ignore-missing-imports --follow-imports=silent headroom/learn/analyzer.py Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: macOS, Python 3.13.12, headroom-ai at this branch (runtime deps from an installed 0.30.0 env). - Exact command / steps: ran the old vs new `_strip_fenced_json` on the exact failing model output (a prose preamble followed by a ```json fence), then applied the fix over an installed 0.30.0 and re-ran the previously failing `headroom learn --apply`. Input sample: `'The JSON is my deliverable for this analysis task. Here it is:\n\n```json\n{"context_file_rules": [], "memory_file_rules": []}\n```'` - Observed result: OLD raised `JSONDecodeError: Expecting value: line 1 column 1 (char 0)`; NEW returned `{'context_file_rules': [], 'memory_file_rules': []}`. The real `headroom learn --apply` run that had been failing with `returned unparseable output` then completed and consumed the LLM analysis instead of dropping it. Full transcript: ```text OLD: JSONDecodeError -> Expecting value: line 1 column 1 (char 0) NEW: {'context_file_rules': [], 'memory_file_rules': []} ``` - Not tested: full end-to-end `headroom learn --apply` was not re-run inside CI here (it shells out to a live `claude` CLI); the parser is exercised deterministically by the added unit tests and the before/after repro above. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation (N/A — updated the function docstring only; no external docs affected) - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable (N/A — no CHANGELOG entry convention observed for this fix; happy to add if maintainers prefer) ## Additional Notes - `mypy` was run against the changed module in isolation (`--ignore-missing-imports --follow-imports=silent`) rather than the full project, because I validated in an ad-hoc environment; the change keeps the existing `-> dict` signature and annotations, so it is type-neutral. - Not addressed here (possible follow-up): the failure is swallowed as a warning in `analyze()`, so users only see "No actionable patterns found" with no signal the LLM pass produced nothing — the same silent-degradation class as #373, on the parsing side. |
||
|
|
5e14b8c0f2
|
fix(memory/sync): don't clobber memories sharing a first line (#1976)
## Description
`ClaudeCodeAdapter.write_memories`
(`headroom/memory/sync_adapters/claude_code.py`) picks
each memory's file name from the **first line of its content only**:
```python
first_line = content.split("\n")[0][:60].strip()
slug = _sanitize_for_filename(first_line)
filename = f"headroom_{slug}.md"
```
So two *distinct* DB memories whose first lines slugify to the same
value map to the same
file. The existing "already on disk?" guard only skips when the on-disk
content hash equals
this memory's hash — for a genuine collision (same slug, different body)
it falls through and
`target.write_text(...)` overwrites the other memory. Data loss.
It also never converges. The overwritten memory never lands on disk, so
on the next
`sync_export` the adapter reads back the agent's files, doesn't find
that memory's hash in
`agent_hashes`, and re-exports it — overwriting the other one this time.
The pair ping-pongs
on every sync, and each round appends a fresh line to `MEMORY.md`.
This is realistic for headed/structured memories (e.g. several entries
that begin
`# Project conventions` or `The user prefers …`).
Closes: no issue filed — found while auditing the memory sync adapters.
## Fix
When the slug is already taken by a **different** memory (a distinct
`headroom_id` in the
existing file's frontmatter), disambiguate the file name with a short
content-hash suffix so
both survive. A matching `headroom_id` means it's an update of the same
memory, so the plain
slug file is rewritten as before — existing file names don't change, so
there's no migration
churn for the common (no-collision) case:
```python
existing_id = existing_fm.get("headroom_id", "")
if headroom_id and existing_id and existing_id != headroom_id:
suffix = (content_hash or hashlib.sha256(content.encode()).hexdigest()[:16])[:8]
filename = f"headroom_{slug}_{suffix}.md"
...
```
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- `headroom/memory/sync_adapters/claude_code.py`: in `write_memories`,
disambiguate the file name with a content-hash suffix when the slug
already belongs to a different `headroom_id`; same-id updates still
rewrite the slug file in place.
- `tests/test_memory_sync.py`: add
`test_write_distinct_memories_sharing_first_line_do_not_clobber` (two
files survive) and `test_write_same_memory_updates_in_place` (no
duplicate on update).
## Testing
- [x] New regression tests added (`tests/test_memory_sync.py`)
- [x] Linting passes (`ruff check`) and formatting is clean (`ruff
format --check`)
- [ ] Full `pytest` deferred to CI (local-OOM reason below).
```text
$ uv run ruff check headroom/memory/sync_adapters/claude_code.py tests/test_memory_sync.py
All checks passed!
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.10, headroom from this branch.
Importing `headroom` pulls in the torch/transformers stack and a full
`pytest` gets OOM-killed on this box, so I verified the write logic with
a dependency-free script (replicating `_sanitize_for_filename` /
`_parse_frontmatter` / the write loop against a real temp dir) and left
the full pytest to CI.
- Exact command / steps: wrote two memories that share the first line `#
Project conventions` but differ in body (distinct `headroom_id`),
through both the old and new logic, then wrote a same-id update.
- Observed result: the old logic reports `written=2` but leaves **one**
file (the first memory's body is gone); the new logic keeps both, and a
same-id update rewrites in place instead of duplicating:
```text
OLD: written=2 files=1 tabs=False fridays=True
NEW: written=2 files=2 tabs=True fridays=True
UPDATE: files=1 second=True
MEMORY COLLISION FIX VERIFIED
```
- Not tested: a full `sync_export`/`sync_import` round-trip through the
DB backend (needs the heavy stack). The fix is confined to the
file-naming decision in `write_memories`, and the new tests drive that
method directly. Full local `pytest` deferred to CI (OOM, per above).
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes — ran
lint + a standalone logic check; full pytest deferred to CI (local OOM,
disclosed above)
- [x] I have updated the CHANGELOG.md if applicable
## Additional Notes
- No new dependencies; a small, migration-safe naming guard plus tests.
- @JerrettDavis tagging you — this one is a quiet data-loss path in the
Claude memory sync (a collision drops one memory and then thrashes on
every sync), so it may be worth a look when you get a chance.
|
||
|
|
4cb33cd9e3
|
fix(proxy): strip inbound Content-Encoding on messages/chat forward (#1970)
## Description
The Anthropic `/v1/messages` and OpenAI `/v1/chat/completions` handlers
decode the
inbound request body before forwarding it upstream.
`read_request_json_with_bytes`
(helpers.py) inflates `zstd`/`gzip`/`deflate`/`br` bodies, and the
handler forwards
the resulting plain JSON. But both handlers build the upstream-bound
header dict and
pop only `host`, `content-length`, and `accept-encoding` — they leave
the original
`Content-Encoding` header in place:
```python
headers = dict(request.headers.items())
headers.pop("host", None)
headers.pop("content-length", None)
headers.pop("accept-encoding", None) # content-encoding NOT popped
```
So when a client — or an edge proxy like a Cloudflare Worker — sends a
request with
`Content-Encoding: gzip` (or `zstd`/`br`/`deflate`) and a compressed
body, Headroom
decompresses it, then forwards plain JSON that still advertises
`content-encoding: gzip`.
The upstream provider tries to gunzip already-decoded JSON and rejects
the request with
HTTP 400. Every such request fails.
This is a known class of bug: the `/v1/responses` handler already fixes
exactly this at
`openai.py` with the comment *"Leaving a stale content-encoding header
makes the upstream
try to decompress already-decoded JSON and reject it with HTTP 400
(#1542)."* That fix
landed only on the `/responses` path — the messages and chat paths were
missed.
Closes: no issue filed — found while auditing request-header forwarding
across the handlers.
## Fix
Pop `content-encoding` and `transfer-encoding` in both handlers, right
after the existing
`content-length` pop, mirroring the `/v1/responses` handler:
```python
headers.pop("content-encoding", None)
headers.pop("transfer-encoding", None)
```
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- `headroom/proxy/handlers/anthropic.py`: strip
`content-encoding`/`transfer-encoding` from the upstream-bound request
headers in `handle_anthropic_messages`.
- `headroom/proxy/handlers/openai.py`: same strip in the
`/v1/chat/completions` handler.
- `tests/test_proxy_compression_headers.py`: add
`TestRequestContentEncodingStripping` covering gzip/zstd/deflate/br,
`transfer-encoding`, and the absent-header (plain curl) case.
## Testing
- [x] New regression tests added
(`tests/test_proxy_compression_headers.py`)
- [x] Linting passes (`ruff check`) and formatting is clean (`ruff
format --check`)
- [ ] Full `pytest` deferred to CI (local-OOM reason below).
```text
$ uv run ruff check headroom/proxy/handlers/anthropic.py headroom/proxy/handlers/openai.py tests/test_proxy_compression_headers.py
All checks passed!
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.10, headroom from this branch.
Importing `headroom` pulls in the torch/transformers stack and a full
`pytest` gets OOM-killed on this box, so I verified the header logic
with a dependency-free script (the same pure-dict pattern the existing
tests in this file use) and left the full pytest to CI.
- Exact command / steps: replicated the handler's request-header
stripping (old vs new) in a standalone script and ran a
`gzip`/`zstd`/`deflate`/`br` request through both.
- Observed result: the old logic keeps `content-encoding` (which is what
makes the upstream 400); the new logic strips it while preserving
`authorization` and `content-type`:
```text
OK gzip: old leaks 'gzip' -> upstream 400 ; new strips it
OK zstd: old leaks 'zstd' -> upstream 400 ; new strips it
OK deflate: old leaks 'deflate' -> upstream 400 ; new strips it
OK br: old leaks 'br' -> upstream 400 ; new strips it
OK transfer-encoding stripped
OK safe when absent
CONTENT-ENCODING STRIP VERIFIED
```
- Not tested: an end-to-end POST of a real gzip body through a booted
proxy to a live upstream (needs the heavy stack + a provider key). The
header now matches the byte-faithful forwarding the `/responses` path
already does, and the new tests exercise the exact strip logic. Full
local `pytest` deferred to CI (OOM, per above).
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes — ran
lint + a standalone logic check; full pytest deferred to CI (local OOM,
disclosed above)
- [x] I have updated the CHANGELOG.md if applicable
## Additional Notes
- Small, contained parity fix — two `pop()` calls plus tests, no new
dependencies.
- @JerrettDavis tagging you since you've been triaging the
proxy-forwarding fixes (this is the sibling of the #1542 `/responses`
fix) — should be a quick one if you have a moment.
|
||
|
|
10e4829201
|
fix(dedup): shorter fold pointer + MIN_LINES=3, dedup Anthropic list-… (#1932)
…content tool_results, line-number reanchoring
cross_turn_dedup:
- Compact the fold pointer (~35c vs ~100c) and lower MIN_LINES=3 /
MIN_CHARS=40 so the abundant short (2-4 line) re-read repeats pay off
(~4.3% -> ~6% lossless observation-byte reduction on Opus SWE-bench,
measured on 73 real trajectories).
- Add uniform-offset line-number reanchoring: a re-read whose line
numbers all shifted by a constant after an edit now still folds.
Matching keys on the number-stripped content; the offset is carried in
the pointer so recovery of the original numbered bytes stays exact
(unpadded grep/sed/rg -n form only). Cache-safe (prefix-monotonic) and
information-preserving (earliest copy kept verbatim). Small real-data
gain, but closes the renumbering blind spot.
content_router:
- Dedup Anthropic list-content tool_result blocks (the single
{"type":"text"} sub-block), which the string-only extraction path
previously skipped entirely, so cross-turn dedup never fired on
Anthropic-native tool results.
## Description
<!-- Briefly explain the change and why it is needed. -->
Closes #
## Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
-
## Testing
<!-- Check what you actually ran, then paste the real command output
below. -->
- [ ] Unit tests pass (`pytest`)
- [ ] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [ ] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
# Paste relevant command output or artifact links here
```
## Real Behavior Proof
- Environment:
- Exact command / steps:
- Observed result:
- Not tested:
## Review Readiness
- [ ] I have performed a self-review
- [ ] This PR is ready for human review
## Checklist
- [ ] My code follows the project's style guidelines
- [ ] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable
## Screenshots (if applicable)
Add screenshots to help explain your changes.
## Additional Notes
<!-- Mention any N/A checklist items, tradeoffs, follow-ups, or
maintainer context. -->
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
7dbb9c3810
|
chore: extract agent-evals into standalone headroom-bench repo (#1967)
## Description `agent-evals` was a self-contained nested project (a coding-agent accuracy A/B framework: run trusted coding benchmarks WITH vs WITHOUT Headroom). It has no runtime coupling to the `headroom` wheel and was never wired into `make ci-precheck`. It has been extracted into its own repo (`headroom-bench`) so its heavy benchmark deps (swebench, mini-swe-agent, modal) never touch headroom and it can iterate on its own cadence. This PR removes the 33 nested files. Full history is preserved in the extracted repo. Closes # ## Type of Change - [x] Code refactoring (no functional changes) ## Changes Made - Remove `agent-evals/` (33 files) — extracted to the standalone `headroom-bench` repo. ## Testing `agent-evals` was never imported by the headroom package and never part of `make ci-precheck`, so headroom's build/lint/type/test surface is unaffected by this pure deletion. ### Test Output ```text # No headroom code touched. Verification that the removal is self-contained: $ git grep -Ei 'agent[-_]evals' -- ':!agent-evals/' ':!*.lock' CHANGELOG.md:270:* **agent-evals:** Phase 0 ... # historical changelog entry only (kept) # -> zero code / CI / import references $ git diff --name-only upstream/main..HEAD | wc -l 33 $ git diff --name-only upstream/main..HEAD | grep -vc '^agent-evals/' 0 # every changed file is under agent-evals/ ``` ## Real Behavior Proof - Environment: `headroom` @ branch `chore/extract-agent-evals` (1 commit over `upstream/main`; fork in sync, 0 drift). - Exact command / steps: `git subtree split --prefix=agent-evals` -> seeded the new repo `headroom-bench` (history preserved); `git rm -r agent-evals` here. - Observed result: 33-file deletion, all under `agent-evals/`; no dangling references in code, `Makefile`, or `.github/workflows/`. The extracted repo is intact and its suite passes (78 passed, 3 skipped). - Not tested: nothing runtime in headroom changes (agent-evals was never imported by the wheel). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] My changes generate no new warnings - [x] New and existing unit tests pass locally with my changes ## Screenshots (if applicable) n/a |
||
|
|
88e41b65a1
|
Extract request log redaction policy (#1968)
## Description Extracts the pure image-base64 request-log redaction decision/transform logic from `request_logger.py` into a dedicated policy module. `RequestLogger` remains the owner of the Prometheus-facing redaction counter and existing request_logger constants remain available for compatibility. Closes # ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made - Added `headroom.proxy.request_log_redaction_policy` with a pure `RedactionResult` outcome. - Kept global redaction metrics/counter side effects in `request_logger.py`. - Added direct policy tests for count reporting, nested image paths, and data URL threshold behavior. - Carried forward the LiteLLM callback compatibility shim needed for current mypy on `main`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text python -m pytest tests\test_request_log_redaction_policy.py tests\test_image_log_redaction.py 20 passed in 0.31s python -m ruff check . All checks passed! python -m ruff format --check . 1095 files already formatted python -m mypy headroom --ignore-missing-imports Success: no issues found in 409 source files gitleaks protect --staged --no-banner --redact no leaks found ``` ## Real Behavior Proof - Environment: Windows, Python 3.13.13, branch `jd/architecture-slice-23`. - Exact command / steps: ran targeted request-log redaction tests, ruff, ruff format check, mypy, and staged gitleaks scan. - Observed result: redaction behavior remains covered through existing logger tests and new pure policy tests; local lint/type/security checks pass. - Not tested: full proxy runtime; this slice only moves pure redaction policy and keeps the logger entry point intact. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes Documentation and changelog updates are N/A for this internal architecture-only refactor. The push reported existing default-branch Dependabot alerts; no staged secret leaks were found for this PR. |
||
|
|
1d2b76e72e
|
fix: harden persistent install startup (#1851)
## Description Hardens persistent install startup and proxy compression behavior for issue #1843. Repeated `headroom install start` / scheduled ensure calls no longer spawn duplicate runtimes by default, and `/v1/compress` now fails open on compression timeout instead of returning a 503. The PR also adds a machine-readable platform feature matrix and app-level stabilization tests for health, compression functionality, timeout behavior, and matrix evidence. Refs #1843 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [x] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Wrapped direct persistent deployment starts with the existing profile-local runtime start lock. - Made `headroom install start` idempotent when the deployment is already healthy. - Added wedged-runtime handling: if a PID is running but `/readyz` does not recover inside the grace window, stop it before starting again. - Kept `install agent ensure` inside the already-held lock while delegating to the shared start helper. - Changed `/v1/compress` timeout behavior from `503 compression_timeout` to fail-open `200` with original messages, `compression_skipped: true`, and `skip_reason: compression_timeout`. - Added `tests/test_platform_stabilization_functional.py` covering real FastAPI health/compression routes, successful compression metrics, timeout fail-open speed, and a real JSON tool payload that reduces tokens. - Added `docs/platform-feature-matrix.json` and `docs/platform-stabilization.md` for Linux/macOS/Windows hardening coverage and known gaps. - Strengthened matrix tests so cited local test/workflow paths must exist. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text # Clean source tree without copied Rust extension: functional module is skipped locally, as CI copies _core from the built wheel. > python -m pytest tests/test_install tests/test_cli/test_install_cli.py tests/test_platform_feature_matrix.py tests/test_platform_stabilization_functional.py -q collected 120 items / 1 skipped 119 passed, 2 skipped in 17.08s > python -m ruff check headroom/proxy/handlers/openai.py tests/test_platform_stabilization_functional.py tests/test_platform_feature_matrix.py All checks passed! # Local Windows compiled-core proof: > python -m maturin build --profile ci --out dist-local Built wheel for abi3 Python >= 3.10 to dist-local\headroom_ai-0.29.0-cp310-abi3-win_amd64.whl # Copied _core.pyd from the wheel into headroom/ for local route execution, then: > python -m pytest tests/test_platform_stabilization_functional.py -q collected 4 items 4 passed in 6.71s > python -m pytest tests/test_install tests/test_cli/test_install_cli.py tests/test_platform_feature_matrix.py -q collected 120 items 119 passed, 1 skipped in 17.16s Commit hooks: Sync plugin versions.....................................................Passed check for merge conflicts................................................Passed ruff.....................................................................Passed ruff-format..............................................................Passed mypy.....................................................................Passed ``` ## Real Behavior Proof - Environment: Windows 11, PowerShell, Python 3.13.13, worktree `C:\git\headroom-stabilization` on branch `jd/cross-platform-stabilization`. - Exact command / steps: built the Windows wheel with `maturin`, extracted `_core.pyd`, ran the new FastAPI route tests and install/matrix tests listed above, then removed generated artifacts before committing. - Observed result: direct start paths now no-op when healthy, skip spawning when the start lock is contended, and stop a wedged runtime before restart. `/v1/compress` now returns original messages quickly on timeout instead of a 503. The real JSON tool-payload smoke test returns `tokens_before > tokens_after`, `tokens_saved > 0`, `compression_ratio < 1.0`, and non-empty transforms through the public route. - Not tested: full native Windows persistent process e2e remains blocked by the upstream CRT/wheel issue already documented in workflows and in the matrix. No real OS service was installed locally; service manager behavior is covered by argument-level unit tests. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes CHANGELOG is not updated because this is an unreleased hardening/test/documentation pass. The platform matrix intentionally records partial/blocked Windows/macOS e2e gaps instead of claiming full coverage where the repo cannot currently run it. |
||
|
|
28ca61fc9d
|
fix: patch nltk vulnerability (CVE-2026-54293) (#1929)
## Description
Updates the locked `nltk` package from 3.9.4 to 3.10.0 to address
CVE-2026-54293, reported by OrbisAI Security as an information
disclosure/path traversal issue in `nltk.data.load()`.
Closes #
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- Updated the `nltk` lockfile entry from 3.9.4 to 3.10.0.
- Added the new locked `defusedxml` dependency required by `nltk`
3.10.0.
- Added an explicit `nltk>=3.10.0` uv constraint so future lock
refreshes cannot regress below the fixed version.
- Updated the benchmark-extra comment now that the nltk CVE has an
upstream fixed release.
## Testing
<!-- Check what you actually ran, then paste the real command output
below. -->
- [ ] Unit tests pass (`pytest`)
- [ ] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [ ] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
uv lock --locked
Resolved 257 packages in 1ms
uv run --extra benchmark python -c "import importlib.metadata as md; print('lm-eval', md.version('lm-eval')); print('rouge-score', md.version('rouge-score')); print('nltk', md.version('nltk'))"
lm-eval 0.4.10
rouge-score 0.1.2
nltk 3.10.0
```
## Real Behavior Proof
- Environment: GitHub pull request diff for
headroomlabs-ai/headroom#1929.
- Exact command / steps: Reviewed the PR diff and ran the focused uv
lock/import checks listed above.
- Observed result: The lockfile now points at nltk 3.10.0 artifacts,
includes the new defusedxml dependency, and records the nltk>=3.10.0
resolver constraint.
- Not tested: Full local test suite was not run for this lockfile-only
security update.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable
## Screenshots (if applicable)
N/A.
## Additional Notes
Original automated security context from OrbisAI Security:
- CVE: CVE-2026-54293
- Severity: HIGH
- Scanner: trivy
- Rule: `CVE-2026-54293`
- File: `uv.lock`
- Assessment: Likely exploitable
- Description: nltk information disclosure via path traversal in
`nltk.data.load()`.
---------
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
|
||
|
|
abc557a5dc
|
[codex] Document local LLM prefill benchmarking (#1396)
## Summary - add a Local LLM Prefill Benchmark docs page for baseline-vs-optimized proxy testing - document the `--no-optimize` baseline, optimized rerun, dashboard comparison, and optional `--learn` condition - link the workflow from the proxy and benchmarks docs ## Context This captures the local-inference workflow shown in Joe Maddalone's June 2026 Headroom demo: Headroom can improve local model prompt-processing time by sending fewer prompt tokens, even when token cost is not the main concern. ## Validation - `npm --prefix docs run types:check` - `npm --prefix docs run build` ## Notes - This PR is independent from #1395, which covers Codex audit/maturation evidence. Co-authored-by: Robert Briscoe <robert@briscoe.dev> |
||
|
|
d05802b620
|
fix: emit unknown Anthropic content blocks verbatim in buffered-to-SSE conversion (#1825)
## Description
Unknown Anthropic content block types are now emitted verbatim inside
`content_block_start` during buffered-to-SSE conversion instead of
raising `ValueError`. The block-start loop in
`StreamingMixin._response_to_sse`
(`headroom/proxy/handlers/streaming.py`) previously handled only `text`,
`tool_use`, `thinking`, and `redacted_thinking`; any other type fell
through to a hard raise, which turned a fully-generated upstream
response into an HTTP 502.
Closes #1806
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- Emit unknown content block types, including `server_tool_use`,
`server_tool_result`, `mcp_tool_use`, and future Anthropic block types,
verbatim in `content_block_start` with no delta.
- Preserve main's explicit `server_tool_use` support and newer buffered
CCR/thinking regression coverage after merging current main.
- Keep `content_block_delta` generation gated on known delta-capable
block types, so unknown blocks do not produce spurious deltas.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
uv run --with pytest python -m pytest tests/test_sse_thinking_blocks.py -q
12 passed, 1 warning
uv run --with ruff==0.15.17 ruff check headroom/proxy/handlers/streaming.py tests/test_sse_thinking_blocks.py
All checks passed!
```
## Real Behavior Proof
- Environment: Windows worktree `C:\git\headroom-governance-main`, PR
head `
|
||
|
|
d2a86b5909
|
fix(proxy): strip duplicated upstream server headers (#1828)
## Description Fixes duplicated upstream server headers emitted by the proxy when forwarding responses. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Adjust proxy response forwarding so upstream server headers are not duplicated. - Preserve the intended response-header behavior while avoiding repeated header values. - Keep the change scoped to proxy/header handling. ## Testing - [x] Unit tests pass - [x] New tests added for new functionality when applicable - [x] Manual testing performed ### Test Output ```text Focused tests/review were completed before this governance body cleanup. The current branch is conflicted and still needs merge resolution before final merge readiness. ``` ## Real Behavior Proof - Environment: Headroom development/review context. - Exact command / steps: Reviewed the proxy response-header behavior and existing focused coverage for duplicate upstream server headers. - Observed result: The PR implementation prevents duplicated upstream server headers while preserving proxy forwarding behavior. - Not tested: Current conflicted branch after merge resolution; conflicts still need to be resolved before merge. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Additional Notes This body was normalized by a maintainer after approval so the governance parser reflects the already-reviewed PR state. The PR remains blocked by merge conflicts. --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
98ff203f98
|
ci: allow PyPI deps during CPU torch install (#1930)
## Description Fixes the CI failure exposed on the ` |
||
|
|
8527b910dc | test(litellm): remove unused pytest import | ||
|
|
2d418335a1 | ci: preserve merge labels while state is unknown | ||
|
|
595b709a5b | ci: keep ready label off changes-requested PRs | ||
|
|
1deb947ac1
|
fix(proxy): hoist ccr_workspace_key default so /v1/messages survives CCR-inject off (#1096)
## Summary
`handle_anthropic_messages` only assigns `ccr_workspace_key` /
`ccr_workspace_label` **inside** the
`if (ccr_inject_tool or ccr_inject_system_instructions) and not
_bypass:` block (around `headroom/proxy/handlers/anthropic.py:1302`),
but references `ccr_workspace_key` **unconditionally** in the
proactive-expansion gate at
`headroom/proxy/handlers/anthropic.py:1394-1397`:
```python
if (
self.ccr_context_tracker
and self.config.ccr_proactive_expansion
and ccr_workspace_key # <-- unbound when the inject block was skipped
):
```
Running the proxy with `--no-ccr-inject-tool` and the default
`ccr_inject_system_instructions=False` (a real, supported configuration)
skips the assignment. With `ccr_context_tracking=True` and
`ccr_proactive_expansion=True` (both defaulting to `True`), the gate is
reached and raises `UnboundLocalError`, which FastAPI surfaces as HTTP
500 on **every** `/v1/messages` request. The Claude Code SDK retries ~10
times (`type=system/api_retry`) and then emits the upstream error as the
assistant reply (`API Error: 500 Internal Server Error`), which looked
exactly like an Anthropic outage from the agent side.
Fix: hoist `ccr_workspace_key, ccr_workspace_label = None, None` to
before the gated block. The downstream uses already treat a falsy key as
"workspace unresolved" — `track_compression` short-circuits to the
existing `elif self.ccr_context_tracker and not ccr_workspace_key:` log
line, and the proactive-expansion gate stays closed via short-circuit
`and`. Behavior with CCR inject enabled is byte-identical.
The bug appears to have been introduced by #500 (workspace scoping). I
traced it after my NanoClaw containers started returning `API Error: 500
Internal Server Error` for every scheduled run — `journalctl --user -u
headroom` showed the traceback.
## Reproduction
Failing test in `tests/test_anthropic_ccr_workspace_unbound.py` mirrors
the deployment config:
```python
config = ProxyConfig(
ccr_inject_tool=False, # user passed --no-ccr-inject-tool
ccr_inject_system_instructions=False, # default
ccr_context_tracking=True, # default — installs the tracker
ccr_proactive_expansion=True, # default — reaches the gate
...
)
```
Before the fix:
```
headroom/proxy/handlers/anthropic.py:1397: in handle_anthropic_messages
and ccr_workspace_key
^^^^^^^^^^^^^^^^^
E UnboundLocalError: cannot access local variable 'ccr_workspace_key' where it is not associated with a value
FAILED tests/test_anthropic_ccr_workspace_unbound.py::test_proactive_expansion_does_not_raise_when_ccr_inject_disabled
```
After the fix:
```
tests/test_anthropic_ccr_workspace_unbound.py . [100%]
1 passed
```
## Real behavior proof
**Setup tested on:** Ubuntu 24.04 on WSL2 (NUC15CRH), Python 3.12.3,
`headroom-ai==0.25.0` venv at `/home/adam/headroom-env/`, service
started by user-level systemd unit:
```
headroom proxy --host 0.0.0.0 --port 8787 --mode token \
--no-ccr-inject-tool --no-ccr-marker --no-telemetry --code-aware
```
Provider: Anthropic via direct `CLAUDE_CODE_OAUTH_TOKEN` injection from
the calling container (NanoClaw / Claude Agent SDK on
`claude-opus-4-8`).
**Before the patch** — every request through the proxy 500ed:
```
$ curl -sS -m 5 -o /tmp/r -w "HTTP %{http_code}\n" -X POST http://localhost:8787/v1/messages \
-H "x-api-key: placeholder" -H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{"model":"claude-opus-4-8","max_tokens":10,"messages":[{"role":"user","content":"hi"}]}'
HTTP 500
$ head -c 40 /tmp/r
Internal Server Error
$ journalctl --user -u headroom -n 50 --no-pager | grep -A1 ccr_workspace_key | head
and ccr_workspace_key
^^^^^^^^^^^^^^^^^
UnboundLocalError: cannot access local variable 'ccr_workspace_key' where it is not associated with a value
```
NanoClaw container logs showed the SDK's 10 `system/api_retry` events
then surfacing `API Error: 500 Internal Server Error` as the assistant
result.
**After the patch** (applied in place to the installed file, service
restarted):
```
$ systemctl --user restart headroom
$ TOKEN=$(jq -r .claudeAiOauth.accessToken ~/.claude/.credentials.json)
$ curl -sS -m 30 -o /tmp/r -w "HTTP %{http_code}\n" -X POST http://localhost:8787/v1/messages \
-H "Authorization: Bearer $TOKEN" -H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{"model":"claude-opus-4-8","max_tokens":20,"messages":[{"role":"user","content":"reply with just the word pong"}]}'
HTTP 429
$ cat /tmp/r
{"type":"error","error":{"type":"rate_limit_error","message":"Error"},"request_id":"req_011Cc9PSZHi4QssEKLhZX5uq"}
```
The local 500 is gone — the proxy now forwards cleanly and surfaces
upstream's real response (here a 429 because the retry storm had been
hammering the account for hours; the shape of the response, and the
presence of an `anthropic-request_id`, confirms the proxy is no longer
crashing on its own code path).
Then `journalctl --user -u headroom --since "5 min ago" | grep -iE
'unbound|traceback'` returned no new occurrences after the restart at
12:30 PDT.
**What I did *not* test:**
- The `_bypass=True` path (same fix protects it, but I did not exercise
it end-to-end).
- The CCR-inject-on path — relied on the existing
`tests/test_proxy_anthropic_cache_stability.py` and
`tests/test_proxy_system_prompt_immutable.py` suites passing (they do;
ran `uv run pytest tests/test_proxy_anthropic_cache_stability.py
tests/test_anthropic_pre_upstream_backpressure.py
tests/test_anthropic_stage_timings.py
tests/test_provider_proxy_routes.py
tests/test_proxy_system_prompt_immutable.py` → 68 passed).
## Test plan
- [x] `uv run pytest tests/test_anthropic_ccr_workspace_unbound.py` —
fails on `main`, passes on this branch.
- [x] `uv run pytest tests/test_proxy_anthropic_cache_stability.py
tests/test_anthropic_pre_upstream_backpressure.py
tests/test_anthropic_stage_timings.py
tests/test_provider_proxy_routes.py
tests/test_proxy_system_prompt_immutable.py` — 68 passed.
- [x] `uv run ruff check` / `uv run ruff format --check` on modified
files — clean.
- [x] Live proxy verified against the configuration that reproduced the
bug.
Co-authored-by: Adam Barnum <adamleebarnum@gmail.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
|
||
|
|
772adc93b2
|
fix(scripts): rename .releaseetadata to .releasemetadata (#1246)
## Description Fixes a typo in the release metadata filename written by `scripts/version-sync.py`. The file was being created as `.releaseetadata` (double `e`) instead of `.releasemetadata`. Any downstream tooling or developer looking for the artifact by its correct name would not find it. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `scripts/version-sync.py`: corrected the filename in `write_release_metadata()` — both the docstring and the `metadata_path` assignment. - `scripts/tests/test_version_sync.py`: updated 3 test assertions to reference `.releasemetadata`. ## Testing - [x] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [ ] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ uv run python -m pytest scripts/tests/test_version_sync.py -q ============================= test session starts ============================== platform linux -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 rootdir: /home/sepurisaikrishna/Documents/calude-here/headroom configfile: pyproject.toml collected 6 items scripts/tests/test_version_sync.py ...... [100%] =============================== warnings summary =============================== PytestConfigWarning: Unknown config option: asyncio_mode -- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html ========================= 6 passed, 1 warning in 1.00s ========================= $ git diff --check origin/main..HEAD # no output; command exited 0 ``` ## Real Behavior Proof - Environment: Linux, Python 3.14.0, uv-managed .venv, branch based on current origin/main. - Exact command / steps: grep -r "releaseetadata" scripts/ before the fix returns hits; after the fix returns nothing. Confirmed .releasemetadata is written correctly by test_release_metadata_written. - Observed result: all 6 test_version_sync.py tests pass with the corrected filename. - Not tested: full repository pytest, ruff, and mypy — this is a one-line spelling fix with no logic changes. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A. ## Additional Notes - The typo was consistent across implementation and tests, so all tests passed before this fix with the wrong name. The fix corrects both the code and the test expectations together. - No production behaviour changes the file is written but not yet consumed by any workflow step. |
||
|
|
9be727de68
|
fix(litellm): inherit CustomLogger so future hooks don't crash proxy (#1114) (#1391)
## Summary Fixes #1114 — LiteLLM 1.89.x added `async_post_call_success_hook` and started calling it after every successful completion. `HeadroomCallback` was a plain `object` subclass with no such method, causing: ``` AttributeError: type object 'HeadroomCallback' has no attribute 'async_post_call_success_hook' ``` This crashed the LiteLLM proxy on every successful API call. ### Root cause ```python class HeadroomCallback: # plain object — no-op hooks not inherited ... ``` ### Fix Inherit from `litellm.integrations.custom_logger.CustomLogger` which provides no-op defaults for every hook it defines. Future additions to `CustomLogger` will be covered automatically. ```python try: from litellm.integrations.custom_logger import CustomLogger as _CustomLogger except ImportError: _CustomLogger = object # litellm not installed — graceful fallback class HeadroomCallback(_CustomLogger): ... def __init__(self, ...): super().__init__() ... ``` ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `headroom/integrations/litellm_callback.py` — inherit `CustomLogger`; add `super().__init__()` - `tests/test_litellm_callback.py` — 7 tests: instantiation, `async_post_call_success_hook` present + callable + no-op, all current hooks present, pre-call hook still works ## Real behavior proof **Setup:** Python 3.13, litellm 1.89.1, headroom-ai 0.27.0-dev **Steps after patch:** ```bash python3 -c " from headroom.integrations.litellm_callback import HeadroomCallback import asyncio cb = HeadroomCallback() # Simulate what litellm proxy calls on success asyncio.run(cb.async_post_call_success_hook(data={}, user_api_key_dict={}, response=None)) print('OK — no AttributeError') " ``` **After-fix evidence:** Runs without exception. Before fix: `AttributeError: type object 'HeadroomCallback' has no attribute 'async_post_call_success_hook'`. **What I did not test:** Live LiteLLM proxy with YAML config (no LiteLLM proxy running in test env); tested via unit tests and direct Python instantiation. ## Test Results ``` tests/test_litellm_callback.py 7/7 passed ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) |
||
|
|
7836aea2be
|
fix(proxy): preserve upstream 5xx status on retry exhaustion (#1570)
## What When the upstream returns a retryable 5xx (529 Overloaded, 503), `_retry_request` retried up to the cap and then raised, which the caller collapsed into a generic 502. That hides the retryable signal: clients see a 502 and give up instead of applying their own overload backoff. On exhaustion, return the last upstream response (preserving its status and body) when one is available. Connection and timeout errors still raise — only an `HTTPStatusError` carrying a real upstream response is surfaced. ## Why this scope `_retry_request` is provider-agnostic, so this fix applies uniformly to all providers (no per-handler change needed). It is purely a returned-status correctness fix and does not touch request accounting — a separate change handles counting an exhausted 5xx as a failed request across all provider handlers. ## Verification `tests/test_retry_preserve_upstream_status.py`: 529/503 status+body preservation, 4xx no-retry, connect-error still raises, success passthrough. Against unpatched main the 503-preservation test fails (collapses to 502); with the fix all pass. Addresses #1568. |
||
|
|
e365ad7152
|
fix(proxy): count exhausted upstream 5xx as failed across all providers (#1571)
## What A companion to the retry-exhaustion change that returns the real upstream 5xx (e.g. 529 Overloaded) instead of a synthetic 502. Once the real 5xx is returned, it flows through the success funnel and is recorded via `record_request`, feeding the savings/cost stats and inflating the save-rate. `RequestOutcome` now carries the upstream `status_code` (default 200). In `emit_request_outcome`, a `status_code >= 500` records a failed request and returns before the savings/cost/log success path. 4xx stay on the normal funnel (client errors the proxy still served). The real status is threaded onto the retry-fed `RequestOutcome` at every provider site: Anthropic (message, batch, passthrough), OpenAI (chat, responses, passthrough), Gemini (generateContent, all-non-text path, countTokens). Sites that cannot carry a 5xx keep the default 200: local cache hits, backend-routed paths that early-return on error, websocket units (no HTTP status), and streaming generators that early-return on `>= 400`. ## Scope notes - **429**: an exhausted 429 (rate limit) currently stays on the success funnel since it is < 500. Extending the failed-accounting to exhausted-429 is a separate follow-up, kept out of this 5xx-scoped change. - **Streaming**: streaming responses return before the retry-exhaustion logic, so they do not receive an exhausted 5xx through this path; `from_stream` is unchanged. ## Verification `tests/test_outcome_records_5xx_as_failed.py` exercises the `>= 500` funnel guard; `tests/test_5xx_accounting_all_providers.py` pins the per-provider contract for each wired site. Against unpatched main the guard test fails (no `status_code` field); with the change all pass. Addresses #1568. Builds on #1570 (preserve-5xx-status): the 503 accounting takes effect once that lands; the 429/529 accounting is independent. |
||
|
|
75fff43eca
|
deps: bump @types/node from 25.5.2 to 26.1.1 in /docs (#1683)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.5.2 to 26.1.1. <details> <summary>Commits</summary> <ul> <li>See full diff in <a href="https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node">compare view</a></li> </ul> </details> <br /> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
e8b66a27e1
|
deps: bump fumadocs-typescript from 4.0.14 to 5.3.0 in /docs (#1684)
Bumps [fumadocs-typescript](https://github.com/fuma-nama/fumadocs) from 4.0.14 to 5.3.0. <details> <summary>Release notes</summary> <p><em>Sourced from <a href="https://github.com/fuma-nama/fumadocs/releases">fumadocs-typescript's releases</a>.</em></p> <blockquote> <h2>fumadocs-typescript@5.3.0</h2> <h3>Default to Base UI</h3> <p>Internal packages & templates now use Base UI rather than Radix UI.</p> <h2>fumadocs-typescript@5.2.7</h2> <h3>Migrate to <code>cnfast</code></h3> <p>Drop <code>tailwind-merge</code>.</p> </blockquote> </details> <details> <summary>Commits</summary> <ul> <li><a href=" |
||
|
|
350daeba73
|
deps: bump @types/node from 22.19.15 to 26.1.1 in /plugins/openclaw (#1685)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.19.15 to 26.1.1. <details> <summary>Commits</summary> <ul> <li>See full diff in <a href="https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node">compare view</a></li> </ul> </details> <br /> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
87151952ee
|
deps: bump @types/node from 22.20.0 to 26.1.1 in /plugins/opencode (#1688)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.20.0 to 26.1.1. <details> <summary>Commits</summary> <ul> <li>See full diff in <a href="https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node">compare view</a></li> </ul> </details> <br /> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
8879c50dbe
|
fix(adaptive-sizer): char bigrams for spaceless CJK items (#1748)
## Description `compute_unique_bigram_curve` — the adaptive sizer's coverage-curve builder, mirrored in Rust and Python — word-splits each item on whitespace to form word bigrams. A spaceless CJK item has no whitespace, so it collapsed into one `(whole_string, "")` pseudo-bigram: the coverage curve then grew ~1 per item, the kneedle knee detector found no knee, and CJK lists under-compressed. Spaceless CJK items now use character bigrams, producing a real coverage curve. Mirrored byte-exactly in Rust and Python (identical reference-test curve values). Non-CJK items — anything whitespace-bearing or spaceless-ASCII — are byte-identical to before, so the `smart_crusher` parity fixtures are unchanged. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `crates/headroom-core/src/transforms/adaptive_sizer.rs` + `headroom/transforms/adaptive_sizer.py`: add `is_cjk_char`/`_is_cjk_char` (identical code-point ranges) and a spaceless-CJK character-bigram branch in `compute_unique_bigram_curve`. - Rust unit tests + `tests/test_adaptive_sizer.py`: CJK curve, single-char CJK, ASCII-unchanged, empty-item — the Rust and Python reference values are identical. ## Testing - [x] Unit tests pass (`cargo test` + `pytest`) - [x] Linting passes (`cargo clippy` / `cargo fmt` / `ruff` / `mypy`) - [x] New tests added for new functionality - [x] Manual testing performed (see Real Behavior Proof) ### Test Output ```text $ cargo test -p headroom-core --lib adaptive_sizer test result: ok. 35 passed; 0 failed $ .venv/bin/python -m pytest tests/test_adaptive_sizer.py 20 passed $ .venv/bin/python -m pytest -k "smart_crusher and parity" 18 passed, 6 skipped # non-CJK fixtures unchanged ``` ## Real Behavior Proof - Environment: macOS (Darwin), Rust via cargo, Python in a uv venv, branch `feat/adaptive-sizer-cjk` off `main`. - Exact command / steps: called `compute_unique_bigram_curve` on a CJK list and on ASCII lists, in both implementations. - Observed result: `compute_unique_bigram_curve(["数据库连接失败", "数据库连接成功"])` returns `[6, 8]` in **both** Rust and Python (before: ~`[1, 2]` — one pseudo-bigram per item, no coverage signal). ASCII curves are unchanged: `["the cat", "the dog", "a fish"]` → `[1, 2, 3]`. The `smart_crusher` parity suite (all-ASCII fixtures) stays green, confirming non-CJK output is byte-identical. - Byte-exact parity: the Rust reference test (`vec![6, 8]`) and the Python test (`[6, 8]`) use the same inputs and the same expected values, so the two implementations are pinned to agree. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation — N/A (internal sizing heuristic) - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md — N/A: internal sizing-heuristic fix, no user-facing surface change ## Additional Notes - This is a parity-locked function (Rust and Python must agree byte-for-byte). The fix is CJK-gated, so non-CJK output is byte-identical and the `smart_crusher` parity fixtures need no re-recording. |
||
|
|
985621d60e
|
fix(search-compressor): CJK-aware relevance + harden Rust/Python parity (#1749)
## Description
The search compressor's relevance scorer (`score_matches`, present in
both the Rust runtime path and the Python legacy mirror) split the query
on whitespace. A spaceless CJK query therefore matched a result line
only when the WHOLE query was a literal substring of that line — partial
overlaps never boosted relevant lines, so correct matches got dropped
when the result set was over budget.
This adds CJK character bigrams to the query match set, so a longer CJK
query boosts lines that share a substring. It also fixes two latent
Rust/Python parity divergences the ASCII-only fixtures had masked:
- **Length filter**: Rust counted word length in BYTES (`w.len()`),
Python in codepoints (`len(w)`), so a CJK word crossed the `> 2`
threshold differently. Rust now uses `chars().count()`.
- **Dedup**: Rust collected words into a `Vec` (no dedup), Python into a
`set`, so a repeated query word double-counted in Rust. Rust now uses a
`BTreeSet`.
Both scorers are byte-exact now; non-CJK output is unchanged (the 53
existing tests and the parity fixtures stay green).
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- `crates/headroom-core/src/transforms/search_compressor.rs` +
`headroom/transforms/search_compressor.py`: add
`is_cjk_char`/`_is_cjk_char` and `cjk_bigrams`/`_cjk_bigrams` (identical
ranges + logic), union CJK bigrams into the query match set, and align
the Rust word set to Python (`chars().count()` length, `BTreeSet`
dedup).
- `tests/test_search_compressor_cjk.py` + a Rust unit test: CJK bigram
extraction (same input/expected in both languages) and a CJK query
boosting a partially-overlapping line.
- Corrected a stale `_score_matches` docstring that referenced a
non-existent parity assertion; it now states honestly how the two sides
are pinned (test-equal for word-overlap + CJK bigrams; a few error-boost
keywords still diverge, fixed only Rust-side).
## Testing
- [x] Unit tests pass (`cargo test` + `pytest`)
- [x] Linting passes (`cargo clippy` / `cargo fmt` / `ruff` / `mypy`)
- [x] New tests added for new functionality
- [x] Manual testing performed (see Real Behavior Proof)
### Test Output
```text
$ cargo test -p headroom-core --lib search_compressor
test result: ok. 16 passed; 0 failed
$ .venv/bin/python -m pytest tests/test_search_compressor_cjk.py \
tests/test_transforms_search_compressor.py tests/test_search_compressor.py
55 passed # 2 new CJK tests + 53 existing (no regression)
```
## Real Behavior Proof
- Environment: macOS (Darwin), Rust via cargo, Python in a uv venv
(`_core` rebuilt on this branch), branch `feat/search-compressor-cjk`
off `main`.
- Exact command / steps: scored a CJK content line against a longer CJK
query whose whole form is not a substring of the line.
- Observed result: for content `src/a.py:10:认证令牌已过期需要重新登录` and query
`认证令牌缓存淘汰策略` (the whole query is NOT a substring of the line, but its
bigrams are), the line now scores `> 0` (bigrams 认证 / 证令 / 令牌 match);
before, it scored `0`. An ASCII-only line still scores `0`. All 53
existing search-compressor tests are unchanged. `cjk_bigrams("认证令牌")`
returns `{认证, 证令, 令牌}` in **both** Rust and Python.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation — N/A
(internal relevance scoring)
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md — N/A: internal relevance-scoring
fix, no user-facing surface change
## Additional Notes
- The two parity divergences (byte-vs-codepoint length, `Vec`-vs-`set`
dedup) were pre-existing and only reachable with non-ASCII or
repeated-word queries — the all-ASCII fixtures never exercised them.
This PR brings both sides back to byte-exact for the word-overlap +
CJK-bigram scoring. The remaining error-boost keyword divergence is
pre-existing (fixed only Rust-side in the 3e.1 port) and is now
documented in the code rather than glossed over.
|
||
|
|
c85731dc23
|
fix(mcp): correct default Claude Code config path in ClaudeRegistrar (#1859)
## Description <!-- Briefly explain the change and why it is needed. --> `ClaudeRegistrar` originally assumed Claude Code's modern per-user MCP config lives at `~/.claude/.claude.json`. On a real Claude Code 2.1.202 install with `CLAUDE_CONFIG_DIR` unset, the actual file is `~/.claude.json`, directly under the home directory. Whenever `claude mcp add` failed for any transient reason and the registrar fell back to writing the config file directly, it wrote to a path Claude Code never reads — registration reported success with no error, but the server silently never became available, and once that wrong file existed, `get_server()` kept reading it back as already-registered, so the registrar never retried. While fixing the path, several related correctness and test-isolation issues in the same file were found and fixed: - Three tests instantiated `ClaudeRegistrar(claude_cli=None)` without `home_dir`, so the legacy config path resolved to the developer's real `~/.claude/mcp.json` — one test was actually deleting a `headroom` entry from it. - `detect()` only checked the legacy `~/.claude` directory, so installs where the `claude` CLI is absent from `PATH` and only the modern `~/.claude.json` exists were treated as not detected, skipping registration entirely. - `unregister_server()` returned early on CLI success without cleaning the legacy config file, so a stale legacy entry could survive a successful `claude mcp remove` and `get_server()` would keep reporting the server as registered. - `_read_server_entry`, `_remove_from_file`, and `_register_via_file` assumed `mcpServers` was always a dict once present; a hand-edited or corrupted config with `mcpServers` set to `null`, a list, or a string crashed with an unhandled `AttributeError`/`TypeError`. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `_resolve_claude_config_dir` now defaults to `home` (the modern config is `~/.claude.json`), keeping the `CLAUDE_CONFIG_DIR` and explicit `config_dir` overrides intact; the legacy `.claude` directory is pinned to `home / ".claude"` independently of where the modern config resolves. - `detect()` also recognizes an install via `self._modern_config.exists()`, not just the legacy directory. - `unregister_server()` always cleans both the modern and legacy config files, even after a successful CLI removal. - `_read_server_entry`, `_remove_from_file`, and `_register_via_file` now validate `mcpServers` is a dict before indexing into it, degrading gracefully instead of crashing on malformed config. - Module and constructor docstrings now state the current config-path facts plainly (paths, and what `CLAUDE_CONFIG_DIR` relocates); the constructor docstring also clarifies that `home_dir`/`config_dir` isolate file-based reads/writes but not CLI subprocess calls. - `tests/test_mcp_registry/test_claude_registrar.py`: corrected path expectations, isolated the three previously-unisolated tests from the real home directory, and added coverage for the modern-config-only detect case, CLI-success-with-stale-legacy-entry, and non-dict `mcpServers` values. - Filed #1861 for a related, currently-unexercised gap: CLI subprocess calls don't honor `home_dir`/`config_dir` overrides. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_mcp_registry/test_claude_registrar.py ============================== 35 passed in 0.08s ============================== $ ruff check headroom/mcp_registry/claude.py tests/test_mcp_registry/test_claude_registrar.py All checks passed! $ mypy headroom/mcp_registry/claude.py Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: Claude Code 2.1.202 (macOS, Darwin 25.5.0), `CLAUDE_CONFIG_DIR` unset. - Exact command / steps: Injected a uniquely-named probe server into **only** `~/.claude/.claude.json` (old assumed path) and ran `claude mcp list`; then injected a different probe into **only** `~/.claude.json` (corrected path) and ran `claude mcp list` again; restored both files afterward. 1. `ZZZ_nested_probe` written to `~/.claude/.claude.json` only → `claude mcp list`. 2. `ZZZ_flat_probe` written to `~/.claude.json` only → `claude mcp list`. - Observed result: The nested-path probe (`ZZZ_nested_probe`) was **not** recognized by `claude mcp list` — Claude Code ignores `~/.claude/.claude.json`. The flat-path probe (`ZZZ_flat_probe`) **was** recognized and listed. This confirms `~/.claude.json` is the file Claude Code actually reads. Both config files were restored to their original state after the test. - Not tested: older Claude Code versions (< 2.1.202); Windows/Linux path resolution (logic is platform-agnostic via `pathlib`, but only macOS was exercised); the CLI-subprocess env-isolation gap tracked in #1861. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A — CLI/config-path change with no UI surface. ## Additional Notes - The `CLAUDE_CONFIG_DIR=~/.claude` configuration still correctly resolves the modern config to `~/.claude/.claude.json` via the unchanged env override — only the default (env unset) changed. - Any machine that hit the original bug may have a stale `~/.claude/.claude.json` written by the old fallback; it is harmless and can be deleted. - #1861 tracks a related gap (CLI subprocess env isolation) that isn't exercised by any current production call site. |
||
|
|
33c7f6cd3a
|
fix(bedrock): resolve global.* inference profiles + pin per-user app-profile ARNs (#1795)
## What & why
Bedrock model resolution failed on accounts whose inference profiles use
the newer `global.` cross-region prefix and undated version suffixes. On
such an account, `list_inference_profiles` returns current-gen models as
`global.anthropic.claude-opus-4-8`, `global.anthropic.claude-sonnet-5`,
`global.anthropic.claude-opus-4-6-v1`,
`global.anthropic.claude-fable-5`, etc.
`_normalize_bedrock_profile_id` only stripped `us.`/`eu.`/`apac.`/`au.`
and only matched a dated `-vN:M` suffix, so every `global.`-prefixed
profile was silently dropped from the discovered model map. Requests
then fell through to the fabricated fallback id and Bedrock rejected
them:
```
litellm.BadRequestError: BedrockException - {"message":"The provided model identifier is invalid."}
```
On the affected account the discovered-profile count went from 5 → 14
after the fix.
## Changes
1. **`_normalize_bedrock_profile_id`** — strip the `global.` prefix in
addition to the region prefixes, and match undated version suffixes
(`-v1`, or none at all) alongside the legacy dated `-vN:M`.
2. **`HEADROOM_BEDROCK_MODEL_MAP` operator override** (read from the
process environment). AWS discovery keys the model map by normalized
model name, so it cannot disambiguate application inference profiles
that share one underlying model — e.g. a team where
`claude-sonnet-5-alice` and `claude-sonnet-5-bob` both resolve to
`claude-sonnet-5`. When you need requests billed to a *specific*
application profile (per-user cost attribution), pin it explicitly:
```
HEADROOM_BEDROCK_MODEL_MAP="claude-sonnet-5=arn:aws:bedrock:REGION:ACCT:application-inference-profile/abc123,claude-opus-4-8=arn:...:application-inference-profile/def456"
```
The plain model name (kept plain so a client's tool-search deferral
stays on) resolves to the pinned ARN, routed via the converse endpoint.
The override wins over discovery; when unset, discovery-only behaviour
is unchanged.
## Tests
`tests/test_bedrock_region.py` (43 passing): `global.`-prefixed
normalization across dated / bare-`-v1` / no-suffix shapes; override-map
parsing (empty, single, multi, whitespace, malformed-skip); and
`map_model_id` override routing (pinned name → app-profile ARN via
converse, wins over discovery; unpinned name falls through).
No behavioural change for accounts already on system-defined
region-prefixed profiles.
---------
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
|
||
|
|
49a1a1405b
|
ci(opencode): compile + test the OpenCode plugin on changes (#1750)
The OpenCode plugin (`plugins/opencode`) is the routing shim that carries `headroom wrap opencode` traffic through the proxy — but **no CI job ever compiled it**. It has `typecheck`/`build`/`test` scripts that only ran locally, so: - TypeScript 6.x (#1687) and @types/node 26 (#1688) major-bump PRs had **zero build evidence** (why they're held). - Any source edit to the plugin could silently break the build. This adds a path-gated workflow that runs `npm ci → typecheck → build → test` whenever `plugins/opencode/**` (or this workflow) changes. Matches repo conventions (`setup-node@v6`, node 20, npm cache). **Verified green locally on main:** `tsc --noEmit` clean, `tsup` build ok, 13 vitest tests pass. Unblocks safe evaluation of the held dependency-bump PRs and protects the routing plugin going forward. 🤖 Generated with [Claude Code](https://claude.com/claude-code) |
||
|
|
372d6c8cd4
|
fix(wrap): preserve custom Codex provider base_url during proxy injection (#1894)
## Description Refs #1614 (Bug 2 only; Bug 1's config-mutation ordering is covered by a separate PR). `headroom wrap codex` unconditionally pointed the proxy's upstream OpenAI route at `api.openai.com`, even when the user's Codex config already declared a custom OpenAI-compatible provider such as `freemodel.dev`, LiteLLM, or vLLM under `[model_providers.<name>]`. The proxy then silently rerouted traffic to OpenAI, which rejected the user's gateway API key, and Codex interpreted the resulting auth failures as an invalid session. ## Type of Change - [x] Bug fix ## Changes Made - `_detect_custom_codex_upstream_base_url` and `_codex_custom_provider_base_urls` in `headroom/cli/wrap.py` scan the existing `config.toml` for a user-declared custom `[model_providers.*]` table, excluding Codex built-ins and Headroom's own table, and return its `base_url` when the selection is unambiguous: either the top-level `model_provider` names it directly, or a prior wrap left the original provider in the `# was: <original>` comment from `_redirect_existing_top_level_keys`. - The detector falls back to the sole custom provider when exactly one candidate exists and no matching top-level selection is present, which covers the issue repro where the custom table exists without a static top-level provider pin. - `_inject_codex_provider_config` now detects that custom upstream before building the injected provider block. When found, it adds `X-Headroom-Base-Url` to `env_http_headers`, mapped to `HEADROOM_CODEX_UPSTREAM_BASE_URL`, matching Codex's env-var-based header contract. - `codex()` exports the detected value into `HEADROOM_CODEX_UPSTREAM_BASE_URL` for the launched Codex process unless the user already set it. The proxy's OpenAI HTTP handlers already honor `X-Headroom-Base-Url`, so HTTP `/v1/chat/completions` and `/v1/responses` requests forward to the preserved gateway instead of the default OpenAI upstream. This is scoped to the HTTP request path. Codex's WebSocket transport for `/v1/responses` resolves its upstream from a separate header-independent path and keeps the existing behavior. ## Testing - [x] Focused Codex wrap tests passed locally before PR review: `pytest tests/test_cli/test_wrap_codex.py -q` - [x] Broader Codex CLI test selection passed locally before PR review: `pytest tests/test_cli/ -k codex -q` - [x] CI lint, format, and type checks passed on PR head ` |
||
|
|
662b7bc00e
|
fix(release): sync all package versions to v0.31.0 (#1882)
## Description Current `main` has advanced the core package versions to `0.31.0`, but the plugin marketplace manifests and hook plugin manifests were still left at `0.30.0`. This PR now keeps the original version-sync intent while updating the remaining metadata to the current release line. It also preserves the previously-added `lxml-html-clean>=0.4.5` security floor in `pyproject.toml` / `uv.lock` so the security audit remains unblocked. Closes #1872 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Synced `pyproject.toml`, SDK/package manifests, plugin manifests, marketplaces, `.release-please-manifest.json`, and editable package lock metadata to `0.31.0`. - Updated the OpenClaw plugin dependency on `headroom-ai` to `^0.31.0`. - Merged current `main` and resolved the version metadata conflicts in favor of current `0.31.0` alignment. ## Testing - [x] Unit tests pass (`pytest tests/test_plugin_manifests.py scripts/tests/test_version_sync.py -q`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [ ] New tests added for new functionality - [x] Manual testing performed (`python scripts/verify-versions.py`) ### Test Output ```text python scripts/verify-versions.py All versions aligned at 0.31.0 pytest tests/test_plugin_manifests.py scripts/tests/test_version_sync.py -q 10 passed in 0.42s ``` ## Real Behavior Proof - Environment: Windows, Python 3.13.13, PR head after merging current `main`. - Exact command / steps: Ran `python scripts/verify-versions.py` and `pytest tests/test_plugin_manifests.py scripts/tests/test_version_sync.py -q`. - Observed result: Version verification exits successfully with `All versions aligned at 0.31.0`; focused manifest/version-sync tests pass. - Not tested: Full wheel/build matrix; this is metadata-only version alignment and CI will cover the broader matrix. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] My changes generate no new warnings - [x] New and existing focused tests pass locally with my changes - [ ] I have made corresponding changes to the documentation - [ ] I have updated the CHANGELOG.md if applicable --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
42bdf23d24
|
fix(install): write deployment manifest atomically and tolerate corrupt manifests (#1303)
## Description Make deployment-manifest persistence in `headroom/install/state.py` crash-safe by writing the manifest **atomically**. `save_manifest` used a plain `path.write_text(...)` (truncate-then-write), so an interrupted save (Ctrl-C, system restart, container OOM/SIGKILL) could leave a truncated `manifest.json` on disk. > **Note (rebased onto current `main`):** since this PR was opened, #1491 hardened `load_manifest` to raise a typed `ManifestError` on a corrupt manifest. I've rebased and **dropped my original `load_manifest → return None` change in favour of that deliberate typed-error design**, so this PR now scopes down to the still-missing piece: the **atomic write** (upstream `save_manifest` is still a plain `write_text`), plus a regression test for the `ManifestError` path that `main` added without test coverage. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Add `_atomic_write_text(path, data)`: write to a same-directory temp file → `flush()` + `os.fsync()` → `os.replace()` (atomic rename on POSIX and Windows); the temp file is cleaned up if anything fails. - `save_manifest` now persists via `_atomic_write_text` instead of `path.write_text(...)`, so a crash between truncate and full write leaves either the previous file or the complete new one — never a truncated manifest. - `load_manifest` is left exactly as `main` has it (raises `ManifestError` on a corrupt payload) — no behavioural change from me there. - Tests: add `test_save_manifest_writes_atomically` (no leftover temp file; manifest round-trips) and `test_load_manifest_raises_manifest_error_on_corrupt_payload` (covers the `ManifestError` path #1491 introduced but did not test). ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_install/test_state.py -q ..... [100%] 5 passed in 0.11s $ ruff check headroom/install/state.py tests/test_install/test_state.py All checks passed! $ ruff format --check headroom/install/state.py tests/test_install/test_state.py 2 files already formatted $ mypy headroom/install/state.py Success: no issues found in 1 source file ``` ## Real Behavior Proof - **Environment:** macOS (Darwin), Python 3.13, rebased onto current `main`. - **Exact command / steps:** `save_manifest(manifest)` then inspect the profile dir and reload. - **Observed result:** after a save the profile directory contains only `manifest.json` (no leftover `.manifest.json.*.tmp`), and `load_manifest("default")` round-trips the persisted manifest. A deliberately-corrupt `manifest.json` (`"{not json"`) makes `load_manifest` raise `ManifestError` (typed), not a raw `JSONDecodeError`. - **Not tested:** the physical-crash-mid-write window is reasoned about via `os.replace()` atomicity, not fault-injected. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes Documentation / CHANGELOG boxes are unchecked as N/A — this is an internal persistence-hardening fix with no user-facing surface. The diff is now small (atomic write + two tests); the corrupt-manifest handling itself lives in `main` via #1491. |
||
|
|
1de35e775f
|
fix(code): parse-probe tree-sitter availability in code_handler (#1231) (#1300)
## Description `_check_tree_sitter()` in `headroom/compression/handlers/code_handler.py` only verified that `tree_sitter_language_pack` could be imported. When tree-sitter core and the language pack are built against different ABIs, the import succeeds but `parser.language = get_language(...)` raises at request time, silently falling back to the generic text compressor — with no warning, while the banner still reports code-aware as enabled. #1299 fixed the same class of bug in `transforms/code_compressor.py`. This PR is the defensive follow-up tracked by #1231: it applies the same parse probe to the compression **structure handler** so both code-aware paths are consistent. Closes #1231 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Replace the import-only probe in `code_handler._check_tree_sitter()` with a real parse probe: construct a `Parser`, assign a Python language, and parse `b"x = 1\n"` — if any step fails, mark unavailable - Log a WARNING when import succeeds but parsing fails, so the downgrade is visible - Add `TestAvailabilityProbe` covering the simulated ABI mismatch (-> False) and healthy install (-> True) cases > Note: an earlier revision of this PR also touched `transforms/code_compressor.py`, but that fix landed independently via #1299. After rebasing onto current `main`, this PR is scoped to the remaining `code_handler.py` gap only. ## Testing - [x] Unit tests pass (`pytest tests/test_compression/test_code_handler.py` -> 22 passed, 8 skipped) - [x] Linting / formatting pass (`ruff check .`, `ruff format --check .`) - [x] New tests added for new functionality ### Real Behavior Proof - Setup: Windows 11 (GBK locale), Python 3.10, tree-sitter NOT installed - Before fix: `_check_tree_sitter()` returns `True` on a partial/ABI-mismatched install (import succeeds), then silently degrades to the text compressor at request time with no warning - After fix: the probe parses a trivial snippet; an ABI mismatch is caught at probe time, `_check_tree_sitter()` returns `False`, and a WARNING is logged. `TestAvailabilityProbe::test_abi_mismatch_returns_false` reproduces this with a fake Parser whose `language` setter raises. Signed-off-by: RTCartist <wangshengb@buaa.edu.cn> |
||
|
|
85804043ff
|
fix(proxy): record cache metrics for non-streaming backend paths (#1271)
## Description
Fixes missing cache metric propagation in backend-routed non-streaming
request paths.
The streaming implementations already populate cache usage metrics
(`cache_read`, `cache_write`, cache hit percentage) in `RequestOutcome`,
but the equivalent non-streaming paths were left incomplete after the P0
proxy pipeline audit:
- `anthropic.py` (Bedrock / Vertex non-streaming): extracted only
`output_tokens` from the backend usage block — `cache_read_input_tokens`
and `cache_creation_input_tokens` were never read. A comment in the code
explicitly acknowledged this: *"Cache metrics aren't extracted from the
backend response here yet — that's a follow-up."*
- `openai.py` (OpenAI backend non-streaming): extracted cache metrics
and fed them to `openai_prefix_tracker`, but never forwarded them into
`RequestOutcome`. The values were computed then silently dropped.
As a result, all non-streaming backend-routed requests reported:
```text
cache_read=0 cache_write=0 cache_hit_pct=0
```
even when upstream usage data contained valid cache counters.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- `headroom/proxy/handlers/anthropic.py`: Extract
`cache_read_input_tokens`, `cache_creation_input_tokens`, and TTL bucket
splits (`cache_write_5m_tokens`, `cache_write_1h_tokens`) from the
Bedrock non-streaming usage block. Compute `uncached_input_tokens`. Pass
all five fields to `RequestOutcome`.
- `headroom/proxy/handlers/openai.py`: Compute `uncached_input_tokens`
and forward the already-extracted `cache_read_tokens`,
`cache_write_tokens`, and `uncached_input_tokens` into `RequestOutcome`
in the backend non-streaming path.
## Testing
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
# Existing regression suite that specifically targets this omission:
# tests/test_backend_nonstreaming_cache_metrics.py
#
# Module docstring from the file explicitly documents the bug class:
#
# "The **non-streaming** backend paths were left behind — the same bug class
# on the parallel code path: anthropic.py extracted only output_tokens;
# openai.py extracted cache fields but never threaded them into RequestOutcome."
#
# Four tests cover both handlers and both the positive (cache data present)
# and zero (no cache data in upstream response) cases:
#
# test_openai_backend_nonstreaming_emits_perf_with_cache_read_and_inferred_write
# test_openai_backend_nonstreaming_perf_zeros_when_upstream_omits_cache_usage
# test_anthropic_backend_nonstreaming_emits_perf_with_cache_read_and_write
# test_anthropic_backend_nonstreaming_perf_zeros_when_upstream_omits_cache_usage
#
# Tests were written to fail on main before this fix (intentional regression tests).
# Local test execution is blocked by a missing MSVC toolchain (maturin/headroom._core
# Rust extension cannot compile on this machine without VS Build Tools).
```
## Real Behavior Proof
- **Environment:** Windows, Python 3.13, headroom main branch (commit
`
|
||
|
|
ebd23152d5
|
ci: bump actions/cache from 5 to 6 (#1413)
Bumps [actions/cache](https://github.com/actions/cache) from 5 to 6. <details> <summary>Release notes</summary> <p><em>Sourced from <a href="https://github.com/actions/cache/releases">actions/cache's releases</a>.</em></p> <blockquote> <h2>v6.0.0</h2> <h2>What's Changed</h2> <ul> <li>Update packages, migrate to ESM by <a href="https://github.com/Samirat"><code>@Samirat</code></a> in <a href="https://redirect.github.com/actions/cache/pull/1760">actions/cache#1760</a></li> </ul> <p><strong>Full Changelog</strong>: <a href="https://github.com/actions/cache/compare/v5...v6.0.0">https://github.com/actions/cache/compare/v5...v6.0.0</a></p> <h2>v5.1.0</h2> <h2>What's Changed</h2> <ul> <li>Bump <code>@actions/cache</code> to v5.1.0 - handle read-only cache access by <a href="https://github.com/jasongin"><code>@jasongin</code></a> in <a href="https://redirect.github.com/actions/cache/pull/1775">actions/cache#1775</a></li> </ul> <p><strong>Full Changelog</strong>: <a href="https://github.com/actions/cache/compare/v5...v5.1.0">https://github.com/actions/cache/compare/v5...v5.1.0</a></p> <h2>v5.0.5</h2> <h2>What's Changed</h2> <ul> <li>Update ts-http-runtime dependency by <a href="https://github.com/yacaovsnc"><code>@yacaovsnc</code></a> in <a href="https://redirect.github.com/actions/cache/pull/1747">actions/cache#1747</a></li> </ul> <p><strong>Full Changelog</strong>: <a href="https://github.com/actions/cache/compare/v5...v5.0.5">https://github.com/actions/cache/compare/v5...v5.0.5</a></p> <h2>v5.0.4</h2> <h2>What's Changed</h2> <ul> <li>Add release instructions and update maintainer docs by <a href="https://github.com/Link"><code>@Link</code></a>- in <a href="https://redirect.github.com/actions/cache/pull/1696">actions/cache#1696</a></li> <li>Potential fix for code scanning alert no. 52: Workflow does not contain permissions by <a href="https://github.com/Link"><code>@Link</code></a>- in <a href="https://redirect.github.com/actions/cache/pull/1697">actions/cache#1697</a></li> <li>Fix workflow permissions and cleanup workflow names / formatting by <a href="https://github.com/Link"><code>@Link</code></a>- in <a href="https://redirect.github.com/actions/cache/pull/1699">actions/cache#1699</a></li> <li>docs: Update examples to use the latest version by <a href="https://github.com/XZTDean"><code>@XZTDean</code></a> in <a href="https://redirect.github.com/actions/cache/pull/1690">actions/cache#1690</a></li> <li>Fix proxy integration tests by <a href="https://github.com/Link"><code>@Link</code></a>- in <a href="https://redirect.github.com/actions/cache/pull/1701">actions/cache#1701</a></li> <li>Fix cache key in examples.md for bun.lock by <a href="https://github.com/RyPeck"><code>@RyPeck</code></a> in <a href="https://redirect.github.com/actions/cache/pull/1722">actions/cache#1722</a></li> <li>Update dependencies & patch security vulnerabilities by <a href="https://github.com/Link"><code>@Link</code></a>- in <a href="https://redirect.github.com/actions/cache/pull/1738">actions/cache#1738</a></li> </ul> <h2>New Contributors</h2> <ul> <li><a href="https://github.com/XZTDean"><code>@XZTDean</code></a> made their first contribution in <a href="https://redirect.github.com/actions/cache/pull/1690">actions/cache#1690</a></li> <li><a href="https://github.com/RyPeck"><code>@RyPeck</code></a> made their first contribution in <a href="https://redirect.github.com/actions/cache/pull/1722">actions/cache#1722</a></li> </ul> <p><strong>Full Changelog</strong>: <a href="https://github.com/actions/cache/compare/v5...v5.0.4">https://github.com/actions/cache/compare/v5...v5.0.4</a></p> <h2>v5.0.3</h2> <h2>What's Changed</h2> <ul> <li>Bump <code>@actions/cache</code> to v5.0.5 (Resolves: <a href="https://github.com/actions/cache/security/dependabot/33">https://github.com/actions/cache/security/dependabot/33</a>)</li> <li>Bump <code>@actions/core</code> to v2.0.3</li> </ul> <p><strong>Full Changelog</strong>: <a href="https://github.com/actions/cache/compare/v5...v5.0.3">https://github.com/actions/cache/compare/v5...v5.0.3</a></p> <h2>v.5.0.2</h2> <h1>v5.0.2</h1> <h2>What's Changed</h2> <!-- raw HTML omitted --> </blockquote> <p>... (truncated)</p> </details> <details> <summary>Changelog</summary> <p><em>Sourced from <a href="https://github.com/actions/cache/blob/main/RELEASES.md">actions/cache's changelog</a>.</em></p> <blockquote> <h1>Releases</h1> <h2>How to prepare a release</h2> <blockquote> <p>[!NOTE] Relevant for maintainers with write access only.</p> </blockquote> <ol> <li>Switch to a new branch from <code>main</code>.</li> <li>Run <code>npm test</code> to ensure all tests are passing.</li> <li>Update the version in <a href="https://github.com/actions/cache/blob/main/package.json"><code>https://github.com/actions/cache/blob/main/package.json</code></a>.</li> <li>Run <code>npm run build</code> to update the compiled files.</li> <li>Update this <a href="https://github.com/actions/cache/blob/main/RELEASES.md"><code>https://github.com/actions/cache/blob/main/RELEASES.md</code></a> with the new version and changes in the <code>## Changelog</code> section.</li> <li>Run <code>licensed cache</code> to update the license report.</li> <li>Run <code>licensed status</code> and resolve any warnings by updating the <a href="https://github.com/actions/cache/blob/main/.licensed.yml"><code>https://github.com/actions/cache/blob/main/.licensed.yml</code></a> file with the exceptions.</li> <li>Commit your changes and push your branch upstream.</li> <li>Open a pull request against <code>main</code> and get it reviewed and merged.</li> <li>Draft a new release <a href="https://github.com/actions/cache/releases">https://github.com/actions/cache/releases</a> use the same version number used in <code>package.json</code> <ol> <li>Create a new tag with the version number.</li> <li>Auto generate release notes and update them to match the changes you made in <code>RELEASES.md</code>.</li> <li>Toggle the set as the latest release option.</li> <li>Publish the release.</li> </ol> </li> <li>Navigate to <a href="https://github.com/actions/cache/actions/workflows/release-new-action-version.yml">https://github.com/actions/cache/actions/workflows/release-new-action-version.yml</a> <ol> <li>There should be a workflow run queued with the same version number.</li> <li>Approve the run to publish the new version and update the major tags for this action.</li> </ol> </li> </ol> <h2>Changelog</h2> <h3>6.1.0</h3> <ul> <li>Bump <code>@actions/cache</code> to v6.1.0 to pick up <a href="https://redirect.github.com/actions/toolkit/pull/2435">actions/toolkit#2435 Handle cache write error due to read-only token</a></li> <li>Switch redundant "Cache save failed" warning to debug log in save-only</li> </ul> <h3>6.0.0</h3> <ul> <li>Updated <code>@actions/cache</code> to ^6.0.1, <code>@actions/core</code> to ^3.0.1, <code>@actions/exec</code> to ^3.0.0, <code>@actions/io</code> to ^3.0.2</li> <li>Migrated to ESM module system</li> <li>Upgraded Jest to v30 and test infrastructure to be ESM compatible</li> </ul> <h3>5.0.4</h3> <ul> <li>Bump <code>minimatch</code> to v3.1.5 (fixes ReDoS via globstar patterns)</li> <li>Bump <code>undici</code> to v6.24.1 (WebSocket decompression bomb protection, header validation fixes)</li> <li>Bump <code>fast-xml-parser</code> to v5.5.6</li> </ul> <h3>5.0.3</h3> <ul> <li>Bump <code>@actions/cache</code> to v5.0.5 (Resolves: <a href="https://github.com/actions/cache/security/dependabot/33">https://github.com/actions/cache/security/dependabot/33</a>)</li> <li>Bump <code>@actions/core</code> to v2.0.3</li> </ul> <h3>5.0.2</h3> <!-- raw HTML omitted --> </blockquote> <p>... (truncated)</p> </details> <details> <summary>Commits</summary> <ul> <li><a href=" |
||
|
|
adf8fed9bd
|
fix(code): stop TS export duplication + comment displacement (#1906)
## Description `CodeAwareCompressor` (AST-based code compression, `headroom/transforms/code_compressor.py`) had two bugs in its structure-reassembly path, found while investigating a reported Go brace-duplication issue (the Go bug itself — `statement_list` row-range swallowing a block's closing brace — was already fixed on `main` in #1668; this PR fixes what was *actually* still broken): 1. **TS/JS `export` keyword duplication.** `export function foo() {}` / `export class Foo {}` compressed to `export export function foo() {}` — invalid syntax, silently discarded by `_verify_syntax`'s fallback (the caller never sees an error, compression just quietly no-ops). Root cause: `_compress_function_ast` / `_compress_class_ast` slice a node's source by **line**, not by byte offset, deliberately — to preserve leading indentation for definitions nested inside classes. But when a node shares its *first* line with a preceding sibling (the `export` keyword is a sibling of the function inside tree-sitter's `export_statement` node, not part of the function node itself), that line-based slice pulled the sibling's text in too. The `export_statement` handler then re-prepended the same `export` text on top, producing the duplicate. 2. **Doc-comment displacement (all languages).** A `/** ... */` or `//` doc comment directly above a top-level function/class/type got detached from its declaration during AST extraction and re-emitted in one cluster at the very end of the compressed output, instead of staying attached to what it documents. Root cause: doc comments are top-level *siblings* of the declaration they document, not children of it — the extractor didn't attach them to anything, so they fell through to a "leftover top-level code" bucket that gets flushed as a single block after all functions. Also tightens `test_actual_go_compression`, which — per its own comment — was written to *tolerate* the Go bug (`compression_ratio may be 1.0 if compression produces invalid syntax`) rather than catch it. Since the underlying Go bug is already fixed on `main`, this now asserts real compression (`compression_ratio < 1.0`), matching its JS/Python siblings. Closes #1905 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made Two commits: the fix itself, then the tests that prove it — bisectable independently, both pass the full suite on their own. **Commit 1 — `fix(code):`** - `headroom/transforms/code_compressor.py`: add `_get_node_lines()` — line-based node slicing that still preserves indentation, but trims a preceding sibling's text from the first line when that prefix isn't pure whitespace (i.e. an `export` keyword sharing the line), so callers that re-add the sibling text themselves don't get a duplicate; used by `_compress_function_ast` and `_compress_class_ast`. - `headroom/transforms/code_compressor.py`: add `_get_leading_comment_text()` — walks a node's `prev_sibling` chain to collect contiguous doc-comment nodes immediately above it (no blank line in between) and returns them for the caller to prepend, also marking their byte ranges as captured so they aren't independently swept into the leftover top-level-code bucket; wired into every capture branch in `_extract_structure` (package, import, export statement, decorator, function, class, type). - `CHANGELOG.md`: added an entry under `### Fixed`. **Commit 2 — `test(code):`** - `tests/test_transforms/test_code_compressor.py`: `test_actual_go_compression` now asserts `compression_ratio < 1.0` instead of tolerating a 1.0 fallback. - `tests/test_code_aware_brace_comment_regressions.py` (new): 4 regression tests — TS `export` not duplicated + valid syntax, TS doc comments stay attached, Go doc comments stay attached, and a real-TS-compression parity test matching the existing JS/Python/Go "actual compression" tests. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ ruff check headroom/transforms/code_compressor.py tests/test_transforms/test_code_compressor.py tests/test_code_aware_brace_comment_regressions.py All checks passed! $ ruff format --check headroom/transforms/code_compressor.py tests/test_transforms/test_code_compressor.py tests/test_code_aware_brace_comment_regressions.py 3 files already formatted $ pytest tests/test_transforms/test_code_compressor.py tests/test_code_aware_regressions.py tests/test_code_aware_brace_comment_regressions.py -q 83 passed in 6.23s $ pytest -q # full suite 7912 passed, 5 failed, 442 skipped in 417.43s (0:06:57) # The 5 failures are pre-existing and unrelated: confirmed to fail identically # with this PR's changes stashed out (clean upstream/main checkout). # - test_wrap_marker_is_stale_when_pid_reused (PID-reuse detection, env-specific) # - test_read_cached_oauth_token_falls_back_to_gh_cli (leaks real local `gh` credentials) # - test_rtk_reader_returns_none_on_nonzero_exit / test_lean_ctx_reader_returns_none_on_failure_and_logs # (pass in isolation; fail only in full-suite order — pre-existing test-pollution, unrelated to code_compressor.py) # - test_parser_usable_in_thread_pool (test itself passes a str to parser.parse(), # which tree-sitter's binding has always required as bytes — a pre-existing test # bug unrelated to this change; separate fix in progress on another branch) $ mypy headroom Success: no issues found in 408 source files ``` ## Real Behavior Proof - Environment: macOS (Darwin 24.6.0), Python 3.14.5, headroom-ai dev checkout built via `uv sync --extra dev` + `maturin develop -m crates/headroom-py/Cargo.toml` (real `headroom._core` build, not mocked), `tree-sitter==0.25.2` / `tree-sitter-language-pack` per the pinned `[code]` extra. - Exact command / steps: ran `CodeAwareCompressor(CodeCompressorConfig(min_tokens_for_compression=10, enable_ccr=False)).compress(open("sdk/typescript/src/client.ts").read(), language="typescript")` identically against `git stash`-ed (pre-fix) and current (post-fix) trees; full snippet and additional samples below. - Observed result: `client.ts` (real 20KB SDK file in this repo) went from `compression_ratio=1.0` with a silent fallback (`export export class HeadroomClient` in the raw AST attempt, invalid syntax) to `compression_ratio=0.942`, `syntax_valid=True` — real compression, no duplication; full before/after table below. - Not tested: real-world repos beyond this repo's own SDK sample and the bundled benchmark fixture — broader corpus testing may follow as a comment on this PR. **Exact command, full snippet:** ```python from headroom.transforms.code_compressor import CodeAwareCompressor, CodeCompressorConfig compressor = CodeAwareCompressor(CodeCompressorConfig(min_tokens_for_compression=10, enable_ccr=False)) with open("sdk/typescript/src/client.ts") as f: code = f.read() result = compressor.compress(code, language="typescript") ``` **Observed result, before vs. after, real code:** | Sample | Before (main) | After (this fix) | |---|---|---| | `sdk/typescript/src/client.ts` (real 20KB SDK file, this repo) | `compression_ratio=1.0`, silent fallback — `export export class HeadroomClient` in the raw AST attempt, invalid syntax | `compression_ratio=0.942`, `syntax_valid=True` — real compression, no duplication | | TS fixture exercising both bugs (exported fn/class + doc comments) | `compression_ratio=1.0`, silent fallback | `compression_ratio=0.993`, `syntax_valid=True` | | `middleware/ratelimit.go` (bundled benchmark sample) | `compression_ratio=0.862`, `syntax_valid=True` — unaffected (Go bug already fixed on `main` by #1668) | `compression_ratio=0.862`, `syntax_valid=True` — unchanged, confirms no regression | | `generate_go_code(3)` (existing test fixture) | `compression_ratio=0.498` | `compression_ratio=0.498` — unchanged, confirms no regression | On code shaped to actually exercise elision (function bodies long enough to exceed `max_body_lines=5`), TypeScript compresses in line with other languages once the correctness bug stops blocking it entirely: | Language | Compression savings (synthetic fixture, ~10-line function bodies) | |---|---| | Python | 64.4% | | Go | 52.3% | | TypeScript | 49.0% | | JavaScript | 42.8% | (`client.ts`'s real-world 5.8% savings is lower than the synthetic TypeScript number above because most of its methods are ≤5 lines — under the elision threshold regardless of language — not because of a language-specific limitation.) ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Additional Notes The Go brace-duplication bug that motivated this investigation was already fixed on `main` (#1668, merged before this branch was based) — confirmed via the minimal repro and `ratelimit.go`, both compress cleanly with no duplicated braces. This PR fixes what was still actually broken: the TS/JS `export`-duplication bug and the doc-comment displacement bug (both present across languages), found empirically while verifying the original bug report against the current `main`. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: JD Davis <mxjerrett@gmail.com> |
||
|
|
e3b45e402b
|
fix(learn): handle Windows UTF-8, drive-letter paths, and CLI shim fallback (#1895)
## Description `headroom learn --verbosity` is broken on Windows in three related ways: - Transcript/profile reads can use the platform default codec, so non-ASCII content can raise `UnicodeDecodeError` and collapse learning signals to empty output. - `--project <path>` can miss real Claude project directories because Windows profile junctions can raise `PermissionError` during directory walks, and escaped Claude project folder names cannot always distinguish `vibe-remote` from `vibe\remote`. - `headroom learn --agent codex` can fail with `` `claude` not found in PATH `` even when the npm-installed CLI exists, because Windows `.cmd` shims require `PATHEXT` resolution. Refs https://github.com/headroomlabs-ai/headroom/issues/1624 for the Windows learn failures. The dashboard-hint UX and third-party-provider-auth items in that issue are unrelated and out of scope for this PR. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/learn/verbosity.py`: read and write verbosity transcripts/profiles with `encoding="utf-8"` so non-ASCII content works regardless of the Windows locale codec. - `headroom/learn/plugins/claude.py`: skip inaccessible siblings one entry at a time during greedy project path decoding, so one Windows junction no longer hides valid project directories. - `headroom/learn/plugins/claude.py`: prefer a valid `cwd` found in Claude session JSONL when discovering project paths, which resolves ambiguous escaped folder names such as `vibe-remote` versus `vibe\remote`. - `headroom/learn/analyzer.py`: resolve Windows CLI shim paths through `shutil.which()` after `FileNotFoundError`, then retry once for streaming and non-streaming CLI calls. - `CHANGELOG.md`: document the Windows learn fixes under `Unreleased`. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_verbosity_learn.py::TestWindowsEncoding tests/test_learn/test_scanner.py::TestGreedyPathDecode::test_permission_denied_sibling_does_not_abort_the_walk tests/test_learn/test_analyzer.py::TestWindowsCliShimFallback tests/test_learn/test_scanner.py::TestDecodeProjectPath::test_discover_project_prefers_session_cwd_over_ambiguous_folder_name -q`) - [x] Linting passes (`uv run ruff check headroom/learn/plugins/claude.py tests/test_learn/test_scanner.py`) - [x] Formatting passes (`uv run ruff format --check headroom/learn/plugins/claude.py tests/test_learn/test_scanner.py`) - [ ] Type checking passes (`uv run mypy headroom`) not run; no new public type surface - [x] New tests added for the Windows `cwd` disambiguation regression - [x] Manual testing performed ### Test Output ```text uv run ruff format headroom/learn/plugins/claude.py 1 file reformatted uv run ruff check headroom/learn/plugins/claude.py tests/test_learn/test_scanner.py All checks passed! uv run ruff format --check headroom/learn/plugins/claude.py tests/test_learn/test_scanner.py 2 files already formatted uv run pytest tests/test_verbosity_learn.py::TestWindowsEncoding tests/test_learn/test_scanner.py::TestGreedyPathDecode::test_permission_denied_sibling_does_not_abort_the_walk tests/test_learn/test_analyzer.py::TestWindowsCliShimFallback tests/test_learn/test_scanner.py::TestDecodeProjectPath::test_discover_project_prefers_session_cwd_over_ambiguous_folder_name -q 9 passed in 0.25s ``` CI on current head ` |