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:
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user