fix(codex): send ChatGPT-Account-Id on /models probes

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.
This commit is contained in:
TARS 2026-07-15 17:31:35 +10:00 committed by Teknium
parent 64702f8f91
commit c44c2fbb0b
3 changed files with 72 additions and 2 deletions

View file

@ -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(),
)

View file

@ -0,0 +1 @@
MLcogTech

View file

@ -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: