fix(xai-oauth): honor [WKE=unauthenticated:...] disambiguator in entitlement classifier (#29344)

``_is_entitlement_failure`` over-matched on xAI 403s.  xAI returns the
same permission-denied ``code`` text for two distinct conditions:

  1. Unsubscribed account ("active Grok subscription. Manage at
     https://grok.com" in the ``error`` field).
  2. Stale OAuth access token ("OAuth2 access token could not be
     validated. [WKE=unauthenticated:bad-credentials]" in the ``error``
     field).

The classifier's "does not have permission + grok" substring heuristic
treated both identically, so the credential-pool refresh path was
short-circuited for case (2) — long-running TUI sessions stuck on a
stale OAuth token surfaced a non-retryable client error and the user
had to exit + reopen the TUI to recover (the startup-resolve path
bypasses the classifier entirely, which is why bridge adapters with
proactive refresh cadences didn't see this in practice).

This patch adopts the reporter's recommended fix (option 1, tightest):
honor xAI's explicit ``[WKE=unauthenticated:...]`` suffix and the
``OAuth2 access token could not be validated`` phrasing as
authoritative "this is auth, not entitlement" signals.  When either
appears anywhere in the body's text fields, the classifier returns
False eagerly — *before* the entitlement keyword checks run — so the
refresh-on-401 path takes over and the existing loop-protection still
guards against runaway refresh storms if the refresh itself fails.

Two small adjustments fall out of this:

* The haystack now also covers ``code`` and ``error`` keys directly,
  not just the ``message``/``reason`` shape ``_extract_api_error_context``
  produces.  Real runtime paths use the normalised shape, but the test
  suite and any future call sites that pass raw bodies get the same
  treatment.  Backwards compatible: missing keys default to empty
  strings, the haystack still skips when everything is blank.

* Both disambiguator checks fire BEFORE the entitlement keyword
  checks.  If a future xAI body somehow lands with both an entitlement
  message AND the WKE suffix, the WKE suffix wins (correct — auth is
  recoverable; entitlement is not, and a refreshed token will surface
  the entitlement message on the next request anyway).

Existing tests (``test_is_entitlement_failure_matches_real_xai_bodies``,
``test_is_entitlement_failure_false_for_unrelated_auth_errors``,
``test_recover_with_credential_pool_skips_refresh_on_entitlement_403``,
``test_recover_with_credential_pool_still_refreshes_genuine_auth_failure``)
continue to pass unchanged — the unsubscribed-account path, the
generic auth-error path, and the refresh-on-401 path are all left
intact.
This commit is contained in:
xxxigm
2026-05-20 21:46:42 +07:00
committed by Teknium
parent 64b3eb0dd7
commit 8b3cb930c9

View File

@@ -1368,6 +1368,18 @@ class AIAgent:
* xAI OAuth: "do not have an active Grok subscription" / * xAI OAuth: "do not have an active Grok subscription" /
"out of available resources" / "does not have permission" + "grok" "out of available resources" / "does not have permission" + "grok"
Disambiguator for xAI (#29344): the same ``code`` text ("The caller
does not have permission to execute the specified operation") is
returned for BOTH an unsubscribed account AND a stale OAuth access
token. xAI ships an explicit signal in the ``error`` field that
tells the two apart: a ``[WKE=unauthenticated:...]`` suffix (and/or
the ``OAuth2 access token could not be validated`` phrasing) means
the credentials failed validation — that's recoverable by refreshing
the token, NOT by surfacing an entitlement message. When either
signal is present we return False eagerly so the credential-pool
refresh path runs, letting long-running TUI sessions recover from
stale tokens without an exit/reopen cycle.
Extend here for new providers as we discover them (Anthropic's Extend here for new providers as we discover them (Anthropic's
Claude Max OAuth entitlement errors look distinct enough today that Claude Max OAuth entitlement errors look distinct enough today that
the existing 1M-context-beta branch handles them; revisit if other the existing 1M-context-beta branch handles them; revisit if other
@@ -1377,11 +1389,29 @@ class AIAgent:
return False return False
if not isinstance(error_context, dict): if not isinstance(error_context, dict):
return False return False
# Build a single lowercase haystack covering every field shape the
# body might land in. ``_extract_api_error_context`` normalises to
# ``message``/``reason``, but callers (and the test suite) may also
# hand us the raw body with ``code``/``error`` keys; cover both so
# the WKE disambiguator below fires regardless of entry point.
message = str(error_context.get("message") or "").lower() message = str(error_context.get("message") or "").lower()
reason = str(error_context.get("reason") or "").lower() reason = str(error_context.get("reason") or "").lower()
haystack = f"{message} {reason}" code = str(error_context.get("code") or "").lower()
err = str(error_context.get("error") or "").lower()
haystack = f"{message} {reason} {code} {err}"
if not haystack.strip(): if not haystack.strip():
return False return False
# xAI's authoritative disambiguator for "stale token" vs
# "unsubscribed account". Both conditions share the same
# permission-denied ``code`` text; only one carries this suffix.
# Bail out before the entitlement keyword checks so a stale OAuth
# token routes through the credential-refresh path instead of the
# surface-error-as-entitlement path. See #29344 for the long-
# running TUI failure mode this closes.
if "[wke=unauthenticated:" in haystack:
return False
if "oauth2 access token could not be validated" in haystack:
return False
if "do not have an active grok subscription" in haystack: if "do not have an active grok subscription" in haystack:
return True return True
if "out of available resources" in haystack and "grok" in haystack: if "out of available resources" in haystack and "grok" in haystack: