diff --git a/CHANGELOG.md b/CHANGELOG.md index a3e7cc53b..66a2ca1f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## 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=[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 * **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. diff --git a/README.md b/README.md index f2903aad3..104c587d9 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ shows an **Output Tokens Saved** card next to input compression, labelled | Agent | `headroom wrap` | Notes | |--------------|:---------------:|----------------------------------| -| Claude Code | ✅ | `--memory` · `--code-graph` | +| Claude Code | ✅ | `--memory` · `--code-graph` · `--1m` | | Codex | ✅ | shares memory with Claude | | Cursor | ✅ | prints config — paste once | | Aider | ✅ | starts proxy + launches | diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 2e1416e90..216073649 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -146,6 +146,29 @@ _TOOL_SEARCH_ENV = TOOL_SEARCH_ENV _TOOL_SEARCH_DEFAULT = TOOL_SEARCH_DEFAULT _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: """Validate an ``ENABLE_TOOL_SEARCH`` value and return it normalized. @@ -3086,6 +3109,17 @@ def unwrap() -> None: default=None, 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=[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("--prepare-only", is_flag=True, hidden=True) @click.argument("claude_args", nargs=-1, type=click.UNPROCESSED) @@ -3101,6 +3135,7 @@ def claude( tool_search: str | None, backend: str | None, region: str | None, + context_1m: bool, verbose: bool, prepare_only: bool, claude_args: tuple, @@ -3121,6 +3156,7 @@ def claude( 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-serena # Skip Serena MCP registration + headroom wrap claude --1m # Preserve the 1M context window """ if prepare_only: if not no_rtk: @@ -3321,6 +3357,16 @@ def claude( "(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) raise SystemExit(result.returncode) diff --git a/tests/test_cli/test_wrap_helpers.py b/tests/test_cli/test_wrap_helpers.py index 5594a9d6c..87806e5e5 100644 --- a/tests/test_cli/test_wrap_helpers.py +++ b/tests/test_cli/test_wrap_helpers.py @@ -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)) 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]"