diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 96fc51780f58..93eed273e5d0 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 base64 import hashlib import ipaddress import json @@ -1933,6 +1934,34 @@ def _codex_oauth_token_fingerprint(access_token: str) -> str: return hashlib.sha256(access_token.encode("utf-8")).hexdigest()[:16] +def _extract_chatgpt_account_id(access_token: str) -> Optional[str]: + """Extract ``chatgpt_account_id`` from the Codex OAuth JWT. + + The Codex ``/backend-api/codex/models`` endpoint returns the per-account + catalog only when the ``ChatGPT-Account-Id`` header is present; without + it, the endpoint returns ``{"models":[]}`` (HTTP 200) and the context + probe falls back to the hardcoded defaults — which can be stale or + wrong for the active account's plan. Mirrors the same extraction done + in ``auxiliary_client.py`` for the request path. + + Returns ``None`` on any parse error rather than raising, so a bad + token still surfaces as a normal probe failure instead of crashing + the metadata resolver. + """ + try: + parts = access_token.split(".") + if len(parts) < 2: + return None + payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4) + claims = json.loads(base64.urlsafe_b64decode(payload_b64)) + if not isinstance(claims, dict): + return None + acct_id = claims.get("https://api.openai.com/auth", {}).get("chatgpt_account_id") + return acct_id if isinstance(acct_id, str) and acct_id else None + except Exception: + return None + + def _fetch_codex_oauth_context_lengths_with_source( access_token: str, ) -> Tuple[Dict[str, int], bool]: @@ -1953,10 +1982,15 @@ def _fetch_codex_oauth_context_lengths_with_source( if now - cached_at < _CODEX_OAUTH_CONTEXT_CACHE_TTL: return cached_models, False + headers = {"Authorization": f"Bearer {access_token}"} + acct_id = _extract_chatgpt_account_id(access_token) + if acct_id: + headers["ChatGPT-Account-Id"] = acct_id + try: resp = requests.get( "https://chatgpt.com/backend-api/codex/models?client_version=1.0.0", - headers={"Authorization": f"Bearer {access_token}"}, + headers=headers, timeout=(5, 10), verify=_resolve_requests_verify(), ) diff --git a/contributors/emails/tars@users.noreply.github.com b/contributors/emails/tars@users.noreply.github.com new file mode 100644 index 000000000000..cf051880ec8a --- /dev/null +++ b/contributors/emails/tars@users.noreply.github.com @@ -0,0 +1 @@ +MLcogTech diff --git a/hermes_cli/codex_models.py b/hermes_cli/codex_models.py index a56cddf73a20..021d31918bc3 100644 --- a/hermes_cli/codex_models.py +++ b/hermes_cli/codex_models.py @@ -2,6 +2,7 @@ from __future__ import annotations +import base64 import json import logging from pathlib import Path @@ -93,13 +94,47 @@ def _add_forward_compat_models(model_ids: List[str]) -> List[str]: return ordered +def _extract_chatgpt_account_id(access_token: str) -> Optional[str]: + """Best-effort extraction of ``chatgpt_account_id`` from the OAuth JWT. + + The Codex backend requires the ``ChatGPT-Account-Id`` header for the + per-account catalog. Without it, ``GET /backend-api/codex/models`` + returns ``{"models":[]}`` (HTTP 200) — which masquerades as "no + models available" and silently degrades the picker to the curated + fallback list. The request-side path in ``auxiliary_client.py`` + already extracts the same claim; this mirrors that logic here so the + probe sees the same catalog the request path will actually use. + + Returns ``None`` on any parse error — the probe then degrades + gracefully to the unauthenticated fallback list instead of crashing. + """ + try: + parts = access_token.split(".") + if len(parts) < 2: + return None + payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4) + claims = json.loads(base64.urlsafe_b64decode(payload_b64)) + acct_id = ( + claims.get("https://api.openai.com/auth", {}).get("chatgpt_account_id") + if isinstance(claims, dict) + else None + ) + return acct_id if isinstance(acct_id, str) and acct_id else None + except Exception: + return None + + def _fetch_models_from_api(access_token: str) -> List[str]: """Fetch available models from the Codex API. Returns visible models sorted by priority.""" try: import httpx + headers = {"Authorization": f"Bearer {access_token}"} + acct_id = _extract_chatgpt_account_id(access_token) + if acct_id: + headers["ChatGPT-Account-Id"] = acct_id resp = httpx.get( "https://chatgpt.com/backend-api/codex/models?client_version=1.0.0", - headers={"Authorization": f"Bearer {access_token}"}, + headers=headers, timeout=10, ) if resp.status_code != 200: