Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 29 additions & 19 deletions agent/model_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -1228,13 +1228,13 @@ def _query_anthropic_context_length(model: str, base_url: str, api_key: str) ->
return None


# Known ChatGPT Codex OAuth context windows (observed via live
# chatgpt.com/backend-api/codex/models probe, Apr 2026). These are the
# `context_window` values, which are what Codex actually enforces β€” the
# direct OpenAI API has larger limits for the same slugs, but Codex OAuth
# caps lower (e.g. gpt-5.5 is 1.05M on the API, 272K on Codex).
# Known ChatGPT Codex OAuth default context windows (observed via live
# chatgpt.com/backend-api/codex/models probe, Apr 2026). These conservative
# defaults are used only when the live probe fails (no token, network error).
#
# Used as a fallback when the live probe fails (no token, network error).
# The live endpoint can also advertise a larger per-slug `max_context_window`
# (for example gpt-5.4 can expose a 1M usable window). Prefer the live value
# whenever an access token is available.
# Longest keys first so substring match picks the most specific entry.
_CODEX_OAUTH_CONTEXT_FALLBACK: Dict[str, int] = {
"gpt-5.1-codex-max": 272_000,
Expand Down Expand Up @@ -1263,11 +1263,12 @@ def _query_anthropic_context_length(model: str, base_url: str, api_key: str) ->
def _fetch_codex_oauth_context_lengths(access_token: str) -> Dict[str, int]:
"""Probe the ChatGPT Codex /models endpoint for per-slug context windows.

Codex OAuth imposes its own context limits that differ from the direct
OpenAI API (e.g. gpt-5.5 is 1.05M on the API, 272K on Codex). The
`context_window` field in each model entry is the authoritative source.
Codex OAuth has provider-specific context limits that differ from the direct
OpenAI API. Prefer the live ``max_context_window`` when present (that is the
usable upper bound Codex advertises), then fall back to ``context_window``
for older entries.

Returns a ``{slug: context_window}`` dict. Empty on failure.
Returns a ``{slug: resolved_context_window}`` dict. Empty on failure.
"""
global _codex_oauth_context_cache, _codex_oauth_context_cache_time
now = time.time()
Expand Down Expand Up @@ -1301,7 +1302,9 @@ def _fetch_codex_oauth_context_lengths(access_token: str) -> Dict[str, int]:
if not isinstance(item, dict):
continue
slug = item.get("slug")
ctx = item.get("context_window")
ctx = item.get("max_context_window")
if not isinstance(ctx, int) or ctx <= 0:
ctx = item.get("context_window")
if isinstance(slug, str) and isinstance(ctx, int) and ctx > 0:
result[slug.strip()] = ctx

Expand Down Expand Up @@ -1483,16 +1486,23 @@ def get_model_context_length(
if base_url and provider != "lmstudio":
cached = get_cached_context_length(model, base_url)
if cached is not None:
# Invalidate stale Codex OAuth cache entries: pre-PR #14935 builds
# resolved gpt-5.x to the direct-API value (e.g. 1.05M) via
# models.dev and persisted it. Codex OAuth caps at 272K for every
# slug, so any cached Codex entry at or above 400K is a leftover
# from the old resolution path. Drop it and fall through to the
# live /models probe in step 5 below.
if provider == "openai-codex" and cached >= 400_000:
# Codex OAuth is account/model metadata and has changed over time
# (e.g. gpt-5.4 gained max_context_window=1M after 272K entries
# were already cached). If we have a token, prefer a fresh live
# probe over persistent cache; the live probe has its own in-memory
# TTL, and save_context_length() writes the refreshed value back.
# Without a token, fall back to reasonable cached values, while
# still dropping old pre-PR #14935 direct-API-sized entries.
if provider == "openai-codex" and api_key:
logger.info(
"Refreshing Codex cache entry %s@%s -> %s via live /models probe",
model, base_url, f"{cached:,}",
)
_invalidate_cached_context_length(model, base_url)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This deletes the only persisted last-known-good maximum before /models has succeeded. If the probe returns 503/401 or raises, _fetch_codex_oauth_context_lengths() returns {} and the resolver falls back; retain this entry until a successful response selects and persists a replacement.

elif provider == "openai-codex" and cached >= 400_000:
logger.info(
"Dropping stale Codex cache entry %s@%s -> %s (pre-fix value); "
"re-resolving via live /models probe",
"re-resolving via fallback/live probe if possible",
model, base_url, f"{cached:,}",
)
_invalidate_cached_context_length(model, base_url)
Expand Down
106 changes: 86 additions & 20 deletions tests/agent/test_model_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,11 +260,13 @@ def test_dict_is_not_empty(self):
# =========================================================================

class TestCodexOAuthContextLength:
"""ChatGPT Codex OAuth imposes lower context limits than the direct
OpenAI API for the same slugs. Verified Apr 2026 via live probe of
chatgpt.com/backend-api/codex/models: most models return 272k, while
models.dev reports 1.05M for gpt-5.5/gpt-5.4 and 400k for the rest.
(Known exception: gpt-5.3-codex-spark is 128k.)
"""ChatGPT Codex OAuth exposes provider-specific context limits that
differ from the direct OpenAI API for the same slugs. Verified Apr 2026
via live probe of chatgpt.com/backend-api/codex/models: default
context_window can be 272k while max_context_window may advertise a
larger usable window for selected models such as gpt-5.4. When the live
probe is unavailable, conservative fallback defaults are used (known
exception: gpt-5.3-codex-spark is 128k).
"""

def setup_method(self):
Expand All @@ -274,7 +276,7 @@ def setup_method(self):

def test_fallback_table_used_without_token(self):
"""With no access token, the hardcoded Codex fallback table wins
over models.dev (which reports 1.05M for gpt-5.5 but Codex is 272k).
over models.dev (which reports larger direct-API values for GPT-5 slugs).
"""
from agent.model_metadata import get_model_context_length

Expand Down Expand Up @@ -303,17 +305,18 @@ def test_fallback_table_used_without_token(self):
"(models.dev leakage?)"
)

def test_live_probe_overrides_fallback(self):
"""When a token is provided, the live /models probe is preferred
and its context_window drives the result."""
def test_live_probe_prefers_max_context_window_when_available(self):
"""When Codex advertises a larger max_context_window, Hermes should
use it as the provider-enforced usable window.
"""
from agent.model_metadata import get_model_context_length

fake_response = MagicMock()
fake_response.status_code = 200
fake_response.json.return_value = {
"models": [
{"slug": "gpt-5.5", "context_window": 300_000},
{"slug": "gpt-5.4", "context_window": 400_000},
{"slug": "gpt-5.5", "context_window": 272_000, "max_context_window": 272_000},
{"slug": "gpt-5.4", "context_window": 272_000, "max_context_window": 1_000_000},
]
}

Expand All @@ -332,8 +335,31 @@ def test_live_probe_overrides_fallback(self):
api_key="fake-token",
provider="openai-codex",
)
assert ctx_55 == 300_000
assert ctx_54 == 400_000
assert ctx_55 == 272_000
assert ctx_54 == 1_000_000

def test_live_probe_falls_back_to_context_window_when_max_missing(self):
"""Older Codex model entries without max_context_window still resolve
from context_window.
"""
from agent.model_metadata import get_model_context_length

fake_response = MagicMock()
fake_response.status_code = 200
fake_response.json.return_value = {
"models": [{"slug": "gpt-5.4", "context_window": 400_000}]
}

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="gpt-5.4",
base_url="https://chatgpt.com/backend-api/codex",
api_key="fake-token",
provider="openai-codex",
)
assert ctx == 400_000

def test_probe_failure_falls_back_to_hardcoded(self):
"""If the probe fails (non-200 / network error), we still return
Expand Down Expand Up @@ -384,8 +410,8 @@ def test_stale_codex_cache_over_400k_is_invalidated(self, tmp_path, monkeypatch)
"""Pre-PR #14935 builds cached gpt-5.5 at 1.05M (from models.dev)
before the Codex-aware branch existed. Upgrading users keep that
stale entry on disk and the cache-first lookup returns it forever.
Codex OAuth caps at 272k for every slug, so any cached Codex
entry >= 400k must be dropped and re-resolved via the live probe.
Cached direct-API-sized Codex entries remain suspicious and must be
dropped and re-resolved via the live probe or conservative fallback.
"""
from agent import model_metadata as mm

Expand Down Expand Up @@ -425,9 +451,50 @@ def test_stale_codex_cache_over_400k_is_invalidated(self, tmp_path, monkeypatch)
assert stale_key not in remaining, "Stale entry was not invalidated from the cache file"
assert remaining.get(other_key) == 128_000, "Unrelated cache entries must not be touched"

def test_fresh_codex_cache_under_400k_is_respected(self, tmp_path, monkeypatch):
"""Codex entries at the correct 272k must NOT be invalidated β€”
only stale pre-fix values (>= 400k) get dropped."""
def test_stale_codex_272k_cache_is_refreshed_when_token_available(self, tmp_path, monkeypatch):
"""Codex /models can raise a slug's usable max_context_window after a
272k value was cached. With an access token, prefer a fresh live probe
over stale persistent cache.
"""
from agent import model_metadata as mm

cache_file = tmp_path / "context_length_cache.yaml"
monkeypatch.setattr(mm, "_get_context_cache_path", lambda: cache_file)

base_url = "https://chatgpt.com/backend-api/codex/"
stale_key = f"gpt-5.4@{base_url}"
other_key = "gpt-5.5@https://chatgpt.com/backend-api/codex/"
import yaml as _yaml
cache_file.write_text(_yaml.dump({"context_lengths": {
stale_key: 272_000,
other_key: 272_000,
}}))

fake_response = MagicMock()
fake_response.status_code = 200
fake_response.json.return_value = {
"models": [
{"slug": "gpt-5.4", "context_window": 272_000, "max_context_window": 1_000_000},
{"slug": "gpt-5.5", "context_window": 272_000, "max_context_window": 272_000},
]
}

with patch("agent.model_metadata.requests.get", return_value=fake_response), \
patch("agent.model_metadata.save_context_length") as mock_save:
ctx = mm.get_model_context_length(
model="gpt-5.4",
base_url=base_url,
api_key="fake-token",
provider="openai-codex",
)

assert ctx == 1_000_000
mock_save.assert_called_with("gpt-5.4", base_url, 1_000_000)

def test_codex_cache_used_when_no_token_available(self, tmp_path, monkeypatch):
"""Without an access token, Hermes cannot do a live Codex probe and
should still use a reasonable persistent cache entry.
"""
from agent import model_metadata as mm

cache_file = tmp_path / "context_length_cache.yaml"
Expand All @@ -439,12 +506,11 @@ def test_fresh_codex_cache_under_400k_is_respected(self, tmp_path, monkeypatch):
f"gpt-5.5@{base_url}": 272_000,
}}))

# If the invalidation incorrectly fired, this would be called; assert it isn't.
with patch("agent.model_metadata.requests.get") as mock_get:
ctx = mm.get_model_context_length(
model="gpt-5.5",
base_url=base_url,
api_key="fake-token",
api_key="",
provider="openai-codex",
)
assert ctx == 272_000
Expand Down