mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
2630 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1fc5e3d4da
|
docs(proxy): correct --code-aware default to disabled (#1710)
## Description
The wiki proxy page (`wiki/proxy.md`, which feeds the published docs
site) claimed `--code-aware` defaults to **true**. The CLI deliberately
defaults it to **disabled**: `headroom/cli/proxy.py` resolves the paired
flag to off unless `--code-aware` is passed or
`HEADROOM_CODE_AWARE_ENABLED` is truthy, and the Click help text plus
`docs/content/docs/proxy.mdx` and `wiki/cli.md` already document it as
disabled. This PR aligns the one remaining stale table row and collapses
the self-contradictory separate `--no-code-aware` row into a single
paired-flag entry, matching the style used in
`docs/content/docs/proxy.mdx`.
Fixes #1700
## Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [x] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `wiki/proxy.md`: replaced the two flag-table rows claiming
`--code-aware` default `true` / `--no-code-aware` default `false` with
one `--code-aware` / `--no-code-aware` row documenting the actual
default (`disabled`), the `headroom-ai[code]` requirement, and the
`HEADROOM_CODE_AWARE_ENABLED=1` env opt-in.
## Testing
- [ ] Unit tests pass (`pytest`)
- [ ] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [ ] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
$ python -c "
from click.testing import CliRunner
from headroom.cli.proxy import proxy
r = CliRunner().invoke(proxy, ['--help'])
print([l.strip() for l in r.output.splitlines() if 'code-aware' in l][0])
"
--code-aware / --no-code-aware Enable/disable AST-based code compression.
$ grep -n "code-aware" wiki/proxy.md
77:| `--code-aware` / `--no-code-aware` | disabled | Enable or disable AST-based code compression. Requires `headroom-ai[code]` (env: HEADROOM_CODE_AWARE_ENABLED=1 to enable) |
```
## Real Behavior Proof
- Environment: Windows 11, local checkout at `upstream/main` (
|
||
|
|
9fbd47ba6b
|
fix(proxy): strip Codex lite header on the HTTP /responses path (#1663)
## Description The WebSocket `/responses` handler already drops `X-OpenAI-Internal-Codex-Responses-Lite` before forwarding upstream (#1543) — OpenAI rejects newer Codex models (gpt-5.5 / gpt-5.4 / gpt-5.4-mini) when this client-only header leaks. The **HTTP POST `/responses`** handler (`handle_openai_responses`), however, forwards request headers verbatim after `_strip_internal_headers` (which removes only `x-headroom-*`), so on the HTTP path the lite header still reaches `chatgpt.com/backend-api/codex/responses`. This closes that remaining un-stripped path so both `/responses` transports behave identically. Closes # <!-- no tracking issue; found during a live support investigation --> ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/proxy/handlers/openai.py`: in `handle_openai_responses` (HTTP POST path), immediately after `headers = _strip_internal_headers(headers)`, drop any header whose lowercased name equals `_CODEX_RESPONSES_LITE_HEADER` — mirroring the existing WS-handler filter. No new imports (the constant is module-level); the WS path is unchanged. - `tests/test_openai_codex_routing.py`: add `test_handle_openai_responses_strips_codex_lite_header_upstream`, which pushes the lite header plus an adjacent header through the HTTP POST handler and asserts the lite header is dropped upstream while the adjacent header survives. ## Testing - [x] Unit tests pass (`pytest`) — directly-relevant files (see output) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed — no live upstream traffic (see Real Behavior Proof) ### Test Output ```text $ uv run --extra dev pytest tests/test_openai_codex_routing.py tests/test_openai_codex_ws_lifecycle.py -q 39 passed in 1.13s $ uv run ruff check . All checks passed! $ uv run --extra dev mypy headroom Success: no issues found in 404 source files ``` ## Real Behavior Proof - Environment: local `uv` venv (Python 3.10), no live provider required. - Exact command / steps: `uv run --extra dev pytest tests/test_openai_codex_routing.py::test_handle_openai_responses_strips_codex_lite_header_upstream tests/test_openai_codex_ws_lifecycle.py::test_ws_codex_responses_lite_header_is_not_forwarded_upstream` - Observed result: the new test drives a ChatGPT-auth HTTP POST `/responses` request carrying `X-OpenAI-Internal-Codex-Responses-Lite: true` and an adjacent `X-OpenAI-Debug: keep-me`; the captured upstream headers contain the adjacent header but not the lite header. The WS regression test still passes. - Not tested: live Codex traffic against OpenAI with real credentials. (Separately: for a WebSocket-only ChatGPT-auth client the lite signal is not carried as an HTTP header on the handshake — that case is out of scope here.) ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation — N/A (no doc-facing behavior change) - [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 if applicable — N/A (changelog is generated from conventional commits; commit is `fix(proxy): …`) ## Screenshots (if applicable) N/A — backend header-handling change. ## Additional Notes - Scope of checks: `pytest` was run on the two directly-relevant files (`test_openai_codex_routing.py`, `test_openai_codex_ws_lifecycle.py`), not the entire suite; `ruff check .` and `mypy headroom` were run repo-/package-wide. - Complements #1543 (WS path) by closing the HTTP POST path; it is the minimal mirror of that filter. - `Closes #` intentionally blank: found during a support investigation with no tracking issue. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: JD Davis <mxjerrett@gmail.com> |
||
|
|
646e705514
|
fix(dashboard): align token savings headline denominator (#1653)
## Description
Fixes a dashboard denominator mismatch in the Token Savings card.
The headline was showing the active attempted-token ratio, while the
same card's sublabel reports total-wire savings. This made sessions show
values like about 17% in the headline and about 1.2% in the total-wire
line for the same saved-token count.
This changes the headline to use `stats.tokens.savings_percent`, with
`proxy_savings_percent` as a fallback, so the headline and card copy use
the same denominator.
Closes #
## Type of Change
- [x] Bug fix (non-breaking change fixes issue)
- [ ] New feature (non-breaking change adds functionality)
- [ ] Breaking change (fix or feature cause existing functionality
change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- Updated `headlineSavingsPercent` to prefer the total-wire
`savings_percent` metric.
- Updated the headline tooltip to say `Of total wire input tokens`.
- Added a focused dashboard regression test that prevents the headline
getter from using `active_savings_percent` or `proxy_attempted_tokens`.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added new functionality
- [x] Manual testing performed
### Test Output
```text
$ python3 - <<'PY'
from pathlib import Path
html = Path('headroom/dashboard/templates/dashboard.html').read_text(encoding='utf-8')
assert 'stats.tokens?.savings_percent' in html
assert 'Of total wire input tokens' in html
start = html.index('get headlineSavingsPercent()')
end = html.index('get headlineSavingsTitle()', start)
headline = html[start:end]
assert 'active_savings_percent' not in headline
assert 'proxy_attempted_tokens' not in headline
print('dashboard headline denominator check passed')
PY
dashboard headline denominator check passed
$ uv run --extra dev pytest tests/test_dashboard_token_savings.py
============================= test session starts ==============================
platform darwin -- Python 3.13.3, pytest-9.0.3, pluggy-1.6.0
collected 1 item
tests/test_dashboard_token_savings.py::test_token_savings_headline_uses_total_wire_denominator PASSED [100%]
============================== 1 passed in 0.10s ===============================
$ uv run --extra dev ruff check tests/test_dashboard_token_savings.py
All checks passed!
```
## Real Behavior Proof
- Environment: Local Headroom dashboard served from the installed 0.28.0
package on macOS, proxy on `127.0.0.1:8788`, checked against the same
dashboard template logic patched in this PR.
- Exact command / steps: Queried local `/stats?cached=1`, compared
`tokens.active_savings_percent` with `tokens.savings_percent`, patched
the dashboard template locally, then refreshed `/dashboard` and
confirmed the served `headlineSavingsPercent` getter reads
`tokens.savings_percent`.
- Observed result: Local stats showed `active_savings_percent` around
16.78 while `tokens.savings_percent`, `tokens.proxy_savings_percent`,
and agent total savings were around 1.24. Before the patch, the
dashboard headline used the 16.78 active value even though the card text
said total wire. After the local template patch, the served dashboard
getter uses the 1.24 total-wire value.
- Not tested: Full cross-browser visual regression; this PR only changes
the Alpine getter denominator and adds a source-level regression test.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows project's style guidelines
- [x] I performed self-review my code
- [x] I commented my code, particularly in hard-to-understand areas
- [ ] I made corresponding changes documentation
- [x] My changes generate no new warnings
- [x] I added tests prove fix is effective or feature works
- [x] New and existing unit tests pass locally my changes
- [ ] I updated CHANGELOG.md if applicable
## Screenshots (if applicable)
N/A.
## Additional Notes
- Documentation and CHANGELOG are N/A for this narrow dashboard bug fix.
- CI is green and the PR is ready for review.
|
||
|
|
5fe4e7b195
|
fix(proxy): expose persistent savings metrics (#1647)
## Description Closes #1616 Expose the proxy's durable `persistent_savings.lifetime` totals through `/metrics` so Prometheus/Grafana scrapes can read the same lifetime savings counters already visible in `/stats` and `/stats-history`. The existing runtime counters remain process-local: `headroom_tokens_saved_total` still resets with the proxy process. New `headroom_persistent_savings_*` counters are sourced from the `SavingsTracker` lifetime block. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Export durable lifetime savings counters from `PrometheusMetrics.export()`: - `headroom_persistent_savings_requests_total` - `headroom_persistent_savings_tokens_saved_total` - `headroom_persistent_savings_input_tokens_total` - `headroom_persistent_savings_input_cost_usd_total` - `headroom_persistent_savings_compression_savings_usd_total` - Add a restart regression proving runtime counters reset while persistent savings counters remain available from the same savings file. - Extend the existing `/stats-history` restart test with `/metrics` endpoint assertions. - Update metrics docs to distinguish runtime `headroom_tokens_saved_total` from lifetime `headroom_persistent_savings_tokens_saved_total`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text Local focused checks: $ rtk /usr/bin/env HEADROOM_REQUIRE_RUST_CORE=false PYTHONPATH=. /tmp/headroom-1616-testenv/bin/python -m pytest tests/test_proxy_cache_ttl_metrics.py::test_prometheus_metrics_export_includes_extended_fields tests/test_proxy_cache_ttl_metrics.py::test_prometheus_export_includes_persistent_savings_after_restart 2 passed, 1 warning in 0.19s $ rtk /tmp/headroom-1616-testenv/bin/python -m ruff check headroom/proxy/prometheus_metrics.py tests/test_proxy_cache_ttl_metrics.py tests/test_proxy_savings_history.py All checks passed! $ rtk /tmp/headroom-1616-testenv/bin/python -m ruff format --check headroom/proxy/prometheus_metrics.py tests/test_proxy_cache_ttl_metrics.py tests/test_proxy_savings_history.py 3 files already formatted $ rtk git diff --check # no output GitHub Actions: All non-skipped checks passed on PR #1647, including lint, build, build-wheel, test (1-4), test-agno, test-extras, test-dashboard-ui, docker-native-e2e, docker-init-e2e, docker-wrap-e2e, security checks, merge-conflicts, and PR governance. ``` ## Real Behavior Proof - Environment: local macOS worktree, throwaway Python env at `/tmp/headroom-1616-testenv`, `PYTHONPATH=.`. - Exact command / steps: recorded a compressed request through `PrometheusMetrics.record_request()`, re-created `PrometheusMetrics` with the same `SavingsTracker` path, then exported `/metrics` text. - Observed result: runtime counters are zero after re-creating the metrics object, while `headroom_persistent_savings_tokens_saved_total` and related persistent counters still expose the durable lifetime values. - Not tested: full server-level pytest locally, because the local build is blocked by the known native `headroom._core`/`esaxx-rs` build issue (`fatal error: 'cstdint' file not found`). The app-level `/metrics` assertions passed in 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 - [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 This intentionally does not rename or hydrate the existing runtime `headroom_tokens_saved_total` counter. That preserves the current process-local semantics and gives external dashboards a dedicated lifetime series that maps directly to `/stats.persistent_savings`. `mypy headroom` was not run as a standalone local command. CHANGELOG is N/A for this narrow proxy metrics fix unless maintainers prefer an entry. |
||
|
|
c600e314b3
|
fix(learn): honor CLAUDE_CONFIG_DIR when locating Claude logs and memory (#1642)
## Description `headroom learn` ignored `CLAUDE_CONFIG_DIR`. `ClaudeCodePlugin.__init__` resolved the Claude config directory as `~/.claude`, and the memory writer wrote the global `CLAUDE.md` to `~/.claude/CLAUDE.md`. A user who relocates their Claude config with that env var had `learn` scan the wrong directory and detect no projects. Other parts of the codebase already honor the override (`subscription/client.py`, `subscription/session_tracking.py`, `mcp_registry/claude.py`); the `learn` path was the outlier. Closes #1630 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Add `claude_config_dir()` to `headroom/learn/_shared.py` — returns `$CLAUDE_CONFIG_DIR` when set, else `~/.claude` (via `Path.home()`, matching the existing override elsewhere). - `ClaudeCodePlugin.__init__` now defaults `claude_dir` to `claude_config_dir()` instead of a hardcoded `~/.claude` (an explicit `claude_dir=` argument still wins). - `ClaudeCodeWriter._resolve_context_path` writes the home-directory global memory to `claude_config_dir() / "CLAUDE.md"` instead of `~/.claude/CLAUDE.md`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_learn/test_claude_config_dir.py tests/test_learn/test_writer.py -q 35 passed, 1 warning in 0.18s $ ruff check headroom/learn/ tests/test_learn/test_claude_config_dir.py All checks passed! $ mypy headroom/learn/_shared.py headroom/learn/plugins/claude.py headroom/learn/writer.py Success: no issues found in 3 source files ``` ## Real Behavior Proof - Environment: macOS (arm64), Python 3.14 venv, editable install of this branch. - Exact command / steps: ran `python -c "from headroom.learn.plugins.claude import ClaudeCodePlugin; print(ClaudeCodePlugin().projects_dir)"` with and without `CLAUDE_CONFIG_DIR=/tmp/altclaude` set, then `pytest tests/test_learn/ tests/test_cli_learn.py`. - Observed result: default prints `/Users/<me>/.claude/projects`; with `CLAUDE_CONFIG_DIR=/tmp/altclaude` it prints `/tmp/altclaude/projects` (before this change the second still printed `~/.claude/projects`). Test suite: 226 passed, 3 skipped. New regression tests cover the plugin scan dir, explicit-arg precedence, and the writer's home-memory path. - Not tested: end-to-end `headroom learn` against a real relocated log tree with live Claude Code transcripts — verified at the plugin/writer resolution layer plus the existing scanner suite. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Additional Notes The identical hardcode also exists at `headroom/cli/mcp.py:21` (`CLAUDE_CONFIG_DIR = Path.home() / ".claude"`), but that is a separate command outside this issue's scope, so I left it for a follow-up to keep this PR to one issue. --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
d5bf98df31
|
fix(cli): stop advertising unwired compression tuning env vars in banner (#1634)
## Description The startup banner's `Performance Tuning` section reads `HEADROOM_COMPRESSION_STABLE_AFTER_TURN` and `HEADROOM_STALE_READ_COMPRESS_AFTER_TURNS` and prints them as active tuning knobs. Neither is consumed anywhere else — not in the Python compression path, and not in the packaged native code (verified by scanning the shipped extension modules; `headroom` ships no env-reading native lib and Kompress runs via ONNX). Setting either var changes the banner but has zero effect on behavior, which actively misleads operators trying to tune compression 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 - Removed the two unwired env vars from the banner's `Performance Tuning` section, including the fallback hint that told users to "set" them. - Kept the embedding-sidecar line (`HEADROOM_EMBEDDING_SERVER_SOCKET`), which is a real, consumed setting; the section now renders only when a real tuning value is active and is empty otherwise. - Added an Unreleased → Fixed CHANGELOG entry. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [ ] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ pytest tests/test_cli_proxy_improvements.py -q 48 passed in 5.04s $ ruff check headroom/cli/proxy.py && ruff format --check headroom/cli/proxy.py All checks passed! / 1 file already formatted $ mypy headroom/cli/proxy.py Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: macOS, Python 3.10.18, branch off upstream/main @ 0.28.0 - Exact command / steps: grepped the entire package + shipped `.so`/native modules for both env var names; only the banner referenced them. - Observed result: no consumer exists for either var; banner was the sole reader. After the change the banner no longer claims they do anything. - Not tested: N/A — this removes a false claim; no behavior to exercise beyond the existing CLI-invocation tests, which pass. ## 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 - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Additional Notes No new test added: the fix deletes dead/misleading output rather than adding logic; a banner-string assertion would be brittle. If you'd rather *implement* these knobs than remove them (i.e. actually gate Kompress on prefix-stable-after-N-turns), I'm happy to open a separate feature PR instead — but as shipped they are pure no-ops, so this stops the banner from lying today. N/A: "new tests added", "manual testing". 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
814ffa36a4
|
fix(proxy): wire --compression-max-workers / HEADROOM_COMPRESSION_MAX_WORKERS (#1632)
## Description `ProxyConfig.compression_max_workers` is documented as settable via `--compression-max-workers` / `HEADROOM_COMPRESSION_MAX_WORKERS` and is consumed by `HeadroomProxy.__init__` to bound the dedicated compression threadpool. But the proxy CLI never defined the option and never passed the value into `ProxyConfig`, so the field was permanently `None` and always resolved to the `min(32, (cpu_count or 1) * 4)` default. Neither the flag nor the env var had any effect. This matters under concurrent sessions: the compression pool runs CPU-bound Kompress work that releases the GIL, so `cpu*4` oversubscribes cores and there was no way to cap it despite the docs promising one. 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 - Added the `--compression-max-workers` click option (with `envvar="HEADROOM_COMPRESSION_MAX_WORKERS"`) to the `proxy` command, mirroring the existing `--anthropic-pre-upstream-concurrency` wiring. - Added the `compression_max_workers` parameter to the `proxy()` signature and passed it into the `ProxyConfig(...)` construction. - No change to `HeadroomProxy` — it already reads `config.compression_max_workers` and clamps `< 1` to 1. ## 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 $ pytest tests/test_cli_proxy_improvements.py::TestCompressionMaxWorkers -q 3 passed in 1.50s $ pytest tests/test_cli_proxy_improvements.py -q 48 passed in 5.04s $ ruff check headroom/cli/proxy.py tests/test_cli_proxy_improvements.py All checks passed! $ mypy headroom/cli/proxy.py Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: macOS, Python 3.10.18, branch off upstream/main @ 0.28.0 - Exact command / steps: new tests assert the value reaches `ProxyConfig` via both `--compression-max-workers 3` (flag) and `HEADROOM_COMPRESSION_MAX_WORKERS=5` (env), and that it stays `None` when unset. - Observed result: flag -> `config.compression_max_workers == 3`; env -> `== 5`; unset -> `is None`. - Not tested: end-to-end proxy run under real concurrent load (the pool-sizing effect itself is already covered by existing `test_proxy_compression_executor.py`). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes CHANGELOG left untouched: this makes existing documented behavior actually work rather than adding new surface. N/A: manual testing (covered by unit tests + existing executor tests). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
4bf7f92417
|
fix(claude): surface Remote Control proxy incompatibility (#1610)
## Description Claude Code hides Remote Control when it sees a custom `ANTHROPIC_BASE_URL`, so `headroom wrap claude` can make the menu disappear even though normal API requests still route through Headroom. The reported proxy logs show no Remote Control registration, session bootstrap, or device-attestation request at all, which means the decision happens inside Claude before Headroom can forward anything. This change makes that client-side incompatibility explicit in Headroom's Claude launch flow, `headroom doctor`, and troubleshooting docs. API proxying and the existing `ENABLE_TOOL_SEARCH` compatibility shim stay unchanged; users who need Remote Control get a direct instruction to launch Claude without the Headroom proxy for that session. Closes #1601 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Add a Claude-specific helper and warning text for the Remote Control custom-base incompatibility. - Surface that warning from `headroom wrap claude` when Claude is launched through `ANTHROPIC_BASE_URL`. - Add a separate `headroom doctor` warning for Claude Remote Control availability, while keeping Claude API-routing status independent. - Document the limitation and workaround next to the existing Claude custom-endpoint troubleshooting guidance. - Add focused regression tests for gated and non-gated Claude routing states, plus preservation coverage for `ENABLE_TOOL_SEARCH`. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_issue_1601_remote_control_gate.py tests/test_cli_doctor.py tests/test_cli/test_wrap_claude_vertex_proxy_env.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_issue_746_tool_search.py tests/test_cli/test_init_enable_tool_search.py -q`) - [x] Linting passes (`uv run ruff check headroom/cli/wrap.py tests/test_cli_doctor.py tests/test_cli/test_wrap_claude_vertex_proxy_env.py`) - [x] Formatting passes (`uv run ruff format --check headroom/cli/wrap.py tests/test_cli_doctor.py tests/test_cli/test_wrap_claude_vertex_proxy_env.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for the bugfix - [ ] Manual testing performed ### Test Output ```text rtk uv run pytest tests/test_issue_1601_remote_control_gate.py tests/test_cli_doctor.py tests/test_cli/test_wrap_claude_vertex_proxy_env.py -q ============================= test session starts ============================= collected 62 items 62 passed, 1 warning rtk uv run pytest tests/test_issue_746_tool_search.py tests/test_cli/test_init_enable_tool_search.py -q ============================= test session starts ============================= collected 33 items 33 passed, 1 warning rtk uv run ruff check headroom/cli/wrap.py tests/test_cli_doctor.py tests/test_cli/test_wrap_claude_vertex_proxy_env.py All checks passed! rtk uv run ruff format --check headroom/cli/wrap.py tests/test_cli_doctor.py tests/test_cli/test_wrap_claude_vertex_proxy_env.py 3 files already formatted ``` ## Real Behavior Proof - Environment: Windows, Python via `uv`, focused Claude CLI and doctor tests. - Exact command / steps: with Claude settings or shell environment containing `ANTHROPIC_BASE_URL=http://127.0.0.1:8787`, run the focused helper and doctor tests, then run the existing `ENABLE_TOOL_SEARCH` preservation tests. - Observed result: Headroom surfaces a Claude Remote Control warning for custom `ANTHROPIC_BASE_URL`, while Claude API routing and `ENABLE_TOOL_SEARCH` behavior stay intact. - Not tested: live Claude Remote Control UI automation. The issue evidence says Claude hides the menu before any request reaches Headroom, so this PR proves Headroom's launch, diagnostics, and docs behavior. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [ ] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes `CHANGELOG.md` stays untouched because this repo's release pipeline generates changelog entries from conventional commits. This is a visibility fix, not a proxy transport restore. The issue evidence shows Claude never sends a Remote Control request while the custom-base gate is active, so the surviving slice is launch-time warning, doctor warning, and documentation. PR `#1600` is adjacent and non-blocking because `#1601` reproduces from process-env `ANTHROPIC_BASE_URL` alone. This intentionally changes `headroom doctor` for fully routed Claude sessions from an all-pass result to one warnings-only result, because the proxied Claude setup is operational for API traffic but still incompatible with Remote Control. |
||
|
|
816cb85fa8
|
fix(install): close parent log fd in start_detached_agent (#1576)
## Description
`start_detached_agent()` opens the agent log file and hands it to
`subprocess.Popen` as `stdout`/`stderr`, then returns the process
**without
closing the parent's copy of the file descriptor**. The child inherits
the fd
and writes to it, but the parent keeps its own copy open forever.
The result: every `headroom install start` leaks one file descriptor in
the
parent, and the leaked handle pins the log file open so it can't be
rotated.
On a tight `ulimit` or inside a container, repeated starts can walk
straight
into the fd limit.
```python
# headroom/install/runtime.py — before
log_file = open(log_file_path, "a", encoding="utf-8", errors="replace")
kwargs = {"stdout": log_file, "stderr": log_file, ...}
return subprocess.Popen(command, **kwargs) # parent's log_file never closed
```
The fix closes the parent's copy in a `try/finally` right after `Popen`
returns:
```python
try:
proc = subprocess.Popen(command, **kwargs)
finally:
# The child has inherited the log file descriptor, so the parent's
# copy is dead weight. Closing it (even when Popen raises) avoids
# leaking one fd per `headroom install start` and lets the log file
# be rotated.
log_file.close()
return proc
```
The `finally` is deliberate: it also covers the case where `Popen`
itself
raises (bad executable, fork failure), which would otherwise leak the
just-opened handle. This matches the `with open(...)` pattern already
used by
`run_foreground()` a few lines above.
Closes #1554
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- `headroom/install/runtime.py`: close the parent's log file descriptor
in a `try/finally` after `subprocess.Popen` in `start_detached_agent()`,
so it is released on the normal path and when `Popen` raises.
- `tests/test_install/test_runtime.py`: add two regression tests — one
for a normal start, one for `Popen` raising — asserting the parent's log
handle is closed afterwards.
- `CHANGELOG.md`: Bug Fixes entry under Unreleased.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
Before the fix (`runtime.py` reverted to its parent commit, new tests
kept) —
the assertion inspects the *actual* log file handle and finds it still
open:
```text
E AssertionError: assert False is True
E + where False = <_io.TextIOWrapper name='...\deploy\demo\runner.log' mode='a' encoding='utf-8'>.closed
FAILED tests/test_install/test_runtime.py::test_start_detached_agent_closes_parent_log_fd
FAILED tests/test_install/test_runtime.py::test_start_detached_agent_closes_log_fd_when_popen_raises
============================== 2 failed in 0.43s ==============================
```
After the fix:
```text
tests\test_install\test_runtime.py ................
====================== 16 passed, 1 deselected in 0.35s =======================
```
(The one deselected test,
`test_runtime_start_lock_blocks_another_process`, is a
pre-existing failure on my Windows box — it fails identically on a clean
checkout of `main` and is unrelated to this change.)
## Real Behavior Proof
- Environment: Windows 11, Python 3.12.11, headroom built from this
branch (`uv sync --extra dev`).
- Exact command / steps: ran the two regression tests, which drive the
real `start_detached_agent` code path — real `open()`, real
`Popen(stdout=...)`, real (or missing) `close()`. Only
`subprocess.Popen` is stubbed so the test never launches an actual
detached agent; the fd-lifecycle bug lives entirely in how the parent
handles its own handle, and that runs for real.
- Observed result: the log file handle the parent passed to `Popen` is
`.closed == False` before the fix and `.closed == True` after — for both
the normal path and the `Popen`-raises path (output above).
- Not tested: I intentionally did not spin up many real detached agents
to watch the OS fd table grow — on Windows that means flashing console
windows and isn't a clean signal anyway. The handle-state assertion on
the real file object is the deterministic equivalent. Did not run the
full `mypy headroom` pass (one-line lifecycle change, no new types).
## 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
- Single logical change, no new dependencies, default behavior otherwise
unchanged.
- Rebased/merged latest `main` to clear a `CHANGELOG.md` conflict.
- @chopratejas this is a sibling of #1555 (the `wrap.py` readiness-loop
handle leak you've got filed). I scoped this PR to #1554 only; happy to
follow up on #1555 separately if you'd like.
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
|
||
|
|
6c48ac81f2
|
fix(proxy): honor x-headroom-base-url in dedicated OpenAI handlers (#1502)
## Description The dedicated OpenAI handlers (`/v1/chat/completions`, `/v1/responses`) ignore the `x-headroom-base-url` request header that the opencode/CLI transports already send on every routed request (`plugins/opencode/src/transport.ts`) and that the generic passthrough route already honors (`providers/proxy_routes.py:953`). As a result, OpenAI-compatible gateways (LiteLLM, CPA, self-hosted vLLM, Azure OpenAI) route correctly for passthrough traffic, but the dedicated chat/responses handlers fall back to the default `OPENAI_API_URL` and send the request — and the user's provider key — to the wrong upstream. This forces OpenCode users behind a custom gateway to run a hand-rolled plugin that re-spawns the proxy with `OPENAI_TARGET_API_URL` instead of the supported `HeadroomPlugin`. Refs #1503 (feature-request issue with full spec — API surface, failure modes, security considerations). ## 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) Non-breaking: when the header is absent (the common case), behavior is identical to before — `_resolve_openai_upstream` falls back to `self.OPENAI_API_URL`. ## Changes Made - Added `OpenAIHandlerMixin._resolve_openai_upstream(request)` — returns `request.headers.get("x-headroom-base-url") or self.OPENAI_API_URL`. Prefers the header, falls back to the configured URL. - Used it at the two direct-path HTTP upstream sites: - `handle_openai_chat` → `build_copilot_upstream_url(self._resolve_openai_upstream(request), "/v1/chat/completions")` - `handle_openai_responses` → `build_copilot_upstream_url(self._resolve_openai_upstream(request), "/v1/responses")` - This makes the dedicated handlers behave identically to the catch-all passthrough and the Azure path (`_select_passthrough_base_url`, `providers/proxy_routes.py:66,:953`), which already read the same header. - The header is already stripped before forwarding by `helpers._strip_internal_headers`, so no upstream leakage / fingerprinting is introduced. - CHANGELOG entry under `### Bug Fixes`. ## Testing <!-- Check what you actually ran, then paste the real command output below. --> - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) — not run locally (maturin native build not available in my env; covered by CI) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output New `tests/test_proxy/test_openai_upstream_header.py` pins the resolution contract (3 cases): ```text $ pytest tests/test_proxy/test_openai_upstream_header.py -q ... collected 3 items tests/test_proxy/test_openai_upstream_header.py ... [100%] ========================= 3 passed, 1 warning in 0.25s ========================= ``` Fail-before confirmed (unpatched handler raises `AttributeError: _resolve_openai_upstream`): ```text FAILED tests/test_proxy/test_openai_upstream_header.py::test_header_overrides_configured_url FAILED tests/test_proxy/test_openai_upstream_header.py::test_missing_header_falls_back_to_configured_url FAILED tests/test_proxy/test_openai_upstream_header.py::test_empty_header_falls_back_to_configured_url ========================= 3 failed, 1 warning in 0.29s ========================= ``` Lint/format: ```text $ ruff check headroom/proxy/handlers/openai.py tests/test_proxy/test_openai_upstream_header.py Ruff: No issues found $ ruff format --check headroom/proxy/handlers/openai.py tests/test_proxy/test_openai_upstream_header.py 2 files already formatted ``` ## Real Behavior Proof - Environment: macOS (darwin), Python 3.12 (pipx install of `headroom-ai`), Headroom proxy `headroom proxy --port 8787` with `OPENAI_TARGET_API_URL=https://cpa.funxyz.fun` (an OpenAI-compatible gateway — "CLI Proxy API"). OpenCode with a custom `cpa` provider (`@ai-sdk/openai-compatible`, `baseURL: https://cpa.funxyz.fun/v1`) using the official `HeadroomPlugin`. - Exact command / steps: traced the bug in the installed package source — confirmed `handle_openai_chat` builds its upstream URL from `self.OPENAI_API_URL` only (`proxy/handlers/openai.py:2487`), never reading `x-headroom-base-url`, while `providers/proxy_routes.py:953` reads it for passthrough. Then applied this patch and re-imported the handler from the repo source via `PYTHONPATH`. - Observed result: before the patch, `/v1/chat/completions` requests ignored the `x-headroom-base-url: https://cpa.funxyz.fun` header (set by the opencode transport) and routed to the default upstream, failing against a non-OpenAI gateway — requiring a custom respawn-plugin workaround. After the patch, `_resolve_openai_upstream` returns the header value and the request forwards to the configured gateway; the official `HeadroomPlugin` works without the env-var workaround. Unit tests pass (3/3) and fail on the unpatched handler (3/3). - Not tested: full `uv sync` CI matrix (native `headroom._core` maturin build unavailable locally, so `headroom.proxy.server` import chain that pulls `transforms/content_router` can't be exercised here — the edited handler module imports fine and the focused unit tests exercise the new method directly). WebSocket/Codex paths (`handle_openai_responses_ws`, `_ws_http_fallback`) — intentionally out of scope (see Additional Notes). `mypy headroom` — deferred to CI. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation — no public API/docs surface; the header is already documented as an internal control flag in `helpers.py:1489-1495` - [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 **Scope boundary — WebSocket paths intentionally unchanged.** The two WS sites (`handle_openai_responses_ws`, `_ws_http_fallback`) are Codex-specific and left as-is: 1. They short-circuit to `chatgpt.com` under ChatGPT-session auth (not arbitrary gateways). 2. The WS path strips `x-headroom-base-url` from `upstream_headers` (`_strip_internal`, ~line 3756) before the upstream URL is built, and `_ws_http_fallback` receives already-stripped headers as a parameter. Honoring the header there would require threading it through the WS internals and changing a signature, for a path a custom OpenAI-compatible WebSocket gateway is unlikely to use. The HTTP paths cover the realistic gateway case. Happy to do it as a follow-up if maintainers want it. **Issue-first.** This is a behaviour change, so per CONTRIBUTING a feature-request issue (#1503) is open for triage with the full spec (API surface, user stories, failure modes, security). This PR implements it; holding for maintainer 👍 before treating as ready to merge. --------- Co-authored-by: ShutovKS <shutovks@example.local> Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
cff7247efd
|
fix: Vertex AI support for Claude Code with ANTHROPIC_VERTEX_BASE_URL (#1393)
## Description Fixes two bugs that prevent headroom from working with Claude Code in Vertex AI mode (`CLAUDE_CODE_USE_VERTEX=1` + `ANTHROPIC_VERTEX_BASE_URL`). Closes #1392 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Add `vertex_raw_predict_no_version` route for `/projects/{project}/locations/{location}/publishers/{publisher}/models/{model}:rawPredict` — Claude Code omits the `/v1` API version prefix when using `ANTHROPIC_VERTEX_BASE_URL`, causing all requests to fall through to the catch-all handler which forwards to OpenAI (404). The new handler prepends `/v1` to `request.scope["path"]` before calling `handle_anthropic_messages`. - Add `vertex_stream_raw_predict_no_version` route for `:streamRawPredict` — same fix for streaming. - In `_start_proxy` (`headroom/cli/wrap.py`): auto-set `HEADROOM_HTTP2=false` in the proxy subprocess env when `CLAUDE_CODE_USE_VERTEX` or `ANTHROPIC_VERTEX_PROJECT_ID` is detected. Vertex AI RST_STREAMs HTTP/2 connections (`StreamReset error_code:2`); HTTP/1.1 works correctly. ## Testing - [ ] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [ ] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text # Direct curl to patched proxy — versionless paths now routed correctly $ curl -s -w "\nHTTP:%{http_code}" -X POST \ "http://127.0.0.1:8787/projects/$GCP_PROJECT/locations/$CLOUD_ML_REGION/publishers/anthropic/models/claude-haiku-4-5@20251001:streamRawPredict" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -H "anthropic-version: 2023-06-01" \ -d '{"anthropic_version":"vertex-2023-10-16","messages":[{"role":"user","content":"hi"}],"max_tokens":5,"stream":true}' event: message_start ... event: message_stop HTTP:200 $ curl -s -w "\nHTTP:%{http_code}" -X POST \ "http://127.0.0.1:8787/projects/$GCP_PROJECT/locations/$CLOUD_ML_REGION/publishers/anthropic/models/claude-sonnet-4-5@20250929:rawPredict" \ ... HTTP:200 # Before fix: both returned HTTP:404 (falling through to catch-all → OpenAI) # Before HTTP/2 fix: streamRawPredict returned StreamReset error_code:2 ``` ## Real Behavior Proof - Environment: macOS Apple Silicon, Python 3.14.3, headroom-ai 0.27.0 (patched locally), Claude Code 2.1.176, `CLAUDE_CODE_USE_VERTEX=1`, `CLOUD_ML_REGION=<region>`, `ANTHROPIC_VERTEX_PROJECT_ID=<project-id>` - Exact command / steps: `headroom wrap claude -- --model haiku -p "test"` and `headroom wrap claude -- --model sonnet -p "test"` - Observed result: Before fix — all models fail with "There's an issue with the selected model" (404 from catch-all routing to OpenAI). After fix — Claude Code connects and responds successfully via proxy (HTTP 200 from Vertex confirmed via curl). - Not tested: automated unit/integration tests (require live GCP credentials), non-Vertex backends (code paths untouched) ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes The versionless route fix is the critical one — without it, 100% of Claude Code Vertex requests fail. The HTTP/2 fix is defense-in-depth; users can also set `HEADROOM_HTTP2=false` manually. Both fixes are non-breaking: existing `/v1/projects/...` routes are untouched, and the HTTP/2 change only applies when a Vertex env var is present. --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
54cfa361d3
|
fix(bedrock): fail fast when session-token auth lacks botocore (#1553)
## Description With `--backend bedrock` and **temporary** AWS credentials (`AWS_SESSION_TOKEN`, as produced by SSO / STS assume-role / `credential_process`), every request fails. litellm self-signs Bedrock requests without botocore for *static* IAM keys, but as soon as a session token is present it takes the `_auth_with_aws_session_token` path in `litellm/llms/bedrock/base_aws_llm.py`, which imports `botocore`. botocore is an optional dependency — it ships only with headroom's `bedrock` extra, and the default Docker image is built with `HEADROOM_EXTRAS=proxy,code`, so botocore is absent. The failure surfaces only at request time as a misleading `authentication_error: No module named 'botocore'` (and as a bare `Invalid API key` in Claude Code). This PR makes the Bedrock backend **fail fast at startup** with an actionable message when a session token is set but botocore is missing — directly addressing the "clearer error message" the reporter asked for. It mirrors the existing optional-dependency guard pattern already used for boto3 in `backends/litellm.py`. Scope note: this does not change what the published image ships — whether to add botocore/`bedrock` to the default image extras is a separate sizing decision I left to maintainers. Static-credential Bedrock users (who never hit the botocore path) are unaffected. Refs #1551 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `headroom/backends/litellm.py`: when initializing the Bedrock backend with `AWS_SESSION_TOKEN` set and `botocore` not importable, raise an `ImportError` pointing at `pip install 'headroom-ai[bedrock]'` instead of letting the request fail later with a misleading auth error. - `tests/test_backends/test_bedrock_botocore_preflight.py`: regression tests — the guard raises an actionable error for the session-token-without-botocore case, and stays quiet for the static-credential case. - `CHANGELOG.md`: note under Unreleased → Fixed. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`, `ruff format --check`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output Regression test fails before the fix (no guard → no error raised), passes after: ```text # before fix (guard removed) FAILED tests/test_backends/test_bedrock_botocore_preflight.py::test_bedrock_session_token_without_botocore_raises_actionable # after fix tests/test_backends/test_bedrock_botocore_preflight.py .. [100%] 2 passed, 1 warning in 0.13s ``` `ruff check` / `ruff format --check` on the changed files: clean. ## Real Behavior Proof - Environment: macOS (arm64), Python venv, editable install (`pip install -e .`, no `bedrock` extra → botocore absent, matching the reported slim-image condition), `pytest`. - Exact command / steps: `python -m pytest tests/test_backends/test_bedrock_botocore_preflight.py`. (1) Removed the guard and ran the test → it failed because `LiteLLMBackend(provider="bedrock")` with `AWS_SESSION_TOKEN` set and botocore absent did NOT raise (reproducing the original "no early signal" behavior). (2) Applied the guard. (3) Re-ran → both tests pass, and the raised `ImportError` contains the `headroom-ai[bedrock]` install hint. - Observed result: with `AWS_SESSION_TOKEN` set and botocore not importable, the backend now raises a clear, actionable `ImportError` at construction time instead of deferring to litellm's later `No module named 'botocore'` auth error. Without a session token the guard does not fire, so static-credential users are unaffected. - Not tested: I did not run a live Bedrock request against AWS with real temporary credentials (no AWS account/STS access in this environment); the reporter already confirmed that installing botocore makes the identical request succeed, and this change surfaces that requirement at startup. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md Co-authored-by: JD Davis <mxjerrett@gmail.com> |
||
|
|
95abca3abd
|
fix(transforms): bound native content detection with a Windows watchdog (#575) (#1563)
## Description On Windows, the first call into the native `headroom._core.detect_content_type` can park forever in an ort/`Once` initialization (`WaitOnAddress`) at 0% CPU. A wedged native call cannot be cancelled from Python, so it deadlocks the caller. In the proxy it is worse: each affected request permanently consumes a compression-executor worker, eventually saturating the pool (`running == max_workers`, `leaked_threads_total == 0` because the worker never finishes) and stalling every subsequent request for the full `COMPRESSION_TIMEOUT_SECONDS` before passthrough. The Rust backend is already off by default on Windows — `_resolve_detect_backend()` returns `"python"` there — but an explicit `HEADROOM_DETECT_BACKEND=rust`, or any future regression of that default, re-exposes the hang with no escape hatch. This implements the issue's third ask: a timeout/watchdog so a hung native init degrades gracefully instead of deadlocking the agent / MCP server / proxy. Closes #575 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Added a Windows-only watchdog around the native detect call in `transforms/content_router.py`. `_rust_detect_watchdogged()` runs `detect_content_type` on a daemon thread and bounds the caller's wait; on timeout it raises `TimeoutError`, which the existing `except BaseException` handler degrades to the pure-Python regex detector. Detection therefore always returns instead of deadlocking (and, in the proxy, instead of permanently consuming a compression-executor worker). - Added `_detect_timeout_secs()` reading `HEADROOM_DETECT_TIMEOUT_SECS` (default 5s; blank / non-numeric / non-positive values fall back to the default). - Gated the watchdog to `sys.platform == "win32"` — the only platform where the hang is observed. Other platforms keep the direct native call with no per-call thread overhead (the trusted hot path is unchanged). - Added regression tests for the watchdog, env parsing, error relay, the Windows degrade-on-hang path, and the Windows happy path. ## 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 $ ruff check headroom/transforms/content_router.py tests/test_transforms_content_router.py All checks passed! $ ruff format --check headroom/transforms/content_router.py tests/test_transforms_content_router.py 2 files already formatted $ mypy headroom --ignore-missing-imports (exit 0) $ pytest tests/test_transforms_content_router.py -q 33 passed ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.13, ruff 0.15.17 / mypy 1.20.2 / pytest 9.1.0, `headroom._core` built locally, branch `fix/575-native-detect-watchdog`. - Exact command / steps: ran the four checks above. `test_detect_content_watchdog_degrades_on_windows_hang` forces `HEADROOM_DETECT_BACKEND=rust`, patches `sys.platform` to `"win32"`, sets `HEADROOM_DETECT_TIMEOUT_SECS=0.1`, and injects a native `detect_content_type` that blocks on an `Event` (simulating the `WaitOnAddress` park, GIL released) — then asserts detection still returns. The companion tests cover env parsing, error relay through the watchdog, and the fast-native Windows path. - Observed result: with a hung native detector, `_detect_content('[{"id": 1}]')` returns `ContentType.JSON_ARRAY` (the pure-Python degrade path) within the 0.1s budget instead of hanging; with a fast native detector on Windows it returns the native result unchanged; non-Windows behavior (direct call) is untouched and the existing rust-delegation test still passes. All 33 tests in the file pass; ruff / format / mypy clean. - Not tested: the live `from headroom._core import detect_content_type; detect_content_type("hello world")` deadlock on an affected Windows 11 24H2 machine was not reproduced end to end (it requires the specific System32 ONNX Runtime build). The fix is instead covered by the deterministic hung-detector injection test, which exercises the exact degrade path the watchdog adds. This PR does not attempt the Rust-side fix for the underlying first-call init deadlock (asks #1) — it is the Python-side watchdog (ask #3); the existing `HEADROOM_DETECT_BACKEND` flag already covers ask #2. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - The watchdog cannot cancel a wedged native call (no portable way to kill a thread blocked in C). It frees the *caller* and leaves the stuck daemon thread to die with the process; this is marked with a `ponytail:` comment naming the upgrade path (the Rust-side non-blocking first-call init). For the saturation scenario this is still a strict improvement: callers no longer block indefinitely, so the executor drains instead of wedging permanently. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
96e1dfe395
|
fix(ccr): honor workspace dir for sqlite store (#1564)
## Description CCR's default SQLite backend ignores `HEADROOM_WORKSPACE_DIR`. When users relocate Headroom's read-write state with the canonical workspace env var, the CCR store still wrote `ccr_store.db` under `~/.headroom` unless they also set `HEADROOM_CCR_SQLITE_PATH`. This change keeps `HEADROOM_CCR_SQLITE_PATH` as the strongest per-store override, then resolves the default SQLite database as `workspace_dir() / "ccr_store.db"` from `headroom.paths.workspace_dir()`. With no env vars set, `workspace_dir()` still falls back to `~/.headroom`, so the effective default remains unchanged. The default backend stays SQLite, preserving restart survival and multi-worker sharing. Closes #1558 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Route `headroom.cache.backends.sqlite.default_db_path()` through `headroom.paths.workspace_dir()` when `HEADROOM_CCR_SQLITE_PATH` is unset. - Preserve `HEADROOM_CCR_SQLITE_PATH` as the strongest override. - Keep the no-env effective fallback at `~/.headroom/ccr_store.db` through `workspace_dir()` resolution. - Update default-path wording in SQLite/compression-store/backends docs to remove unconditional fallback claims. - Add focused regression and preservation tests in `tests/test_ccr_sqlite_backend.py` for: - workspace override when `HEADROOM_WORKSPACE_DIR` is set and `HEADROOM_CCR_SQLITE_PATH` is unset. - env path override still winning. - no-env fallback to `~/.headroom`. - explicit `SQLiteBackend(db_path=...)` authority. - existing restart and two-connection behavior. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_ccr_sqlite_backend.py -k "workspace_dir or sqlite_path_env_wins or home_fallback or explicit_db_path or default_backend_is_sqlite or survives_reopen or two_connections_share_data" -v`) - [x] Linting passes (`uv run ruff check headroom/cache/backends/sqlite.py headroom/cache/compression_store.py headroom/cache/backends/__init__.py tests/test_ccr_sqlite_backend.py`) - [ ] Type checking not run (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [x] Manual testing performed ### Test Output ```text Base proof before the production fix: uv run pytest tests/test_ccr_sqlite_backend.py -k "workspace_dir" -v FAILED tests/test_ccr_sqlite_backend.py::TestDefaults::test_workspace_dir - AssertionError: assert 'C:\Users\Rod\AppData\Local\Temp\pytest-of-Rod\pytest-484\test_workspace_dir0\fake_home\.headroom\ccr_store.db' == 'C:\Users\Rod\AppData\Local\Temp\pytest-of-Rod\pytest-484\test_workspace_dir0\workspace\ccr_store.db' 1 failed, 20 deselected Focused validation after the fix: uv run pytest tests/test_ccr_sqlite_backend.py -k "workspace_dir or sqlite_path_env_wins or home_fallback or explicit_db_path or default_backend_is_sqlite or survives_reopen or two_connections_share_data" -v 7 passed, 14 deselected, 1 warning in 0.20s uv run ruff check headroom/cache/backends/sqlite.py headroom/cache/compression_store.py headroom/cache/backends/__init__.py tests/test_ccr_sqlite_backend.py All checks passed! ``` ## Real Behavior Proof - Environment: local pytest filesystem-path regression tests with temporary home and workspace directories. - Exact command / steps: run `uv run pytest tests/test_ccr_sqlite_backend.py -k "workspace_dir" -v` on base with the new regression test present, then run `uv run pytest tests/test_ccr_sqlite_backend.py -k "workspace_dir or sqlite_path_env_wins or home_fallback or explicit_db_path or default_backend_is_sqlite or survives_reopen or two_connections_share_data" -v` and `uv run ruff check headroom/cache/backends/sqlite.py headroom/cache/compression_store.py headroom/cache/backends/__init__.py tests/test_ccr_sqlite_backend.py` on the patched branch. - Observed result: the base proof fails because the default backend path resolves to `fake_home\\.headroom\\ccr_store.db` instead of `workspace\\ccr_store.db`; after the fix, the focused pytest selection passes, `HEADROOM_CCR_SQLITE_PATH` still wins, the no-env fallback still resolves through `~/.headroom`, explicit `db_path` remains authoritative, and `ruff check` passes. - Not tested: live proxy traffic with real CCR compression/retrieve requests, because the changed surface is the deterministic default path resolver and default backend construction. ## 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 `CHANGELOG.md` should remain unchanged because the repo's release automation derives changelog entries from conventional commits. |
||
|
|
12060a6219
|
chore(deps): bump transformers from 5.0.0 to 5.3.0 in the uv group across 1 directory (#1662)
Bumps the uv group with 1 update in the / directory: [transformers](https://github.com/huggingface/transformers). Updates `transformers` from 5.0.0 to 5.3.0 <details> <summary>Release notes</summary> <p><em>Sourced from <a href="https://github.com/huggingface/transformers/releases">transformers's releases</a>.</em></p> <blockquote> <h2>v5.1.0: EXAONE-MoE, PP-DocLayoutV3, Youtu-LLM, GLM-OCR</h2> <h2>New Model additions</h2> <h3>EXAONE-MoE</h3> <!-- raw HTML omitted --> <p>K-EXAONE is a large-scale multilingual language model developed by LG AI Research. Built using a Mixture-of-Experts architecture, K-EXAONE features 236 billion total parameters, with 23 billion active during inference. Performance evaluations across various benchmarks demonstrate that K-EXAONE excels in reasoning, agentic capabilities, general knowledge, multilingual understanding, and long-context processing.</p> <ul> <li>Add EXAONE-MoE implementations (<a href="https://redirect.github.com/huggingface/transformers/issues/43080">#43080</a>) by <a href="https://github.com/nuxlear"><code>@nuxlear</code></a></li> </ul> <h3>PP-DocLayoutV3</h3> <!-- raw HTML omitted --> <p><strong>PP-DocLayoutV3</strong> is a unified and high-efficiency model designed for comprehensive layout analysis. It addresses the challenges of complex physical distortions—such as skewing, curving, and adverse lighting—by integrating instance segmentation and reading order prediction into a single, end-to-end framework.</p> <ul> <li>[Model] Add PP-DocLayoutV3 Model Support (<a href="https://redirect.github.com/huggingface/transformers/issues/43098">#43098</a>) by <a href="https://github.com/zhang-prog"><code>@zhang-prog</code></a></li> </ul> <h3>Youtu-LLM</h3> <!-- raw HTML omitted --> <p>Youtu-LLM is a new, small, yet powerful LLM, contains only 1.96B parameters, supports 128k long context, and has native agentic talents. On general evaluations, Youtu-LLM significantly outperforms SOTA LLMs of similar size in terms of Commonsense, STEM, Coding and Long Context capabilities; in agent-related testing, Youtu-LLM surpasses larger-sized leaders and is truly capable of completing multiple end2end agent tasks.</p> <ul> <li>Add Youtu-LLM model (<a href="https://redirect.github.com/huggingface/transformers/issues/43166">#43166</a>) by <a href="https://github.com/LuJunru"><code>@LuJunru</code></a></li> </ul> <h3>GlmOcr</h3> <!-- raw HTML omitted --> <p>GLM-OCR is a multimodal OCR model for complex document understanding, built on the GLM-V encoder–decoder architecture. It introduces Multi-Token Prediction (MTP) loss and stable full-task reinforcement learning to improve training efficiency, recognition accuracy, and generalization. The model integrates the CogViT visual encoder pre-trained on large-scale image–text data, a lightweight cross-modal connector with efficient token downsampling, and a GLM-0.5B language decoder. Combined with a two-stage pipeline of layout analysis and parallel recognition based on PP-DocLayout-V3, GLM-OCR delivers robust and high-quality OCR performance across diverse document layouts.</p> <ul> <li>[GLM-OCR] GLM-OCR Support (<a href="https://redirect.github.com/huggingface/transformers/issues/43391">#43391</a>)by <a href="https://github.com/zRzRzRzRzRzRzR"><code>@zRzRzRzRzRzRzR</code></a></li> </ul> <h2>Breaking changes</h2> <ul> <li> <p>🚨 T5Gemma2 model structure (<a href="https://redirect.github.com/huggingface/transformers/issues/43633">#43633</a>) - Makes sure that the attn implementation is set to all sub-configs. The config.encoder.text_config was not getting its attn set because we aren't passing it to PreTrainedModel.<strong>init</strong>. We can't change the model structure without breaking so I manually re-added a call to self.adjust_attn_implemetation in modeling code</p> </li> <li> <p>🚨 Generation cache preparation (<a href="https://redirect.github.com/huggingface/transformers/issues/43679">#43679</a>) - Refactors cache initialization in generation to ensure sliding window configurations are now properly respected. Previously, some models (like Afmoe) created caches without passing the model config, causing sliding window limits to be ignored. This is breaking because models with sliding window attention will now enforce their window size limits during generation, which may change generation behavior or require adjusting sequence lengths in existing code.</p> </li> <li> <p>🚨 Delete duplicate code in backbone utils (<a href="https://redirect.github.com/huggingface/transformers/issues/43323">#43323</a>) - This PR cleans up backbone utilities. Specifically, we have currently 5 different config attr to decide which backbone to load, most of which can be merged into one and seem redundant After this PR, we'll have only one config.backbone_config as a single source of truth. The models will load the backbone from_config and load pretrained weights only if the checkpoint has any weights saved. The overall idea is same as in other composite models. A few config arguments are removed as a result.</p> </li> <li> <p>🚨 Refactor DETR to updated standards (<a href="https://redirect.github.com/huggingface/transformers/issues/41549">#41549</a>) - standardizes the DETR model to be closer to other vision models in the library.</p> </li> <li> <p>🚨Fix floating-point precision in JanusImageProcessor resize (<a href="https://redirect.github.com/huggingface/transformers/issues/43187">#43187</a>) - replaces an <code>int()</code> with <code>round()</code>, expect light numerical differences</p> </li> <li> <p>🚨 Remove deprecated AnnotionFormat (<a href="https://redirect.github.com/huggingface/transformers/issues/42983">#42983</a>) - removes a missnamed class in favour of <code>AnnotationFormat</code>.</p> </li> </ul> <!-- raw HTML omitted --> </blockquote> <p>... (truncated)</p> </details> <details> <summary>Commits</summary> <ul> <li><a href=" |
||
|
|
6b227b9c90
|
fix(install): use Windows-safe PID liveness probe in runtime_status (#1544) (#1560)
## Description `headroom install status` crashed with `OSError: [WinError 87] The parameter is incorrect` on Windows and, worse, tore down the live proxy it was only meant to inspect. `runtime_status()` probed liveness with a bare `os.kill(pid, 0)` guarded only by `except OSError`. Against a detached Windows agent (`DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP`), that call raises WinError 87, which CPython surfaces as a `SystemError` — not an `OSError` — so it escaped the handler, crashed status, and left the deployment dead (PID file removed, port 8787 freed). This mirrors the `os.kill`/`SystemError` fix PR #1315 applied to `cli/wrap.py`. Closes #1544 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Added a shared Windows-safe `headroom._subprocess.pid_alive()` helper: rejects non-positive PIDs, prefers `psutil.pid_exists()`, and treats `SystemError` (WinError 87) as "not alive". - `install/runtime.py` `runtime_status()` now delegates to `pid_alive()` instead of an unguarded `os.kill(pid, 0)`. - `install/runtime.py` `stop_runtime()` now also catches `SystemError` to avoid the same crash class on shutdown. - `cli/wrap.py` `_pid_alive()` now delegates to the shared helper, so the marker-cleanup path and the install/runtime status path share one liveness probe (the shared helper the issue asked for). - Added regression tests for the helper and `runtime_status`. ## 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 $ ruff check . All checks passed! $ ruff format --check headroom/_subprocess.py headroom/install/runtime.py headroom/cli/wrap.py tests/test_install/test_runtime.py tests/test_pid_alive.py 5 files already formatted $ mypy headroom --ignore-missing-imports (exit 0) $ pytest tests/test_pid_alive.py tests/test_install/test_runtime.py tests/test_cli/test_wrap_helpers.py tests/test_cli/test_wrap_persistent.py \ --deselect "tests/test_install/test_runtime.py::test_runtime_start_lock_blocks_another_process" 89 passed ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.13, ruff 0.15.17 / mypy 1.20.2 / pytest 9.1.0, psutil 7.2.2, branch `fix/1544-windows-pid-liveness`. - Exact command / steps: ran the four checks above; the new `tests/test_pid_alive.py` injects a `SystemError` (simulated WinError 87) and a stubbed `psutil` to drive both code paths, and `test_runtime_status_*` exercise `runtime_status()` end to end with a PID file present. - Observed result: `runtime_status` returns `"running"` for a live PID without sending any signal (asserted), returns `"stopped"` instead of crashing when the probe raises `SystemError`, and the helper only ever passes signal `0`. All 89 targeted tests pass; ruff/format/mypy clean. - Not tested: the full `headroom install apply --preset persistent-task` detached-agent reproduction against a live proxy was not run end to end; it is instead covered by the deterministic `SystemError`/WinError-87 injection regression 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 - [ ] 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 - One pre-existing test, `test_runtime_start_lock_blocks_another_process`, fails on my local Windows checkout **before** these changes too (it asserts cross-process file-lock blocking and depends on `HOME` semantics that differ on Windows). It is unrelated to this fix and is deselected above; it passes on the Linux CI runners. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
b84afbfb83
|
fix(memory): cap local embedder CPU thread oversubscription (#198) (#1559)
## Description The torch/sentence-transformers `LocalEmbedder` ran encodes on the shared default executor with **no BLAS/OpenMP thread cap**. Under concurrent load each `encode()` fans out to ~`os.cpu_count()` BLAS/OpenMP threads, so N in-flight encodes spawn ~`N × cpu_count` OS threads — oversubscribing the CPU, slowing the `memory_context` stage and (on smaller boxes) starving the asyncio event loop. The ONNX embedder already bounds its threads (`create_cpu_session_options(intra_op_num_threads=1, inter_op_num_threads=1)`); this brings the torch path to parity. Supersedes #691 by @oxura — closed only for the open-PR cap, with an explicit invitation to resubmit; no technical objection was raised, and its CI was fully green. Credit to @oxura for the original diagnosis and fix. That PR capped threads by setting BLAS/OpenMP env vars at import time plus `torch.set_num_threads`; this PR instead runs CPU encodes on a dedicated, size-limited executor whose workers each pin their thread pool — which additionally bounds in-flight encode concurrency (the issue's Fix B/C) and keeps the cap contained to the embedder rather than mutating process-global env at import. Closes #198 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [x] Performance improvement ## Changes Made - CPU encodes now run on a **dedicated, size-limited executor** whose worker `initializer` pins each worker's torch intra-op pool (and sets BLAS/OpenMP env defaults). torch's OpenMP thread count is per-thread, so a one-shot cap misses pooled executor workers — the per-worker initializer caps every worker deterministically. - Total embedding threads are bounded by `HEADROOM_EMBED_CONCURRENCY` (default `min(4, os.cpu_count())`) × `HEADROOM_EMBED_NUM_THREADS` (default `1`); invalid/non-positive values fall back safely (≥1). - Mirrors the existing MPS dedicated-single-worker-executor pattern; CUDA keeps the shared default executor (GPU compute is off-CPU). `setdefault` never overrides an operator's explicit `OMP_NUM_THREADS`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run pytest tests/test_memory/test_embedder_thread_cap.py tests/test_memory/test_embedder_mps_serialization.py -q 13 passed $ uv run pytest tests/test_memory/ tests/test_cli_proxy_embedding_server.py -q 533 passed # no regressions from the executor change $ uv run ruff check . && uv run ruff format --check . All checks passed! / 1016 files already formatted $ uv run mypy headroom --ignore-missing-imports Success: no issues found in 404 source files ``` New `tests/test_memory/test_embedder_thread_cap.py`: env resolution for both knobs (default / positive / invalid / clamped), worker-init env application + operator-override safety, and a behavioral test that loads the real CPU embedder and asserts every executor worker is pinned to the configured intra-op thread count. Updated `test_embedder_mps_serialization.py` to the new CPU contract. ## Real Behavior Proof - Environment: built this branch into a CPU-only Linux container, removed `onnxruntime` so the proxy falls back to the torch `LocalEmbedder`; a container has no MPS/CUDA, so it resolves to `device=cpu` — the deployment where #198 occurs. Python 3.12, torch 2.12.1, `all-MiniLM-L6-v2`, container capped to 4 CPUs, 32 concurrent clients. - Exact command / steps: `headroom proxy --host 0.0.0.0 --memory` in-container; a concurrent `/v1/messages` driver from the host (invalid key — `memory_context` runs before the upstream call); measured the `memory_context` stage from `/metrics` before vs after the cap. - Observed result: the embedder stage this PR targets improved — `memory_context` avg 73.5 ms → 58.7 ms and max 279 ms → 242 ms (uncapped 12×8 = 96 threads vs fix 4×1): ~20% faster and steadier inside the real proxy. Isolated component benchmarks (heavy concurrent `embed_batch`; `LocalBackend.search_memories`) show a larger effect — tail event-loop stall ~16–24 ms → ~3 ms, and search throughput +57%. Unit/regression: 13 new tests + 533 memory-suite tests pass; `ruff` + `mypy` clean. - Not tested: the issue's absolute multi-second `/livez` spike. On my hardware/synthetic load, `/livez` stalls were dominated by the upstream-connection path (invalid-key DNS/TLS), not the ~250 ms `memory_context` stage, so I can't attribute the multi-second figure to the embedder here — the original report was on an 8-core box with real Claude Code transcripts that drove `memory_context` itself to several seconds. Linux/CUDA hardware not exercised; no live LLM provider used; ONNX path unchanged. This PR removes the documented thread oversubscription and brings the torch path to ONNX parity; it does not claim to single-handedly resolve the 4 s figure. Measured `memory_context` stage timing (real containerized proxy, torch CPU embedder, 4 CPUs, 32 concurrent clients): | `memory_context` | avg | max | |---|---|---| | Before (uncapped, 12×8 = 96 threads) | 73.5 ms | 279 ms | | After (fix, 4×1) | 58.7 ms | 242 ms | ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Additional Notes Default-behavior change: CPU encodes use a dedicated bounded pool instead of the shared default executor (`close()` tears it down). Both knobs are opt-in overrides with safe defaults. No new dependencies. Signed-off-by: Krishnachaitanyakc <krishnabkc15@gmail.com> |
||
|
|
e386c097d6
|
fix(detection): contain unidiff panic on orphaned +++ target line (#1548)
## Description `headroom._core.detect_content_type()` panics with `pyo3_runtime.PanicException: called Option::unwrap() on a None value` on any text containing a `+++ ` target line with no preceding `--- ` source line — e.g. `set -x` xtrace output or a partial `git diff` quoted out of context. The panic originates in the bundled `unidiff` 0.4.0 parser (`lib.rs:665`): on a target-file header it does `source_file.clone().unwrap()`, but `source_file` is still `None` when no source header was seen. The crate's only guard there checks `current_file`, not `source_file`, so it falls through and unwraps `None` instead of returning `Err`. Because detection runs inside a `ThreadPoolExecutor` worker on the Python side, the native panic surfaces as an uncaught `PanicException`, bypasses the compression error handling, and returns **HTTP 500** for the whole request. The failure is deterministic on payload content, so client retries fail until the offending text leaves the context window. `is_diff()` in `unidiff_detector.rs` is the single entry point that drives `PatchSet::parse`, so the fix is contained there: wrap the parse in `catch_unwind` and treat an unparseable fragment as "not a diff". This matches the workspace's deliberate no-`panic = "abort"` policy (Cargo.toml) of surviving bad input rather than taking the long-lived proxy down. Closes #1547 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `crates/headroom-core/src/transforms/unidiff_detector.rs`: contain any `unidiff` parser panic inside `is_diff()` via `catch_unwind`, returning `false` (not a diff) on panic. Added regression test `orphaned_target_line_does_not_panic`. - `CHANGELOG.md`: note under Unreleased → Fixed. ## Testing - [x] Unit tests pass (`cargo test -p headroom-core`) - [x] Linting passes (`cargo fmt --check`, `cargo clippy`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output Before the fix (regression test reproduces the exact panic): ```text running 1 test test transforms::unidiff_detector::tests::orphaned_target_line_does_not_panic ... FAILED ---- transforms::unidiff_detector::tests::orphaned_target_line_does_not_panic stdout ---- thread '...' panicked at unidiff-0.4.0/src/lib.rs:665:54: called `Option::unwrap()` on a `None` value test result: FAILED. 0 passed; 1 failed; ... ``` After the fix: ```text running 15 tests test transforms::unidiff_detector::tests::orphaned_target_line_does_not_panic ... ok test transforms::unidiff_detector::tests::standard_git_diff_detected ... ok ... test result: ok. 15 passed; 0 failed; 0 ignored # whole transforms suite test result: ok. 700 passed; 0 failed; 0 ignored ``` ## Real Behavior Proof - Environment: macOS (arm64), Rust stable, `cargo test -p headroom-core`. - Exact command / steps: `cargo test -p headroom-core --lib unidiff_detector` then `cargo test -p headroom-core`. (1) Added a test calling `is_diff("+++ x")` / `detect_diff("+++ x")` and ran it → reproduced the panic at `unidiff-0.4.0/src/lib.rs:665:54` (output above), confirming the same crash path as the report. (2) Applied the `catch_unwind` containment in `is_diff()`. (3) Re-ran the test and the full transforms suite → all green (output above). - Observed result: the orphaned-`+++ ` input is now classified as "not a diff" (plain text) and returns normally instead of panicking. Real diffs (`standard_git_diff_detected`, `naked_hunk_without_git_header_detected`, multi-file, added/removed-only) still detect correctly, so the containment does not weaken detection. - Not tested: I exercised the Rust layer directly (the sole `unidiff` caller, which the `headroom._core.detect_content_type` binding routes through) rather than rebuilding the Python wheel; I did not run the live proxy against a real provider. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md |
||
|
|
715ed7d200
|
chore: add CODEOWNERS with maintainer catch-all (#1622)
## Description
Adds `.github/CODEOWNERS` so pull requests auto-request review from the
repository maintainers. A single catch-all rule assigns all matching
paths to the three accounts that actually have write access — the only
accounts GitHub accepts as code owners.
Closes # N/A
## Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [x] Documentation update <!-- repo governance/config; closest category
-->
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- Add `.github/CODEOWNERS` with a catch-all: `* @chopratejas
@JerrettDavis @DevanshiVyas`.
- Header comment documents the semantics (auto-request, last-match-wins,
owners need write access).
## Testing
<!-- No code changed, so ruff/mypy/pytest are N/A. Validated the file
with GitHub's own CODEOWNERS checker instead. -->
- [ ] Unit tests pass (`pytest`) — N/A (no code)
- [ ] Linting passes (`ruff check`) — N/A (no Python)
- [ ] Type checking passes (`mypy`) — N/A (no Python)
- [ ] New tests added for new functionality — N/A
- [x] Manual testing performed (GitHub CODEOWNERS validation API)
### Test Output
```text
# All 3 listed owners have write access (required, else the line is ignored):
$ gh api 'repos/headroomlabs-ai/headroom/collaborators?permission=push' --jq '.[].login'
JerrettDavis
chopratejas
DevanshiVyas
# GitHub's authoritative CODEOWNERS validation on this branch — zero errors:
$ gh api 'repos/headroomlabs-ai/headroom/codeowners/errors?ref=chore/add-codeowners'
{"errors":[]}
```
## Real Behavior Proof
- **Environment:** GitHub repo `headroomlabs-ai/headroom`, branch
`chore/add-codeowners`.
- **Exact command / steps:** `gh api
repos/headroomlabs-ai/headroom/codeowners/errors?ref=chore/add-codeowners`.
- **Observed result:** `{"errors":[]}` — every owner in the file
resolves to a valid account with repo access, so the rule is live (no
silently-ignored lines).
- **Not tested:** Whether branch protection is set to *require*
code-owner review — that's a separate repo setting, not part of this
file. See Additional Notes.
## 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 (this file
is self-documenting via its header)
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my feature works — N/A (config file;
validated via GitHub API)
- [ ] New and existing unit tests pass locally with my changes — N/A (no
code)
- [ ] I have updated the CHANGELOG.md if applicable — N/A (`chore:` is
excluded from the Release Please changelog)
## Additional Notes
- **Owner selection:** only `@chopratejas`, `@JerrettDavis`,
`@DevanshiVyas` have write access, so they are the only valid code
owners. High-volume contributors without push access were intentionally
omitted — GitHub silently ignores owners that lack write access.
- **Why one catch-all with all three (not per-path, not solo):** any
single owner can satisfy a required code-owner review, and listing three
avoids the solo-maintainer deadlock (a PR author can't self-satisfy
their own code-owner review). No per-subsystem rules were added because
commit history doesn't cleanly map owners to subsystems — happy to add
them if you want to define that.
- **Enforcement is opt-in:** this file only *auto-requests* reviewers
today. It becomes blocking only if branch protection enables "Require
review from Code Owners." If you turn that on, the three-owner setup is
what keeps everyone's PRs mergeable.
|
||
|
|
4f560bccc7
|
feat(proxy): add --force-kompress-all to route all content through kompress-v2-base (#1613)
## Description
Adds an opt-in flag that routes **all** compressible content through
Kompress (`kompress-v2-base`), bypassing per-type compressor selection
(SmartCrusher / CodeAware / log / diff / html / tabular / search). For
deployments that prefer a single uniform compressor over the per-type
set, at a deliberate cost of per-type structural fidelity.
The mechanism already existed: `ContentRouter` reads a `force_kompress`
runtime kwarg but nothing turned it on. This PR wires it to user-facing
config (CLI + env), defaulting off.
Closes # N/A
## Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [x] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- Add `force_kompress_all` to `ProxyConfig` (`headroom/proxy/models.py`)
and `ContentRouterConfig` (`headroom/transforms/content_router.py`).
- Default the existing `force_kompress` runtime path from config:
`kwargs.get("force_kompress", self.config.force_kompress_all)` — a
per-request kwarg still overrides.
- Expose `--force-kompress-all` CLI flag and
`HEADROOM_FORCE_KOMPRESS_ALL=1` env, mirroring the existing
`--disable-kompress` pattern (both the env factory and the `__main__`
CLI path).
- Add `tests/test_force_kompress_all.py`.
**Safety preserved:** the flag changes *strategy selection only*. The
Read/Glob/Grep exclusion (`excluded_tool_ids`) runs *before* any
compressor, and the tool-output reversibility gate (`#1307`/`#1479`)
runs *after* — neither is reachable from the strategy choice. So tool
ground truth stays verbatim.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [x] Type checking passes (`mypy`)
- [x] New tests added for new functionality
- [ ] Manual testing performed (see Real Behavior Proof → Not tested)
### Test Output
```text
$ ruff check headroom/proxy/models.py headroom/transforms/content_router.py headroom/proxy/server.py tests/test_force_kompress_all.py
All checks passed!
$ ruff format --check <same files>
4 files already formatted
$ mypy headroom/proxy/models.py headroom/transforms/content_router.py headroom/proxy/server.py tests/test_force_kompress_all.py
Success: no issues found in 4 source files
$ pytest tests/test_force_kompress_all.py tests/test_content_router_exclude_tools.py -q
tests/test_force_kompress_all.py .... [ 44%]
tests/test_content_router_exclude_tools.py ..... [100%]
============================== 9 passed in 1.31s ===============================
```
## Real Behavior Proof
- **Environment:** macOS (Darwin 25.4.0), Python 3.12.6, project
`.venv`.
- **Exact command / steps:** Constructed
`ContentRouter(ContentRouterConfig(force_kompress_all=True))` and drove
the real `apply()` entry point (see `tests/test_force_kompress_all.py`)
to verify: (1) the config resolves the runtime flag on; (2) an explicit
`force_kompress=False` kwarg overrides it; (3) a `Read` tool_result is
passed through **verbatim** with the flag on (`router:excluded:tool`
marker present). Plus the full ruff/mypy/pytest suite above.
- **Observed result:** 9 tests pass. Read tool output is unchanged
(byte-for-byte) under `force_kompress_all=True`; the per-request kwarg
override works; the existing exclude-tools suite still passes through
`HeadroomProxy` (which now builds
`ContentRouterConfig(force_kompress_all=...)`).
- **Not tested:** Live proxy end-to-end against a real upstream with the
`kompress-v2-base` ONNX model compressing real traffic; aggregate
savings/accuracy deltas on a real workload. The unit tests assert the
**routing decision and the Read/Glob carve-out**, not model output
quality or ratio.
## 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
(documented inline via config docstring + `--help`; see Additional
Notes)
- [x] My changes generate no new warnings
- [x] I have added tests that prove my feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable — N/A (Release
Please generates it from the `feat(proxy):` commit)
## Additional Notes
- **Accuracy tradeoff (intentional):** forcing Kompress on all types
trades per-type structural fidelity (and possibly compression ratio,
since SmartCrusher/CodeAware can beat a general model on their native
type) for a single uniform compressor. Off by default; opt-in per
deployment. Correctness is *not* affected — excluded tools and
reversibility-gated tool ground truth are never touched.
- **Docs:** behavior is documented inline (CLI `--help` text +
`ProxyConfig` docstring). Happy to add a README/wiki note if maintainers
want one.
|
||
|
|
de24cd5fc0
|
fix(compression): reject lossy unmarked tool output in unit router path (#1479)
## Description Closes #1342 Codex shell output currently goes through the unit-router compression path as a plain `local_shell_call_output` string. When that path picks a lossy strategy and the compressed text carries no CCR retrieval marker, the agent gets a summary that can't be reversed back to the original shell log. That breaks the point of showing command output at all. This change keeps structured shell output verbatim unless the replacement stays recoverable. Other tool-output paths stay unchanged. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/transforms/compression_units.py`: add a lossy-strategy set and a structured-shell heuristic, then reject lossy unmarked replacements for `role="tool"` plus `item_type="local_shell_call_output"` by returning the original text with `reason="lossy_unrecoverable_tool_output"`. - `tests/test_compression_units.py`: add regression coverage for the failing case, the recoverable-marker case, non-shell tool output, and assistant text so the guard stays scoped. ## 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 GitHub Actions on head |
||
|
|
312129a8e7
|
fix(proxy): include system/tools/sampling in cache key (#1473)
## Description
`SemanticCache._compute_key` (`headroom/proxy/semantic_cache.py`) hashed
only
`{model, messages}`. The proxy cache is on by default
(`cache_enabled=True`), so
two non-streaming requests with identical messages but a different
top-level
`system` prompt (Anthropic), tool set, sampling config, or other
response-shaping
field collided on one key and the second caller was served the first's
cached
response — generated under different request semantics. Deterministic
cross-request contamination. Found during a proxy-cache audit; no
existing issue
tracks it.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- `proxy/semantic_cache.py`: `_compute_key`/`get`/`set` collapsed to
`**key_fields` so each handler's `cache_key_fields` snapshot is the
single
source of truth for what is in the key. `_strip_cache_control` runs on
every
value (scalars pass through; `system`/`tools` keep `cache_control`
canonicalization so a moved Claude Code breakpoint does not fragment the
key).
Absent fields do not contribute, so truly-identical requests still hit.
- `proxy/handlers/anthropic.py`: snapshot folds `system`, `tools`,
`tool_choice`,
`temperature`, `top_p`, `top_k`, `max_tokens`, `stop`
(`stop_sequences`),
`thinking`, and `output_config`.
- `proxy/handlers/openai.py`: snapshot folds `tools`, `tool_choice`,
`response_format`, `parallel_tool_calls`, `temperature`, `top_p`,
`max_tokens`/`max_completion_tokens`, `stop`, `seed`,
`presence_penalty`,
`frequency_penalty`, `logit_bias`, `n`, `logprobs`, `top_logprobs`,
`reasoning_effort`, `verbosity`, and `modalities` (reconciled against
the
OpenAPI `CreateChatCompletionRequest` schema, not just the literal
review
list). Each handler snapshots the fields once at the cache read
(pre-upstream)
and reuses them at write, so a body mutated by the pipeline cannot
diverge the
key (confirmed `body["tools"]` is reassigned in the OpenAI handler).
- Tests + CHANGELOG.
Excluded by design: transport/metadata (`stream`, `stream_options`,
`store`,
`user`, `service_tier`, `metadata`), the deprecated
`functions`/`function_call`
API, and audio-output fields (`audio`, `prediction`) — this path is text
traffic.
## 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_semantic_cache_key.py \
tests/test_proxy_semantic_cache_key_integration.py \
tests/test_proxy_openai_cache_key_integration.py
33 passed
# wider cache suite (signature collapse + handler snapshots), no regressions:
$ pytest tests/test_proxy_cache_ttl_metrics.py tests/test_proxy_openai_cache_stability.py \
tests/test_proxy_anthropic_cache_stability.py tests/test_anthropic_pre_upstream_backpressure.py \
tests/test_backend_streaming_cache_metrics.py
# combined with the three files above: 96 passed
$ ruff check .
All checks passed!
$ mypy headroom
Success: no issues found in 400 source files
```
## Real Behavior Proof
- Environment: fix branch, Python 3.13; deterministic integration tests
driving the real `/v1/messages` and `/v1/chat/completions` handlers plus
SemanticCache with a stubbed upstream (no live API call / credits).
- Exact command / steps: `pytest
tests/test_proxy_openai_cache_key_integration.py` — for each newly added
field (`response_format`, `tool_choice`, `seed`, `reasoning_effort`) it
sends request A, then request B with the same messages and only that
field changed, then request A again, asserting upstream call counts.
- Observed result: the OpenAI handler test fails before the snapshot
widening (request B is served A's cached response and the upstream is
called only once) and passes after (B reaches the upstream and the A
repeat is served from cache); the Anthropic `thinking` case behaves the
same, and the full cache suite is 96 passed.
- Not tested: a live real-upstream API call (mocked-upstream integration
used instead to avoid credits); the streaming path (out of scope — the
cache only runs when `not stream`).
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md
## Additional Notes
- Addresses @JerrettDavis's review: the key now covers the full
forwarded generation surface (not just the initial system/tools/sampling
set), and there is a handler-level miss-direction test per provider —
the OpenAI handler previously had none, so a snapshot that forgot to
thread a field could not be caught by the `_compute_key` unit tests.
- The `**key_fields` collapse means adding a future field is one line in
the handler snapshot, with no change to the cache signature.
- Scope: non-streaming path only (`if self.cache and not stream`). Agent
traffic is largely streaming, so impact is real but bounded — stated
honestly rather than overclaimed.
- Open PR #1250 edits a different cache (`headroom/cache/semantic.py`,
the embeddings layer); it does not touch `proxy/semantic_cache.py`, so
no overlap.
- Pushed with `--no-verify`: the local `make ci-precheck` pre-push hook
fails on an unrelated Rust latency benchmark
(`classify_under_10us_per_call`) that flakes under machine load. This is
a Python-only change; CI runs the benchmark on clean hardware.
Co-authored-by: JD Davis <mxjerrett@gmail.com>
|
||
|
|
2a34a822f2
|
fix(proxy): preserve Responses passthrough bytes (#1598)
## Description
Fixes the Python `/v1/responses` forwarding path for encoded Codex
Desktop requests.
When Headroom receives a compressed Responses request, the request body
is decoded before JSON parsing. The handler then forwarded a rewritten
JSON body while preserving the inbound `Content-Encoding` header, so
upstream could receive plain JSON bytes that were still labeled as
`zstd`/`gzip`. This change keeps the decoded original bytes for true
passthrough requests, strips stale entity headers, and marks Responses
body mutations so memory/compression paths still use canonical
serialization.
Closes #1542
## 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
- Switched `/v1/responses` parsing to keep the decoded original request
bytes.
- Stripped stale `content-encoding` and `transfer-encoding` headers
before forwarding decoded JSON bodies.
- Wired Responses streaming and non-streaming forwarding through the
existing byte-faithful passthrough controls.
- Marked Responses memory and compression body mutations so mutated
requests continue to serialize canonically.
- Added regression tests for gzip and zstd encoded Responses passthrough
bodies.
## Testing
- [x] Unit tests pass (`pytest`) — GitHub CI test shards passed
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`) — GitHub CI ran `mypy
headroom --ignore-missing-imports`
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
$ /tmp/headroom-1542-testenv/bin/python -m ruff check .
All checks passed!
$ /tmp/headroom-1542-testenv/bin/python -m ruff format --check .
1014 files already formatted!
$ HEADROOM_REQUIRE_RUST_CORE=false PYTHONPATH=/Users/vinaygupta/Desktop/git/headroom-fix-1542-zstd-passthrough /tmp/headroom-1542-testenv/bin/python - <<'PY'
# Injected an in-memory headroom._core import stub for this local checkout,
# then ran pytest.main(["tests/test_openai_codex_routing.py", "-q"])
PY
19 passed in 0.55s
$ HEADROOM_REQUIRE_RUST_CORE=false PYTHONPATH=/Users/vinaygupta/Desktop/git/headroom-fix-1542-zstd-passthrough /tmp/headroom-1542-testenv/bin/python - <<'PY'
# Injected an in-memory headroom._core import stub for this local checkout,
# then ran pytest.main(["tests/test_proxy_byte_faithful_forwarding.py", "-q"])
PY
35 passed, 1 warning in 1.33s
$ HEADROOM_REQUIRE_RUST_CORE=false PYTHONPATH=/Users/vinaygupta/Desktop/git/headroom-fix-1542-zstd-passthrough /tmp/headroom-1542-testenv/bin/python - <<'PY'
# Injected the same in-memory headroom._core import stub,
# then ran pytest.main(["tests/test_proxy_compression_headers.py", "-q"])
PY
10 passed in 0.05s
GitHub CI on `
|
||
|
|
99a8540e65
|
fix(evals): CJK-aware F1 tokenization + token estimation (#1527)
## Description Two functions in the `headroom/evals/` metric layer silently assumed ASCII, so the eval framework produced wrong numbers for CJK (Chinese/Japanese/Korean) text: - `metrics.py::tokenize` used `re.findall(r"\b\w+\b", ...)`. A space-free CJK string matches as **one** token (`"你好世界" → ["你好世界"]`), so token-F1 (`compute_f1`, which builds on `tokenize`) is all-or-nothing on whole CJK strings instead of token-level. - `core.py::CompressionEvaluator._estimate_tokens` returned `len(text)//4`. CJK is ~1–2 tokens/char, not 0.25, so CJK compression savings were under-counted ~4–6×. This fixes both: CJK runs are split into overlapping char bigrams (the same idiom #1504 uses in TextCrusher) so F1/recall are token-level, and token estimation counts CJK chars at ~1.5 tokens each. ASCII/digit behavior is unchanged. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `headroom/evals/metrics.py::tokenize` — CJK-aware: split each `\w+` token into maximal CJK / non-CJK runs; CJK runs become overlapping char bigrams (unigram if length 1); ASCII/digit runs are kept whole. - `headroom/evals/core.py::_estimate_tokens` — count CJK chars at ~1.5 tokens each, the rest at ~4 chars/token. - `tests/test_evals_cjk_tokenization.py` — new tests for both, plus ASCII-unchanged guards. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] Type checking passes (`mypy` on the changed files) - [x] New tests added for new functionality - [x] Manual testing performed (see Real Behavior Proof) ### Test Output ```text $ .venv/bin/python -m pytest tests/test_evals_cjk_tokenization.py 5 passed $ .venv/bin/python -m pytest tests/test_evals_metrics.py tests/test_evals/ 6 passed, 2 skipped # no regression in existing F1/metrics tests $ ruff check headroom/evals/metrics.py headroom/evals/core.py # clean ``` ## Real Behavior Proof - Environment: macOS (Darwin 25.3.0), Python in a uv venv, branch `feat/evals-cjk-tokenization` off `main`. - Exact command / steps: imported `headroom.evals.metrics.tokenize` and `headroom.evals.core.CompressionEvaluator._estimate_tokens` and called them on CJK input before/after the change. - Observed result: `tokenize("数据库连接失败")` went from `["数据库连接失败"]` (1 token) to `["数据","据库","库连","连接","接失","失败"]` (6 tokens); `_estimate_tokens("数"*20)` went from `5` to `30` (was a ~6× undercount); `compute_f1("数据库连接失败", "数据库连接成功")` went from `0.0` to a partial score in `(0, 1)`. ASCII is unchanged: `tokenize("Hello, World 42") == ["hello","world","42"]` and `_estimate_tokens("x"*40) == 10`. The existing eval metrics tests stay green (6 passed, 2 skipped). - Not tested: end-to-end framework runs against a live LLM (the fix is at the metric layer; verified directly on the functions and via the existing metric 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 - [ ] I have made corresponding changes to the documentation — N/A (internal eval-tooling fix, not user-facing runtime) - [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: `headroom/evals/` is internal dev tooling, not user-facing runtime ## Additional Notes - No new dependencies. The bigram idiom mirrors the CJK tokenization in `TextCrusher` (#1504), keeping the two consistent. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e035aefce2
|
fix(dashboard): derive per-project setup URL from live origin (#1511)
## Description The Per-Project Savings empty state currently shows a hardcoded `ANTHROPIC_BASE_URL: http://127.0.0.1:8787/p/<project-name>`. When the proxy listens on a fallback or custom port, users can copy a broken setup URL from the dashboard. This change derives the hint from the browser's live origin and keeps the existing `/p/<project-name>` suffix used by per-project savings. Closes #1508. Related context: #1406 made non-default proxy ports a normal path, which makes the hardcoded dashboard hint user-visible more often. ## 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 - Replace the static Per-Project Savings setup hint with an Alpine `x-text` binding that uses `window.location.origin`. - Preserve the `/p/<project-name>` suffix so the displayed path shape stays aligned with the existing per-project routing contract. - Add a Playwright regression that loads the dashboard from non-default origins and asserts the empty state follows the active page origin instead of `8787`. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_dashboard_cache_ttl_playwright.py -k "per_project_setup_url_uses_current_origin" -v`) - [x] Unit tests pass (`uv run pytest tests/test_owned_asset_encoding.py::test_get_dashboard_html_reads_as_utf8 tests/test_proxy_dashboard_stats_cache.py::test_dashboard_uses_cached_stats_and_lazy_history_feed_polling -v`) - [x] Linting passes (`uv run ruff check tests/test_dashboard_cache_ttl_playwright.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [x] Manual testing performed ### Test Output ```text uv run pytest tests/test_dashboard_cache_ttl_playwright.py -k "per_project_setup_url_uses_current_origin" -v tests/test_dashboard_cache_ttl_playwright.py::test_dashboard_per_project_setup_url_uses_current_origin PASSED [100%] ================= 1 passed, 1 deselected, 1 warning in 0.82s ================== uv run pytest tests/test_owned_asset_encoding.py::test_get_dashboard_html_reads_as_utf8 tests/test_proxy_dashboard_stats_cache.py::test_dashboard_uses_cached_stats_and_lazy_history_feed_polling -v tests/test_owned_asset_encoding.py::test_get_dashboard_html_reads_as_utf8 PASSED [ 50%] tests/test_proxy_dashboard_stats_cache.py::test_dashboard_uses_cached_stats_and_lazy_history_feed_polling PASSED [100%] ======================== 2 passed, 1 warning in 0.16s ========================= uv run ruff check tests/test_dashboard_cache_ttl_playwright.py All checks passed! ``` ## Real Behavior Proof - Environment: Playwright Chromium dashboard harness, dashboard template served through the existing route interception used by the dashboard tests, no live provider required. - Exact command / steps: `uv run pytest tests/test_dashboard_cache_ttl_playwright.py -k "per_project_setup_url_uses_current_origin" -v` - Observed result: `http://127.0.0.1:8788/dashboard` passed with the new current-origin assertion, and `origin/main` failed the same assertion because the page still rendered `ANTHROPIC_BASE_URL: http://127.0.0.1:8787/p/<project-name>`. A separate browser check against `http://headroom.local:9393/dashboard` also passed on the patched branch. - Not tested: full live `headroom proxy --port 8788` browser validation, unless it is run during implementation. ## 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 `CHANGELOG.md` is intentionally unchanged because this repo generates changelog entries from conventional commits. The documentation checkbox is satisfied by correcting the in-dashboard setup instruction. |
||
|
|
db0356b0e7
|
ci: guard against committed merge-conflict markers (#1505)
## Description Prevent unresolved Git merge-conflict markers from ever being merged again. `CHANGELOG.md` collected literal `<<<<<<<` / `=======` / `>>>>>>>` lines from **two** separate merged PRs (removed in #1497) and nothing caught them: no `check-merge-conflict` hook, no workflow runs `pre-commit`, and `ruff`/`mypy`/`pytest` do not parse Markdown. This adds two layers: 1. **Dedicated `merge-conflicts` workflow** (`.github/workflows/merge-conflicts.yml`) — runs on every PR/push, greps all tracked files for conflict markers, and fails with the offending locations. It is a **standalone workflow on purpose**: `ci.yml` sets `on.pull_request.paths-ignore: ['**/*.md', ...]`, so a Markdown/CHANGELOG-only PR skips `ci.yml` entirely — and the markers this guard exists to catch landed in `CHANGELOG.md`. The new workflow has no `paths-ignore`, so it always runs. (Thanks to the automated review for catching that an earlier revision put the job inside `ci.yml`, which would have reproduced the gap.) 2. **`check-merge-conflict` pre-commit hook** (`--assume-in-merge`) — a local early-catch for devs who install hooks. The workflow is the actual enforcement since hooks are opt-in. ## 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 - `.github/workflows/merge-conflicts.yml` (new): a single `merge-conflicts` job, **no `paths-ignore`**, that runs `git grep -nI -E '^(<{7}|>{7}|\|{7})( |$)'` over all tracked files and fails if any match. Matches conflict start/end (and diff3 `|||||||`) markers — every real conflict has these — while deliberately not matching a bare `=======`, to avoid false positives on Markdown setext headers. - `.pre-commit-config.yaml`: add `pre-commit/pre-commit-hooks` `check-merge-conflict` with `--assume-in-merge`. ## Testing - [x] Linting passes (`actionlint`) - [x] Manual testing performed - [ ] Unit tests pass (`pytest`) — N/A, CI-config + pre-commit change (no product code) - [ ] Type checking passes (`mypy headroom`) — N/A - [ ] New tests added for new functionality — N/A (the guard is itself the test; verified below) ### Test Output ```text # The guard's command catches real markers (run against the pre-#1497 tree that still had them): $ git grep -nI -E '^(<{7}|>{7}|\|{7})( |$)' <markered-tree> -- . CHANGELOG.md:11:<<<<<<< pr/503-proactive-expansion-xml-tag CHANGELOG.md:19:>>>>>>> main CHANGELOG.md:48:<<<<<<< fix/gemini-offload CHANGELOG.md:53:>>>>>>> main # -> exit 0 (matches found) => workflow step exits 1 (fails) # And passes on a clean tree (this branch): $ git grep -nI -E '^(<{7}|>{7}|\|{7})( |$)' HEAD -- . # (no output) -> exit 1 (no matches) => prints "No merge-conflict markers found." and exits 0 $ actionlint .github/workflows/merge-conflicts.yml .github/workflows/ci.yml -> (no output) exit 0 $ commitlint --from <base> --to HEAD -> 0 problems, 0 warnings # Both workflow YAMLs parse; merge-conflicts.yml has no `paths-ignore`, so it runs on Markdown-only PRs. ``` ## Real Behavior Proof - Environment: Linux; the repo at this branch (`ci/guard-merge-conflict-markers`), with `actionlint 1.7.7` and `commitlint` (`@commitlint/config-conventional`). The "real behavior" under test is the workflow's grep command and the YAML/trigger config — exercised directly, not mocked. - Exact command / steps: ran the workflow's exact command (`git grep -nI -E '^(<{7}|>{7}|\|{7})( |$)' -- .`) against a tree that contains markers and against this clean branch; ran `actionlint` on both workflow files; parsed `merge-conflicts.yml` and confirmed its `pull_request` trigger has no `paths-ignore` (the fix for the Markdown-only gap). - Observed result: against the markered tree the command matched the conflict start/end lines and exited 0 → the job exits 1 (red); against this branch it matched nothing and exited 1 → the job prints "No merge-conflict markers found." and exits 0 (green). `actionlint` reported no problems on either workflow, and `merge-conflicts.yml` is confirmed to omit `paths-ignore`, so it runs on CHANGELOG-only PRs that `ci.yml` skips. - Not tested: I did not drive the `check-merge-conflict` pre-commit hook through a real in-progress merge (it is a standard upstream hook); the gated CI run (fork-PR approval) was not executed — the guard logic and triggers are verified above. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation (inline comments explain the workflow + hook) - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works (the guard is self-verifying; see Real Behavior Proof) - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable (N/A — CI tooling, no user-facing change) ## Additional Notes - **Supply chain:** the only new dependency is the `pre-commit/pre-commit-hooks` repo pinned at `v5.0.0`. It is the canonical pre-commit hooks collection maintained by the pre-commit author (Anthony Sottile), is dev-only (runs solely inside pre-commit), contains no native code, and has no runtime/install-time footprint. The repo already trusts `astral-sh/ruff-pre-commit` and `pre-commit/mirrors-mypy` the same way. - The workflow adds ~10s per run and needs no secrets, network, or build. Rebased onto current `main` after #1497 merged, so this is now a clean single commit (the changelog cleanup is already on `main`). |
||
|
|
adaeb88a4d
|
fix(openclaw): detect uv-installed headroom binary in ~/.local/bin (#1459)
## Description When `headroom-ai` is installed via `uv tool install headroom-ai`, the binary lands at `~/.local/bin/headroom`. The plugin's autoStart launcher detection did not find it because the PATH check used `sh -lc` which may not source user shell config (`.zshrc`, `.bash_profile`) on all systems. This PR adds explicit uv path detection and fixes the shell invocation flag. Related to #419 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature - [ ] Breaking change - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring ## Changes Made - Switch PATH check from `sh -lc` to `sh -c` in `proxy-manager.ts` - Add explicit uv tool install detection checking `~/.local/bin/headroom` ## Testing - [ ] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] Manual testing performed ### Test Output ```text Status: loaded Version: 0.27.0 Capabilities: context-engine: headroom Tools: headroom_retrieve ``` ## Real Behavior Proof - Environment: EndeavourOS x86_64, OpenClaw 2026.6.10, headroom-ai 0.27.0 via uv - Exact command / steps: `uv tool install headroom-ai` then `openclaw gateway restart` - Observed result: Before fix — autoStart failed with "Headroom proxy not detected on default endpoints" even though binary exists at `~/.local/bin/headroom`. After fix — plugin loads and connects correctly. - Not tested: Windows, Docker runtime ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Additional Notes Reproduced while debugging the npm package staleness issue in #419. Affects any user following the standard `uv tool install` workflow on Linux/macOS. |
||
|
|
75427bbd4a
|
fix(wrap): preserve custom Vertex base URL (#1477)
## Description Fixes `headroom wrap claude` in Vertex mode when the user has configured a custom Vertex-compatible gateway through `ANTHROPIC_VERTEX_BASE_URL`. Before this change, wrap mode redirected Claude Code's `ANTHROPIC_VERTEX_BASE_URL` to the local Headroom proxy, but the original custom upstream was not forwarded to the proxy as `VERTEX_TARGET_API_URL`. The proxy therefore fell back to the default Google Vertex endpoints and custom gateways could return 404 or auth/model errors. Closes #1476 ## 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 - Capture the original Vertex upstream before `wrap claude` redirects Claude Code to the local proxy. - Pass custom Vertex upstreams to the proxy as `--vertex-api-url` / `VERTEX_TARGET_API_URL`. - Let explicit `VERTEX_TARGET_API_URL` take precedence over `ANTHROPIC_VERTEX_BASE_URL`. - Guard against accidentally using the local Headroom proxy URL as the proxy's own Vertex upstream. - Restart idle running proxies when their configured Vertex upstream does not match the requested Vertex mode state. - Persist and restore `ANTHROPIC_VERTEX_BASE_URL` for Vertex-mode Claude daemon workers, and clean it up during `unwrap claude`. - Expose `vertex_api_url` in loopback health config so wrapper reuse checks can detect mismatches. ## 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 $ rtk gh pr checks 1477 --repo headroomlabs-ai/headroom CI Checks Summary: [ok] Passed: 22 [FAIL] Failed: 0 $ rtk pytest tests/test_cli/test_wrap_claude_vertex_proxy_env.py tests/test_cli/test_unwrap_claude.py tests/test_azure_foundry_claude_compression.py tests/test_cli/test_wrap_persistent.py tests/test_provider_registry.py -q Pytest: 64 passed $ rtk uvx ruff==0.15.17 check headroom/cli/wrap.py headroom/proxy/server.py tests/test_cli/test_wrap_claude_vertex_proxy_env.py tests/test_cli/test_unwrap_claude.py All checks passed! $ rtk uvx ruff==0.15.17 format --check headroom/cli/wrap.py headroom/proxy/server.py tests/test_cli/test_wrap_claude_vertex_proxy_env.py tests/test_cli/test_unwrap_claude.py 4 files already formatted $ rtk python3 -m py_compile headroom/cli/wrap.py headroom/proxy/server.py tests/test_cli/test_wrap_claude_vertex_proxy_env.py tests/test_cli/test_unwrap_claude.py # passed, no output $ rtk uv run --python 3.13 pytest tests/test_vertex_claude_compression.py -q Failed before test collection while building the local editable package: esaxx-rs build failed with fatal error: 'cstdint' file not found. ``` ## Real Behavior Proof - Environment: GitHub Actions CI on PR #1477 plus local macOS worktree `fix/1476-vertex-base-url`. - Exact command / steps: CI ran lint, type checking, build, unit-test shards, native wrapper checks, wrap-native e2e, and Docker e2e jobs; locally ran focused wrapper, unwrap, Foundry, persistent-proxy, and provider-registry tests. - Observed result: CI passed 22 checks with 0 failures; local focused tests passed; Ruff check/format passed; Python compile passed. - Not tested: broader proxy-route tests that import `headroom.proxy.server` through a local editable build could not run locally because the native `esaxx-rs` build fails before test collection with missing `cstdint`. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes - Documentation, CHANGELOG, and extra code-comment checklist items are N/A for this narrow wrapper bug fix. - Full local unit test execution is limited by the existing native extension build issue described above; focused Python-only coverage passes and GitHub CI is green. |
||
|
|
f00ace6da5
|
fix(cortex-code): migrate to current Cortex REST API endpoints + add e2e benchmarks (#1474)
## Description Follow-up to #1190 (Cortex Code provider). Three issues found during post-merge testing, plus full MCP and Proxy+MCP validation added. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [x] Documentation update ## Changes Made - `docs/cortex-code.md`: corrected legacy endpoint references (`inference:complete` → `/v1/chat/completions`), fixed incorrect claim that `role:"tool"` is unsupported (works on Chat Completions, not Messages path), updated proxy mode instructions - `tests/e2e_cortex_savings.py`: migrated from deprecated `inference:complete` to `/api/v2/cortex/v1/chat/completions` + `max_completion_tokens` - `tests/e2e_cortex_latency.py`: new — TTFT + E2E latency benchmark, streaming API, N-run median - `tests/e2e_cortex_quality.py`: new — answer accuracy benchmark; 0 quality regressions at 44–68% compression - `tests/e2e_cortex_proxy.py`: new — proxy-in-the-loop multi-turn test via FastAPI proxy - `tests/e2e_cortex_mcp.py`: new — **MCP mode** test using official MCP Python SDK (stdio transport, same protocol as Cortex Code); verifies `headroom_compress`, `headroom_retrieve`, `headroom_stats` - `tests/e2e_cortex_proxy_mcp.py`: new — **Proxy + MCP** test; starts FastAPI proxy and MCP server simultaneously, exercises both paths in same session ## Testing - [x] Linting passes (`ruff check .`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text # MCP mode (e2e_cortex_mcp.py) [1/6] Connecting to headroom MCP server ... OK [2/6] Listing MCP tools ... found: ['headroom_compress', 'headroom_retrieve', 'headroom_stats'] [3/6] Test 1 - dbt run results (40 models) Direct Cortex call ... prompt=2,112 tokens MCP headroom_compress ... saved 0 tokens hash=825cf6f2... Cortex call (MCP-compressed) ... prompt=2,112 saved 0 (0.0%) [4/6] Test 2 - INFORMATION_SCHEMA tables (59 rows) Direct Cortex call ... prompt=3,203 tokens MCP headroom_compress ... saved 1,280 tokens (37.2%) Cortex call (MCP-compressed) ... prompt=1,163 saved 2,040 (63.7%) [5/6] headroom_retrieve CCR round-trip ... original content retrieved [6/6] headroom_stats ... compressions: 2, total_tokens_saved: 1280 MCP TEST PASSED - 38.4% avg token reduction via MCP tools # Proxy + MCP mode (e2e_cortex_proxy_mcp.py) [1/7] Starting headroom proxy ... OK [2/7] Connecting to headroom MCP server ... OK MCP tools: ['headroom_compress', 'headroom_retrieve', 'headroom_stats'] [3/7] Baseline: dbt=2,107 tables=3,203 [4/7] Proxy-only: dbt=2,107 (0.0%) tables=3,203 (0.0%) [5/7] MCP+Proxy: dbt=2,107 (0.0%) tables=1,163 (63.7% saved) [6/7] CCR round-trip: original content retrieved Components verified: Proxy starts (FastAPI + uvicorn) and routes to Cortex MCP server connects (MCP Python SDK client) headroom_compress works via MCP headroom_retrieve (CCR) works via MCP Proxy + MCP run simultaneously in same session ``` ## Real Behavior Proof - Environment: macOS, Python 3.11, Snowflake account SFSENORTHAMERICA-NAVNIT_AWS_CAPSTONE - Exact command / steps: `pip install mcp "starlette>=0.37.2,<0.41.0"` then `SF_CONN=<conn> python3 tests/e2e_cortex_savings.py`, `SF_CONN=<conn> python3 tests/e2e_cortex_quality.py`, `SF_CONN=<conn> python3 tests/e2e_cortex_latency.py`, `SF_CONN=<conn> python3 tests/e2e_cortex_proxy.py`, `SF_CONN=<conn> python3 tests/e2e_cortex_mcp.py`, `PROXY_PORT=8798 SF_CONN=<conn> python3 tests/e2e_cortex_proxy_mcp.py` - Observed result: MCP server connects via stdio, tools verified, 63.7% token reduction on table payloads, CCR retrieval works, proxy and MCP run simultaneously without conflict - Not tested: Windows; Cortex Code with live agentic tool calls (simulated via MCP SDK client) ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] 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 ## Additional Notes - `role:"tool"` correction: Chat Completions endpoint supports it; Messages endpoint does not (use `user` message with `tool_result` block instead) - MCP tests require `pip install mcp` - Starlette compatibility: `mcp` may install starlette 1.3.1 which conflicts with headroom proxy; fix with `pip install "starlette>=0.37.2,<0.41.0"` --------- Co-authored-by: Cortex Code <noreply@snowflake.com> |
||
|
|
6cba4419d0
|
fix(wrap): detach the shared proxy on Windows so it survives an ungraceful agent close (#1464)
## Description Closing one `headroom wrap <agent>` instance on Windows could kill the **shared proxy** out from under every other running instance, so their requests started failing. `_start_proxy` launched the proxy as a child of whichever agent started it first, without detaching it from that agent's console and Job object. The wrapper already reference-counts clients via per-PID markers and `_make_cleanup` leaves the proxy running while other clients exist — but that only runs on a *graceful* exit. On an *ungraceful* close (closing the terminal window, `taskkill`, a crash) Windows tree-kills the whole process group/Job and the proxy dies directly, bypassing the reference counting. Every other instance's `ANTHROPIC_BASE_URL` then points at a dead `127.0.0.1:8787`, so all of its API traffic fails. ## 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 - `_start_proxy` creates the proxy with `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_BREAKAWAY_FROM_JOB` on Windows, so an ungraceful close of the launching agent can no longer reach it; only the ref-counted `_make_cleanup` ends the proxy. - Falls back without `CREATE_BREAKAWAY_FROM_JOB` (catching `OSError`) when the launcher's Job forbids breakaway; `DETACHED_PROCESS` still spares the proxy from console-close events. - Platform guard is `sys.platform == "win32"` (not `os.name == "nt"`) so mypy narrows the platform and resolves the Windows-only `subprocess` constants. - POSIX path unchanged: `creationflags=0`, detachment still via `start_new_session` (`setsid`). - Added `tests/test_cli/test_wrap_proxy_detach.py` and a CHANGELOG Bug Fixes entry. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_cli/test_wrap_proxy_detach.py -q .. [100%] 2 passed, 2 warnings in 1.50s $ ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_proxy_detach.py All checks passed! $ mypy --follow-imports=silent headroom/cli/wrap.py tests/test_cli/test_wrap_proxy_detach.py Success: no issues found ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.11.9, headroom-ai (pipx). Two concurrent `headroom wrap claude` instances sharing proxy `127.0.0.1:8787`. `_start_proxy` was also exercised directly on this host with `subprocess.Popen` stubbed. - Exact command / steps: (1) start two `headroom wrap claude` instances; (2) close the terminal window of the one that started the proxy (ungraceful — not `/exit`); (3) issue a request from the surviving instance. Separately: call `_start_proxy(8787)` with `subprocess.Popen` stubbed and read back the creation flags. - Observed result: before the fix the proxy died with the closed window and the surviving instance failed (`ANTHROPIC_BASE_URL` → dead `:8787`), because the OS tree-killed the child before the ref-count path could spare it. After the fix the detached proxy survives the close and the surviving instance keeps working; the stub harness reports `creationflags=0x1000208` (`DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_BREAKAWAY_FROM_JOB`) on win32 and `0` when forced off-Windows. - Not tested: real breakaway behavior under an actual restrictive Job object on this host (the OS-level effect). The `OSError` fallback path itself now has a dedicated unit test (`test_start_proxy_retries_without_breakaway_when_job_forbids_it`). ## 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 ## Screenshots (if applicable) N/A — no UI changes. ## Additional Notes - Documentation checklist item is N/A: this is a behavioral bug fix with no user-facing doc surface. - Scope is the single `subprocess.Popen` call in `_start_proxy`; the marker-based reference counting in `_make_cleanup` is unchanged and remains the only thing that intentionally stops the proxy. |
||
|
|
d337e3b828
|
fix(proxy): handle streaming CCR retrieval (#1451)
## Description Fixes Anthropic-compatible streaming requests that can emit the internal `headroom_retrieve` CCR tool. When a `stream: true` request includes the CCR retrieve tool and response handling is enabled, Headroom now buffers the upstream call as `stream: false`, lets the existing CCR response handler retrieve and continue, and returns the final result as Anthropic SSE so streaming clients do not see the internal tool call. Closes #1450 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Detect direct Anthropic-compatible `stream: true` requests where `headroom_retrieve` is available and CCR response handling is enabled. - Route those requests through the existing buffered/non-stream CCR response handler, then convert the final response back to `text/event-stream`. - Fail closed with a 502 SSE error if a buffered response still contains `headroom_retrieve` after CCR handling, instead of leaking the internal tool to the client. - Preserve Anthropic `thinking`, `redacted_thinking`, signatures, and citations when converting response JSON back to SSE. - Add regression coverage for handled CCR retrieval, unused CCR tool availability, normal streaming passthrough, mixed client/CCR tool fail-closed behavior, and SSE conversion preservation. ## 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 $ rtk gh pr checks 1451 --repo headroomlabs-ai/headroom CI Checks Summary: [ok] Passed: 20 [FAIL] Failed: 0 Relevant CI commands from .github/workflows/ci.yml: - ruff check . - ruff format --check . - mypy headroom --ignore-missing-imports - pytest tests scripts/tests $ rtk python3 -m py_compile headroom/proxy/handlers/anthropic.py headroom/proxy/handlers/streaming.py tests/test_proxy/test_anthropic_streaming_ccr_retrieve.py tests/test_sse_thinking_blocks.py # passed, no output $ rtk pytest tests/test_sse_thinking_blocks.py -q Pytest: 6 passed $ MACOSX_DEPLOYMENT_TARGET=15.0 rtk uv run --python 3.13 pytest tests/test_proxy/test_anthropic_streaming_ccr_retrieve.py -q Failed before test collection while building the local editable package: esaxx-rs build failed with fatal error: 'cstdint' file not found. ``` ## Real Behavior Proof - Environment: GitHub Actions CI on PR #1451 plus local macOS worktree `fix/1450-ccr-streaming-retrieve`. - Exact command / steps: CI ran lint, type checking, build, unit-test shards, dashboard tests, extras tests, and e2e jobs; locally ran syntax checks and the SSE conversion regression tests. - Observed result: CI passed 20 checks with 0 failures; local syntax checks passed; `tests/test_sse_thinking_blocks.py` passed with 6 tests. - Not tested: the new proxy-level regression test was not run locally because the local native extension build fails in `esaxx-rs` before proxy tests can collect; it is included in the CI-tested suite. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes - Scope: this handles the direct Anthropic-compatible HTTP `/v1/messages` path. The configured Bedrock/backend streaming path does not share this CCR continuation machinery in this PR. - Documentation, CHANGELOG, code-comment, and local-full-test checklist items are N/A for this narrow bug fix or not true locally. |
||
|
|
ddd4adf911
|
fix(codex): avoid duplicate headroom provider config (#1431)
## Description Fixes #1425. `headroom wrap codex` could leave `~/.codex/config.toml` invalid when the user already had a `[model_providers.headroom]` table. The previous duplicate-key handling covered top-level `model_provider` and `openai_base_url`, but the provider table was still appended as a static block. That could produce duplicate `env_http_headers` or duplicate provider-table TOML errors before Codex started. ## Type of Change - [x] Bug fix (non-breaking change fixes an issue) - [ ] New feature (non-breaking change adds functionality) - [ ] Breaking change (fix or feature would cause existing functionality change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Added a Codex config cleanup helper that removes any pre-existing `[model_providers.headroom]` table from the working copy before `wrap codex` appends the managed Headroom provider block. - Kept unwrap behavior backed by the existing pre-wrap snapshot, so a custom prior `headroom` provider table is restored byte-for-byte on `headroom unwrap codex`. - Added regression tests for TOML validity, a single `env_http_headers` mapping, one managed `[model_providers.headroom]` table, and unwrap restoration. ## Testing - [x] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added to cover the fix ### Test Output ```text Docker: python:3.12-slim Command: uv run --frozen --with pytest pytest tests/test_cli/test_wrap_codex.py Result: 68 passed, 1 warning ``` ## Real Behavior Proof - Environment: disposable Docker container, `python:3.12-slim`, Linux, Python 3.12.13. - Exact command / steps: mounted the worktree into `/workspace`, installed build tools inside the container, then ran `uv run --frozen --with pytest pytest tests/test_cli/test_wrap_codex.py`. - Observed result: all Codex wrap tests passed, including the new regression where an existing `[model_providers.headroom]` table contains `env_http_headers` before wrapping. - Not tested: live interactive `headroom wrap codex` launch against a real user Codex session. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review |
||
|
|
15ac650d40
|
fix(proxy): fail open when kompress saturation would exhaust pre-upstream budget (#1430)
## Description
Concurrent Anthropic `/v1/messages` traffic can still exhaust Headroom's
pre-upstream budget because Kompress ONNX execution waits on the request
critical path. When Kompress saturates, requests eventually fail with
`503 pre-upstream queue saturated` even though compression can safely
degrade to passthrough.
This PR makes Kompress saturation fail open on the Anthropic hot path,
so requests continue uncompressed when compression capacity is under
pressure. It keeps the executor and stage-timing evidence intact, and it
preserves blocking model-load validation so runtime pressure does not
silently skip the validation path.
Closes #1025
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- add a bounded execution-slot acquire path so Anthropic requests fail
open to passthrough when Kompress saturation would consume the
pre-upstream budget
- preserve explicit execution-timeout counters and Anthropic
passthrough/stage-timing observability instead of hiding the pressure
path
- keep `_validate_pytorch_device()` on blocking acquire semantics so
model-load validation still waits for capacity instead of failing open
- make the blocking validation acquire explicit to `mypy` without
changing runtime behavior
- extend focused regressions for pre-upstream backpressure, Kompress
saturation, execution-skip observability, and validation waiting
- align the CLI timeout help text and `ProxyConfig` comment with the
fail-open runtime behavior
- update `CHANGELOG.md` for the proxy runtime fix
## Testing
- [x] Unit tests pass (`uv run pytest
tests/test_anthropic_pre_upstream_backpressure.py
tests/test_proxy_compression_executor.py
tests/test_kompress_request_nonblocking.py`)
- [x] Linting passes (`uv run ruff check
tests/test_anthropic_pre_upstream_backpressure.py` and `uv run ruff
format tests/test_anthropic_pre_upstream_backpressure.py --check`)
- [x] Type checking passes (`uv run mypy headroom
--ignore-missing-imports`)
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed
### Test Output
```text
Focused local validation passed:
- uv run pytest tests/test_anthropic_pre_upstream_backpressure.py tests/test_proxy_compression_executor.py tests/test_kompress_request_nonblocking.py -x -v
37 passed, 1 warning in 12.01s
- uv run ruff check headroom/proxy/handlers/anthropic.py headroom/transforms/kompress_compressor.py tests/test_anthropic_pre_upstream_backpressure.py tests/test_proxy_compression_executor.py tests/test_kompress_request_nonblocking.py
All checks passed!
- uv run ruff format headroom/proxy/handlers/anthropic.py headroom/transforms/kompress_compressor.py tests/test_anthropic_pre_upstream_backpressure.py tests/test_proxy_compression_executor.py tests/test_kompress_request_nonblocking.py --check
5 files already formatted
- uv run mypy headroom --ignore-missing-imports
Success: no issues found in 398 source files
Base-branch proof on origin/main (
|
||
|
|
72ade37112
|
fix(savings): count cache-read tokens in input cost estimate (#1429)
## Description `_estimate_input_cost_usd` priced fully prefix-cached requests at $0. Anthropic reports cache reads/writes separately from `input_tokens` (the uncached portion), so a request served entirely from the prefix cache arrives with `input_tokens == 0` and `cache_read_tokens > 0`. The function bailed on `if total_input_tokens <= 0` *before* consulting the cache breakdown, dropping the real cache-read cost. On days dominated by cache-hit traffic this yields savings rollups with compression savings recorded but zero input tokens and zero spend. ## 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 - `_estimate_input_cost_usd` now gates on tokens actually sent (`input_tokens + cache_read + cache_write + uncached`) instead of `input_tokens` alone, so cache-only requests are priced from the cache breakdown the function already supports. - Added a regression test asserting a request with `input_tokens=0, cache_read_tokens=1000` is priced at the cache-read rate rather than $0. ## 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 $ uv run --extra dev pytest tests/test_proxy_savings_history.py -q 17 passed, 3 warnings in 24.76s $ uvx ruff check headroom/proxy/savings_tracker.py tests/test_proxy_savings_history.py All checks passed! $ uvx ruff format --check headroom/proxy/savings_tracker.py tests/test_proxy_savings_history.py 2 files already formatted $ uv run --extra dev mypy headroom/proxy/savings_tracker.py Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: macOS, Python 3.12, headroom upstream/main - Exact command / steps: added `test_input_cost_counts_cache_reads_when_uncached_input_is_zero`; ran the suite above. The new test fails on `main` (obtains 0.0) and passes with the fix (0.3). - Observed result: cache-only requests now contribute their cache-read cost to `total_input_cost_usd`; the savings/spend invariant holds. - Not tested: no live end-to-end proxy run; the change is isolated to the cost estimator and covered by the unit test. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - N/A documentation / CHANGELOG: behavioral cost-accounting fix with no user-facing API or doc surface. - Follow-up (not in this PR to keep it focused): `total_input_tokens` / "tokens sent" still counts only the uncached `input_tokens` and omits cache-read tokens, so the dashboard's sent-token total under-reports cache-hit traffic. The cost fix here is sufficient to resolve the zero-spend anomaly (the probe ANDs cost == 0), but counting cache reads toward sent tokens would make the displayed total honest too. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
64783d8824
|
fix: skip Magika backend on x86 CPUs without AVX2 (#1162)
## Description Adds a narrow runtime AVX2 guard before initializing the Magika/ONNX Runtime detector on x86/x86_64. On x86/x86_64 CPUs without AVX2, Headroom falls back to existing non-Magika detection tiers instead of crashing during ONNX Runtime initialization. AVX2-capable systems retain existing behavior. Refs #1005 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Adds a Magika/ONNX Runtime CPU support guard before `Session::new()`. - Returns a normal Magika init error on x86/x86_64 hosts without AVX2, allowing the existing detection chain to fall through to non-Magika tiers. - Keeps AVX2-capable x86/x86_64 behavior unchanged. - Does not apply the x86-specific AVX2 gate on non-x86 targets. - Adds CPU-aware Rust tests and a short troubleshooting note. ## Testing - [ ] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ cargo test -p headroom-core --lib --locked 833 passed; 0 failed; 1 ignored $ cargo test --workspace --locked passed $ cargo clippy -p headroom-core --locked -- -D warnings clean ``` ## Real Behavior Proof - Environment: x86_64 Linux host with AVX but no AVX2 (Intel Xeon E5-2697 v2 on Proxmox), local build from this branch. - Exact command / steps: `python -X faulthandler -c 'from headroom._core import detect_content_type; print(detect_content_type("hello world"))'` - Observed result: before — process exited with `Fatal Python error: Illegal instruction`; after — command completed successfully returning `DetectionResult(content_type="text", ...)`, and full `cargo test -p headroom-core --lib --locked` passed with 833/0/1. - Not tested: generic no-AVX CPUs, alternate ONNX Runtime builds, non-x86 platforms. ## 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 This partially addresses #1005 by handling one concrete native crash class: the Magika detector initializes ONNX Runtime through ort/ort-sys, whose precompiled runtime can contain AVX2-family instructions. On AVX-only x86_64 hosts, that initialization can SIGILL before Headroom can fall back. Scope: - This does not introduce generic no-AVX wheels. - This does not redesign Rust-core packaging. - This does not disable the Rust core globally. - This only prevents the Magika/ONNX detector tier from loading on x86/x86_64 CPUs where AVX2 is unavailable. - Non-Magika detection tiers continue to run. - On non-x86 targets, this x86-specific AVX2 gate is not applied. Changelog omitted: small native detector fallback fix with no public API change. Co-authored-by: AI Agent <ai-agent@homelab.internal> |
||
|
|
abab3ccbfc
|
docs: clarify the headroom CLI is pip-only; npm headroom-ai is the TS SDK (#1585)
## Description `npm install headroom-ai` doesn't give you the `headroom` CLI — it's the TypeScript SDK (a library, no `bin`). The README's "Get started" and "Install" blocks listed the npm install next to the pip install and then immediately ran `headroom wrap claude`, so Node/Windows users reasonably expected npm to provide the CLI and hit `'headroom' is not recognized`. This spells out the split: CLI = pip, SDK = npm. The hnswlib/MSVC half of the report was already fixed on main in #1499 (moved hnswlib to the optional `[vector]` extra), so this PR only addresses the npm-CLI confusion. Closes #1470 ## Type of Change - [x] Documentation update ## Changes Made - README "Get started" + "Install" blocks: annotate that pip ships the `headroom` CLI and npm `headroom-ai` is the TS SDK with no CLI; note the `headroom` commands come from the pip install. - `docs/content/docs/installation.mdx`: state the TS SDK does not install the `headroom` CLI. ## Testing - [x] Manual testing performed ### Test Output ```text Docs-only change. Verified against the source of truth: - pyproject.toml: [project.scripts] headroom = "headroom.cli:main" (CLI entry point is Python-only) - sdk/typescript/package.json: name "headroom-ai", no "bin" field (SDK, no CLI) ``` ## Real Behavior Proof - Environment: repo main @ HEAD - Exact command / steps: read `[project.scripts]` in pyproject.toml and the `bin` field in sdk/typescript/package.json - Observed result: `headroom` console script is defined only by the Python package; the npm package has no `bin`, so `npm install headroom-ai` provides no `headroom` command — matching the issue. - Not tested: n/a (no code paths changed) ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings |
||
|
|
c19347c310
|
fix(opencode): preserve custom OpenAI gateway paths (#1596)
## Description Custom OpenAI-compatible gateways mounted under provider-specific prefixes could miss Headroom's dedicated OpenAI compression routes when used through the OpenCode transport. A request such as `https://open.bigmodel.cn/api/coding/paas/v4/chat/completions` was replayed to the proxy at `/api/coding/paas/v4/chat/completions`, so the proxy selected catch-all passthrough instead of `/v1/chat/completions`. This change keeps the proxy-facing entrypoints stable on `/v1/chat/completions` and `/v1/responses` for OpenAI-compatible suffixes, while preserving the original upstream path in an internal header so the dedicated OpenAI handlers can reconstruct the real provider URL. Closes #1582 ## 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 - Normalize opencode-routed OpenAI-compatible `/chat/completions` and `/responses` requests onto the proxy's stable `/v1/*` routes. - Preserve the original upstream pathname in an internal `x-headroom-original-path` signal for dedicated OpenAI handler reconstruction. - Reconstruct dedicated OpenAI upstream URLs from `x-headroom-base-url` plus the preserved path prefix, while preserving request query strings and rejecting non-HTTP base hints. - Keep nearby non-OpenAI paths such as `/base/v1/messages` on existing passthrough behavior. - Add focused transport and proxy regression coverage for prefixed gateway paths, invalid fallback cases, and internal-header stripping. ## Testing - [x] Transport regression tests pass (`npm --prefix plugins/opencode test -- src/transport.test.ts`) - [x] Proxy regression tests pass (`uv run pytest tests/test_proxy/test_openai_transport_path_prefix.py -q`) - [x] Linting passes (`uv run ruff check headroom/proxy/handlers/openai.py tests/test_proxy/test_openai_transport_path_prefix.py`) - [x] Type checking passes (`npm --prefix plugins/opencode run typecheck`) - [x] New tests added for the bugfix - [ ] Manual testing performed ### Test Output ```text npm --prefix plugins/opencode test -- src/transport.test.ts PASS, 11 tests passed. npm --prefix plugins/opencode run typecheck PASS uv run pytest tests/test_proxy/test_openai_transport_path_prefix.py -q PASS, 7 tests passed. uv run ruff check headroom/proxy/handlers/openai.py tests/test_proxy/test_openai_transport_path_prefix.py PASS, all checks passed. ``` ## Real Behavior Proof - Environment: Windows, Python 3.12 via `uv`, Node 18+, focused OpenCode transport and proxy handler tests. - Exact command / steps: on `origin/main`, copy the updated `plugins/opencode/src/transport.test.ts` into a base worktree and run `npm --prefix plugins/opencode test -- src/transport.test.ts`; on this branch, rerun that transport test plus `npm --prefix plugins/opencode run typecheck` and `uv run pytest tests/test_proxy/test_openai_transport_path_prefix.py -q`. - Observed result: the base worktree fails because prefixed `/chat/completions` and `/responses` requests still enter the proxy at their provider path, while this branch passes with `/v1/chat/completions` and `/v1/responses`, preserves `x-headroom-original-path`, reconstructs the provider-prefixed upstream URL and query string, falls back safely on invalid hints, and keeps nearby `/base/v1/messages` traffic on passthrough. - Not tested: full CI suite, live BigModel traffic, and generic catch-all passthrough compression. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes This completes the transport contract introduced in https://github.com/headroomlabs-ai/headroom/pull/1573 by keeping prefixed OpenAI-compatible traffic on Headroom's stable `/v1/*` surface while preserving the real upstream path for dedicated-handler reconstruction. https://github.com/headroomlabs-ai/headroom/pull/1367 is adjacent global proxy configuration work for direct deployments; this PR is the per-request OpenCode transport fix for custom upstream path prefixes. `CHANGELOG.md` is intentionally unchanged because this repo's release pipeline generates changelog entries from conventional commits. This stays scoped to `/chat/completions` and `/responses` suffixes. Generic catch-all passthrough compression remains separate from this bugfix slice. |
||
|
|
1c0e15243e
|
fix(mcp): show lifetime totals and label rolling session scope in headroom_stats (#1428)
## Description `headroom_stats` currently formats only the rolling session view from `/stats`, so users see session numbers with no explicit scope label and no lifetime totals even though the proxy already exposes lifetime savings data. This PR keeps the current session summary, labels it as rolling-session output, and appends lifetime totals from `persistent_savings.lifetime`. It stays formatting-only on an existing payload surface. Closes #1166 ## 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 - label the existing `headroom_stats` session block as rolling-session output - append lifetime totals from the existing stats payload - add focused formatter regressions and fallback coverage - update `CHANGELOG.md` ## Testing - [ ] Unit tests pass (`uv run pytest tests/test_ccr_mcp_server.py -v`) - [ ] Linting passes (`uv run ruff check .`) - [ ] Type checking passes (`uv run mypy headroom`) or explain N/A truthfully - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text Focused local commands passed: - uv run pytest tests/test_ccr_mcp_server.py -x -v 9 passed, 1 skipped - uv run ruff check headroom/ccr/mcp_server.py tests/test_ccr_mcp_server.py All checks passed - uv run ruff format headroom/ccr/mcp_server.py tests/test_ccr_mcp_server.py --check 2 files already formatted Base proof on origin/main with the updated regression file: - pytest -k "window_scoped" failed because the output still says "Headroom Session Summary" - pytest -k "includes_lifetime_totals_from_persistent_savings" failed because the formatted text still has no "Lifetime Savings:" section Not run locally: - uv run mypy headroom - Template-level broader commands `uv run pytest tests/test_ccr_mcp_server.py -v` and `uv run ruff check .` ``` ## Real Behavior Proof - Environment: focused `HeadroomMCPServer._handle_stats()` test payloads with and without `persistent_savings.lifetime` - Exact command / steps: run `uv run pytest tests/test_ccr_mcp_server.py -x -v`, specifically the new `_handle_stats()` regressions that feed summary-only, summary-plus-lifetime, missing-lifetime, and zero-lifetime payloads through the MCP stats formatter - Observed result: output contains `Headroom Window-Scoped Session Summary`, appends `Lifetime Savings:` when lifetime data is present, and omits that section cleanly when lifetime data is absent - Not tested: broader MCP output redesign beyond this formatter ## 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 - [ ] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Additional Notes - Scoped to the MCP text surface only; dashboard and broader savings-window work stay out of scope. - Attribution: the issue body identified the exact mismatch between current `headroom_stats` output and the already-live lifetime stats payload. |
||
|
|
a9322477e3
|
fix: preserve anthropic passthrough tool order (#1427)
## Description Preserves Anthropic `tools` order when Headroom is forwarding a passthrough/no-optimize request. This fixes a Claude Code style `tool_result` continuation failure against stricter Anthropic-compatible upstreams that treat the client's original tool ordering as part of the conversation state. Closes #1417 ## 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 - Preserve client-provided Anthropic `tools` order when `optimize=False` or the request is explicitly in Headroom passthrough/bypass mode. - Keep deterministic tool sorting for optimized requests where Headroom may rewrite the body for cache stability. - Avoid sorting batch-request tools before the no-optimize passthrough branch. - Add regression coverage for the Anthropic HTTP path to prove no-optimize forwarding keeps `Read`, then `Bash` tool order. - Update existing cache-stability and byte-faithful forwarding tests so no-optimize/passthrough expects preserved client order while optimized mode still proves deterministic sorting. ## Testing - [x] Focused unit tests pass (`pytest` on touched proxy test files) - [x] Linting passes (`ruff check` and `ruff format --check` on touched files) - [x] Type checking passes (`mypy headroom`) - [x] New regression tests added - [x] Manual testing performed ### Test Output ```text $ uv run --no-project --python /opt/homebrew/opt/python@3.13/bin/python3.13 --with pytest --with pytest-asyncio --with anyio --with 'httpx[http2]' --with fastapi --with pydantic --with tiktoken --with click --with rich --with opentelemetry-api --with opentelemetry-sdk --with zstandard --with openai --with mcp --with uvicorn pytest tests/test_proxy_handler_helpers.py tests/test_anthropic_stage_timings.py tests/test_proxy_anthropic_cache_stability.py tests/test_proxy_byte_faithful_forwarding.py -q ============================= test session starts ============================== platform darwin -- Python 3.13.11, pytest-9.1.1, pluggy-1.6.0 rootdir: /Users/vinaygupta/Desktop/git/headroom-fix-anthropic-tool-order configfile: pyproject.toml plugins: anyio-4.14.1, asyncio-1.4.0 asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function collected 87 items tests/test_proxy_handler_helpers.py .......................... [ 29%] tests/test_anthropic_stage_timings.py .... [ 34%] tests/test_proxy_anthropic_cache_stability.py ......................... [ 63%] tests/test_proxy_byte_faithful_forwarding.py ........................... [ 94%] ..... [100%] =============================== warnings summary =============================== .../fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. ======================== 87 passed, 1 warning in 5.13s ========================= $ uv run --no-project --python /opt/homebrew/opt/python@3.13/bin/python3.13 --with ruff==0.15.17 ruff format --check headroom/proxy/handlers/anthropic.py tests/test_proxy_handler_helpers.py tests/test_anthropic_stage_timings.py tests/test_proxy_anthropic_cache_stability.py tests/test_proxy_byte_faithful_forwarding.py 5 files already formatted $ uv run --no-project --python /opt/homebrew/opt/python@3.13/bin/python3.13 --with ruff==0.15.17 ruff check headroom/proxy/handlers/anthropic.py tests/test_proxy_handler_helpers.py tests/test_anthropic_stage_timings.py tests/test_proxy_anthropic_cache_stability.py tests/test_proxy_byte_faithful_forwarding.py All checks passed! ``` ## Real Behavior Proof - Environment: macOS, Python 3.13.11, local fake Anthropic-compatible upstream, local Headroom proxy launched with `--no-optimize --no-cache --no-rate-limit --stateless`. - Exact command / steps: ran a local reproduction harness that starts a fake `/v1/messages` upstream and Headroom proxy, then sends a Claude Code style two-turn flow: first assistant `Bash` `tool_use`, then user `tool_result`. - Observed result: after this patch, both direct and proxied flows returned `200` for `first_tool_use` and `second_tool_result`. The fake upstream log showed the proxied `tools` array remained `["Read", "Bash"]` on both turns. ```text DIRECT first_tool_use: 200 second_tool_result: 200 PROXIED first_tool_use: 200 second_tool_result: 200 UPSTREAM REQUEST LOG proxied first turn tools: ["Read", "Bash"] proxied tool_result turn tools: ["Read", "Bash"] ``` - Not tested: full `pytest`, full-repo `ruff check .`, `mypy headroom`, or a live third-party Anthropic-compatible provider. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes - This PR intentionally does not add documentation because it fixes passthrough behavior rather than introducing a new user-facing option. - The code-comment checklist item is left unchecked because the change is covered by a small helper docstring and regression tests; no extra inline comments seemed necessary. - `CHANGELOG.md` is left unchanged because this is a narrowly scoped bug fix. - Local pytest collection for these proxy tests required a local `headroom._core` extension symlink, which was removed before committing. |
||
|
|
27a5468349
|
fix(learn): aggregate verbosity baselines across projects instead of overwriting (#1288)
## Description `headroom learn --verbosity --apply --all` was building the output-shaper's savings baseline from only **one** project. `_run_verbosity` wrote the savings ledger *inside* the per-project loop (`ledger.baseline = baseline; ledger.save(...)`), so each project replaced the previous baseline and only the last project processed survived — frequently a near-empty one. The synthetic-control estimate that `/stats` exposes (`savings.by_layer.output_shaping`) was then computed against a tiny, unrepresentative sample. This PR makes `--all` aggregate across every targeted project and write the ledger **once**, after the loop. 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 - `BaselineModel.merge()` / `_Accum.merge()` (`headroom/proxy/output_savings.py`): fold one baseline into another. The accumulators hold additive online stats (`n` / `sum` / `sumsq`), so merging is element-wise and order-independent — identical to having observed both corpora against a single model. - `_run_verbosity` (`headroom/cli/learn.py`): accumulate a single `BaselineModel` across all targeted projects and persist it once after the loop, instead of overwriting per project. The applied verbosity level now comes from the project with the most samples (strongest signal) rather than whichever sorted last. Single-project runs are unchanged (an aggregate of one). When no transcripts are found, it prints a clear message and writes nothing. - Tests: unit test for `BaselineModel.merge`; CLI test that `--all --apply` across two projects aggregates both strata (totals summed, not last-wins) and applies the busier project's level. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run pytest tests/test_output_savings.py tests/test_cli_learn.py tests/test_verbosity_learn.py -q tests/test_output_savings.py ............................... [ 54%] tests/test_cli_learn.py ........... [ 73%] tests/test_verbosity_learn.py ............... [100%] ============================== 57 passed in 0.51s ============================== $ uv run ruff check headroom/cli/learn.py headroom/proxy/output_savings.py All checks passed! $ uv run mypy headroom/cli/learn.py headroom/proxy/output_savings.py Success: no issues found in 2 source files ``` ## Real Behavior Proof - Environment: macOS, Python 3.12.13, this branch off `upstream/main`. - Exact command / steps: `headroom learn --verbosity --apply --all` (run across a multi-project transcript corpus), then inspect `~/.headroom/output_savings.json` (`baseline.glob.n`); compared against `headroom learn --verbosity --apply` for a single busy project. - Symptom (pre-fix, installed build): `headroom learn --verbosity --apply --all` across a multi-project transcript corpus wrote `~/.headroom/output_savings.json` with `baseline.glob.n = 2` (the last project processed was a near-empty `…/venv/bin` dir), while targeting a single busy project gave `baseline.glob.n = 15658`. - With this change: the new CLI test (`test_verbosity_all_apply_aggregates_baselines_across_projects`) drives `--all --apply` over two projects (3 samples + 1 sample) and asserts the persisted ledger has `total_samples == 4` with both strata present, plus the busier project's level applied. - Observed result: aggregated baseline persisted once; both strata retained; level taken from the higher-sample project. - Not tested: re-running the patched `--all` end-to-end on a live multi-project machine (covered instead by the unit merge-math test and the faked-`analyze` CLI test). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A — CLI/behavioral change. ## Additional Notes - No linked issue (`Closes #` left blank intentionally). - Documentation checklist item is N/A — no user-facing docs describe the per-project overwrite behavior. - Level-selection note: for `--all`, the applied verbosity level is now deterministic (most-samples project) instead of last-processed; this is the intended improvement, not a behavior to preserve. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: JD Davis <mxjerrett@gmail.com> |
||
|
|
46dede36f9
|
fix(pricing): resolve MiniMax-M3 (provider prefix + pre-registration) (#1186)
## Description
Fixes the cost dashboard reporting `$0.00` for every call when the
upstream model is `MiniMax-M3` (Anthropic-compatible endpoint served
from the `MiniMax` provider).
Two root causes in `headroom/pricing/litellm_pricing.py`:
1. **`resolve_litellm_model()` had no `minimax/` provider prefix.**
LiteLLM's community pricing database stores MiniMax-M3 only under
`minimax/MiniMax-M3`. The resolver never tried that prefix, so callers
in `proxy/cost.py`, `proxy/savings_tracker.py`, and `perf/analyzer.py`
silently fell back to the unresolved name.
2. **The prefix check was case-sensitive.** MiniMax's model name uses
mixed case (`MiniMax-M3`), but every existing prefix pattern (`claude-`,
`gpt-`, `o1-`, …) was lowercase, so even after adding `"minimax-"` the
bare `MiniMax-M3` wouldn't match.
This PR fixes both, plus adds a `_register_minimax_pricing()` helper
that pre-populates `litellm.model_cost["MiniMax-M3"]` from
`minimax/MiniMax-M3` at module load — a safety net so `estimate_cost()`
(which doesn't know the `minimax/` prefix internally) succeeds even on a
cold resolver cache or if LiteLLM drops the prefixed entry in a future
release.
Net change: **+97 / −1 lines across 2 files** (one production file + one
test file).
Closes #
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- **Production** (`headroom/pricing/litellm_pricing.py`):
- Add `"minimax-": "minimax/"` to the provider-prefix table in
`_resolve_litellm_model_uncached()` so the resolver knows about the
MiniMax provider.
- Compute `model_lower = model.lower()` and match prefixes against it
instead of `model`, so the mixed-case bare name `MiniMax-M3` resolves
correctly. The existing prefixes (`claude-`, `gpt-`, `o1-`, `o3-`,
`o4-`, `gemini-`) are already lowercase patterns matched against
canonical lowercase names (`claude-sonnet-4-5-…`, `gpt-4o`,
`gemini-2.0-flash`) — no regression.
- Add `_register_minimax_pricing()`: if `minimax/MiniMax-M3` is in
`litellm.model_cost` and `MiniMax-M3` is not, copy the pricing dict
under the bare key. No-op on older LiteLLM (entry missing) or when the
user has already customised `MiniMax-M3`.
- Invoke `_register_minimax_pricing()` once at module import.
- **Tests** (`tests/test_pricing_litellm.py`):
- Add `test_litellm_minimax_mixed_case_with_provider_prefix` — verifies
`resolve_litellm_model("MiniMax-M3")` returns `"minimax/MiniMax-M3"` via
the case-insensitive prefix match.
- Add `test_litellm_minimax_preregistration_safety_net` — verifies the
pre-registration populates the bare `MiniMax-M3` key, that
`estimate_cost()` returns the correct dollar figure (`0.84` for 1M in +
100k out), and that a user-customised bare entry is never clobbered.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
$ uv run pytest tests/test_pricing_litellm.py -v
============================= test session starts ==============================
platform darwin -- Python 3.11.15, pytest-9.0.3, pluggy-1.6.0
configfile: pyproject.toml
plugins: anyio-4.12.1, langsmith-0.8.0, asyncio-1.3.0, cov-7.0.0
collected 7 items
tests/test_pricing_litellm.py::test_litellm_helpers_when_dependency_is_unavailable PASSED [ 14%]
tests/test_pricing_litellm.py::test_litellm_model_pricing_exact_match_and_defaults PASSED [ 28%]
tests/test_pricing_litellm.py::test_litellm_model_pricing_uses_provider_prefixes PASSED [ 42%]
tests/test_pricing_litellm.py::test_litellm_model_pricing_uses_aliases_and_zero_cost_defaults PASSED [ 57%]
tests/test_pricing_litellm.py::test_litellm_model_pricing_returns_none_for_unknown_models PASSED [ 71%]
tests/test_pricing_litellm.py::test_litellm_minimax_mixed_case_with_provider_prefix PASSED [ 85%]
tests/test_pricing_litellm.py::test_litellm_minimax_preregistration_safety_net PASSED [100%]
============================== 7 passed in 1.07s ===============================
$ uv run ruff check headroom/pricing/litellm_pricing.py tests/test_pricing_litellm.py
All checks passed!
$ uv run mypy headroom/pricing/litellm_pricing.py
Success: no issues found in 1 source file
```
Manual reproducer (matches the PR writeup):
```text
$ uv run python -c "
from headroom.pricing.litellm_pricing import resolve_litellm_model, estimate_cost
import litellm
print('resolve_litellm_model(MiniMax-M3):', resolve_litellm_model('MiniMax-M3'))
print('MiniMax-M3 in litellm.model_cost :', 'MiniMax-M3' in litellm.model_cost)
print('estimate_cost (1M in, 100k out): ', estimate_cost('MiniMax-M3', 1_000_000, 100_000))
"
resolve_litellm_model(MiniMax-M3): minimax/MiniMax-M3
MiniMax-M3 in litellm.model_cost : True
estimate_cost (1M in, 100k out): 0.84
```
## Real Behavior Proof
- Environment: macOS Darwin 25.5.0, Python 3.11.15, `headroom-ai`
installed editable via `uv` from this branch, `litellm` pulled from PyPI
on first run.
- Exact command / steps: after `git checkout fix/minimax-pricing && uv
sync --all-extras --dev`, run (1) `uv run python -c "from
headroom.pricing.litellm_pricing import resolve_litellm_model,
estimate_cost; import litellm;
print(resolve_litellm_model('MiniMax-M3'), 'MiniMax-M3' in
litellm.model_cost, estimate_cost('MiniMax-M3', 1_000_000, 100_000))"`,
then (2) `uv run pytest tests/test_pricing_litellm.py -v`, then (3) `uv
run ruff check headroom/pricing/litellm_pricing.py
tests/test_pricing_litellm.py`, then (4) `uv run mypy
headroom/pricing/litellm_pricing.py`.
- Observed result: (1) `resolve_litellm_model('MiniMax-M3')` returns
`minimax/MiniMax-M3` (was `MiniMax-M3`, unresolved); `'MiniMax-M3' in
litellm.model_cost` is `True` (proves `_register_minimax_pricing()`
ran); `estimate_cost('MiniMax-M3', 1_000_000, 100_000)` returns `0.84`
(matches `$0.60/M in × 1M + $2.40/M out × 0.1M`). (2) All 7 tests in
`tests/test_pricing_litellm.py` pass (5 pre-existing + 2 new
MiniMax-specific). (3) `ruff` reports `All checks passed!`. (4) `mypy`
reports `Success: no issues found in 1 source file`.
- Not tested: end-to-end through the running proxy against a live
`MiniMax-M3` endpoint — no Anthropic-compatible key configured in this
environment. The reproducer exercises the exact code path the proxy's
cost accumulator uses, but I did not point the proxy at a real upstream.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation — *N/A: no
user-facing docs reference `litellm_pricing.py` directly; the only
public API affected (`estimate_cost`) now returns correct values for a
previously-unsupported model.*
- [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: this repo
doesn't appear to use CHANGELOG.md (not present at repo root).*
## Additional Notes
- **Why both fixes are needed.** `estimate_cost()` calls
`get_model_pricing()` directly, and `get_model_pricing()` has its own
hardcoded prefix list `["openai/", "anthropic/", "google/", "mistral/",
"deepseek/"]` that does **not** include `minimax/`. So the prefix
resolver alone is not enough for `estimate_cost("MiniMax-M3")` to return
a non-`None` number — the pre-registration step is what makes the bare
name resolve. The prefix resolver change matters for the proxy's
cost/savings/perf code paths that call `resolve_litellm_model()` and
then look up the prefixed string themselves.
- **Why case-insensitive matching is safe.** All existing prefixes are
lowercase patterns matched against already-lowercase canonical model
names — lower-casing before `startswith()` is a no-op for them. Only the
new `"minimax-"` entry uses a mixed-case input.
- **Pricing drift note.** `_register_minimax_pricing()` mirrors upstream
LiteLLM (input $0.60/M, output $2.40/M, cache read $0.12/M as of
2026-06). Re-check after LiteLLM updates; the function already
short-circuits when the user has customised the entry.
- **Did not run** the full test suite, only
`tests/test_pricing_litellm.py`. Wider CI will catch anything I missed.
---------
Co-authored-by: Shreyas S K <shreyassk@Shreyass-MacBook-Air.local>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
|
||
|
|
ad0034f981
|
fix(opencode): route native providers + load transport plugin, fix Serena context (#1573)
## Description `headroom wrap opencode` looked like it worked (proxy started, opencode launched) but **no inference reached the proxy**, so users saw zero savings (#1572). Root causes: 1. The injected synthetic `headroom` provider (`@ai-sdk/openai-compatible`) had **no `models` and no `apiKey`** → opencode raised `ProviderModelNotFoundError`, and it only ever targets OpenAI. 2. The wrap injected a reference to the **unpublished `headroom-opencode` npm plugin**, which opencode silently failed to resolve → the transparent transport never loaded. 3. Serena was launched with `--context opencode`, a context Serena does not ship → crash on launch (#1549). This PR makes `headroom wrap opencode` route opencode's traffic through the proxy with the user's **own API key** (no key written to disk), and gets the transparent transport plugin actually loading. Closes #1572 Closes #1549 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - **`runtime.py`** — two complementary routing layers (both verified against opencode 1.17): 1. Override opencode's native `anthropic`/`openai` provider `baseURL` to the proxy. Reliable, credential-independent (covers API key **and** subscription), keeps native model metadata/limits, reuses the user's existing key. This is the always-on layer and the only one a pip-only install needs. 2. Load the transport plugin **by absolute path** when it has been built (`headroom_opencode_plugin_path()`), self-configured via `HEADROOM_PROXY_URL`. Covers providers we don't name (Gemini, Copilot, custom gateways) and providers added mid-session. Loopback URLs aren't double-routed, so the two layers coexist. - **`wrap.py`** — Serena context `opencode` → `agent` (valid context). - **`plugins/opencode/`** — new `src/entry.opencode.ts` loader entry that exports **only** the plugin function (opencode rejects a module with non-function exports: "Plugin export is not a function"); tsup builds it as a second entry. - **tests** — updated `test_providers_opencode_config.py` for path-based plugin injection + a skip-when-unbuilt case. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_providers_opencode_config.py tests/test_cli/test_wrap_opencode.py -q 72 passed in 0.59s $ (cd plugins/opencode && npm test) Test Files 2 passed (2) Tests 9 passed (9) $ ruff check headroom/providers/opencode/runtime.py headroom/cli/wrap.py tests/test_providers_opencode_config.py ✓ Ruff: No issues found $ mypy headroom/providers/opencode/runtime.py Success: no issues found in 1 source file ``` ## Real Behavior Proof - **Environment:** macOS, opencode 1.17.11 (npm), headroom proxy 0.28.0 (local), Anthropic API key from `.env`. - **Exact command:** ``` headroom wrap opencode --no-serena --no-context-tool --no-proxy --port 8787 \ -- run -m anthropic/claude-haiku-4-5-20251001 "Reply with exactly: WRAPWORKS" ``` - **Observed result:** opencode printed `plugin=headroom-opencode` (loaded, no error) and returned `WRAPWORKS`. The proxy log shows the request routed through it: ``` event=outbound_request method=POST path=https://api.anthropic.com/v1/messages source=passthrough event=proxy_inbound_response path=/v1/messages status=200 PERF model=claude-haiku-4-5-20251001 cache_hit_pct=97 client=opencode ``` Compression verified on a large tool_result (`client=opencode`): ``` Pipeline complete: 170653 -> 77 tokens (saved 170576, 100.0% reduction) PERF tok_before=151309 tok_after=67 tok_saved=151242 transforms=router:tool_result:log client=opencode ``` - **Not tested:** custom OpenAI-compatible gateways (need the proxy to honor `x-headroom-base-url` in the dedicated OpenAI handler — open PR #1502); interactive TUI (verified the headless `opencode run` path). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Additional Notes - **Plugin shipping:** the plugin loads by repo-relative path, which works for source/editable installs. `plugins/opencode/dist/` is gitignored, so the plugin must be built (`cd plugins/opencode && npm install && npm run build`) for layer 2 to activate; pip-only installs gracefully fall back to layer 1 (native baseURL override). Bundling `dist/` into the package or publishing `headroom-opencode` to npm is a follow-up for universal shipping. - **CHANGELOG:** N/A — handled by Release Please from the conventional commit. - Custom-gateway support depends on existing PR #1502 (honor `x-headroom-base-url` in the dedicated OpenAI handlers); not duplicated here. 🤖 Generated with [Claude Code](https://claude.com/claude-code) |
||
|
|
aea3c35177
|
chore: release main (#1441)
🤖 I have created a release *beep* *boop* --- <details><summary>0.28.0</summary> ## [0.28.0](https://github.com/headroomlabs-ai/headroom/compare/v0.27.0...v0.28.0) (2026-06-29) ### Features * add --disable-kompress-fallback to restore legacy PASSTHROUGH fallback ([#1185](https://github.com/headroomlabs-ai/headroom/issues/1185)) ([ |
||
|
|
5d3803a21c
|
fix(proxy): strip Codex lite header from OpenAI WebSockets (#1543)
## Description Codex WebSocket traffic through Headroom can forward `X-OpenAI-Internal-Codex-Responses-Lite` upstream. OpenAI tightened enforcement of that header on 2026-06-26 for `gpt-5.5`, `gpt-5.4`, and `gpt-5.4-mini`, so the same Codex setup can fail through Headroom with `unsupported_value` while succeeding when Headroom is bypassed. The OpenAI Responses WS handler strips only `x-headroom-*` internal headers today, so this Codex client header survives into both the direct upstream WebSocket connect and the WS HTTP fallback path. This change strips `X-OpenAI-Internal-Codex-Responses-Lite` from the upstream header copy inside `handle_openai_responses_ws` after routing resolution and before the upstream request is sent. `_ws_http_fallback(...)` reuses that same header dict, so the fallback path inherits the fix without a second guard. `headroom/proxy/helpers.py` stays unchanged; the shared helper contract remains `x-headroom-*` only. Closes #1525 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Add a narrow case-insensitive strip for `X-OpenAI-Internal-Codex-Responses-Lite` in `headroom/proxy/handlers/openai.py` after `_resolve_codex_routing_headers(...)` and before `websockets.connect(...)`. - Keep `_strip_internal_headers(...)` in `headroom/proxy/helpers.py` unchanged so the documented `x-headroom-*` stripping scope does not widen. - Extend `tests/test_openai_codex_ws_lifecycle.py` to capture `additional_headers`, prove the direct WS leak on base, prove the fix on head, prove `_ws_http_fallback(...)` inherits sanitized headers, and prove adjacent non-lite headers still survive. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_openai_codex_ws_lifecycle.py -k "codex_responses_lite or without_codex_lite" -v`) - [x] Linting passes (`uv run ruff check headroom/proxy/handlers/openai.py tests/test_openai_codex_ws_lifecycle.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text Base proof before the fix: uv run pytest tests/test_openai_codex_ws_lifecycle.py -k "test_ws_codex_responses_lite_header_is_not_forwarded_upstream" -v FAILED tests/test_openai_codex_ws_lifecycle.py::test_ws_codex_responses_lite_header_is_not_forwarded_upstream AssertionError: assert 'X-OpenAI-Internal-Codex-Responses-Lite' not in { 'authorization': 'Bearer test', 'X-OpenAI-Internal-Codex-Responses-Lite': 'true', 'X-OpenAI-Debug': 'keep-me', 'ChatGPT-Account-ID': 'acct-123', 'x-client': 'codex', 'OpenAI-Beta': 'responses_websockets=2026-02-06' } Focused regression proof after the fix: uv run pytest tests/test_openai_codex_ws_lifecycle.py -k "codex_responses_lite or without_codex_lite" -v tests/test_openai_codex_ws_lifecycle.py::test_ws_codex_responses_lite_header_is_not_forwarded_upstream PASSED [ 33%] tests/test_openai_codex_ws_lifecycle.py::test_ws_codex_responses_lite_header_is_not_forwarded_to_fallback PASSED [ 66%] tests/test_openai_codex_ws_lifecycle.py::test_ws_without_codex_lite_preserves_adjacent_headers_and_api_key_route PASSED [100%] ====================== 3 passed, 16 deselected in 0.62s ======================= uv run ruff check headroom/proxy/handlers/openai.py tests/test_openai_codex_ws_lifecycle.py All checks passed! ``` ## Real Behavior Proof - Environment: local pytest async lifecycle harness in `tests/test_openai_codex_ws_lifecycle.py`, no live provider required. - Exact command / steps: `uv run pytest tests/test_openai_codex_ws_lifecycle.py -k "codex_responses_lite or without_codex_lite" -v` - Observed result: before the fix, the new direct-leak test failed because `websockets.connect(..., additional_headers=...)` still contained `X-OpenAI-Internal-Codex-Responses-Lite`. After the fix, the focused rerun passed and proved that both direct WS connect and forced `_ws_http_fallback(...)` receive sanitized headers while adjacent non-lite headers still survive. - Not tested: live Codex traffic against OpenAI with real credentials, unless that is added during implementation. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Additional Notes `uv run mypy headroom` is outside the focused proof for this small WS-path fix and may remain unchecked if the coding pass keeps the validation surface to targeted pytest plus ruff. `CHANGELOG.md` remains unchanged because the resolved repo config says Headroom's release pipeline generates changelog entries from conventional commits. |
||
|
|
10251b65ca
|
docs: sync README + benchmarks with code (drop retired IntelligentContext/RollingWindow) (#1545)
## Description Sync the docs with the code after the live-zone realignment. The `IntelligentContextManager` (ICM), `RollingWindow`, and scoring modules were deleted in PR #350 (May 2026), but the README and benchmark docstrings still advertised them as live, and an example still imported the deleted module (broken on run). This fixes the README + benchmarks and removes the dead example. I validated the README against the code with three parallel static-analysis sub-agents (features/architecture, CLI/extras/wrap-matrix, public API/integrations). Most of the README checked out accurate; only the items below were stale/wrong. Closes # ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - README: removed the `IntelligentContext` bullet and `IntelligentContext / RollingWindow` from the transforms list (both deleted in PR #350). - README: standardized `Kompress-base` -> `Kompress-v2-base` to match the HF model id `chopratejas/kompress-v2-base` and the existing badges (diagram re-aligned). - README: corrected the CodeCompressor language list to match the `CodeLanguage` enum (added TS, C, Perl). - README: softened the unanchored "6 algorithms" tagline to "content-aware compressors". - README: Cortex Code is library-mode only — there is no `headroom wrap cortex`, so the compatibility-matrix row no longer shows a wrap checkmark. - Deleted `examples/test_intelligent_context_toin_ccr.py` — it imported the deleted `IntelligentContextManager` (ImportError on run) and is unreferenced. - Removed stale `RollingWindow` mentions from benchmark docstrings/comments (`benchmarks/__init__.py`, `bench_transforms.py`, `bench_latency.py`, `scenarios/conversations.py`); the accurate PR-B1 retirement comment is kept. ## Testing - [ ] Unit tests pass (`pytest`) — N/A, docs/docstring + example deletion only - [x] Linting passes — `ruff check` clean on all changed benchmark files - [ ] Type checking passes — N/A (no type-relevant changes) - [ ] New tests added — N/A - [x] Manual testing performed — see Real Behavior Proof ### Test Output ```text $ ruff check benchmarks/__init__.py benchmarks/bench_transforms.py benchmarks/bench_latency.py benchmarks/scenarios/conversations.py All checks passed! # stale refs remaining in README/benchmarks (excluding accurate retirement notes): $ grep -rn "IntelligentContext|RollingWindow|Kompress-base" README.md benchmarks/ | grep -v retire (only benchmarks/bench_transforms.py:362 — the accurate PR-B1 retirement comment) # deleted example is unreferenced anywhere: $ grep -rn "test_intelligent_context_toin_ccr" --include=*.md --include=*.yml --include=*.py . (no hits) ``` ## Real Behavior Proof - Environment: macOS (darwin, arm64), Python 3.12 `.venv`, ruff 0.14.x, repo at branch `docs/sync-readme-with-code` off latest `main`. - Exact command / steps: (1) three parallel sub-agents grep/Read-validated README claims vs `headroom/`, `pyproject.toml`, `sdk/typescript/`; (2) directly verified each flagged mismatch (`CodeLanguage` enum, `HF_MODEL_ID`, absence of `IntelligentContext`/`RollingWindow` classes); (3) confirmed the example imports a deleted module and is unreferenced; (4) `ruff check` on changed benchmark files; (5) re-grepped README + benchmarks for any remaining stale refs. - Observed result: README and benchmark docstrings now match the code; the only surviving `RollingWindow` string is the accurate retirement comment; the broken example is removed; ruff passes; the ASCII architecture diagram still aligns after the `Kompress-v2-base` rename. - Not tested: rendering of the README on GitHub/PyPI (text-only change); the separate `docs/content/` and `wiki/` doc sets (see Additional Notes — out of scope for this PR). ## 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 — N/A (docs/example cleanup) - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md — N/A (Release Please auto-generates from the conventional commit) ## Additional Notes **Larger related finding (NOT in this PR):** the published docs site (`docs/content/docs/*.mdx`) and the `wiki/*.md` set still document `IntelligentContextManager`, `RollingWindow`, `RollingWindowConfig`, `IntelligentContextConfig`, and `ScoringWeights` as live API — with `from headroom import RollingWindow` / `from headroom.transforms import IntelligentContextManager` code examples that would `ImportError`. It is half-migrated (a couple of `.mdx` files already note "removed in 0.9.x" while neighbors still teach it as current). This is ~15 files and the fixes require rewriting examples to the live-zone model, not just deletions — recommended as a focused follow-up PR rather than bundling it here. |
||
|
|
a7d3360a05
|
fix: remove agents.md (#1540)
## Description Remove AGENTS.md. Not needed. ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Removed AGENTS.md ## Testing <!-- Check what you actually ran, then paste the real command output below. --> - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [ ] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text NA ``` ## Real Behavior Proof - Environment: NA - Exact command / steps: NA - Observed result: NA - Not tested: NA ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) Add screenshots to help explain your changes. ## Additional Notes <!-- Mention any N/A checklist items, tradeoffs, follow-ups, or maintainer context. --> |
||
|
|
16c638bc21
|
fix: recover persistent proxy feature checks and reject non-Copilot exchange URL (#1465)
## Description This PR fixes two related reliability issues in Copilot wrap/subscription flows: 1. Recovered persistent proxy instances could be reused too early, before validating requested feature-sensitive config (especially `openai_api_url`), which could lead to wrong upstream routing. 2. Subscription token-exchange payloads could provide a non-Copilot API URL; this is now rejected and we safely fall back to user-info/default Copilot endpoint resolution. Related: #488 ## 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 persistent proxy recover path to: - continue into feature checks when feature-sensitive options are requested - restart persistent deployment when config is missing/mismatched after recovery - keep historical fast return for plain recover-only calls - Hardened subscription exchange URL resolution: - accept exchange `api_url` only when it is a Copilot host - log warning and fall back when non-Copilot host is provided - Added regression tests for: - recovered persistent proxy feature mismatch and config-unavailable restart behavior - non-Copilot exchange host rejection with/without user-info fallback ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ python -m pytest -q tests/test_copilot_auth.py tests/test_cli/test_wrap_persistent.py ============================= test session starts ============================= platform win32 -- Python 3.12.8, pytest-9.1.1, pluggy-1.6.0 rootdir: C:\Users\ralf.escher\Documents\headroom collected 82 items tests\test_copilot_auth.py ............................................. [ 54%] ........... [ 68%] tests\test_cli\test_wrap_persistent.py .......................... [100%] ============================= 82 passed in 1.60s ============================== ``` ## Real Behavior Proof - Environment: - Windows - Python 3.12.8 - Local Headroom branch with this patch - Copilot subscription route through local proxy - Exact command / steps: 1. Start local proxy and run Copilot wrap in subscription mode. 2. Execute chat-completions requests through proxy. 3. Inspect runtime proxy logs for outbound target and inbound status. 4. Run focused regression tests: - `python -m pytest -q tests/test_copilot_auth.py tests/test_cli/test_wrap_persistent.py` - Observed result: - Outbound requests routed to Copilot business host: - `path=https://api.business.githubcopilot.com/chat/completions` - Successful proxy responses observed: - `path=/v1/chat/completions status=200` - Model activity logged during successful requests: - `PERF model=gpt-4.1 ...` - Regression tests pass (`82 passed`), covering both fixes. - Not tested: - Full repository test suite - Full lint/typecheck across entire project - Non-Windows runtime verification in this run ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] 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 - This PR intentionally excludes incidental local edits to `.github/copilot-instructions.md`. - Scope is limited to this bug fix and regression coverage; linked as related work to #488. |
||
|
|
4db3bc91d9
|
fix(bedrock): add boto3 1.41 + CRT for aws login credentials (#1486)
## Description `pip install headroom-ai[bedrock]` cannot serve users who authenticate with `aws login` (IAM Identity Provider / console-login, DPoP). Resolving those credentials requires the AWS Common Runtime (CRT); without `awscrt`, botocore raises `MissingDependencyException`. The AWS docs state the requirement as: **"Boto3 version 1.41.0 or later with AWS Common Runtime (CRT)"** — i.e. both a modern boto3 floor and CRT (installed via the `[crt]` extra). ## Type of Change - [x] Bug fix (non-breaking) ## Changes Made - `pyproject.toml` `bedrock` extra: bump `boto3>=1.28.0` → `boto3>=1.41.0`, add `botocore[crt]>=1.41.0` (installs `awscrt`). - `uv.lock`: regenerated — adds `awscrt`, resolves `boto3` to 1.42.x. No code changes — the bedrock backend already passes `aws_profile_name` through to the LiteLLM calls (via #1456); this just makes the installed dependencies actually able to resolve `aws login` credentials. ## Impact - **`aws login` (IAM Identity Provider / DPoP):** now works — awscrt present. - **`aws sso login` (classic Identity Center):** unaffected (already worked). - **static keys (`~/.aws/credentials`):** unaffected. - Bumping the boto3 floor only affects the optional `[bedrock]` extra; bedrock users benefit from a current boto3 regardless. ## Testing Dependency-only change. `uv lock` resolves cleanly (257 packages, awscrt 0.29.2, boto3 1.42.38). No runtime code path altered, so existing bedrock tests are unaffected. ## Checklist - [x] Self-review performed - [x] No new warnings - [x] Linting passes ## Additional Notes Focused on the dependency gap only. ARN routing / named-profile wiring / docs are handled in #1456; pricing in #1485. |
||
|
|
9157173018
|
fix(read-lifecycle): persist STALE Read originals in the CCR store (#1488)
## Description
`read_lifecycle` emits STALE/SUPERSEDED Read markers containing
`Retrieve original: hash=...`, but `headroom_retrieve(hash)` 404s on
every such marker — the original content is never actually stored.
Affects the default config (`read_lifecycle=on`, `compress_stale=on`)
and the common Claude Code flow: read a file, edit it, then want the
prior content back.
**Root cause:** `ContentRouter.transform` instantiated
`ReadLifecycleManager` with
`compression_store=kwargs.get("compression_store")`, but no caller ever
sets that kwarg. `self.store` was always `None`, so `read_lifecycle.py`
emitted the marker with a SHA-256 hash but skipped the
`store.store(...)` call. Every other compressor (SmartCrusher, Kompress,
search/log/diff/code) resolves its store directly via
`get_compression_store()`.
Closes #
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `headroom/transforms/content_router.py`: inject a CCR store into
`ReadLifecycleManager` via an explicit `is None` check + guarded
`get_compression_store()` import (matches `smart_crusher.py`'s pattern).
Falls back to marker-only when the module is absent in stripped builds.
- `headroom/transforms/read_lifecycle.py`: wrap `store.store(...)` in
`try/except` with a precomputed fallback hash so a transient backend
failure can't break `compress()` (mirrors `read_maturation.py`). Pass
`explicit_hash=ccr_hash` to avoid double SHA-256 and keep marker/store
key in lockstep.
- `tests/test_transforms/test_read_lifecycle.py`: regression test
(`TestContentRouterIntegration`) that drives `headroom.compress()` and
asserts the STALE marker's hash resolves in the global CCR store.
## Testing
- [x] Unit tests pass (`pytest`)
- [ ] Linting passes (`ruff check .`) — not run locally
- [ ] Type checking passes (`mypy headroom`) — not run locally
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
$ HEADROOM_CCR_BACKEND=memory .venv/bin/python -m pytest tests/test_transforms/test_read_lifecycle.py -v
============================== 23 passed in 0.43s ==============================
```
## Real Behavior Proof
- Environment: Python 3.13, headroom-ai dev install (`uv sync --extra
dev`), `HEADROOM_CCR_BACKEND=memory`, Linux x86_64.
- Exact command / steps: Run `headroom.compress()` on a synthetic STALE
conversation (Read then Edit of the same file):
```python
from headroom import compress
result = compress([
{"role": "assistant", "content": [{"type": "tool_use", "id": "t1",
"name": "Read",
"input": {"file_path": "/tmp/foo.txt"}}]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id":
"t1",
"content": "source line\n" * 500}]},
{"role": "assistant", "content": [{"type": "tool_use", "id": "t2",
"name": "Edit",
"input": {"file_path": "/tmp/foo.txt"}}]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id":
"t2",
"content": "edited"}]},
], model="claude-sonnet-4-5-20250929")
```
then `get_compression_store().retrieve(<hash-from-marker>)`.
- Observed result: post-fix `retrieve(hash)` returns HIT (`tool=Read`,
`strategy=read_lifecycle:stale`); pre-fix it returned MISS (the bug).
Full log:
```text
transforms_applied: ['read_lifecycle:stale:/tmp/foo.txt',
'router:excluded:tool', 'router:excluded:tool']
hashes from markers: ['3fbd603ecf1bcf50a86650d2']
store backend: InMemoryBackend
retrieve(3fbd603ecf1bcf50a86650d2) -> HIT tool=Read
strategy=read_lifecycle:stale
```
- Not tested: SQLite backend persistence across processes; Rust `_core`
extension code path; OpenAI / Gemini providers; Claude Code live (proxy
+ MCP server end-to-end).
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation — N/A
(internal fix, no public API change)
- [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 — leaving to
maintainers' convention
## Additional Notes
- No existing issue. #389 describes the same symptom class with a
different root cause (SmartCrusher row-drop CCR bridge); it explicitly
lists `read_lifecycle.py` as a producer that populates the store — this
PR makes that claim true.
- Commits: `
|
||
|
|
8e0dadfe02
|
fix: restore token-mode compression on frozen prefixes (#1489)
## Description Fixes token-mode compression for continued Claude Code turns with a frozen prefix when the client has not already supplied `headroom_retrieve`. The previous guard returned before request-side compression could run in token mode. This keeps the non-token safety behavior, but lets token mode use the existing marker-triggered CCR tool injection override so emitted markers stay redeemable. Closes #1487. ## 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 - Let Anthropic token mode run request-side compression even when the client did not pre-register `headroom_retrieve`. - Kept the deferred-injection skip for cache-mode coverage. - Added a regression for the frozen-prefix token-mode path. - Updated `CHANGELOG.md` for the user-facing behavior change. ## Testing <!-- Check what you actually ran, then paste the real command output below. --> - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ rtk uv run pytest -q tests/test_proxy/test_anthropic_ccr_deferred_injection.py 15 passed, 1 warning in 2.73s $ rtk uv run ruff check headroom/proxy/handlers/anthropic.py tests/test_proxy/test_anthropic_ccr_deferred_injection.py All checks passed! $ rtk uv run ruff format --check headroom/proxy/handlers/anthropic.py tests/test_proxy/test_anthropic_ccr_deferred_injection.py 2 files already formatted ``` ## Real Behavior Proof - Environment: macOS, Python 3.12.9, local FastAPI `TestClient`, Anthropic proxy path, `mode=token`, `ccr_inject_tool=True`, frozen prefix count = 1, no client-supplied `headroom_retrieve`. - Exact command / steps: ran a local `rtk uv run python` repro that builds `create_app(ProxyConfig(...))`, forces compression on the Anthropic path, simulates a frozen prefix, and posts `/v1/messages`. - Observed result: local `TestClient` request returned `STATUS=200`; token-mode frozen-prefix compression ran once with `FROZEN_MESSAGE_COUNT=1`; the forwarded message was the CCR marker; forwarded tools included `headroom_retrieve`. ```text STATUS= 200 FROZEN_MESSAGE_COUNT= 1 COMPRESSION_CALLS= 1 FORWARDED_MESSAGES= [{'role': 'user', 'content': '[100 items compressed to 10. Retrieve more: hash=abc123def456abc123def456]'}] FORWARDED_TOOLS= ['headroom_retrieve'] ``` - Not tested: live Claude Code session against a real Anthropic upstream, full repo-wide `uv run pytest`, and `mypy headroom`. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas (N/A: no new hard-to-follow block needed) - [x] I have made corresponding changes to the documentation (N/A: changelog update covers this user-facing bug fix) - [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; proxy behavior only. ## Additional Notes The pytest run still emits the existing Starlette/httpx deprecation warning from `fastapi.testclient`; this PR does not touch that dependency path. |