diff --git a/agent/account_usage.py b/agent/account_usage.py index a828f9d47b..ffa3a8cceb 100644 --- a/agent/account_usage.py +++ b/agent/account_usage.py @@ -352,8 +352,10 @@ def _codex_banked_resets(payload: dict) -> int: def _codex_headers(token: str, account_id: Optional[str]) -> dict[str, str]: + """auth.json's ``account_id`` wins over the JWT claim; the JWT still supplies the residency header.""" + from agent.codex_headers import codex_account_headers return {"Authorization": f"Bearer {token}", "Accept": "application/json", "User-Agent": "codex-cli", - **({"ChatGPT-Account-Id": account_id} if account_id else {})} + **codex_account_headers(token), **({"ChatGPT-Account-ID": account_id} if account_id else {})} def _get_json(url: str, headers: dict[str, str], *, timeout: float) -> dict: diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 26419717ad..b601ca4645 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -1724,19 +1724,6 @@ 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]: - """``chatgpt_account_id`` from the Codex OAuth JWT, or None on any parse error. Without the - ``ChatGPT-Account-Id`` header /backend-api/codex/models returns ``{"models":[]}`` (HTTP 200) - and the probe silently falls back. Mirrors auxiliary_client.py.""" - try: - payload_b64 = access_token.split(".")[1] - claims = json.loads(base64.urlsafe_b64decode(payload_b64 + "=" * (-len(payload_b64) % 4))) - 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_codex_oauth_context_lengths_with_source(access_token: str) -> Tuple[Dict[str, int], bool]: """Codex catalogue ``{slug: context_window}`` plus whether it came from HTTP. Cached per token fingerprint (windows vary by entitlement). An in-process hit reports False: not a fresh @@ -1746,10 +1733,10 @@ def _fetch_codex_oauth_context_lengths_with_source(access_token: str) -> Tuple[D cached = _codex_oauth_context_cache.get(cache_key) if cached is not None and now - cached[1] < _CODEX_OAUTH_CONTEXT_CACHE_TTL: return cached[0], False - headers = {"Authorization": f"Bearer {access_token}"} - acct_id = _extract_chatgpt_account_id(access_token) - if acct_id: - headers["ChatGPT-Account-Id"] = acct_id + # Without ChatGPT-Account-ID /backend-api/codex/models returns ``{"models":[]}`` (HTTP 200) and + # the probe silently falls back; residency-enforced workspaces 401 without the residency header. + from agent.codex_headers import codex_account_headers + headers = {"Authorization": f"Bearer {access_token}", **codex_account_headers(access_token)} try: _ensure_requests() resp = requests.get(CODEX_MODELS_CATALOG_URL, headers=headers, timeout=(5, 10), verify=_resolve_requests_verify()) diff --git a/hermes_cli/auth_codex.py b/hermes_cli/auth_codex.py index daa8d97fd0..d0e8cab951 100644 --- a/hermes_cli/auth_codex.py +++ b/hermes_cli/auth_codex.py @@ -617,15 +617,11 @@ def _probe_codex_quota_restored( _codex_quota_probe_cache[cache_key] = (now, None) result: Optional[bool] = None try: + # Account/residency headers from the JWT (required for some account shapes). + from agent.codex_headers import codex_account_headers headers = { "Authorization": f"Bearer {token}", "Accept": "application/json", - "User-Agent": "codex-cli"} - # Best-effort ChatGPT-Account-Id from the JWT (required for some account shapes). - auth_claims = _decode_jwt_claims(token).get("https://api.openai.com/auth") - account_id = ( - auth_claims.get("chatgpt_account_id") if isinstance(auth_claims, dict) else None) - if _nonempty_str(account_id): - headers["ChatGPT-Account-Id"] = account_id.strip() + "User-Agent": "codex-cli", **codex_account_headers(token)} with _codex_http_client(timeout=10.0) as client: response = client.get(_codex_usage_probe_url(base_url), headers=headers) if response.status_code == 200: diff --git a/hermes_cli/codex_models.py b/hermes_cli/codex_models.py index a652f10541..29ea65b747 100644 --- a/hermes_cli/codex_models.py +++ b/hermes_cli/codex_models.py @@ -101,28 +101,6 @@ def _drop_undiscovered_astra(model_ids: List[str]) -> List[str]: return [model for model in model_ids if not is_astra_model(model)] -def _extract_chatgpt_account_id(access_token: str) -> Optional[str]: - """Best-effort ``chatgpt_account_id`` from the OAuth JWT; None on any parse error. - - The Codex backend requires the ``ChatGPT-Account-Id`` header for the per-account catalog; - without it ``GET /backend-api/codex/models`` returns ``{"models":[]}`` with HTTP 200, which - masquerades as "no models" and silently degrades the picker to the curated fallback. - """ - 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 _ranked_slugs(entries: object) -> List[str]: """Visible slugs from a Codex catalog ``models`` list, sorted by (priority, slug), deduped. @@ -151,10 +129,10 @@ 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 + # The per-account catalog needs ChatGPT-Account-ID (else ``{"models":[]}`` with HTTP 200 + # masquerades as "no models") and, for residency-enforced workspaces, the residency header. + from agent.codex_headers import codex_account_headers + headers = {"Authorization": f"Bearer {access_token}", **codex_account_headers(access_token)} from agent.model_metadata import CODEX_MODELS_CATALOG_URL resp = httpx.get(CODEX_MODELS_CATALOG_URL, headers=headers, timeout=10) if resp.status_code != 200: diff --git a/tests/agent/test_account_usage.py b/tests/agent/test_account_usage.py index 8da478f8f3..1705228f3e 100644 --- a/tests/agent/test_account_usage.py +++ b/tests/agent/test_account_usage.py @@ -118,9 +118,9 @@ def test_codex_usage_falls_back_to_native_credential_pool(monkeypatch, codex_usa assert snapshot.windows[1].label == "Weekly" assert calls[0]["url"] == "https://chatgpt.com/backend-api/wham/usage" assert calls[0]["headers"]["Authorization"] == "Bearer pooled-token" - # Pool creds have no account_id concept — the ChatGPT-Account-Id header must + # Pool creds have no account_id concept — the ChatGPT-Account-ID header must # be omitted rather than sent stale/wrong. - assert "ChatGPT-Account-Id" not in calls[0]["headers"] + assert "ChatGPT-Account-ID" not in calls[0]["headers"] @@ -164,7 +164,7 @@ def test_codex_usage_account_id_read_failure_keeps_singleton_token(monkeypatch, assert snapshot is not None assert calls[0]["headers"]["Authorization"] == "Bearer singleton-token" # account_id read failed → header omitted, but the singleton token is kept. - assert "ChatGPT-Account-Id" not in calls[0]["headers"] + assert "ChatGPT-Account-ID" not in calls[0]["headers"] def test_codex_usage_retries_401_with_forced_refresh(monkeypatch, codex_usage_payload): diff --git a/tests/hermes_cli/test_auth_codex_quota_probe.py b/tests/hermes_cli/test_auth_codex_quota_probe.py index b4b5c6184b..ee299cbc0d 100644 --- a/tests/hermes_cli/test_auth_codex_quota_probe.py +++ b/tests/hermes_cli/test_auth_codex_quota_probe.py @@ -121,7 +121,7 @@ def test_probe_sends_chatgpt_account_id_from_jwt(monkeypatch): } ) assert _probe_codex_quota_restored(token) is True - assert calls[0]["headers"].get("ChatGPT-Account-Id") == "acct-123" + assert calls[0]["headers"].get("ChatGPT-Account-ID") == "acct-123" # ---------------------------------------------------------------------------