mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
6 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b50d9c17ce
|
feat(wrap): add --1m to preserve the 1M context window on wrap claude (#1158) (#1351)
## Description `headroom wrap claude` is the recommended Claude Code integration, but for subscription users entitled to the **1M** context window it silently caps usable context at **200k**. Root cause (upstream, anthropics/claude-code#68522): when `ANTHROPIC_BASE_URL` points at a custom host (the Headroom proxy), Claude Code does **not** send the `context-1m-2025-08-07` beta header and treats the window as 200k. The `/model opus[1m]` picker selection does not survive a custom base URL, and `CLAUDE_CODE_AUTO_COMPACT_WINDOW` alone does not lift the cap. Headroom itself already forwards `anthropic-beta` and sizes Opus at 1M internally — but since `wrap claude` owns the launched process's environment and is the documented path, users hit this and blame Headroom first. This adds the opt-in fix the issue proposes. Closes #1158 ## 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 - `headroom/cli/wrap.py`: new opt-in `--1m` flag on `wrap claude`. When set, `ANTHROPIC_MODEL=<opus>[1m]` is exported on the launched process so Claude Code sends the `context-1m` beta header. Logic extracted to a testable helper `_resolve_1m_model`: a model the user already selected via `ANTHROPIC_MODEL` is preserved (only the `[1m]` suffix is appended when missing); otherwise it falls back to the default Opus. Idempotent (no double suffix). Default behavior is unchanged (opt-in). - `tests/test_cli/test_wrap_helpers.py`: unit tests for `_resolve_1m_model` (append-to-user-model, idempotent, default fallback). - `README.md`: `--1m` added to the Claude Code row of the agent compatibility matrix. - `CHANGELOG.md`: Unreleased → Features 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 $ uv run pytest tests/test_cli/test_wrap_helpers.py tests/test_cli/test_wrap_claude_base_url.py -q 61 passed in 0.46s $ uv run ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_helpers.py All checks passed! $ uv run mypy headroom/cli/wrap.py Success: no issues found in 1 source file ``` #### TDD verification (RED → GREEN) RED — new tests with the prod change reverted (`_resolve_1m_model` absent): ```text E AttributeError: module 'headroom.cli.wrap' has no attribute '_resolve_1m_model' 3 failed, 40 deselected in 0.56s ``` GREEN — with the change applied: ```text 3 passed, 40 deselected in 0.34s ``` ## Real Behavior Proof - Environment: Linux, Python 3.13, headroom @ this branch. - Exact command / steps: `headroom wrap claude --1m --help` shows the new flag, and the flag resolves the model id that triggers the 1M window: ```text $ headroom wrap claude --help | grep -A1 -- --1m --1m Preserve the 1M context window. Behind a custom ANTHROPIC_BASE_URL Claude Code drops the ... # model-id resolution (what --1m exports as ANTHROPIC_MODEL): _resolve_1m_model("claude-opus-4-1-20250805") -> "claude-opus-4-1-20250805[1m]" _resolve_1m_model("claude-opus-4-8[1m]") -> "claude-opus-4-8[1m]" (idempotent) _resolve_1m_model(None) -> "claude-opus-4-8[1m]" (default) ``` - Observed result: with `--1m`, the launched Claude Code process gets `ANTHROPIC_MODEL=<opus>[1m]`, which is the documented trigger for the `context-1m` beta header (verified in the issue against `~/.headroom/logs/proxy.log`). - Not tested: the live Claude Code subscription handshake against Anthropic's servers (requires a 1M-entitled subscription + the proprietary client); the model-id → header behavior is Claude Code's, documented in the issue and upstream anthropics/claude-code#68522. Headroom's side (export the env var that flips it on) is covered above and by the unit tests. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Additional Notes Opt-in only — without `--1m` nothing changes. The `_DEFAULT_1M_MODEL` constant is only consulted when the user has no `ANTHROPIC_MODEL` set; users on a specific model keep it (suffix appended), so the default's freshness does not affect them. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
b0146c4ccd
|
fix(wrap): show the dashboard URL when the proxy is already running (#1313)
## Description
I was running `headroom wrap claude` and could not find the dashboard
URL anywhere. I eventually spotted it in the README demo gif. The reason
is that `_ensure_proxy` only echoes the URL on the path that starts or
restarts the proxy. Once a proxy is already up, the function prints
`Proxy already running on port {port}` and returns, with no URL. That
early-return path is the common case: every wrap after the first one
hits it, so in practice the dashboard URL is almost never shown.
This adds the same `Dashboard: http://127.0.0.1:{port}/dashboard` line
to the two already-running branches (the inline one and the
persistent-deployment one), so the URL shows up every time, not just on
a cold start.
Closes # N/A (no tracking issue)
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: echo the dashboard URL in both "proxy already
running" branches of `_ensure_proxy`, matching the line the
start/restart path already prints.
- `tests/test_cli/test_wrap_helpers.py`: new test that drives
`_ensure_proxy` down the already-running path and asserts the dashboard
URL is in the output.
## 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 --extra dev python -m pytest tests/test_cli/test_wrap_helpers.py -q
40 passed in 0.20s
$ uv run --extra dev ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_helpers.py
All checks passed!
$ uv run --extra dev mypy headroom/cli/wrap.py
Success: no issues found in 1 source file
```
## Real Behavior Proof
- Environment: macOS, Python 3.13, this branch, `headroom wrap claude`
against an already-running proxy on port 8787.
- Exact command / steps: run `claude` (aliased to `headroom wrap
claude`) a second time, so the proxy is already up and `_ensure_proxy`
takes the early-return path.
- Observed result: before this change the output stopped at `Proxy
already running on port 8787` with no URL. After it, the next line is
`Dashboard: http://127.0.0.1:8787/dashboard`. The new unit test pins
this by mocking a healthy running proxy and asserting the URL is
printed.
- Not tested: I did not open the rendered dashboard in a browser as part
of this change. The fix is purely the printed line, which the unit test
covers.
## 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
I scoped this to the print line plus its test on purpose. ruff and mypy
are clean on the files I touched. I left the CHANGELOG checkbox
unchecked because this is a one-line user-facing string fix with no
behavior change beyond the extra output, but I am happy to add a
CHANGELOG entry if you would like one. The same for docs, I don't think
it's needed to have one about this
|
||
|
|
9f712ccbd7
|
fix(wrap): percent-encode non-ASCII cwd names in X-Headroom-Project header (#1071)
## Description Non-ASCII directory names (Chinese, Japanese, Korean, Cyrillic) caused an immediate API error when using `headroom wrap claude`: ``` API Error: Header 'X-Headroom-Project' has invalid value: '第二大脑共享' ``` RFC 7230 requires HTTP header values to be visible ASCII only. The raw cwd basename was being sent directly, breaking the entire session before the first token. Closes #1069 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `headroom/cli/wrap.py` — `_project_name_from_cwd()`: percent-encode non-ASCII chars via `urllib.parse.quote(name, safe="-_.() ")` so the header value is always ASCII-safe - `headroom/proxy/savings_tracker.py` — `sanitize_project_name()`: `urllib.parse.unquote()` before cleanup so the stored/displayed project name is the original Unicode directory name ASCII-only project names are unaffected (quote/unquote is a no-op for them). ## Testing - [x] Unit tests pass (`pytest`) - [x] New tests added for new functionality ### Test Output ```text tests/test_cli/test_wrap_helpers.py::TestApplyProjectHeaderEnv::test_non_ascii_cwd_name_is_percent_encoded PASSED tests/test_cli/test_wrap_helpers.py::TestApplyProjectHeaderEnv::test_non_ascii_cwd_header_is_ascii_safe PASSED tests/test_proxy_project_savings.py::test_sanitize_project_name_decodes_percent_encoded_non_ascii PASSED ======================== 15 passed, 1 warning in 0.42s ========================= ``` ## Real Behavior Proof - Environment: macOS 15, Python 3.11.9, headroom dev install from source - Exact command / steps: `mkdir /tmp/test-中文-项目 && cd /tmp/test-中文-项目`, then run `.venv/bin/pytest tests/test_cli/test_wrap_helpers.py::TestApplyProjectHeaderEnv::test_non_ascii_cwd_header_is_ascii_safe -v` — header_value.encode("ascii") passes without UnicodeEncodeError - Observed result: `X-Headroom-Project` header contains percent-encoded ASCII (`test-%E4%B8%AD%E6%96%87-%E9%A1%B9%E7%9B%AE`); proxy decodes back to `test-中文-项目` for storage - Not tested: live end-to-end wrap session with a real Claude API key ## 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 added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
05bd56bcb6
|
fix(wrap): track shared proxy clients with markers (#877)
## Description Replace argv-based proxy client detection with per-port wrap client markers so cleanup and ephemeral restarts do not tear down a shared proxy while another wrapped session is still attached. Also prune stale markers, guard against PID reuse when process identity is available, and add coverage for the marker-based lifecycle behavior. Fixes #804 ## 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) ## Testing Describe the tests you ran to verify your changes: - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
914a60a2b0
|
feat(proxy): per-project savings breakdown on the dashboard (claude, codex, aider, copilot, cursor) (#803)
## Summary Adds a **per-project savings breakdown** to the proxy dashboard, covering **all wrap-supported agents: Claude Code, Codex, aider, Copilot, and Cursor**. Spec and rationale in #802 (feature-request issue — happy to adjust scope per maintainer feedback). How it works — two attribution channels, by client capability: **Header channel** (clients that can send custom headers): - `headroom wrap claude` appends `X-Headroom-Project: <basename(cwd)>` to `ANTHROPIC_CUSTOM_HEADERS` (a user-supplied x-headroom-project header always wins; other user headers preserved). - `headroom wrap codex` extends the injected `[model_providers.headroom]` block with `env_http_headers = { "X-Headroom-Project" = "HEADROOM_PROJECT" }` and sets `HEADROOM_PROJECT` per launch — Codex only sends the header when the env var is set, so the static config stays inert outside wrap. **Base-URL prefix channel** (clients that cannot send custom headers): - The proxy accepts `/p/<url-encoded-name>/...`; middleware strips the prefix before routing (before Starlette caches the URL) and binds the project. The explicit header wins over the prefix. - `headroom wrap aider` points `OPENAI_API_BASE` / `ANTHROPIC_BASE_URL` at the prefixed URL. - `headroom wrap copilot` points `COPILOT_PROVIDER_BASE_URL` at the prefixed URL (BYOK anthropic/openai provider types and the GitHub-subscription path). - `headroom wrap cursor` prints the prefixed Override Base URL in its setup instructions, with a note explaining the attribution. **Shared plumbing:** - New `headroom/proxy/project_context.py`: header classification, `/p/` prefix split/strip, URL-prefix builder, and a contextvar bound per request (HTTP middleware + WS accept for the Codex responses bridge); the outcome funnel resolves it (explicit `RequestOutcome.project` wins), stamps `RequestLog.tags["project"]`, and forwards it through `PrometheusMetrics.record_request` into the `SavingsTracker`. - `SavingsTracker`: persisted state gains a `projects` map (requests, tokens saved, savings USD, input tokens/cost, last activity). Schema v2 → v3 with transparent forward migration (v2 files load cleanly, `projects` starts empty). Names sanitized (printable-only, 128-char cap); map capped at 50 projects, evicting the smallest bucket. - `/stats` exposes `savings.per_project` (and `projects`/`projects_limit` inside `persistent_savings`); `/stats-history` exposes `projects`. - Dashboard gains a "Per-Project Savings" table mirroring the per-model table (Alpine `x-text` only — names are user-supplied, no HTML injection). **Behavior changes:** none for unattributed traffic — no header and no prefix means no bucket, aggregate totals exactly as before (regression-tested: legacy `/stats` and `/stats-history` shapes are pinned by tests). ## Real behavior proof **Setup tested on:** macOS (Darwin 25.5.0), Python 3.11.9, `pip install -e ".[dev]"`, proxy on spare ports with isolated `HEADROOM_SAVINGS_PATH`; real Claude Code CLI (subscription auth) and real Codex CLI (ChatGPT auth) as clients. **Header channel — exact steps:** ``` HEADROOM_SAVINGS_PATH=/tmp/headroom-proof-savings.json .venv/bin/python -m headroom.cli proxy --port 9123 # background cd /tmp/proof-alpha && headroom wrap claude --port 9123 --no-rtk --no-mcp --no-serena -- -p "Reply with exactly: OK" cd /tmp/proof-beta && headroom wrap claude --port 9123 --no-rtk --no-mcp --no-serena -- -p "Reply with exactly: OK" cd /tmp/proof-beta && headroom wrap codex --port 9123 --no-rtk --no-mcp --no-serena -- exec --skip-git-repo-check "Reply with exactly: OK" ``` **Observed** (copied live `/stats` output; all runs replied `OK` through the proxy): ```json { "proof-beta": { "requests": 3, "tokens_saved": 140, "compression_savings_usd": 0.0007, "total_input_tokens": 38581, "total_input_cost_usd": 0.360433, "last_activity_at": "2026-06-10T09:19:17Z", "savings_percent": 0.36 }, "proof-alpha": { "requests": 1, "tokens_saved": 0, "compression_savings_usd": 0.0, "total_input_tokens": 9050, "total_input_cost_usd": 0.32245, "last_activity_at": "2026-06-10T09:18:09Z", "savings_percent": 0.0 } } ``` `proof-beta` aggregates one Claude Code turn + one Codex `exec` run (Codex attribution flows through `env_http_headers`). **Prefix channel — exact steps** (the same mechanism the aider/copilot/cursor wraps emit, driven by a real client): ``` .venv/bin/python -m headroom.cli proxy --port 9124 # background, isolated savings path ANTHROPIC_BASE_URL="http://127.0.0.1:9124/p/aider-style-project" claude -p "Reply with exactly: OK" ``` **Observed:** ```json { "aider-style-project": { "requests": 1, "tokens_saved": 0, "compression_savings_usd": 0.0, "total_input_tokens": 8571, "total_input_cost_usd": 1.018575, "last_activity_at": "2026-06-10T10:10:13Z", "savings_percent": 0.0 } } ``` `/stats-history` returned `schema_version: 3` with the same `projects` keys; `GET /dashboard` HTML contains the new "Per-Project Savings" table; persisted state survived a tracker reload; a hand-written v2 savings file loaded cleanly with an empty `projects` map. **What I did NOT test live:** the actual aider/Copilot/Cursor binaries end-to-end (their wraps emit exactly the prefixed URLs exercised above — unit tests pin the emitted env/URLs); Codex subscription-mode routing via the built-in `openai` provider (no provider headers there — such traffic simply stays unattributed); multi-worker uvicorn; Windows. ## Tests - `tests/test_proxy_project_savings.py` (17 tests): sanitization, header classification, `/p/` prefix split + URL-builder round-trip, tracker aggregation/persistence/migration/cardinality-cap/state-sanitization, funnel→`/stats` end-to-end, middleware binding for header + prefix + precedence, plus regression tests pinning legacy `/stats`/`/stats-history` shape and unattributed-traffic totals. - Wrap/provider tests extended: `test_cli/test_wrap_helpers.py`, `test_cli/test_wrap_codex.py`, `test_provider_aider.py`, `test_provider_cursor.py`, `test_provider_copilot_wrap.py` (prefixed env/URLs, user-override wins, no duplicate header, TOML block contents, block strip, setup-line note). - Full `pytest` suite run locally; `ruff check` + `ruff format` clean on all touched files. - `CHANGELOG.md` updated. ## Dependencies None added or bumped. Closes #802 (pending maintainer 👍 per CONTRIBUTING — raised there first with the full spec). --------- Co-authored-by: Ash Rhodes <ashley.rhodes@king.com> |
||
|
|
c74ad113a4 |
refactor(cli): factor shared wrap-subcommand scaffolding
Phase G's wrap-CLI breadth (PRs #492-#494) inherited a pre-existing
duplication pattern across the wrap subcommands and faithfully
extended it for cline/continue/goose/openhands. Each Pattern-B
subcommand (proxy-only watcher) inlined the same ~50 LOC of
proxy_holder + _make_cleanup + signal handlers + box-drawing banner
+ `while True: time.sleep(1)` watcher + try/except postlude. Each
Pattern-A subcommand (binary-launching) inlined the same ~15 LOC of
rtk-vs-lean-ctx fork + KeyboardInterrupt handler.
Replace with three focused helpers in wrap.py:
_print_wrap_banner(agent)
Centered 47-char unicode box. Adding a 9th agent no longer
requires hand-padding the title to match the box width.
_setup_context_tool_for_agent(...)
rtk-or-lean-ctx fork + on_rtk_ready callback + rtk_required
gate + KeyboardInterrupt -> SystemExit(130) with marker-path
reporting. Used by cursor/cline/continue/goose/openhands.
_run_proxy_only_watcher(...)
Pattern-B scaffolding: signal handlers + banner + _ensure_proxy
+ setup callback + watcher loop + cleanup-on-finally. Used by
cursor/cline/continue.
Production-code delta is small in raw LOC (+33 net on wrap.py)
because each subcommand still has a ~25-line `_print_X_setup`
callback closure. The win is architectural: adding wrap subcommand
#9 is now a ~25-line affair instead of ~150 lines, and behavior
(banner shape, Ctrl-C handling, cleanup ordering) is centralized
so a future fix lands in every subcommand at once.
Tests:
- New test_wrap_helpers.py (17 tests) directly pins each helper's
contract — 5 branches of _setup_context_tool, 4 of
_run_proxy_only_watcher, centering math of _print_wrap_banner.
- Merged the cline+goose hint-file tests into a single parametrized
test_wrap_hintfile_agents.py (10 tests across [cline, goose]
agents). test_wrap_cline.py is deleted; test_wrap_goose.py keeps
only the goose-specific env-fan-out + binary-missing tests.
- Goose gained the "preserves existing hint-file content" test
case that cline already had — net +1 coverage point.
Side benefit: cursor (pre-existing, not touched by G1) now gets
the SystemExit(130) on Ctrl-C-during-setup behavior the G1
subcommands had. Previously it would have surfaced a KeyboardInterrupt
traceback to the shell.
181 CLI tests pass; ci-precheck green.
|