Compare commits

...

2 Commits

Author SHA1 Message Date
Pat Sukprasert cff5ac0bec Merge branch 'main' into fix/codex-auth-error-info-casing 2026-06-26 16:26:28 +07:00
Pat Sukprasert 7c480aa32c fix(codex-native): match codexErrorInfo auth variant case-insensitively
The structured `codexErrorInfo` auth check used `frozenset({"Unauthorized"})`
(CamelCase), but the Codex app-server enum serializes the variant as lowercase
snake_case (`unauthorized`, verified against the codex 0.140 binary's
`CodexErrorInfo` schema, alongside `usage_limit_exceeded`, `bad_request`, etc.).
So `_classify_codex_error`'s preferred structured signal never matched real
auth errors — classification only worked via the httpStatusCode (401/403) and
message-substring fallbacks (introduced in #1108 / #1250), masking the gap.

Store the auth variant set as lowercase canonical and compare the variant
case-insensitively, so the structured path fires for the real `unauthorized`
enum while still matching legacy `Unauthorized` spellings.

Adds regression cases for the lowercase `unauthorized` variant (string and
tagged-object shapes) with a non-auth message, isolating the structured path.

Co-authored-by: Isaac
2026-06-26 16:09:54 +07:00
2 changed files with 17 additions and 7 deletions
+12 -7
View File
@@ -141,10 +141,13 @@ _CODEX_ELICITATION_REQUEST_METHODS = frozenset(
# shape varies by version, so detecting either keeps the fix robust.
#
# ``codexErrorInfo`` is the app-server's structured classification (e.g.
# ``Unauthorized``, ``UsageLimitExceeded``); auth-class values get a re-auth
# hint. httpStatusCode 401/403 is treated as auth too.
# ``unauthorized``, ``usage_limit_exceeded``); auth-class values get a re-auth
# hint. httpStatusCode 401/403 is treated as auth too. Values are stored and
# compared case-insensitively: the app-server enum serializes as lowercase
# snake_case (``unauthorized``), but older/alternate spellings (``Unauthorized``)
# are matched too.
_CODEX_ERROR_ITEM_TYPE = "error"
_CODEX_AUTH_ERROR_INFO = frozenset({"Unauthorized"})
_CODEX_AUTH_ERROR_INFO = frozenset({"unauthorized"})
_CODEX_AUTH_HTTP_STATUS = frozenset({401, 403})
# Message-substring fallback for app-server versions that omit codexErrorInfo.
# Surface-only, so recall is favored over precision: a false positive only
@@ -736,9 +739,10 @@ def _classify_codex_error(error: dict[str, Any], message: str) -> str:
"""
Classify a Codex ``turn.error`` / ``error`` item as auth-related or generic.
Prefers the structured ``codexErrorInfo`` (``Unauthorized`` or an
httpStatusCode of 401/403); falls back to substring matching against
:data:`_CODEX_AUTH_ERROR_FRAGMENTS` for versions/shapes that omit it.
Prefers the structured ``codexErrorInfo`` (an ``unauthorized`` variant,
case-insensitive, or an httpStatusCode of 401/403); falls back to substring
matching against :data:`_CODEX_AUTH_ERROR_FRAGMENTS` for versions/shapes
that omit it.
:param error: The ``turn.error`` object.
:param message: Its already-extracted message text.
@@ -753,7 +757,8 @@ def _classify_codex_error(error: dict[str, Any], message: str) -> str:
elif isinstance(info, dict):
variant = info.get("type") or info.get("kind") or info.get("variant")
http_status = info.get("httpStatusCode")
if variant in _CODEX_AUTH_ERROR_INFO or http_status in _CODEX_AUTH_HTTP_STATUS:
variant_is_auth = variant is not None and variant.lower() in _CODEX_AUTH_ERROR_INFO
if variant_is_auth or http_status in _CODEX_AUTH_HTTP_STATUS:
return _CODEX_ERROR_KIND_AUTH
lowered = message.lower()
if any(fragment in lowered for fragment in _CODEX_AUTH_ERROR_FRAGMENTS):
+5
View File
@@ -805,6 +805,11 @@ def test_classify_codex_error_auth_vs_generic() -> None:
assert fwd._classify_codex_error({"codexErrorInfo": "Unauthorized"}, "nope") == auth
assert fwd._classify_codex_error({"codexErrorInfo": {"type": "Unauthorized"}}, "nope") == auth
assert fwd._classify_codex_error({"codexErrorInfo": {"httpStatusCode": 401}}, "nope") == auth
# The real app-server enum serializes lowercase snake_case; it must match
# via the structured path (message "nope" has no auth substring to fall
# back on), case-insensitively.
assert fwd._classify_codex_error({"codexErrorInfo": "unauthorized"}, "nope") == auth
assert fwd._classify_codex_error({"codexErrorInfo": {"type": "unauthorized"}}, "nope") == auth
# Message-text fallback when codexErrorInfo is absent.
assert fwd._classify_codex_error({}, "Please run codex login") == auth
assert fwd._classify_codex_error({}, "ChatGPT session expired") == auth