fix: send the Codex residency header on every JWT-derived Codex request

The Codex models catalog probe (agent/model_metadata.py), the picker catalog
fetch (hermes_cli/codex_models.py), the quota-restored probe
(hermes_cli/auth_codex.py) and the /usage dashboard call
(agent/account_usage.py) each re-decoded the OAuth JWT for
ChatGPT-Account-ID and would 401 on residency-enforced workspaces exactly
like the chat client did (#23896). They now share
agent.codex_headers.codex_account_headers, which emits the account and
x-openai-internal-codex-residency headers from one decode; the three
duplicate `_extract_chatgpt_account_id` decoders are gone.

account_usage keeps auth.json's account_id as the winner over the JWT claim
(pool-only credentials still omit it); header casing unifies on the
codex-rs canonical `ChatGPT-Account-ID` (HTTP header names are
case-insensitive on the wire; the two tests asserting the old casing follow).
This commit is contained in:
teknium1
2026-09-18 23:35:12 -07:00
committed by Teknium
parent 0bbf7b7997
commit 7471586ad0
6 changed files with 18 additions and 55 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"
# ---------------------------------------------------------------------------