diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index c8a4fca82..51101e2f1 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -210,6 +210,24 @@ response = client.chat.completions.create( The `RollingWindowConfig`, `IntelligentContextConfig`, and `ScoringWeights` classes are no longer part of Headroom. Context management now happens automatically inside the pipeline (live-zone-only compression). +### Claude 1M context window (`headroom wrap claude --1m`) + +`headroom wrap claude --1m` opts a Claude Code session into Anthropic's 1M-token context window by selecting a `[1m]`-suffixed model id, which makes Claude Code send the `context-1m` beta header. The model that `--1m` targets is resolved in this order: + +1. an explicit `--model` / `ANTHROPIC_MODEL` value (used as-is, with a `[1m]` suffix appended when missing), +2. otherwise `HEADROOM_1M_MODEL`, when set, +3. otherwise the built-in default (currently `claude-opus-5`). + +Set `HEADROOM_1M_MODEL` to point `--1m` at a specific model without pinning `ANTHROPIC_MODEL` globally, so the default can follow a new Opus generation without a code change: + +```bash +# Route --1m at a specific model for this shell / session +export HEADROOM_1M_MODEL=claude-opus-5 +headroom wrap claude --1m +``` + +`HEADROOM_1M_MODEL` is a fallback only: an explicit `--model` or `ANTHROPIC_MODEL` always wins. The value may be given with or without the `[1m]` suffix; both `claude-opus-5` and `claude-opus-5[1m]` are accepted, and the suffix is added when absent. + ## Pipeline Extensions Use a `headroom.pipeline_extension` entry point when you need to normalize or annotate requests before they leave Headroom. The `PRE_SEND` stage is the right place for provider-specific request cleanup, such as turning `content: null` into `content: ""` for upstreams that reject OpenAI-spec tool-call messages. @@ -317,6 +335,7 @@ headroom proxy --learn --min-evidence 3 | `HEADROOM_DEDUPE` | Whole-conversation verbatim cross-turn dedup in the router (cache-safe, information-preserving via retrieval markers). Superseded-read drop + lossless folds run without it; this adds verbatim dedup. | `off` | | `HEADROOM_CACHE_TTL_LEARN` | Append per-turn cache-outcome observations (provider, model, idle, hit/miss) to `cache_ttl_observations.jsonl` for the offline `headroom-cache-ttl` learner. Observation-only (no request-behavior change); respects `HEADROOM_STATELESS`; the log is size-bounded. | `off` | | `HEADROOM_KOMPRESS_ENDPOINT` / `HEADROOM_KOMPRESS_ENDPOINT_TOKEN` | Offload ML compression (Kompress) to a remote endpoint instead of the local ONNX model — used by reasoning compaction and the router when set. | -- | +| `HEADROOM_1M_MODEL` | Fallback model that `headroom wrap claude --1m` targets when neither `--model` nor `ANTHROPIC_MODEL` is set. Accepts the id with or without the `[1m]` suffix (added when absent); an explicit `--model` / `ANTHROPIC_MODEL` always wins. See [Claude 1M context window](#claude-1m-context-window-headroom-wrap-claude---1m). | `claude-opus-5` | For provider-only proxying, prefer `HEADROOM_HTTP_PROXY` over process-wide variables such as `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, or `NO_PROXY`. HTTPX reads those global variables, but Headroom also passes them through to tool executions. diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 585e633cc..2d1f7d52e 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -321,9 +321,13 @@ _AGENT_SAVINGS_WRAP_AGENTS = {"claude", "codex", "cursor", "grok", "grok_build"} # 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" +_1M_MODEL_ENV = "HEADROOM_1M_MODEL" +# Fallback model for `--1m` when nothing else selects one (no ANTHROPIC_MODEL, +# no explicit --model). Overridable via HEADROOM_1M_MODEL so it can track new +# Opus releases without a code change and without pinning ANTHROPIC_MODEL +# globally (which would also change non-`--1m` sessions and override Claude +# Code's /model picker). #2937. +_DEFAULT_1M_MODEL = "claude-opus-5" _OPENCLAUDE_INSTRUCTIONS_FILE = "CONVENTIONS.md" @@ -331,11 +335,12 @@ 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. + appending the ``[1m]`` suffix when missing). When none is set it falls back + to ``HEADROOM_1M_MODEL`` if defined, else the built-in default Opus (#2937). + Idempotent — a value already ending in ``[1m]`` is returned unchanged. """ - base = (current or "").strip() or _DEFAULT_1M_MODEL + fallback = (os.environ.get(_1M_MODEL_ENV) or "").strip() or _DEFAULT_1M_MODEL + base = (current or "").strip() or fallback return base if base.endswith(_CONTEXT_1M_SUFFIX) else f"{base}{_CONTEXT_1M_SUFFIX}" diff --git a/tests/test_cli/test_wrap_helpers.py b/tests/test_cli/test_wrap_helpers.py index 2fad26a26..f53df49ba 100644 --- a/tests/test_cli/test_wrap_helpers.py +++ b/tests/test_cli/test_wrap_helpers.py @@ -752,10 +752,57 @@ def test_resolve_1m_model_is_idempotent() -> None: 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]" +def test_resolve_1m_model_falls_back_to_default_when_unset( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """With no model selected, fall back to the built-in default carrying [1m].""" + monkeypatch.delenv("HEADROOM_1M_MODEL", raising=False) + expected = f"{wrap_mod._DEFAULT_1M_MODEL}[1m]" + assert wrap_mod._resolve_1m_model(None) == expected + assert wrap_mod._resolve_1m_model(" ") == expected + + +def test_resolve_1m_model_env_overrides_builtin_default(monkeypatch: pytest.MonkeyPatch) -> None: + """HEADROOM_1M_MODEL overrides the built-in fallback so --1m can track new + Opus releases without a code change or pinning ANTHROPIC_MODEL (#2937).""" + monkeypatch.setenv("HEADROOM_1M_MODEL", "claude-opus-9") + assert wrap_mod._resolve_1m_model(None) == "claude-opus-9[1m]" + + +def test_resolve_1m_model_current_wins_over_env(monkeypatch: pytest.MonkeyPatch) -> None: + """An explicit ANTHROPIC_MODEL still wins; HEADROOM_1M_MODEL is only the + fallback default when nothing else is selected.""" + monkeypatch.setenv("HEADROOM_1M_MODEL", "claude-opus-9") + assert wrap_mod._resolve_1m_model("claude-sonnet-5") == "claude-sonnet-5[1m]" + + +def test_resolve_1m_model_env_idempotent_on_suffixed_value( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A HEADROOM_1M_MODEL that already carries [1m] is not double-suffixed.""" + monkeypatch.setenv("HEADROOM_1M_MODEL", "claude-opus-9[1m]") + assert wrap_mod._resolve_1m_model(None) == "claude-opus-9[1m]" + + +def test_resolve_1m_model_blank_env_falls_back_to_builtin(monkeypatch: pytest.MonkeyPatch) -> None: + """A blank/whitespace HEADROOM_1M_MODEL falls back to the built-in default.""" + monkeypatch.setenv("HEADROOM_1M_MODEL", " ") + assert wrap_mod._resolve_1m_model(None) == f"{wrap_mod._DEFAULT_1M_MODEL}[1m]" + + +def test_headroom_1m_model_is_documented_and_default_matches_code() -> None: + """The HEADROOM_1M_MODEL knob must stay documented, and the documented + default must track the code, so the supported configuration surface cannot + silently drift or disappear (#2937). + """ + docs = Path(__file__).resolve().parents[2] / "docs" / "content" / "docs" / "configuration.mdx" + text = docs.read_text(encoding="utf-8") + assert wrap_mod._1M_MODEL_ENV in text, f"{wrap_mod._1M_MODEL_ENV} is not documented" + # The env-var catalog row must advertise the current built-in default. + assert f"`{wrap_mod._DEFAULT_1M_MODEL}`" in text, ( + "documented HEADROOM_1M_MODEL default is out of sync with " + f"_DEFAULT_1M_MODEL={wrap_mod._DEFAULT_1M_MODEL!r}" + ) class TestFindAvailablePort: