diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 2cbb62de17ae..96fc51780f58 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -4,6 +4,7 @@ Pure utility functions with no AIAgent dependency. Used by ContextCompressor and run_agent.py for pre-flight context checks. """ +import hashlib import ipaddress import json import logging @@ -1923,27 +1924,34 @@ _CODEX_OAUTH_CONTEXT_FALLBACK: Dict[str, int] = { } -_codex_oauth_context_cache: Dict[str, int] = {} -_codex_oauth_context_cache_time: float = 0.0 +_codex_oauth_context_cache: Dict[str, Tuple[Dict[str, int], float]] = {} _CODEX_OAUTH_CONTEXT_CACHE_TTL = 3600 # 1 hour -def _fetch_codex_oauth_context_lengths(access_token: str) -> Dict[str, int]: - """Probe the ChatGPT Codex /models endpoint for per-slug context windows. +def _codex_oauth_token_fingerprint(access_token: str) -> str: + """Return a non-secret cache key for a Codex OAuth access token.""" + return hashlib.sha256(access_token.encode("utf-8")).hexdigest()[:16] - 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. - Returns a ``{slug: context_window}`` dict. Empty on failure. +def _fetch_codex_oauth_context_lengths_with_source( + access_token: str, +) -> Tuple[Dict[str, int], bool]: + """Fetch Codex catalogue data and report whether it came from HTTP. + + The in-process cache is scoped by token fingerprint because Codex model + availability and context windows can vary by account entitlement. The raw + token is never retained in the cache key. The boolean is false for a + same-token in-process hit, which must not be treated as a fresh provider + confirmation when deciding whether to update persistent state. """ - global _codex_oauth_context_cache, _codex_oauth_context_cache_time + global _codex_oauth_context_cache now = time.time() - if ( - _codex_oauth_context_cache - and now - _codex_oauth_context_cache_time < _CODEX_OAUTH_CONTEXT_CACHE_TTL - ): - return _codex_oauth_context_cache + cache_key = _codex_oauth_token_fingerprint(access_token) + cached = _codex_oauth_context_cache.get(cache_key) + if cached is not None: + cached_models, cached_at = cached + if now - cached_at < _CODEX_OAUTH_CONTEXT_CACHE_TTL: + return cached_models, False try: resp = requests.get( @@ -1957,11 +1965,11 @@ def _fetch_codex_oauth_context_lengths(access_token: str) -> Dict[str, int]: "Codex /models probe returned HTTP %s; falling back to hardcoded defaults", resp.status_code, ) - return {} + return {}, False data = resp.json() except Exception as exc: logger.debug("Codex /models probe failed: %s", exc) - return {} + return {}, False entries = data.get("models", []) if isinstance(data, dict) else [] result: Dict[str, int] = {} @@ -1974,8 +1982,20 @@ def _fetch_codex_oauth_context_lengths(access_token: str) -> Dict[str, int]: result[slug.strip()] = ctx if result: - _codex_oauth_context_cache = result - _codex_oauth_context_cache_time = now + _codex_oauth_context_cache[cache_key] = (result, now) + return result, True + + +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. + + Returns a ``{slug: context_window}`` dict. Empty on failure. + """ + result, _fresh = _fetch_codex_oauth_context_lengths_with_source(access_token) return result @@ -1988,22 +2008,24 @@ def _resolve_codex_oauth_context_length_with_source( have a bearer token), then falls back to ``_CODEX_OAUTH_CONTEXT_FALLBACK``. Returns ``(context_length, source)`` where source is ``"live"`` for a - value returned by the authenticated endpoint or ``"fallback"`` for the - static conservative table. Callers must not persist the latter. + value returned by a fresh authenticated endpoint probe, ``"memory"`` for + a same-token in-process catalogue hit, or ``"fallback"`` for the static + conservative table. Only ``"live"`` is eligible for persistent writes. """ model_bare = _strip_provider_prefix(model).strip() if not model_bare: return None, "" if access_token: - live = _fetch_codex_oauth_context_lengths(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 live[model_bare], "live" + return live[model_bare], live_source # Case-insensitive match in case casing drifts model_lower = model_bare.lower() for slug, ctx in live.items(): if slug.lower() == model_lower: - return ctx, "live" + return ctx, live_source # Fallback: longest-key-first substring match over hardcoded defaults. model_lower = model_bare.lower() diff --git a/cli.py b/cli.py index 287c2de25837..f602e3fe94a0 100644 --- a/cli.py +++ b/cli.py @@ -11904,6 +11904,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): from agent.model_metadata import get_model_context_length _ctx_len = get_model_context_length( self.model, base_url=self.base_url or "", api_key=self.api_key or "", + provider=self.provider or "", config_context_length=getattr(self.agent, "_config_context_length", None) if self.agent else None) _ctx_result = preprocess_context_references( message, cwd=os.getcwd(), context_length=_ctx_len) diff --git a/tests/agent/test_model_metadata.py b/tests/agent/test_model_metadata.py index 025a78402309..e05444509b13 100644 --- a/tests/agent/test_model_metadata.py +++ b/tests/agent/test_model_metadata.py @@ -440,7 +440,6 @@ class TestCodexOAuthContextLength: def setup_method(self): import agent.model_metadata as mm mm._codex_oauth_context_cache = {} - mm._codex_oauth_context_cache_time = 0.0 def test_fallback_table_used_without_token(self): """With no access token, the hardcoded Codex fallback table wins @@ -505,6 +504,55 @@ class TestCodexOAuthContextLength: assert ctx_55 == 300_000 assert ctx_54 == 400_000 + def test_live_catalogue_cache_is_scoped_to_access_token(self): + """Different OAuth tokens must not share entitlement-specific metadata.""" + from agent import model_metadata as mm + from agent.model_metadata import get_model_context_length + + first_response = MagicMock() + first_response.status_code = 200 + first_response.json.return_value = { + "models": [{"slug": "gpt-5.6-terra", "context_window": 272_000}] + } + second_response = MagicMock() + second_response.status_code = 200 + second_response.json.return_value = { + "models": [{"slug": "gpt-5.6-terra", "context_window": 372_000}] + } + + with patch( + "agent.model_metadata.requests.get", + side_effect=[first_response, second_response], + ) as mock_get, patch("agent.model_metadata.save_context_length") as mock_save: + first = get_model_context_length( + "gpt-5.6-terra", + base_url="https://chatgpt.com/backend-api/codex", + api_key="token-account-a", + provider="openai-codex", + ) + first_again = get_model_context_length( + "gpt-5.6-terra", + base_url="https://chatgpt.com/backend-api/codex", + api_key="token-account-a", + provider="openai-codex", + ) + second = get_model_context_length( + "gpt-5.6-terra", + base_url="https://chatgpt.com/backend-api/codex", + api_key="token-account-b", + provider="openai-codex", + ) + + assert (first, first_again, second) == (272_000, 272_000, 372_000) + assert mock_get.call_count == 2 + assert mock_get.call_args_list[0].kwargs["headers"]["Authorization"] == "Bearer token-account-a" + assert mock_get.call_args_list[1].kwargs["headers"]["Authorization"] == "Bearer token-account-b" + assert mock_save.call_count == 2 + assert all( + "token-account" not in key + for key in mm._codex_oauth_context_cache + ) + def test_probe_failure_falls_back_to_hardcoded(self): """If the probe fails (non-200 / network error), we still return the hardcoded 272k rather than leaking through to models.dev 1.05M.""" diff --git a/tests/cli/test_cli_codex_context_reference.py b/tests/cli/test_cli_codex_context_reference.py new file mode 100644 index 000000000000..ffe4d47f91d0 --- /dev/null +++ b/tests/cli/test_cli_codex_context_reference.py @@ -0,0 +1,48 @@ +"""Regression coverage for provider-aware @-context sizing in the CLI.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + + +def test_at_context_resolution_passes_active_provider(): + """The CLI @-reference path must preserve the active Codex provider.""" + from cli import HermesCLI + + cli = HermesCLI.__new__(HermesCLI) + cli.model = "gpt-5.6-terra" + cli.base_url = "https://chatgpt.com/backend-api/codex" + cli.api_key = "token" + cli.provider = "openai-codex" + cli.agent = SimpleNamespace(_config_context_length=None) + cli._active_agent_route_signature = "route" + cli._secret_capture_callback = lambda *_args, **_kwargs: None + cli._last_turn_interrupted = False + cli._ensure_runtime_credentials = lambda: True + cli._resolve_turn_agent_config = lambda _message: { + "signature": "route", + "model": cli.model, + "runtime": None, + "request_overrides": None, + } + cli._init_agent = lambda **_kwargs: True + + blocked_result = SimpleNamespace( + expanded=False, + blocked=True, + references=[], + injected_tokens=0, + warnings=["blocked for test"], + ) + with patch("agent.context_references.preprocess_context_references", return_value=blocked_result), \ + patch("agent.model_metadata.get_model_context_length", return_value=372_000) as mock_context, \ + patch("cli._cprint"): + result = cli.chat("inspect @file:example.py") + + assert result == "blocked for test" + mock_context.assert_called_once_with( + "gpt-5.6-terra", + base_url="https://chatgpt.com/backend-api/codex", + api_key="token", + provider="openai-codex", + config_context_length=None, + )