From c44c2fbb0b80e061b6cc7c52706ed7eb25a3a974 Mon Sep 17 00:00:00 2001 From: TARS Date: Wed, 15 Jul 2026 17:31:35 +1000 Subject: [PATCH] fix(codex): send ChatGPT-Account-Id on /models probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Codex backend returns the per-account model catalog only when the ChatGPT-Account-Id header is present. Without it, GET /backend-api/codex/models responds 200 OK with {"models":[]} and the picker silently degrades to the hardcoded fallback list — which is stale or wrong for the active plan (no GPT-5.6 family, wrong context windows). This was the upstream bug behind slow first responses and HTTP 520/120s SSE hangs: Hermes was sending invalid slugs because the probe never saw them in the catalog, and Codex's request builder also depends on the same JWT claim that's now being threaded through both probe paths. Fixes the probe-side paths in hermes_cli/codex_models.py and agent/model_metadata.py by extracting chatgpt_account_id from the OAuth JWT (mirroring the request-side logic already in auxiliary_client.py) and sending it as a header. Verified live: - _fetch_models_from_api now returns the 10-model catalog (gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna, gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.3-codex-spark, 3x -pro variants) instead of []. - _fetch_codex_oauth_context_lengths resolves all 8 account models to 272K context (matches direct API probes of the same account). - end-to-end: hermes chat -m gpt-5.6-sol -q 'Reply with one word: pong' returns 'pong' cleanly via the openai-codex route. Same class of bug as PR #64760. --- agent/model_metadata.py | 36 +++++++++++++++++- .../emails/tars@users.noreply.github.com | 1 + hermes_cli/codex_models.py | 37 ++++++++++++++++++- 3 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 contributors/emails/tars@users.noreply.github.com 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: