feat(wrap/claude): make the --1m fallback model configurable via HEADROOM_1M_MODEL (#2983)
## Description
The model `headroom wrap claude --1m` falls back to (when no model is
otherwise selected) was a hardcoded constant `claude-opus-4-8`, with no
env var or config key to override it. So it goes stale with every new
Opus release, and the only workaround is pinning `ANTHROPIC_MODEL`
globally -- which also changes every non-`--1m` session and overrides
Claude Code's own `/model` picker. The knob the user actually wants
("what should `--1m` default to") did not exist (#2937).
## Fix
Add a `HEADROOM_1M_MODEL` env override that `_resolve_1m_model` consults
for its fallback default, and bump the built-in default to
`claude-opus-5` (Opus 5 has shipped):
```python
_1M_MODEL_ENV = "HEADROOM_1M_MODEL"
_DEFAULT_1M_MODEL = "claude-opus-5"
def _resolve_1m_model(current: str | None) -> str:
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}"
```
Precedence is unchanged: an explicit `ANTHROPIC_MODEL` (or a
pass-through `--model`, via the existing `_apply_1m_to_claude_args`)
still wins. `HEADROOM_1M_MODEL` only supplies the fallback when nothing
else is selected. The `[1m]` suffixing and idempotency are unchanged.
Fixes #2937
## 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)
- [x] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `docs/content/docs/configuration.mdx`: document `HEADROOM_1M_MODEL`
(new "Claude 1M context window" subsection covering `--1m` resolution
order and `[1m]` acceptance) and register it in the Environment
Variables catalog with its current default.
- `tests/test_cli/test_wrap_helpers.py`: assert the knob stays
documented and the documented default tracks `_DEFAULT_1M_MODEL`, so it
cannot silently drift.
- `headroom/cli/wrap.py`: add `HEADROOM_1M_MODEL` env override in
`_resolve_1m_model`; bump `_DEFAULT_1M_MODEL` to `claude-opus-5`.
- `tests/test_cli/test_wrap_helpers.py`: env override wins the fallback;
an explicit current model still wins over the env; blank env falls back
to the built-in; env value is idempotent for an already-`[1m]` value.
Updated the existing "falls back to default" test to assert against the
constant (robust to future bumps) and to clear the env var.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [x] Type checking passes (`mypy`)
- [x] New tests added
### Test Output
```text
tests/test_cli/test_wrap_helpers.py -k "resolve_1m or apply_1m" 11 passed
tests/test_cli/test_wrap_claude_vertex_proxy_env.py -k 1m 4 passed
# uvx ruff@0.15.22 check -> All checks passed!
# uvx mypy@1.20.2 headroom/cli/wrap.py -> Success: no issues found in 1 source file
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1,
ruff 0.15.22 and mypy 1.20.2 via uvx.
- Exact command / steps: exercised `_resolve_1m_model` directly with the
env var set/unset. With `HEADROOM_1M_MODEL=claude-opus-9` and no
`ANTHROPIC_MODEL`, `--1m` resolves to `claude-opus-9[1m]`; with the env
var unset it resolves to `claude-opus-5[1m]`; a set `ANTHROPIC_MODEL`
(e.g. `claude-sonnet-5`) still wins as `claude-sonnet-5[1m]`.
- Observed result: operators can point `--1m` at the current Opus
without a code change and without pinning `ANTHROPIC_MODEL` globally,
and a fresh install no longer silently opts `--1m` into the previous
generation.
- Not tested: a live Claude Code 1M session (no entitled account here).
The resolution is verified at the helper the launch path uses.
## Runtime Rollout Safety
- Rollout-managed feature(s): none. `wrap claude --1m` model resolution
is a launch-time CLI helper, not a rollout-channel-gated runtime
feature.
- Minimum rollout channel: N/A (no rollout-managed behavior).
- Stable/default behavior changed: yes, narrowly. The built-in `--1m`
fallback default moves from `claude-opus-4-8` to `claude-opus-5` only
when neither `HEADROOM_1M_MODEL` nor `ANTHROPIC_MODEL` is set; any
explicit selection is unaffected.
- Kill switch / disable path: set `HEADROOM_1M_MODEL` (or
`ANTHROPIC_MODEL`) to pin any model; both override the default.
- Unsafe override required: no.
- Qualification impact: none. No proxy request path, routing, or token
accounting is touched.
- Rollback path: revert this PR, or set
`HEADROOM_1M_MODEL=claude-opus-4-8` to restore the prior default without
a code change.
## 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
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`: it is generated by
release-please from my Conventional Commit PR title
## Additional Notes
The default bump (`claude-opus-4-8` -> `claude-opus-5`) is the second
half of the issue's request. If you would rather keep the constant and
ship only the env override, I can drop that one line; the override alone
already lets operators avoid the stale default.
---------
Co-authored-by: JD Davis <mxjerrett@gmail.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
+12
-7
@@ -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}"
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user