diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index b5aeae274f..8d387b86d1 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -161,7 +161,11 @@ def aux_probe_mode(): _aux_probe_state.active = prev from agent.credential_pool import load_pool -from agent.model_metadata import MINIMUM_CONTEXT_LENGTH, get_model_context_length +from agent.model_metadata import ( + MINIMUM_CONTEXT_LENGTH, + get_model_context_length, + strip_codex_context_variant_suffix as _strip_codex_ctx_variant, +) from hermes_cli.config import get_hermes_home from hermes_constants import OPENROUTER_BASE_URL from utils import base_url_host_matches, base_url_hostname, env_float, is_truthy_value, model_forces_max_completion_tokens, normalize_proxy_env_vars @@ -1531,7 +1535,10 @@ class _CodexCompletionsAdapter: ) resp_kwargs: Dict[str, Any] = { - "model": model, + # Strip the Hermes-side ``-900k`` large-context picker suffix — + # the Codex backend only knows the base slug (mirrors the main + # transport in agent/transports/codex.py::build_kwargs). + "model": _strip_codex_ctx_variant(model), "instructions": instructions, "input": input_items or [{"role": "user", "content": ""}], "store": False, diff --git a/agent/model_metadata.py b/agent/model_metadata.py index bad065573a..51643ed5ca 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -2418,9 +2418,18 @@ _CODEX_OAUTH_CONTEXT_FALLBACK: Dict[str, int] = { # ≥11K margin under the observed ceiling and matches the compaction point # Codex's own client config documents for the 1M window. # -# Applied ONLY when the resolved value (live probe or fallback table) is -# exactly the known-stale 272,000 advertisement — if OpenAI moves the -# advertised number in either direction (the gpt-5.6 family shifted +# OPT-IN ONLY (Aug 2026 policy, Teknium): the large window is exposed via +# explicit ``-900k`` picker variants (e.g. ``gpt-5.6-sol-900k``) — the base +# slugs keep the advertised 272K so the cheaper limit is the default. A +# week of the 900K default burned through subscription usage for people +# who never asked for it. The variant suffix is a Hermes-side alias: it is +# stripped before the model id hits the wire (see +# ``strip_codex_context_variant_suffix`` callers in agent/transports/codex.py +# and agent/auxiliary_client.py). +# +# The bump is applied ONLY when the resolved value (live probe or fallback +# table) is exactly the known-stale 272,000 advertisement — if OpenAI moves +# the advertised number in either direction (the gpt-5.6 family shifted # 272K → 372K → 272K during July 2026), the catalog is trusted again and # this table is inert. ``gpt-5.6`` is a FAMILY PREFIX (sol/terra/luna and # dated snapshots; ``-pro`` slugs are not routable on Codex OAuth — the @@ -2438,14 +2447,63 @@ _CODEX_OAUTH_VERIFIED_ABOVE_ADVERTISED_EXACT: Dict[str, int] = { # The advertised value the verified-above table is allowed to override. _CODEX_OAUTH_STALE_ADVERTISED_CTX = 272_000 +# Hermes-side picker suffix that opts a Codex slug into the live-verified +# large window. Never sent on the wire. +CODEX_CONTEXT_VARIANT_SUFFIX = "-900k" -def _verified_codex_ctx_for_slug(model_bare: str) -> Optional[int]: - """Return the live-verified Codex cap for a slug, or ``None``. - Exact slugs first, then family prefixes (````, ``-``, - ``.``) so dated snapshots of a verified family inherit the bump. +def is_codex_context_variant(model: Optional[str]) -> bool: + """True when the model id carries the Hermes ``-900k`` opt-in suffix.""" + return (model or "").strip().lower().endswith(CODEX_CONTEXT_VARIANT_SUFFIX) + + +def strip_codex_context_variant_suffix(model: Optional[str]) -> str: + """Return the wire-safe slug with any ``-900k`` opt-in suffix removed. + + The suffix is a Hermes picker alias (``gpt-5.6-sol-900k``); the Codex + backend only knows the base slug. Case-insensitive; non-variant ids are + returned unchanged. + """ + raw = (model or "").strip() + if raw.lower().endswith(CODEX_CONTEXT_VARIANT_SUFFIX): + return raw[: -len(CODEX_CONTEXT_VARIANT_SUFFIX)] + return raw + + +def has_codex_context_variant(model_bare: str) -> bool: + """True when a Codex BASE slug has a live-verified ``-900k`` variant. + + Used by the model pickers to decide which base slugs get a synthetic + ``-900k`` entry. Exact table first, then family prefixes — + mirrors ``_verified_codex_ctx_for_slug`` minus the suffix requirement. """ slug = (model_bare or "").strip().lower() + if not slug or slug.endswith(CODEX_CONTEXT_VARIANT_SUFFIX): + return False + if slug in _CODEX_OAUTH_VERIFIED_ABOVE_ADVERTISED_EXACT: + return True + for key in _CODEX_OAUTH_VERIFIED_ABOVE_ADVERTISED_PREFIXES: + if slug == key or slug.startswith(key + "-") or slug.startswith(key + "."): + return True + return False + + +def _verified_codex_ctx_for_slug(model_bare: str) -> Optional[int]: + """Return the live-verified Codex cap for an OPTED-IN slug, or ``None``. + + The large window is opt-in: only ``-900k``-suffixed picker variants + (e.g. ``gpt-5.6-sol-900k``) resolve to the verified cap. Base slugs + keep the advertised 272K so the cheaper default limit applies unless + the user explicitly selects the large-context variant. + + After stripping the suffix: exact slugs first, then family prefixes + (````, ``-``, ``.``) so dated snapshots of a verified + family inherit the bump. + """ + slug = (model_bare or "").strip().lower() + if not slug.endswith(CODEX_CONTEXT_VARIANT_SUFFIX): + return None + slug = slug[: -len(CODEX_CONTEXT_VARIANT_SUFFIX)] if not slug: return None exact = _CODEX_OAUTH_VERIFIED_ABOVE_ADVERTISED_EXACT.get(slug) @@ -2586,8 +2644,9 @@ def _resolve_codex_oauth_context_length_with_source( def _apply_verified_bump(ctx: int, source: str) -> Tuple[int, str]: """Lift a known-stale 272K advertisement to the live-verified cap. - Only fires when the resolved value is EXACTLY the stale 272,000 - advertisement for a slug we have probed above it (see + Only fires for explicit ``-900k`` picker variants (opt-in), and only + when the resolved value is EXACTLY the stale 272,000 advertisement + for a slug we have probed above it (see ``_verified_codex_ctx_for_slug``). Any other advertised value — higher or lower — is trusted as a real server-side change. """ @@ -2600,19 +2659,23 @@ def _resolve_codex_oauth_context_length_with_source( return bumped, source return ctx, source + # ``-900k`` variants are Hermes picker aliases — the Codex catalog only + # knows the base slug, so resolve against the stripped id. + lookup_bare = strip_codex_context_variant_suffix(model_bare) + if access_token: live, fresh_probe = _fetch_codex_oauth_context_lengths_with_source(access_token) live_source = "live" if fresh_probe else "memory" - if model_bare in live: - return _apply_verified_bump(live[model_bare], live_source) + if lookup_bare in live: + return _apply_verified_bump(live[lookup_bare], live_source) # Case-insensitive match in case casing drifts - model_lower = model_bare.lower() + model_lower = lookup_bare.lower() for slug, ctx in live.items(): if slug.lower() == model_lower: return _apply_verified_bump(ctx, live_source) # Fallback: longest-key-first substring match over hardcoded defaults. - model_lower = model_bare.lower() + model_lower = lookup_bare.lower() for slug, ctx in sorted( _CODEX_OAUTH_CONTEXT_FALLBACK.items(), key=lambda x: len(x[0]), reverse=True ): diff --git a/agent/transports/codex.py b/agent/transports/codex.py index 38e86a654c..45b53410c5 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -567,8 +567,17 @@ class ResponsesApiTransport(ProviderTransport): # request is issued (openai==2.24.0). Reported for the # ``openai-codex`` / ``gpt-5.5`` combo on chatgpt.com/backend-api/codex # (#32892) when the agent runs without external tools registered. + # Function-level import: agent.model_metadata is imported lazily + # because provider plugins import this transport during + # model_metadata's own module init (circular otherwise). + from agent.model_metadata import ( + strip_codex_context_variant_suffix as _strip_ctx_variant, + ) kwargs = { - "model": model, + # ``-900k`` large-context picker variants are Hermes-side aliases + # (gpt-5.6-sol-900k etc.) — the Codex/OpenAI backend only knows + # the base slug, so strip the suffix before it hits the wire. + "model": _strip_ctx_variant(model), "instructions": instructions, "input": _chat_messages_to_responses_input( payload_messages, diff --git a/agent/usage_pricing.py b/agent/usage_pricing.py index c955bdc7f0..4aaf7409a7 100644 --- a/agent/usage_pricing.py +++ b/agent/usage_pricing.py @@ -994,11 +994,16 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = { # GPT-5.6 "-pro" high-effort variants bill at the same per-token rates as # their base tiers (more tokens per task, not a higher rate). Alias them -# onto the base entries so the snapshot stays single-source. +# onto the base entries so the snapshot stays single-source. The Hermes-side +# "-900k" large-context Codex picker variants are the same underlying model +# (the suffix is stripped on the wire), so they alias identically. for _base_56 in ("gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"): _OFFICIAL_DOCS_PRICING[("openai", f"{_base_56}-pro")] = _OFFICIAL_DOCS_PRICING[ ("openai", _base_56) ] + _OFFICIAL_DOCS_PRICING[("openai", f"{_base_56}-900k")] = _OFFICIAL_DOCS_PRICING[ + ("openai", _base_56) + ] del _base_56 # The direct Gemini provider currently exposes preview IDs for these two diff --git a/hermes_cli/codex_models.py b/hermes_cli/codex_models.py index f058107676..2b17802d87 100644 --- a/hermes_cli/codex_models.py +++ b/hermes_cli/codex_models.py @@ -89,6 +89,38 @@ def _add_forward_compat_models(model_ids: List[str]) -> List[str]: return ordered +def _add_context_variants(model_ids: List[str]) -> List[str]: + """Insert ``-900k`` large-context picker variants after eligible base slugs. + + The ChatGPT Codex backend advertises 272K for the gpt-5.4 / gpt-5.6 + families but accepts ~911K (live-verified Aug 2026). The base slugs keep + the cheaper advertised 272K limit by default; each verified slug gets an + explicit ``-900k`` picker entry that opts into the large window. + The suffix is Hermes-side only — it is stripped before the model id hits + the wire (agent/transports/codex.py, agent/auxiliary_client.py). + """ + from agent.model_metadata import ( + CODEX_CONTEXT_VARIANT_SUFFIX, + has_codex_context_variant, + ) + + out: List[str] = [] + present = set(model_ids) + for model_id in model_ids: + out.append(model_id) + variant = model_id + CODEX_CONTEXT_VARIANT_SUFFIX + if variant in present or variant in out: + continue + if has_codex_context_variant(model_id): + out.append(variant) + return out + + +def _finalize_codex_models(model_ids: List[str]) -> List[str]: + """Forward-compat synthesis + large-context variant synthesis.""" + return _add_context_variants(_add_forward_compat_models(model_ids)) + + def _extract_chatgpt_account_id(access_token: str) -> Optional[str]: """Best-effort extraction of ``chatgpt_account_id`` from the OAuth JWT. @@ -160,7 +192,7 @@ def _fetch_models_from_api(access_token: str) -> List[str]: sortable.append((rank, slug)) sortable.sort(key=lambda x: (x[0], x[1])) - return _add_forward_compat_models([slug for _, slug in sortable]) + return _finalize_codex_models([slug for _, slug in sortable]) def _read_default_model(codex_home: Path) -> Optional[str]: @@ -232,7 +264,7 @@ def get_codex_model_ids(access_token: Optional[str] = None) -> List[str]: if access_token: api_models = _fetch_models_from_api(access_token) if api_models: - return _add_forward_compat_models(api_models) + return _finalize_codex_models(api_models) # Fall back to local sources default_model = _read_default_model(codex_home) @@ -247,4 +279,4 @@ def get_codex_model_ids(access_token: Optional[str] = None) -> List[str]: if model_id not in ordered: ordered.append(model_id) - return _add_forward_compat_models(ordered) + return _finalize_codex_models(ordered) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 5dd8cccbb2..f638be4419 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -178,8 +178,8 @@ def _codex_curated_models() -> list[str]: This keeps the gateway /model picker in sync with the CLI `hermes model` flow without maintaining a separate static list. """ - from hermes_cli.codex_models import DEFAULT_CODEX_MODELS, _add_forward_compat_models - return _add_forward_compat_models(list(DEFAULT_CODEX_MODELS)) + from hermes_cli.codex_models import DEFAULT_CODEX_MODELS, _finalize_codex_models + return _finalize_codex_models(list(DEFAULT_CODEX_MODELS)) # Static fallback for xAI when the models.dev disk cache is empty (fresh diff --git a/tests/agent/test_model_metadata.py b/tests/agent/test_model_metadata.py index 1d589355ca..a0f3b1fb6b 100644 --- a/tests/agent/test_model_metadata.py +++ b/tests/agent/test_model_metadata.py @@ -521,11 +521,11 @@ class TestCodexOAuthContextLength: "gpt-daybreak-blue-latest", # Sol alias; exact verified slug ], ) - def test_stale_272k_advertisement_bumped_to_live_verified_900k(self, slug): - """Codex advertises 272K for these slugs but the backend accepts ~911K - (verified live Aug 2026, after OpenAI enabled the large-context window - for ChatGPT-subscription accounts); the resolver lifts exactly-272K - to 900K.""" + def test_900k_variant_slug_bumped_to_live_verified_900k(self, slug): + """The backend accepts ~911K for these slugs (verified live Aug 2026), + but the large window is OPT-IN: only the explicit ``-900k`` picker + variant resolves to 900K. The catalog only knows the base slug, so + the resolver strips the suffix for the lookup, then applies the bump.""" from agent.model_metadata import get_model_context_length fake_response = MagicMock() @@ -537,16 +537,50 @@ class TestCodexOAuthContextLength: patch("agent.model_metadata.get_cached_context_length", return_value=None), \ patch("agent.model_metadata.save_context_length"): ctx = get_model_context_length( - model=slug, + model=slug + "-900k", base_url="https://chatgpt.com/backend-api/codex", api_key="fake-token", provider="openai-codex", ) assert ctx == 900_000 + @pytest.mark.parametrize( + "slug", + [ + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.4", + "gpt-daybreak-blue-latest", + ], + ) + def test_base_slug_keeps_advertised_272k(self, slug): + """Base slugs (no ``-900k`` suffix) keep the advertised 272K — the + cheaper default limit. The verified-above bump is opt-in only.""" + from agent.model_metadata import get_model_context_length + + fake_response = MagicMock() + fake_response.status_code = 200 + fake_response.json.return_value = { + "models": [{"slug": slug, "context_window": 272_000}] + } + import agent.model_metadata as mm + mm._codex_oauth_context_cache = {} + with patch("agent.model_metadata.requests.get", return_value=fake_response), \ + patch("agent.model_metadata.get_cached_context_length", return_value=None), \ + patch("agent.model_metadata.save_context_length"): + ctx = get_model_context_length( + model=slug, + base_url="https://chatgpt.com/backend-api/codex", + api_key="fake-token", + provider="openai-codex", + ) + assert ctx == 272_000 + def test_non_272k_advertisement_is_trusted_verbatim(self): """Any advertised value other than the known-stale 272,000 — higher or - lower — is a real server-side change and must NOT be overridden.""" + lower — is a real server-side change and must NOT be overridden, even + for an explicit ``-900k`` opt-in variant.""" from agent.model_metadata import get_model_context_length for advertised in (372_000, 200_000, 1_050_000): @@ -561,7 +595,7 @@ class TestCodexOAuthContextLength: patch("agent.model_metadata.get_cached_context_length", return_value=None), \ patch("agent.model_metadata.save_context_length"): ctx = get_model_context_length( - model="gpt-5.6-sol", + model="gpt-5.6-sol-900k", base_url="https://chatgpt.com/backend-api/codex", api_key="fake-token", provider="openai-codex", @@ -591,10 +625,11 @@ class TestCodexOAuthContextLength: ) assert ctx == 272_000 - @pytest.mark.parametrize("slug", ["gpt-5.6-sol", "gpt-daybreak-blue-latest"]) + @pytest.mark.parametrize("slug", ["gpt-5.6-sol-900k", "gpt-daybreak-blue-latest-900k"]) def test_fallback_table_resolution_also_bumped(self, slug): - """When the live probe fails, the 272K fallback-table value for a - verified slug is bumped the same way (same enforcement applies).""" + """When the live probe fails, the 272K fallback-table value for an + opted-in ``-900k`` variant is bumped the same way (same enforcement + applies — the fallback lookup strips the suffix first).""" from agent.model_metadata import get_model_context_length fake_response = MagicMock() @@ -611,6 +646,26 @@ class TestCodexOAuthContextLength: ) assert ctx == 900_000 + @pytest.mark.parametrize("slug", ["gpt-5.6-sol", "gpt-daybreak-blue-latest"]) + def test_fallback_table_base_slug_stays_272k(self, slug): + """Fallback-table resolution for BASE slugs stays at the advertised + 272K — the opt-in rule applies on the offline path too.""" + from agent.model_metadata import get_model_context_length + + fake_response = MagicMock() + fake_response.status_code = 401 + fake_response.json.return_value = {} + with patch("agent.model_metadata.requests.get", return_value=fake_response), \ + patch("agent.model_metadata.get_cached_context_length", return_value=None), \ + patch("agent.model_metadata.save_context_length"): + ctx = get_model_context_length( + model=slug, + base_url="https://chatgpt.com/backend-api/codex", + api_key="expired-token", + provider="openai-codex", + ) + assert ctx == 272_000 + diff --git a/tests/agent/transports/test_codex_transport.py b/tests/agent/transports/test_codex_transport.py index 47b5c4e047..9d80ffed47 100644 --- a/tests/agent/transports/test_codex_transport.py +++ b/tests/agent/transports/test_codex_transport.py @@ -39,8 +39,24 @@ class TestCodexTransportBasic: class TestCodexBuildKwargs: + def test_900k_context_variant_suffix_stripped_on_wire(self, transport): + """``-900k`` large-context picker variants are Hermes-side aliases — + the Codex backend only knows the base slug, so build_kwargs must + strip the suffix from the wire model id.""" + messages = [{"role": "user", "content": "Hi"}] + kw = transport.build_kwargs( + model="gpt-5.6-sol-900k", messages=messages, tools=[], + params={"is_codex_backend": True}, + ) + assert kw["model"] == "gpt-5.6-sol" - + def test_base_slug_model_id_unchanged_on_wire(self, transport): + messages = [{"role": "user", "content": "Hi"}] + kw = transport.build_kwargs( + model="gpt-5.6-sol", messages=messages, tools=[], + params={"is_codex_backend": True}, + ) + assert kw["model"] == "gpt-5.6-sol" diff --git a/tests/hermes_cli/test_codex_models.py b/tests/hermes_cli/test_codex_models.py index afd2a332b2..be27a4e62a 100644 --- a/tests/hermes_cli/test_codex_models.py +++ b/tests/hermes_cli/test_codex_models.py @@ -35,6 +35,23 @@ def test_curated_codex_fallback_excludes_chatgpt_rejected_pro_slugs(monkeypatch) assert CHATGPT_REJECTED_CODEX_PRO_SLUGS.isdisjoint(model_ids) +def test_picker_synthesizes_900k_variants_for_verified_slugs(): + """Every live-verified large-context slug gets an explicit ``-900k`` + picker variant directly after its base entry; slugs that genuinely + enforce 272K (gpt-5.5, gpt-5.4-mini) never get one. Base slugs stay + in the list as the cheaper 272K default.""" + model_ids = get_codex_model_ids() # offline curated path + + for base in ("gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.4"): + assert base in model_ids + assert f"{base}-900k" in model_ids + assert model_ids.index(f"{base}-900k") == model_ids.index(base) + 1 + + assert "gpt-5.5-900k" not in model_ids + assert "gpt-5.4-mini-900k" not in model_ids + assert "gpt-5.3-codex-900k" not in model_ids + + def test_setup_wizard_codex_import_resolves(): diff --git a/website/docs/developer-guide/context-compression-and-caching.md b/website/docs/developer-guide/context-compression-and-caching.md index 31e3c9a0cb..08f2854c01 100644 --- a/website/docs/developer-guide/context-compression-and-caching.md +++ b/website/docs/developer-guide/context-compression-and-caching.md @@ -192,6 +192,22 @@ To keep the 85% autoraise but hide only the one-time notice: hermes config set compression.codex_gpt55_autoraise_notice false ``` +### Codex large-context `-900k` picker variants (opt-in) + +The ChatGPT Codex backend *advertises* a 272K window for the gpt-5.4 and +gpt-5.6 (Sol/Terra/Luna) families, but actually accepts ~911K input tokens +for ChatGPT-subscription accounts (live-verified Aug 2026). Hermes keeps the +**advertised 272K as the default** for the base slugs — a bigger window means +more tokens per request and much faster subscription-usage burn, so the large +window is strictly opt-in. + +To use the large window, pick the explicit `-900k` variant in `/model` (e.g. +`gpt-5.6-sol-900k`, `gpt-5.6-terra-900k`, `gpt-5.6-luna-900k`, +`gpt-5.4-900k`). These are Hermes-side aliases: the suffix is stripped before +the model id is sent to the backend, and pricing/usage accounting treats them +as the base model. Slugs that genuinely enforce 272K (gpt-5.5, gpt-5.4-mini) +have no `-900k` variant. + ### Codex app-server thread compaction Codex app-server sessions (`api_mode: codex_app_server` — the codex CLI/agent