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>
This commit is contained in:
Ben Younes 2026-06-24 17:15:19 +02:00 committed by GitHub
parent e6bbc40b11
commit b50d9c17ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 75 additions and 1 deletions

View file

@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Features
* **wrap:** `headroom wrap claude --1m` preserves the 1M context window. Behind a custom `ANTHROPIC_BASE_URL` (the proxy) Claude Code drops the `context-1m` beta header and caps the window at 200k for entitled subscription users; the opt-in flag sets `ANTHROPIC_MODEL=<opus>[1m]` on the launched process so the 1M window activates through Headroom. A model already selected via `ANTHROPIC_MODEL` is preserved (only the `[1m]` suffix is appended) ([#1158](https://github.com/chopratejas/headroom/issues/1158)).
### Changed ### Changed
* **telemetry:** anonymous usage telemetry is now **opt-in** (off by default) instead of opt-out. Nothing is collected or sent unless you set `HEADROOM_TELEMETRY=on` or pass `--telemetry` to `headroom proxy` / `headroom install apply`. `is_telemetry_enabled()` is fail-closed — only explicit on-values (`on`/`true`/`1`/`yes`/`enable`/`enabled`) enable it; unset, empty, or unrecognized values stay disabled. The existing `--no-telemetry` flag and `HEADROOM_TELEMETRY=off` remain accepted for back-compat, and install manifests now write the `HEADROOM_TELEMETRY` value explicitly so generated deployments are unambiguous. * **telemetry:** anonymous usage telemetry is now **opt-in** (off by default) instead of opt-out. Nothing is collected or sent unless you set `HEADROOM_TELEMETRY=on` or pass `--telemetry` to `headroom proxy` / `headroom install apply`. `is_telemetry_enabled()` is fail-closed — only explicit on-values (`on`/`true`/`1`/`yes`/`enable`/`enabled`) enable it; unset, empty, or unrecognized values stay disabled. The existing `--no-telemetry` flag and `HEADROOM_TELEMETRY=off` remain accepted for back-compat, and install manifests now write the `HEADROOM_TELEMETRY` value explicitly so generated deployments are unambiguous.

View file

@ -192,7 +192,7 @@ shows an **Output Tokens Saved** card next to input compression, labelled
| Agent | `headroom wrap` | Notes | | Agent | `headroom wrap` | Notes |
|--------------|:---------------:|----------------------------------| |--------------|:---------------:|----------------------------------|
| Claude Code | ✅ | `--memory` · `--code-graph` | | Claude Code | ✅ | `--memory` · `--code-graph` · `--1m` |
| Codex | ✅ | shares memory with Claude | | Codex | ✅ | shares memory with Claude |
| Cursor | ✅ | prints config — paste once | | Cursor | ✅ | prints config — paste once |
| Aider | ✅ | starts proxy + launches | | Aider | ✅ | starts proxy + launches |

View file

@ -146,6 +146,29 @@ _TOOL_SEARCH_ENV = TOOL_SEARCH_ENV
_TOOL_SEARCH_DEFAULT = TOOL_SEARCH_DEFAULT _TOOL_SEARCH_DEFAULT = TOOL_SEARCH_DEFAULT
_AGENT_SAVINGS_WRAP_AGENTS = {"claude", "codex", "cursor"} _AGENT_SAVINGS_WRAP_AGENTS = {"claude", "codex", "cursor"}
# 1M context window for `wrap claude` (#1158). Claude Code only sends the
# `context-1m` beta header — unlocking the 1M window for entitled subscription
# users — when the model id carries the `[1m]` suffix. Behind a custom
# ANTHROPIC_BASE_URL (the proxy) its `/model` picker selection does not survive,
# so `--1m` forces the suffix via ANTHROPIC_MODEL on the launched process.
_ANTHROPIC_MODEL_ENV = "ANTHROPIC_MODEL"
_CONTEXT_1M_SUFFIX = "[1m]"
# Only used when no model is otherwise selected (no ANTHROPIC_MODEL set). The
# current default Opus; the suffix logic preserves any model the user did set.
_DEFAULT_1M_MODEL = "claude-opus-4-8"
def _resolve_1m_model(current: str | None) -> str:
"""Return the model id that makes Claude Code request the 1M window (#1158).
Preserves a model the user already selected via ``ANTHROPIC_MODEL`` (only
appending the ``[1m]`` suffix when missing); falls back to the default Opus
when none is set. Idempotent a value already ending in ``[1m]`` is
returned unchanged.
"""
base = (current or "").strip() or _DEFAULT_1M_MODEL
return base if base.endswith(_CONTEXT_1M_SUFFIX) else f"{base}{_CONTEXT_1M_SUFFIX}"
def _normalize_tool_search_mode(value: str) -> str: def _normalize_tool_search_mode(value: str) -> str:
"""Validate an ``ENABLE_TOOL_SEARCH`` value and return it normalized. """Validate an ``ENABLE_TOOL_SEARCH`` value and return it normalized.
@ -3086,6 +3109,17 @@ def unwrap() -> None:
default=None, default=None,
help="Cloud region for Vertex/Bedrock backends (env: HEADROOM_REGION).", help="Cloud region for Vertex/Bedrock backends (env: HEADROOM_REGION).",
) )
@click.option(
"--1m",
"context_1m",
is_flag=True,
help=(
"Preserve the 1M context window. Behind a custom ANTHROPIC_BASE_URL "
"Claude Code drops the context-1m beta header and caps at 200k; this "
"sets ANTHROPIC_MODEL=<opus>[1m] on the launched process so the 1M "
"window activates through the proxy (issue #1158)."
),
)
@click.option("--verbose", "-v", is_flag=True, help="Verbose output") @click.option("--verbose", "-v", is_flag=True, help="Verbose output")
@click.option("--prepare-only", is_flag=True, hidden=True) @click.option("--prepare-only", is_flag=True, hidden=True)
@click.argument("claude_args", nargs=-1, type=click.UNPROCESSED) @click.argument("claude_args", nargs=-1, type=click.UNPROCESSED)
@ -3101,6 +3135,7 @@ def claude(
tool_search: str | None, tool_search: str | None,
backend: str | None, backend: str | None,
region: str | None, region: str | None,
context_1m: bool,
verbose: bool, verbose: bool,
prepare_only: bool, prepare_only: bool,
claude_args: tuple, claude_args: tuple,
@ -3121,6 +3156,7 @@ def claude(
headroom wrap claude --no-context-tool # Skip CLI context-tool setup headroom wrap claude --no-context-tool # Skip CLI context-tool setup
headroom wrap claude --no-mcp # Skip MCP retrieve tool registration headroom wrap claude --no-mcp # Skip MCP retrieve tool registration
headroom wrap claude --no-serena # Skip Serena MCP registration headroom wrap claude --no-serena # Skip Serena MCP registration
headroom wrap claude --1m # Preserve the 1M context window
""" """
if prepare_only: if prepare_only:
if not no_rtk: if not no_rtk:
@ -3321,6 +3357,16 @@ def claude(
"(using your existing environment value)" "(using your existing environment value)"
) )
# Issue #1158: opt-in 1M context window. Claude Code only sends the
# context-1m beta header when the model id carries the [1m] suffix, so
# force it via ANTHROPIC_MODEL on the launched process.
if context_1m:
env[_ANTHROPIC_MODEL_ENV] = _resolve_1m_model(env.get(_ANTHROPIC_MODEL_ENV))
click.echo(
f" {_ANTHROPIC_MODEL_ENV}={env[_ANTHROPIC_MODEL_ENV]} "
"(1M context window; issue #1158)"
)
result = subprocess.run([claude_bin, *claude_args], env=env) result = subprocess.run([claude_bin, *claude_args], env=env)
raise SystemExit(result.returncode) raise SystemExit(result.returncode)

View file

@ -765,3 +765,27 @@ def test_ensure_proxy_already_running_prints_dashboard_url(
output = _run_in_click_context(lambda: wrap_mod._ensure_proxy(port, no_proxy=False)) output = _run_in_click_context(lambda: wrap_mod._ensure_proxy(port, no_proxy=False))
assert f"http://127.0.0.1:{port}/dashboard" in output assert f"http://127.0.0.1:{port}/dashboard" in output
# ---------------------------------------------------------------------------
# _resolve_1m_model — 1M context window suffix logic (#1158).
# ---------------------------------------------------------------------------
def test_resolve_1m_model_appends_suffix_to_user_model() -> None:
"""A model the user already selected via ANTHROPIC_MODEL is preserved, with
only the [1m] suffix appended so Claude Code requests the 1M window."""
assert wrap_mod._resolve_1m_model("claude-opus-4-1-20250805") == (
"claude-opus-4-1-20250805[1m]"
)
def test_resolve_1m_model_is_idempotent() -> None:
"""A model that already carries [1m] is returned unchanged (no double suffix)."""
assert wrap_mod._resolve_1m_model("claude-opus-4-8[1m]") == "claude-opus-4-8[1m]"
def test_resolve_1m_model_falls_back_to_default_when_unset() -> None:
"""With no model selected, fall back to the default Opus carrying [1m]."""
assert wrap_mod._resolve_1m_model(None) == "claude-opus-4-8[1m]"
assert wrap_mod._resolve_1m_model(" ") == "claude-opus-4-8[1m]"