feat(providers): add xAI Grok OAuth with capability-gated X Search (#5035)

This commit is contained in:
chengyongru
2026-07-23 11:55:16 +08:00
committed by GitHub
parent c22efb5f7a
commit c7393c785e
38 changed files with 3881 additions and 104 deletions
+3
View File
@@ -13,6 +13,7 @@ __all__ = [
"AnthropicProvider",
"OpenAICompatProvider",
"OpenAICodexProvider",
"XAIGrokProvider",
"GitHubCopilotProvider",
"AzureOpenAIProvider",
"BedrockProvider",
@@ -22,6 +23,7 @@ _LAZY_IMPORTS = {
"AnthropicProvider": ".anthropic_provider",
"OpenAICompatProvider": ".openai_compat_provider",
"OpenAICodexProvider": ".openai_codex_provider",
"XAIGrokProvider": ".xai_grok_provider",
"GitHubCopilotProvider": ".github_copilot_provider",
"AzureOpenAIProvider": ".azure_openai_provider",
"BedrockProvider": ".bedrock_provider",
@@ -34,6 +36,7 @@ if TYPE_CHECKING:
from nanobot.providers.github_copilot_provider import GitHubCopilotProvider
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
from nanobot.providers.xai_grok_provider import XAIGrokProvider
def __getattr__(name: str):
+10 -2
View File
@@ -60,10 +60,10 @@ def _make_provider_core(
if spec and spec.is_transcription_only:
raise ValueError(f"Provider '{provider_name}' only supports transcription.")
backend = spec.backend if spec else "openai_compat"
if p and p.proxy and backend not in {"openai_compat", "openai_codex"}:
if p and p.proxy and backend not in {"openai_compat", "openai_codex", "xai_grok"}:
raise ValueError(
f"providers.{provider_name}.proxy is only supported for "
"OpenAI-compatible providers and OpenAI Codex."
"OpenAI-compatible providers, OpenAI Codex, and xAI Grok."
)
if backend == "azure_openai":
@@ -91,6 +91,14 @@ def _make_provider_core(
proxy=getattr(p, "proxy", None) if p else None,
extra_body=p.extra_body if p else None,
)
elif backend == "xai_grok":
from nanobot.providers.xai_grok_provider import XAIGrokProvider
provider = XAIGrokProvider(
default_model=model,
proxy=getattr(p, "proxy", None) if p else None,
extra_body=p.extra_body if p else None,
)
elif backend == "azure_openai":
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
+21 -1
View File
@@ -47,7 +47,8 @@ class ProviderSpec:
settings_alias_for: str = "" # compatibility alias grouped under this provider in Settings
# which provider implementation to use
# "openai_compat" | "anthropic" | "azure_openai" | "openai_codex" | "github_copilot" | "bedrock"
# "openai_compat" | "anthropic" | "azure_openai" | "openai_codex" | "xai_grok"
# | "github_copilot" | "bedrock"
backend: str = "openai_compat"
# extra env vars / request headers supplied by the provider integration.
@@ -420,6 +421,25 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
default_api_base="https://chatgpt.com/backend-api",
is_oauth=True,
),
# xAI subscription: OAuth-based, with capability-gated server-hosted X Search.
ProviderSpec(
name="xai_grok",
keywords=("xai-grok", "xai_grok"),
env_key="",
display_name="xAI Grok",
model_catalog="builtin",
builtin_models=(
ProviderModelSpec(
id="xai-grok/grok-4.5",
label="Grok 4.5",
description="Grok via xAI subscription; X Search is enabled when supported.",
context_window=500000,
),
),
backend="xai_grok",
default_api_base="https://cli-chat-proxy.grok.com/v1",
is_oauth=True,
),
# GitHub Copilot: OAuth-based
ProviderSpec(
name="github_copilot",
+545
View File
@@ -0,0 +1,545 @@
"""xAI subscription provider with capability-gated hosted X Search."""
from __future__ import annotations
import asyncio
import base64
import json
import re
import time
import uuid
from collections.abc import Awaitable, Callable
from typing import Any
import httpx
from loguru import logger
from nanobot import __version__
from nanobot.providers.base import (
LLMProvider,
LLMResponse,
ToolCallRequest,
resolve_stream_idle_timeout_s,
)
from nanobot.providers.openai_responses import (
consume_sse_with_reasoning,
convert_messages,
convert_tools,
)
from nanobot.providers.xai_oauth import (
XAI_CLIENT_VERSION,
XAIToken,
get_xai_oauth_token,
)
DEFAULT_XAI_GROK_URL = "https://cli-chat-proxy.grok.com/v1/responses"
DEFAULT_XAI_GROK_MODELS_URL = "https://cli-chat-proxy.grok.com/v1/models"
DEFAULT_XAI_GROK_MODEL = "xai-grok/grok-4.5"
_MODEL_CAPABILITIES_TTL_S = 5 * 60
_MAX_ERROR_BODY_CHARS = 1000
_SENSITIVE_ERROR_KEYS = {
"accesstoken",
"apikey",
"authorization",
"idtoken",
"refreshtoken",
}
class XAIGrokProvider(LLMProvider):
"""Call xAI's subscription proxy and expose supported hosted tools."""
supports_progress_deltas = True
def __init__(
self,
default_model: str = DEFAULT_XAI_GROK_MODEL,
proxy: str | None = None,
extra_body: dict[str, Any] | None = None,
):
super().__init__(api_key=None, api_base=None)
self.default_model = default_model
self.proxy = proxy or None
self._extra_body = dict(extra_body or {})
self._model_capabilities: dict[str, bool] | None = None
self._model_capabilities_fetched_at = 0.0
async def _supports_backend_search(self, token: XAIToken, model: str) -> bool:
now = time.monotonic()
capabilities = self._model_capabilities
if (
capabilities is None
or now - self._model_capabilities_fetched_at >= _MODEL_CAPABILITIES_TTL_S
):
try:
capabilities = await _fetch_xai_model_capabilities(
DEFAULT_XAI_GROK_MODELS_URL,
_build_model_headers(token),
proxy=self.proxy,
)
except Exception as exc:
logger.warning(
"xAI model capability lookup failed; hosted X Search disabled for model {}: "
"type={} error={}",
model,
type(exc).__name__,
str(exc).strip() or "unexpected error",
)
capabilities = {}
self._model_capabilities = capabilities
self._model_capabilities_fetched_at = now
else:
self._model_capabilities = capabilities
self._model_capabilities_fetched_at = now
return capabilities.get(model, False)
async def _call_xai(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None,
model: str | None,
max_tokens: int,
temperature: float,
reasoning_effort: str | None,
tool_choice: str | dict[str, Any] | None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse:
wire_model = _strip_model_prefix(model or self.default_model)
system_prompt, input_items = convert_messages(messages)
stage = "oauth_token"
try:
token = await asyncio.to_thread(get_xai_oauth_token, proxy=self.proxy)
stage = "model_capabilities"
supports_backend_search = await self._supports_backend_search(token, wire_model)
converted_tools = convert_tools(tools or [])
if supports_backend_search:
converted_tools = [
tool for tool in converted_tools if tool.get("name") != "x_search"
]
converted_tools.append({"type": "x_search"})
body: dict[str, Any] = {
"model": wire_model,
"store": False,
"stream": True,
"instructions": system_prompt,
"input": input_items,
"include": ["reasoning.encrypted_content"],
"tools": converted_tools,
"tool_choice": tool_choice or "auto",
"parallel_tool_calls": True,
"stream_tool_calls": True,
"max_output_tokens": max_tokens,
"temperature": temperature,
"reasoning": _build_reasoning_options(reasoning_effort),
}
if self._extra_body:
body.update(self._extra_body)
headers = _build_headers(token.access, wire_model)
stage = "xai_request"
try:
result = await _request_xai(
DEFAULT_XAI_GROK_URL,
headers,
body,
proxy=self.proxy,
on_content_delta=on_content_delta,
on_thinking_delta=on_thinking_delta,
on_tool_call_delta=on_tool_call_delta,
)
except _XAIHTTPError as exc:
if exc.status_code != 401:
raise
stage = "oauth_refresh"
token = await asyncio.to_thread(
get_xai_oauth_token,
proxy=self.proxy,
force_refresh=True,
)
self._model_capabilities = None
self._model_capabilities_fetched_at = 0.0
headers = _build_headers(token.access, wire_model)
stage = "xai_request_retry"
result = await _request_xai(
DEFAULT_XAI_GROK_URL,
headers,
body,
proxy=self.proxy,
on_content_delta=on_content_delta,
on_thinking_delta=on_thinking_delta,
on_tool_call_delta=on_tool_call_delta,
)
content, tool_calls, finish_reason, usage, reasoning_content = result
return LLMResponse(
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason,
usage=usage,
reasoning_content=reasoning_content,
)
except Exception as exc:
response = _xai_error_response(exc)
logger.warning(
"xAI subscription request failed: stage={} type={} retryable={} status={} "
"error_type={} error_code={} response_body={}",
stage,
type(exc).__name__,
response.error_should_retry,
response.error_status_code,
response.error_type,
response.error_code,
getattr(exc, "response_body", None),
)
return response
async def chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
reasoning_effort: str | None = None,
tool_choice: str | dict[str, Any] | None = None,
) -> LLMResponse:
return await self._call_xai(
messages, tools, model, max_tokens, temperature, reasoning_effort, tool_choice
)
async def chat_stream(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
reasoning_effort: str | None = None,
tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse:
return await self._call_xai(
messages,
tools,
model,
max_tokens,
temperature,
reasoning_effort,
tool_choice,
on_content_delta,
on_thinking_delta,
on_tool_call_delta,
)
def get_default_model(self) -> str:
return self.default_model
def _strip_model_prefix(model: str) -> str:
if model.startswith("xai-grok/") or model.startswith("xai_grok/"):
return model.split("/", 1)[1]
return model
def _build_reasoning_options(reasoning_effort: str | None) -> dict[str, str]:
options = {"summary": "concise"}
if reasoning_effort and reasoning_effort.lower() != "none":
options["effort"] = reasoning_effort
return options
def _build_headers(token: str, model: str) -> dict[str, str]:
conversation_id = str(uuid.uuid4())
return {
"Authorization": f"Bearer {token}",
"X-XAI-Token-Auth": "xai-grok-cli",
"x-authenticateresponse": "authenticate-response",
"x-grok-client-version": XAI_CLIENT_VERSION,
"x-grok-client-identifier": "nanobot",
"x-grok-client-mode": "headless",
"x-grok-conv-id": conversation_id,
"x-grok-req-id": str(uuid.uuid4()),
"x-grok-model-override": model,
"x-grok-session-id": conversation_id,
"x-grok-agent-id": str(uuid.uuid4()),
"User-Agent": f"nanobot/{__version__} (python)",
"accept": "text/event-stream",
"content-type": "application/json",
}
def _build_model_headers(token: XAIToken) -> dict[str, str]:
headers = {
"Authorization": f"Bearer {token.access}",
"X-XAI-Token-Auth": "xai-grok-cli",
"x-grok-client-version": XAI_CLIENT_VERSION,
"x-grok-client-identifier": "nanobot",
"x-grok-client-mode": "headless",
"User-Agent": f"nanobot/{__version__} (python)",
"accept": "application/json",
}
claims = _decode_access_token_claims(token.access)
user_id = claims.get("sub")
if claims.get("principal_type") == "Team":
user_id = claims.get("principal_id") or user_id
if isinstance(user_id, str) and user_id:
headers["x-userid"] = user_id
email = claims.get("email")
if not isinstance(email, str) or "@" not in email:
email = token.account_id if token.account_id and "@" in token.account_id else None
if email:
headers["x-email"] = email
return headers
def _decode_access_token_claims(token: str) -> dict[str, Any]:
"""Read identity hints from the signed token; the server still authenticates it."""
parts = token.split(".")
if len(parts) < 2 or not parts[1]:
return {}
payload = parts[1]
try:
decoded = base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))
claims = json.loads(decoded)
except (ValueError, TypeError):
return {}
return claims if isinstance(claims, dict) else {}
class _XAIHTTPError(RuntimeError):
def __init__(
self,
message: str,
*,
status_code: int,
retry_after: float | None = None,
error_type: str | None = None,
error_code: str | None = None,
should_retry: bool | None = None,
response_body: str | None = None,
):
super().__init__(message)
self.status_code = status_code
self.retry_after = retry_after
self.error_type = error_type
self.error_code = error_code
self.should_retry = should_retry
self.response_body = response_body
async def _fetch_xai_model_capabilities(
url: str,
headers: dict[str, str],
*,
proxy: str | None = None,
) -> dict[str, bool]:
client_kwargs: dict[str, Any] = {"timeout": 10.0, "follow_redirects": False}
if proxy:
client_kwargs.update(proxy=proxy, trust_env=False)
async with httpx.AsyncClient(**client_kwargs) as client:
response = await client.get(url, headers=headers)
if response.status_code != 200:
raw = response.content.decode("utf-8", "ignore")
raise _build_xai_http_error(response.status_code, response.headers, raw)
try:
payload = response.json()
except ValueError as exc:
raise RuntimeError("xAI model catalog returned invalid JSON.") from exc
return _parse_xai_model_capabilities(payload)
def _parse_xai_model_capabilities(payload: Any) -> dict[str, bool]:
if isinstance(payload, dict):
rows = payload.get("data")
if not isinstance(rows, list):
rows = payload.get("models")
else:
rows = payload
if not isinstance(rows, list):
return {}
capabilities: dict[str, bool] = {}
for row in rows:
if not isinstance(row, dict):
continue
meta = row.get("_meta") if isinstance(row.get("_meta"), dict) else {}
support_value = row.get("supportsBackendSearch")
if not isinstance(support_value, bool):
support_value = row.get("supports_backend_search")
if not isinstance(support_value, bool):
support_value = meta.get("supportsBackendSearch")
if not isinstance(support_value, bool):
support_value = meta.get("supports_backend_search")
supports_backend_search = support_value if isinstance(support_value, bool) else False
identifiers = (
row.get("model"),
row.get("modelId"),
row.get("id"),
meta.get("model"),
meta.get("modelId"),
)
for identifier in identifiers:
if isinstance(identifier, str) and identifier.strip():
capabilities[_strip_model_prefix(identifier.strip())] = supports_backend_search
return capabilities
async def _request_xai(
url: str,
headers: dict[str, str],
body: dict[str, Any],
*,
proxy: str | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
client_kwargs: dict[str, Any] = {"timeout": resolve_stream_idle_timeout_s()}
if proxy:
client_kwargs.update(proxy=proxy, trust_env=False)
async with httpx.AsyncClient(**client_kwargs) as client:
async with client.stream("POST", url, headers=headers, json=body) as response:
if response.status_code != 200:
content = await response.aread()
raw = content.decode("utf-8", "ignore")
raise _build_xai_http_error(response.status_code, response.headers, raw)
return await consume_sse_with_reasoning(
response,
on_content_delta=on_content_delta,
on_tool_call_delta=on_tool_call_delta,
on_reasoning_delta=on_thinking_delta,
)
def _build_xai_http_error(
status_code: int,
headers: httpx.Headers,
raw: str,
) -> _XAIHTTPError:
retry_after = LLMProvider._extract_retry_after_from_headers(headers)
error_type, error_code = LLMProvider._extract_error_type_code(raw)
response_body = _bounded_error_body(raw)
return _XAIHTTPError(
_friendly_error(status_code, response_body),
status_code=status_code,
retry_after=retry_after,
error_type=error_type,
error_code=error_code,
should_retry=_should_retry_status(status_code, error_type, error_code, raw),
response_body=response_body,
)
def _bounded_error_body(raw: str) -> str | None:
text = raw.strip()
if not text:
return None
try:
payload = json.loads(text)
except (TypeError, ValueError):
pass
else:
text = json.dumps(
_redact_error_payload(payload),
ensure_ascii=False,
separators=(",", ":"),
)
text = re.sub(r"(?i)(bearer\s+)[a-z0-9._~+/=-]+", r"\1[REDACTED]", text)
text = " ".join(text.split())
if len(text) > _MAX_ERROR_BODY_CHARS:
return f"{text[:_MAX_ERROR_BODY_CHARS]}"
return text
def _redact_error_payload(payload: Any) -> Any:
if isinstance(payload, dict):
return {
key: "[REDACTED]" if _is_sensitive_error_key(key) else _redact_error_payload(value)
for key, value in payload.items()
}
if isinstance(payload, list):
return [_redact_error_payload(value) for value in payload]
return payload
def _is_sensitive_error_key(key: str) -> bool:
normalized = re.sub(r"[^a-z0-9]", "", key.casefold())
return normalized in _SENSITIVE_ERROR_KEYS
def _friendly_error(status_code: int, response_body: str | None = None) -> str:
if status_code == 401:
message = "xAI rejected the login. Sign in again with `nanobot provider login xai-grok`."
elif status_code == 403:
message = "This xAI account or subscription cannot access the Grok subscription endpoint."
elif status_code == 426:
message = "xAI requires a newer Grok client version. Update nanobot and try again."
elif status_code == 429:
message = "xAI usage quota or rate limit reached. Please try again later."
else:
message = f"xAI subscription endpoint returned HTTP {status_code}."
if response_body:
return f"{message} Response body: {response_body}"
return message
def _xai_error_response(exc: Exception) -> LLMResponse:
status_code = getattr(exc, "status_code", None)
should_retry = getattr(exc, "should_retry", None)
error_kind: str | None = None
if isinstance(exc, (httpx.TimeoutException, asyncio.TimeoutError)):
error_kind = "timeout"
should_retry = True if should_retry is None else should_retry
elif isinstance(exc, (httpx.NetworkError, httpx.TransportError)):
error_kind = "connection"
should_retry = True if should_retry is None else should_retry
elif isinstance(exc, _XAIHTTPError):
error_kind = "http"
if status_code is not None and should_retry is None:
should_retry = _should_retry_status(
int(status_code),
getattr(exc, "error_type", None),
getattr(exc, "error_code", None),
None,
)
message = str(exc).strip() or "unexpected error"
retry_after = getattr(exc, "retry_after", None)
return LLMResponse(
content=f"Error calling xAI ({type(exc).__name__}): {message}",
finish_reason="error",
retry_after=retry_after,
error_status_code=int(status_code) if status_code is not None else None,
error_kind=error_kind,
error_type=getattr(exc, "error_type", None),
error_code=getattr(exc, "error_code", None),
error_retry_after_s=retry_after,
error_should_retry=should_retry,
)
def _should_retry_status(
status_code: int,
error_type: str | None,
error_code: str | None,
content: str | None,
) -> bool:
if status_code == 429:
return LLMProvider._is_retryable_429_response(
LLMResponse(
content=content or "",
finish_reason="error",
error_status_code=status_code,
error_type=error_type,
error_code=error_code,
)
)
return status_code in LLMProvider._RETRYABLE_STATUS_CODES or status_code >= 500
+745
View File
@@ -0,0 +1,745 @@
"""xAI subscription OAuth (Authorization Code + PKCE) support.
This integration follows the public OAuth client contract used by Grok Build.
Credentials are stored separately from Grok Build so rotating refresh tokens are
never shared between the two applications.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import queue
import secrets
import threading
import time
import webbrowser
from collections.abc import Callable
from contextlib import suppress
from dataclasses import asdict, dataclass
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlencode, urlsplit
import httpx
from filelock import FileLock
from loguru import logger
from nanobot.config.paths import get_data_dir
from nanobot.utils.helpers import _write_text_atomic
XAI_OAUTH_ISSUER = "https://auth.x.ai"
XAI_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
XAI_CLIENT_VERSION = "0.2.109"
XAI_ALLOWED_CALLBACK_ORIGIN = "https://accounts.x.ai"
XAI_OAUTH_SCOPES = (
"openid",
"profile",
"email",
"offline_access",
"grok-cli:access",
"api:access",
"conversations:read",
"conversations:write",
"workspaces:read",
"workspaces:write",
)
_DISCOVERY_URL = f"{XAI_OAUTH_ISSUER}/.well-known/openid-configuration"
_TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000
_DEFAULT_TOKEN_TTL_S = 60 * 60
_HTTP_TIMEOUT_S = 15.0
class XAIOAuthError(RuntimeError):
"""An actionable xAI OAuth failure with no credential material."""
@dataclass(frozen=True)
class XAIToken:
"""Persisted xAI OAuth token material."""
access: str
refresh: str | None
expires: int
account_id: str | None = None
@classmethod
def from_dict(cls, value: Any) -> XAIToken | None:
if not isinstance(value, dict):
return None
access = value.get("access")
if not isinstance(access, str) or not access:
return None
refresh = value.get("refresh")
if not isinstance(refresh, str) or not refresh:
refresh = None
try:
expires = int(value.get("expires") or 0)
except (TypeError, ValueError):
expires = 0
account_id = value.get("account_id")
if not isinstance(account_id, str) or not account_id:
account_id = None
return cls(access=access, refresh=refresh, expires=expires, account_id=account_id)
@dataclass(frozen=True)
class _Discovery:
authorization_endpoint: str
token_endpoint: str
userinfo_endpoint: str | None
@dataclass(frozen=True)
class _CallbackResult:
code: str | None = None
state: str | None = None
error: str | None = None
class XAIOAuthLoginFlow:
"""Pending xAI OAuth login that can finish through loopback or an authorization code."""
def __init__(
self,
*,
authorization_url: str,
redirect_uri: str,
verifier: str,
state: str,
discovery: _Discovery,
proxy: str | None,
result_queue: queue.Queue[_CallbackResult],
server: ThreadingHTTPServer,
timeout_s: float,
) -> None:
self.authorization_url = authorization_url
self.redirect_uri = redirect_uri
self._verifier = verifier
self._state = state
self._discovery = discovery
self._proxy = proxy
self._result_queue = result_queue
self._server = server
self._expires_at = time.monotonic() + timeout_s
self._lock = threading.Lock()
self._stop_event = threading.Event()
self._token: XAIToken | None = None
self._error: Exception | None = None
self._closed = False
self._server_thread = threading.Thread(
target=_serve_callback_server,
args=(server, self._stop_event),
name="nanobot-xai-grok-oauth-callback",
daemon=True,
)
self._server_thread.start()
self._timeout_timer = threading.Timer(timeout_s, self._expire)
self._timeout_timer.daemon = True
self._timeout_timer.start()
@property
def expired(self) -> bool:
return time.monotonic() >= self._expires_at
@property
def remaining_seconds(self) -> int:
return max(0, int(self._expires_at - time.monotonic()))
def complete(self, authorization_code: str | None = None) -> XAIToken | None:
"""Complete this flow, or return ``None`` while loopback is still pending."""
with self._lock:
if self._token is not None:
return self._token
self._raise_if_finished()
callback: _CallbackResult | None
if authorization_code is not None:
callback = _CallbackResult(code=authorization_code.strip())
else:
try:
callback = self._result_queue.get_nowait()
except queue.Empty:
callback = None
if callback is None:
with self._lock:
if self._token is not None:
return self._token
self._raise_if_finished()
if self.expired:
self._expire_locked()
self._raise_if_finished()
return None
return self._finish(callback)
def wait(self, timeout_s: float) -> XAIToken:
"""Wait for the loopback callback and complete this flow."""
with self._lock:
if self._token is not None:
return self._token
self._raise_if_finished()
try:
callback = self._result_queue.get(timeout=timeout_s)
except queue.Empty as exc:
with self._lock:
if self._error is not None:
raise self._error
raise XAIOAuthError(
"Timed out waiting for xAI sign-in. Run "
"`nanobot provider login xai-grok` to try again."
) from exc
return self._finish(callback)
def cancel(self) -> None:
"""Stop the callback listener for an abandoned flow."""
with self._lock:
if self._token is None and self._error is None:
self._error = XAIOAuthError("xAI sign-in was cancelled.")
self._close_locked()
def _finish(self, callback: _CallbackResult) -> XAIToken:
with self._lock:
if self._token is not None:
return self._token
self._raise_if_finished()
self._close_locked()
try:
token = _exchange_callback(
callback,
expected_state=self._state,
discovery=self._discovery,
verifier=self._verifier,
redirect_uri=self.redirect_uri,
proxy=self._proxy,
)
with _token_lock():
_write_token(token)
except Exception as exc:
self._error = exc
raise
self._token = token
return token
def _expire(self) -> None:
with self._lock:
if self._token is not None or self._error is not None:
return
self._expire_locked()
def _expire_locked(self) -> None:
self._error = XAIOAuthError("xAI sign-in expired. Start a new sign-in flow.")
self._close_locked()
def _raise_if_finished(self) -> None:
if self._token is not None:
return
if self._error is not None:
raise self._error
def _close_locked(self) -> None:
if self._closed:
return
self._closed = True
self._timeout_timer.cancel()
self._stop_event.set()
if threading.current_thread() is not self._server_thread:
self._server_thread.join(timeout=2)
def get_xai_oauth_storage_path() -> Path:
"""Return the instance-scoped xAI OAuth credential path."""
return get_data_dir() / "auth" / "xai.json"
def get_xai_oauth_login_status() -> XAIToken | None:
"""Return locally stored login state without making a network request."""
return _load_token()
def logout_xai_oauth() -> bool:
"""Remove this instance's credentials while excluding token refreshes."""
path = get_xai_oauth_storage_path()
with _token_lock():
try:
path.unlink()
except FileNotFoundError:
return False
return True
def login_xai_oauth(
*,
print_fn: Callable[[str], None] = print,
prompt_fn: Callable[[str], str] | None = None,
proxy: str | None = None,
callback_timeout_s: float = 600,
browser_opener: Callable[[str], bool] = webbrowser.open,
) -> XAIToken:
"""Run xAI's browser-based OAuth flow and persist the resulting token.
``prompt_fn`` is used only when no local browser could be opened. It lets a
headless user paste either the final callback URL or its authorization code.
"""
flow = start_xai_oauth_login(proxy=proxy, timeout_s=callback_timeout_s)
try:
print_fn("Opening xAI sign-in in your browser...")
print_fn(f"If it does not open automatically, visit:\n{flow.authorization_url}")
opened = False
with suppress(Exception):
opened = bool(browser_opener(flow.authorization_url))
if not opened and prompt_fn is not None:
pasted = prompt_fn("Paste the final callback URL (or authorization code)")
token = flow.complete(pasted)
if token is None: # pragma: no cover - pasted input always resolves a callback
raise XAIOAuthError("xAI sign-in returned no authorization code.")
return token
return flow.wait(callback_timeout_s)
finally:
flow.cancel()
def start_xai_oauth_login(
*,
proxy: str | None = None,
timeout_s: float = 600,
) -> XAIOAuthLoginFlow:
"""Create a non-blocking OAuth flow for browser or pasted-callback completion."""
discovery = _discover(proxy)
verifier, challenge = _generate_pkce()
state = secrets.token_urlsafe(32)
nonce = secrets.token_urlsafe(32)
result_queue: queue.Queue[_CallbackResult] = queue.Queue(maxsize=1)
server = _make_callback_server(state, result_queue)
redirect_uri = f"http://127.0.0.1:{server.server_port}/callback"
authorization_url = _build_authorize_url(
discovery.authorization_endpoint,
redirect_uri=redirect_uri,
challenge=challenge,
state=state,
nonce=nonce,
)
return XAIOAuthLoginFlow(
authorization_url=authorization_url,
redirect_uri=redirect_uri,
verifier=verifier,
state=state,
discovery=discovery,
proxy=proxy,
result_queue=result_queue,
server=server,
timeout_s=timeout_s,
)
def complete_xai_oauth_login(
flow: XAIOAuthLoginFlow,
authorization_code: str | None = None,
) -> XAIToken | None:
"""Complete a pending login from loopback state or a pasted authorization code."""
return flow.complete(authorization_code)
def get_xai_oauth_token(
*,
proxy: str | None = None,
min_ttl_ms: int = _TOKEN_REFRESH_MARGIN_MS,
force_refresh: bool = False,
) -> XAIToken:
"""Load a usable token, refreshing it under an inter-process lock when needed."""
token = _load_token()
if token is None:
raise XAIOAuthError(
"xAI is not signed in. Run `nanobot provider login xai-grok` first."
)
if not force_refresh and _token_is_fresh(token, min_ttl_ms):
return token
if not token.refresh:
if not force_refresh and token.expires > _now_ms():
return token
raise XAIOAuthError(
"The xAI login has expired and cannot be refreshed. "
"Run `nanobot provider login xai-grok` again."
)
with _token_lock():
latest = _load_token()
if latest is None:
raise XAIOAuthError(
"xAI is not signed in. Run `nanobot provider login xai-grok` first."
)
if not force_refresh and _token_is_fresh(latest, min_ttl_ms):
return latest
if not latest.refresh:
raise XAIOAuthError(
"The xAI login has expired and cannot be refreshed. "
"Run `nanobot provider login xai-grok` again."
)
refreshed = _refresh_token(latest, proxy)
_write_token(refreshed)
return refreshed
def _discover(proxy: str | None) -> _Discovery:
try:
with _http_client(proxy) as client:
response = client.get(_DISCOVERY_URL)
except httpx.HTTPError as exc:
raise XAIOAuthError(f"Could not reach xAI sign-in: {type(exc).__name__}.") from exc
if response.status_code != HTTPStatus.OK:
raise _oauth_http_error(response, "discovery")
try:
payload = response.json()
except ValueError as exc:
raise XAIOAuthError("xAI sign-in discovery returned invalid JSON.") from exc
if payload.get("issuer", "").rstrip("/") != XAI_OAUTH_ISSUER:
raise XAIOAuthError("xAI sign-in discovery returned an unexpected issuer.")
authorization_endpoint = _validate_xai_endpoint(
payload.get("authorization_endpoint"), "authorization"
)
token_endpoint = _validate_xai_endpoint(payload.get("token_endpoint"), "token")
userinfo_raw = payload.get("userinfo_endpoint")
userinfo_endpoint = (
_validate_xai_endpoint(userinfo_raw, "userinfo") if userinfo_raw else None
)
return _Discovery(authorization_endpoint, token_endpoint, userinfo_endpoint)
def _validate_xai_endpoint(value: Any, label: str) -> str:
if not isinstance(value, str):
raise XAIOAuthError(f"xAI sign-in discovery omitted the {label} endpoint.")
parsed = urlsplit(value)
try:
port = parsed.port
except ValueError as exc:
raise XAIOAuthError(
f"xAI sign-in discovery returned an unsafe {label} endpoint."
) from exc
if (
parsed.scheme != "https"
or parsed.hostname != "auth.x.ai"
or parsed.username is not None
or parsed.password is not None
or port not in (None, 443)
):
raise XAIOAuthError(f"xAI sign-in discovery returned an unsafe {label} endpoint.")
return value
def _generate_pkce() -> tuple[str, str]:
verifier = _base64url(secrets.token_bytes(32))
challenge = _base64url(hashlib.sha256(verifier.encode("ascii")).digest())
return verifier, challenge
def _base64url(value: bytes) -> str:
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
def _build_authorize_url(
endpoint: str,
*,
redirect_uri: str,
challenge: str,
state: str,
nonce: str,
) -> str:
params = {
"response_type": "code",
"client_id": XAI_CLIENT_ID,
"redirect_uri": redirect_uri,
"scope": " ".join(XAI_OAUTH_SCOPES),
"code_challenge": challenge,
"code_challenge_method": "S256",
"state": state,
"nonce": nonce,
"referrer": "nanobot",
}
return f"{endpoint}?{urlencode(params)}"
def _make_callback_server(
expected_state: str,
result_queue: queue.Queue[_CallbackResult],
) -> ThreadingHTTPServer:
class CallbackHandler(BaseHTTPRequestHandler):
def do_OPTIONS(self) -> None: # noqa: N802
if self.path.split("?", 1)[0] != "/callback":
self.send_error(HTTPStatus.NOT_FOUND)
return
self.send_response(HTTPStatus.NO_CONTENT)
self._send_cors_headers()
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.send_header("Access-Control-Allow-Private-Network", "true")
self.end_headers()
def do_GET(self) -> None: # noqa: N802
parsed = urlsplit(self.path)
if parsed.path != "/callback":
self.send_error(HTTPStatus.NOT_FOUND)
return
params = parse_qs(parsed.query)
code = _first(params, "code")
received_state = _first(params, "state")
error = _first(params, "error_description") or _first(params, "error")
if code and received_state and hmac.compare_digest(received_state, expected_state):
result = _CallbackResult(code=code, state=received_state)
title = "Signed in to xAI"
message = "You can close this tab and return to nanobot."
elif code:
result = _CallbackResult(error="OAuth state mismatch")
title = "Sign-in failed"
message = "The sign-in response could not be verified. Return to nanobot and retry."
else:
result = _CallbackResult(error=error or "access denied")
title = "Access denied"
message = "Return to nanobot and try signing in again."
with suppress(queue.Full):
result_queue.put_nowait(result)
body = _callback_page(title, message)
encoded = body.encode("utf-8")
self.send_response(HTTPStatus.OK)
self._send_cors_headers()
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(encoded)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(encoded)
def _send_cors_headers(self) -> None:
origin = self.headers.get("Origin")
if origin == XAI_ALLOWED_CALLBACK_ORIGIN:
self.send_header("Access-Control-Allow-Origin", origin)
self.send_header("Vary", "Origin")
self.send_header("Access-Control-Allow-Private-Network", "true")
def log_message(self, _format: str, *_args: Any) -> None:
# Callback query strings contain an authorization code.
return
return ThreadingHTTPServer(("127.0.0.1", 0), CallbackHandler)
def _serve_callback_server(
server: ThreadingHTTPServer,
stop_event: threading.Event,
) -> None:
server.timeout = 0.2
try:
while not stop_event.is_set():
server.handle_request()
finally:
server.server_close()
def _first(params: dict[str, list[str]], key: str) -> str | None:
values = params.get(key)
return values[0] if values else None
def _callback_page(title: str, message: str) -> str:
return f"""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>{title}</title><style>
body{{margin:0;min-height:100vh;display:grid;place-items:center;background:#f5f7fb;color:#172033;
font:16px/1.5 system-ui,sans-serif}}main{{max-width:30rem;margin:1.5rem;padding:2rem;border:1px solid #dfe4ee;
border-radius:18px;background:white;box-shadow:0 16px 50px #17203318}}h1{{margin:0 0 .65rem;font-size:1.5rem}}
p{{margin:0;color:#526078}}</style></head><body><main><h1>{title}</h1><p>{message}</p></main></body></html>"""
def _exchange_callback(
callback: _CallbackResult,
*,
expected_state: str,
discovery: _Discovery,
verifier: str,
redirect_uri: str,
proxy: str | None,
) -> XAIToken:
if callback.error:
raise XAIOAuthError(f"xAI sign-in was not completed: {callback.error}")
if not callback.code:
raise XAIOAuthError("xAI sign-in returned no authorization code.")
if callback.state and not hmac.compare_digest(callback.state, expected_state):
raise XAIOAuthError("xAI sign-in failed because the OAuth state did not match.")
payload = _exchange_code(
discovery.token_endpoint,
code=callback.code,
verifier=verifier,
redirect_uri=redirect_uri,
proxy=proxy,
)
account_id = _fetch_account(discovery.userinfo_endpoint, payload["access_token"], proxy)
return _token_from_response(payload, account_id=account_id)
def _exchange_code(
token_endpoint: str,
*,
code: str,
verifier: str,
redirect_uri: str,
proxy: str | None,
) -> dict[str, Any]:
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": XAI_CLIENT_ID,
"code_verifier": verifier,
}
try:
with _http_client(proxy) as client:
response = client.post(
token_endpoint,
data=data,
headers={"x-grok-client-version": XAI_CLIENT_VERSION},
)
except httpx.HTTPError as exc:
raise XAIOAuthError(f"Could not exchange the xAI sign-in code: {type(exc).__name__}.") from exc
if not response.is_success:
raise _oauth_http_error(response, "token exchange")
return _token_payload(response)
def _refresh_token(token: XAIToken, proxy: str | None) -> XAIToken:
data = {
"grant_type": "refresh_token",
"refresh_token": token.refresh or "",
"client_id": XAI_CLIENT_ID,
}
try:
with _http_client(proxy) as client:
response = client.post(
f"{XAI_OAUTH_ISSUER}/oauth2/token",
data=data,
headers={"x-grok-client-version": XAI_CLIENT_VERSION},
)
except httpx.HTTPError as exc:
raise XAIOAuthError(f"Could not refresh the xAI login: {type(exc).__name__}.") from exc
if not response.is_success:
raise _oauth_http_error(response, "token refresh")
payload = _token_payload(response)
return _token_from_response(
payload,
account_id=token.account_id,
previous_refresh=token.refresh,
)
def _token_payload(response: httpx.Response) -> dict[str, Any]:
try:
payload = response.json()
except ValueError as exc:
raise XAIOAuthError("xAI sign-in returned an invalid token response.") from exc
if not isinstance(payload, dict) or not isinstance(payload.get("access_token"), str):
raise XAIOAuthError("xAI sign-in returned no access token.")
return payload
def _token_from_response(
payload: dict[str, Any],
*,
account_id: str | None,
previous_refresh: str | None = None,
) -> XAIToken:
try:
expires_in = max(1, int(payload.get("expires_in") or _DEFAULT_TOKEN_TTL_S))
except (TypeError, ValueError):
expires_in = _DEFAULT_TOKEN_TTL_S
refresh = payload.get("refresh_token")
if not isinstance(refresh, str) or not refresh:
refresh = previous_refresh
return XAIToken(
access=payload["access_token"],
refresh=refresh,
expires=_now_ms() + expires_in * 1000,
account_id=account_id,
)
def _fetch_account(endpoint: str | None, access_token: str, proxy: str | None) -> str | None:
if not endpoint:
return None
try:
with _http_client(proxy) as client:
response = client.get(endpoint, headers={"Authorization": f"Bearer {access_token}"})
if not response.is_success:
return None
payload = response.json()
except (httpx.HTTPError, ValueError):
return None
if not isinstance(payload, dict):
return None
for key in ("email", "preferred_username", "name", "sub"):
value = payload.get(key)
if isinstance(value, str) and value:
return value
return None
def _oauth_http_error(response: httpx.Response, action: str) -> XAIOAuthError:
code: str | None = None
description: str | None = None
with suppress(ValueError):
payload = response.json()
if isinstance(payload, dict):
raw_code = payload.get("error")
raw_description = payload.get("error_description") or payload.get("message")
code = raw_code[:80] if isinstance(raw_code, str) else None
description = raw_description[:200] if isinstance(raw_description, str) else None
detail = ": ".join(value for value in (code, description) if value)
suffix = f" ({detail})" if detail else ""
return XAIOAuthError(f"xAI OAuth {action} failed with HTTP {response.status_code}{suffix}.")
def _http_client(proxy: str | None) -> httpx.Client:
kwargs: dict[str, Any] = {"timeout": _HTTP_TIMEOUT_S}
if proxy:
kwargs.update(proxy=proxy, trust_env=False)
return httpx.Client(**kwargs)
def _token_lock() -> FileLock:
path = get_xai_oauth_storage_path()
path.parent.mkdir(parents=True, exist_ok=True)
return FileLock(str(path.with_suffix(".lock")), timeout=15)
def _load_token() -> XAIToken | None:
path = get_xai_oauth_storage_path()
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
return None
except (OSError, ValueError, TypeError) as exc:
logger.warning("Could not read xAI OAuth credentials: {}", type(exc).__name__)
return None
return XAIToken.from_dict(payload)
def _write_token(token: XAIToken) -> None:
path = get_xai_oauth_storage_path()
path.parent.mkdir(parents=True, exist_ok=True)
with suppress(OSError):
os.chmod(path.parent, 0o700)
_write_text_atomic(path, json.dumps(asdict(token), indent=2, ensure_ascii=False))
with suppress(OSError):
os.chmod(path, 0o600)
def _token_is_fresh(token: XAIToken, min_ttl_ms: int) -> bool:
return bool(token.access and token.expires > _now_ms() + max(0, min_ttl_ms))
def _now_ms() -> int:
return int(time.time() * 1000)