Replace repository with grok-reg-protocol-cpa project
Overwrite previous grok-keygen contents with the local registration/CPA protocol codebase (secrets and runtime data excluded).
This commit is contained in:
commit
6f7c949681
33 files changed
+11125
No files matched your search
@@ -0,0 +1,52 @@
|
||||
"""CPA xAI (Grok Build free) auth helpers for the register machine.
|
||||
|
||||
Produce CLIProxyAPI-compatible ``xai-<email>.json`` credentials.
|
||||
"""
|
||||
|
||||
from .accounts import AccountLine, existing_cpa_emails, parse_accounts_file
|
||||
from .mint import mint_and_export
|
||||
from .probe import probe_mini_response, probe_models
|
||||
from .protocol_mint import (
|
||||
ProtocolMintError,
|
||||
extract_sso_from_cookies,
|
||||
mint_with_sso_protocol,
|
||||
)
|
||||
from .schema import (
|
||||
CLIENT_ID,
|
||||
DEFAULT_BASE_URL,
|
||||
DEFAULT_CLIENT_HEADERS,
|
||||
DEFAULT_REDIRECT_URI,
|
||||
DEFAULT_TOKEN_ENDPOINT,
|
||||
build_cpa_xai_auth,
|
||||
credential_file_name,
|
||||
expired_from_access_token,
|
||||
)
|
||||
from .writer import write_cpa_xai_auth
|
||||
|
||||
# CLIENT_ID lives in oauth_device; re-export from schema if present
|
||||
try:
|
||||
from .oauth_device import CLIENT_ID as OAUTH_CLIENT_ID
|
||||
except Exception: # pragma: no cover
|
||||
OAUTH_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
|
||||
|
||||
__all__ = [
|
||||
"AccountLine",
|
||||
"CLIENT_ID",
|
||||
"DEFAULT_BASE_URL",
|
||||
"DEFAULT_CLIENT_HEADERS",
|
||||
"DEFAULT_REDIRECT_URI",
|
||||
"DEFAULT_TOKEN_ENDPOINT",
|
||||
"OAUTH_CLIENT_ID",
|
||||
"ProtocolMintError",
|
||||
"build_cpa_xai_auth",
|
||||
"credential_file_name",
|
||||
"existing_cpa_emails",
|
||||
"expired_from_access_token",
|
||||
"extract_sso_from_cookies",
|
||||
"mint_and_export",
|
||||
"mint_with_sso_protocol",
|
||||
"parse_accounts_file",
|
||||
"probe_mini_response",
|
||||
"probe_models",
|
||||
"write_cpa_xai_auth",
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Parse register machine accounts_cli.txt lines."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class AccountLine:
|
||||
email: str
|
||||
password: str
|
||||
sso: str
|
||||
raw: str
|
||||
line_no: int
|
||||
|
||||
|
||||
def parse_accounts_file(path: str | Path) -> list[AccountLine]:
|
||||
path = Path(path)
|
||||
out: list[AccountLine] = []
|
||||
if not path.is_file():
|
||||
return out
|
||||
for i, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
|
||||
s = line.strip()
|
||||
if not s or s.startswith("#"):
|
||||
continue
|
||||
parts = s.split("----")
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
email = parts[0].strip()
|
||||
password = parts[1].strip()
|
||||
sso = parts[2].strip() if len(parts) > 2 else ""
|
||||
if not email or not password:
|
||||
continue
|
||||
out.append(AccountLine(email=email, password=password, sso=sso, raw=s, line_no=i))
|
||||
return out
|
||||
|
||||
|
||||
def existing_cpa_emails(auth_dir: str | Path) -> set[str]:
|
||||
"""Emails already present as xai-*.json in auth_dir."""
|
||||
auth_dir = Path(auth_dir)
|
||||
found: set[str] = set()
|
||||
if not auth_dir.is_dir():
|
||||
return found
|
||||
for p in auth_dir.glob("xai-*.json"):
|
||||
name = p.name[len("xai-") : -len(".json")]
|
||||
if name:
|
||||
found.add(name.lower())
|
||||
try:
|
||||
import json
|
||||
|
||||
d = json.loads(p.read_text(encoding="utf-8"))
|
||||
em = str(d.get("email") or "").strip().lower()
|
||||
if em:
|
||||
found.add(em)
|
||||
except Exception:
|
||||
continue
|
||||
return found
|
||||
File diff suppressed because it is too large.
Load diff
+199
@@ -0,0 +1,199 @@
|
||||
"""High-level: mint CPA xai-*.json for one free registered account."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from .browser_confirm import mint_with_browser
|
||||
from .probe import probe_mini_response, probe_models
|
||||
from .protocol_mint import ProtocolMintError, extract_sso_from_cookies, mint_with_sso_protocol
|
||||
from .proxyutil import proxy_log_label, resolve_proxy, set_runtime_proxy
|
||||
from .schema import DEFAULT_BASE_URL, build_cpa_xai_auth
|
||||
from .writer import write_cpa_xai_auth
|
||||
|
||||
LogFn = Callable[[str], None]
|
||||
|
||||
|
||||
def _noop(_: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def mint_and_export(
|
||||
*,
|
||||
email: str,
|
||||
password: str,
|
||||
auth_dir: str | Path,
|
||||
page: Any | None = None,
|
||||
proxy: str | None = None,
|
||||
headless: bool = False,
|
||||
base_url: str = DEFAULT_BASE_URL,
|
||||
probe: bool = True,
|
||||
probe_chat: bool = False,
|
||||
browser_timeout_sec: float = 240.0,
|
||||
force_standalone: bool = True,
|
||||
cookies: Any | None = None,
|
||||
sso: str | None = None,
|
||||
reuse_browser: bool = True,
|
||||
recycle_every: int = 15,
|
||||
prefer_protocol: bool = True,
|
||||
protocol_only: bool = False,
|
||||
protocol_poll_timeout_sec: float = 90.0,
|
||||
log: LogFn | None = None,
|
||||
cancel: Callable[[], bool] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Full pipeline: (protocol SSO device-flow |) browser device-auth → write CPA → probe.
|
||||
|
||||
Protocol path (curl_cffi + sso cookie) is tried first when prefer_protocol
|
||||
and an sso cookie is available. On failure, falls back to browser mint unless
|
||||
protocol_only=True.
|
||||
|
||||
Returns dict with keys: ok, path, email, probe, error?, mint_method?
|
||||
"""
|
||||
log = log or _noop
|
||||
email = (email or "").strip()
|
||||
if not email or not password:
|
||||
# Protocol can work with sso alone; password only required for browser fallback
|
||||
if not email:
|
||||
return {"ok": False, "email": email, "error": "missing email"}
|
||||
if not (sso or extract_sso_from_cookies(cookies)):
|
||||
return {"ok": False, "email": email, "error": "missing email/password"}
|
||||
|
||||
# Config/explicit proxy wins over shell https_proxy (common 7890 trap).
|
||||
# Thread-local pin — safe under concurrent mint workers.
|
||||
resolved = resolve_proxy(proxy)
|
||||
set_runtime_proxy(resolved or None)
|
||||
log(f"mint start: {email} proxy={proxy_log_label(resolved) or '(none)'}")
|
||||
|
||||
sso_val = (sso or "").strip() or extract_sso_from_cookies(cookies)
|
||||
tokens: dict[str, Any] | None = None
|
||||
protocol_err: str | None = None
|
||||
|
||||
if prefer_protocol and sso_val:
|
||||
log("mint try protocol (SSO HTTP device flow)")
|
||||
try:
|
||||
tokens = mint_with_sso_protocol(
|
||||
sso_cookie=sso_val,
|
||||
email=email,
|
||||
proxy=resolved or None,
|
||||
poll_timeout_sec=protocol_poll_timeout_sec,
|
||||
log=log,
|
||||
cancel=cancel,
|
||||
)
|
||||
log("mint protocol SUCCESS")
|
||||
except ProtocolMintError as e:
|
||||
protocol_err = str(e)
|
||||
log(f"mint protocol failed: {e}")
|
||||
if protocol_only:
|
||||
return {
|
||||
"ok": False,
|
||||
"email": email,
|
||||
"error": f"protocol_only: {e}",
|
||||
"mint_method": "protocol",
|
||||
}
|
||||
log("mint fallback → browser")
|
||||
except Exception as e: # noqa: BLE001
|
||||
protocol_err = str(e)
|
||||
log(f"mint protocol exception: {e}")
|
||||
if protocol_only:
|
||||
return {
|
||||
"ok": False,
|
||||
"email": email,
|
||||
"error": f"protocol_only: {e}",
|
||||
"mint_method": "protocol",
|
||||
}
|
||||
log("mint fallback → browser")
|
||||
elif prefer_protocol and not sso_val:
|
||||
log("mint protocol skipped (no sso cookie) → browser")
|
||||
if protocol_only:
|
||||
return {
|
||||
"ok": False,
|
||||
"email": email,
|
||||
"error": "protocol_only but no sso cookie",
|
||||
"mint_method": "protocol",
|
||||
}
|
||||
elif not prefer_protocol:
|
||||
log("mint protocol disabled → browser")
|
||||
|
||||
if tokens is None:
|
||||
if not password:
|
||||
return {
|
||||
"ok": False,
|
||||
"email": email,
|
||||
"error": protocol_err or "protocol failed and no password for browser fallback",
|
||||
"protocol_error": protocol_err,
|
||||
}
|
||||
try:
|
||||
tokens = mint_with_browser(
|
||||
email=email,
|
||||
password=password,
|
||||
page=None if force_standalone else page,
|
||||
proxy=resolved or None,
|
||||
headless=headless,
|
||||
browser_timeout_sec=browser_timeout_sec,
|
||||
force_standalone=force_standalone,
|
||||
cookies=cookies,
|
||||
reuse_browser=reuse_browser,
|
||||
recycle_every=recycle_every,
|
||||
poll_log=log,
|
||||
cancel=cancel,
|
||||
)
|
||||
tokens["mint_method"] = "browser"
|
||||
if protocol_err:
|
||||
tokens["protocol_error"] = protocol_err
|
||||
except Exception as e: # noqa: BLE001
|
||||
log(f"mint failed: {e}")
|
||||
err = str(e)
|
||||
if protocol_err:
|
||||
err = f"{err} (protocol: {protocol_err})"
|
||||
return {
|
||||
"ok": False,
|
||||
"email": email,
|
||||
"error": err,
|
||||
"protocol_error": protocol_err,
|
||||
}
|
||||
|
||||
payload = build_cpa_xai_auth(
|
||||
email=email,
|
||||
access_token=tokens["access_token"],
|
||||
refresh_token=tokens["refresh_token"],
|
||||
id_token=tokens.get("id_token"),
|
||||
expires_in=tokens.get("expires_in"),
|
||||
base_url=base_url,
|
||||
)
|
||||
path = write_cpa_xai_auth(auth_dir, payload)
|
||||
log(f"wrote {path}")
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"ok": True,
|
||||
"email": email,
|
||||
"path": str(path),
|
||||
"user_code": tokens.get("user_code"),
|
||||
"base_url": base_url,
|
||||
"proxy": proxy_log_label(resolved),
|
||||
"mint_method": tokens.get("mint_method") or "browser",
|
||||
}
|
||||
if protocol_err and result["mint_method"] != "protocol":
|
||||
result["protocol_error"] = protocol_err
|
||||
|
||||
if probe:
|
||||
pr = probe_models(tokens["access_token"], base_url=base_url, proxy=resolved or None)
|
||||
result["probe_models"] = pr
|
||||
log(
|
||||
f"probe models: ok={pr.get('ok')} status={pr.get('status')} "
|
||||
f"has_grok_45={pr.get('has_grok_45')} ids={pr.get('model_ids')} "
|
||||
f"error={str(pr.get('error') or '')[:200]}"
|
||||
)
|
||||
if not pr.get("has_grok_45"):
|
||||
result["ok"] = False
|
||||
result["error"] = "token ok but grok-4.5 not listed"
|
||||
if probe_chat and pr.get("has_grok_45"):
|
||||
ch = probe_mini_response(
|
||||
tokens["access_token"], base_url=base_url, proxy=resolved or None
|
||||
)
|
||||
result["probe_chat"] = ch
|
||||
log(f"probe chat: ok={ch.get('ok')} model={ch.get('model')} text={ch.get('text')!r}")
|
||||
if not ch.get("ok"):
|
||||
result["ok"] = False
|
||||
result["error"] = f"chat probe failed: {ch.get('error') or ch.get('status')}"
|
||||
return result
|
||||
@@ -0,0 +1,314 @@
|
||||
"""xAI OAuth device-code grant (Grok CLI / CPA client).
|
||||
|
||||
Endpoints from https://auth.x.ai/.well-known/openid-configuration
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import ssl
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
from .proxyutil import resolve_proxy
|
||||
|
||||
# Keep in sync with CLIProxyAPI internal/auth/xai/types.go
|
||||
CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
|
||||
ISSUER = "https://auth.x.ai"
|
||||
DEVICE_CODE_URL = "https://auth.x.ai/oauth2/device/code"
|
||||
TOKEN_URL = "https://auth.x.ai/oauth2/token"
|
||||
SCOPE = "openid profile email offline_access grok-cli:access api:access"
|
||||
|
||||
LogFn = Callable[[str], None]
|
||||
|
||||
|
||||
def _noop_log(_: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _proxy_handler(proxy: str | None = None) -> urllib.request.ProxyHandler | None:
|
||||
p = resolve_proxy(proxy)
|
||||
if not p:
|
||||
return None
|
||||
return urllib.request.ProxyHandler({"http": p, "https": p})
|
||||
|
||||
|
||||
def _ssl_context() -> ssl.SSLContext | None:
|
||||
"""Use certifi's CA bundle when available.
|
||||
|
||||
macOS Framework Python / uv venv can have an empty or incomplete OpenSSL
|
||||
trust store, which makes stdlib urllib fail with
|
||||
CERTIFICATE_VERIFY_FAILED even when curl/browser requests work.
|
||||
"""
|
||||
try:
|
||||
import certifi # type: ignore
|
||||
|
||||
return ssl.create_default_context(cafile=certifi.where())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _opener(proxy: str | None = None) -> urllib.request.OpenerDirector:
|
||||
handlers: list[Any] = []
|
||||
ctx = _ssl_context()
|
||||
if ctx is not None:
|
||||
handlers.append(urllib.request.HTTPSHandler(context=ctx))
|
||||
ph = _proxy_handler(proxy)
|
||||
if ph is not None:
|
||||
handlers.append(ph)
|
||||
return urllib.request.build_opener(*handlers) if handlers else urllib.request.build_opener()
|
||||
|
||||
|
||||
def _is_transient_net_error(exc: BaseException) -> bool:
|
||||
"""Proxy/TLS blips that should not kill an already-approved device flow."""
|
||||
if isinstance(exc, (TimeoutError, BrokenPipeError, ConnectionResetError, ConnectionAbortedError, ConnectionRefusedError)):
|
||||
return True
|
||||
if isinstance(exc, urllib.error.URLError):
|
||||
reason = getattr(exc, "reason", None)
|
||||
if isinstance(reason, BaseException) and _is_transient_net_error(reason):
|
||||
return True
|
||||
msg = str(exc).lower()
|
||||
needles = (
|
||||
"broken pipe",
|
||||
"connection reset",
|
||||
"connection aborted",
|
||||
"timed out",
|
||||
"timeout",
|
||||
"temporarily unavailable",
|
||||
"network is unreachable",
|
||||
"name or service not known",
|
||||
"unexpected_eof",
|
||||
"eof occurred",
|
||||
"ssl",
|
||||
"handshake",
|
||||
"remote end closed",
|
||||
"bad gateway",
|
||||
"connection refused",
|
||||
)
|
||||
return any(n in msg for n in needles)
|
||||
# ssl.SSLError and generic OSError (errno 32 Broken pipe, 104 reset, etc.)
|
||||
try:
|
||||
import ssl
|
||||
|
||||
if isinstance(exc, ssl.SSLError):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
if isinstance(exc, OSError):
|
||||
if getattr(exc, "errno", None) in {32, 104, 110, 111, 113, 101}:
|
||||
return True
|
||||
msg = str(exc).lower()
|
||||
return any(n in msg for n in ("broken pipe", "timed out", "connection reset", "ssl"))
|
||||
return False
|
||||
|
||||
|
||||
def _post_form(
|
||||
url: str,
|
||||
form: dict[str, str],
|
||||
timeout: float = 30.0,
|
||||
*,
|
||||
proxy: str | None = None,
|
||||
retries: int = 0,
|
||||
retry_sleep: float = 1.5,
|
||||
) -> tuple[int, dict[str, Any] | str]:
|
||||
data = urllib.parse.urlencode(form).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "grok-reg-cpa-xai-minter/1.0",
|
||||
},
|
||||
)
|
||||
last: BaseException | None = None
|
||||
attempts = max(int(retries), 0) + 1
|
||||
for i in range(attempts):
|
||||
opener = _opener(proxy)
|
||||
try:
|
||||
with opener.open(req, timeout=timeout) as resp:
|
||||
body = resp.read().decode("utf-8", errors="replace")
|
||||
status = getattr(resp, "status", 200) or 200
|
||||
try:
|
||||
return int(status), json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return int(status), body
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
return int(e.code), json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return int(e.code), body
|
||||
except BaseException as e: # noqa: BLE001
|
||||
last = e
|
||||
if not _is_transient_net_error(e) or i + 1 >= attempts:
|
||||
raise
|
||||
time.sleep(retry_sleep * (i + 1))
|
||||
assert last is not None
|
||||
raise last
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceCodeSession:
|
||||
device_code: str
|
||||
user_code: str
|
||||
verification_uri: str
|
||||
verification_uri_complete: str
|
||||
expires_in: int
|
||||
interval: int
|
||||
raw: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenResult:
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
id_token: str | None
|
||||
token_type: str
|
||||
expires_in: int
|
||||
raw: dict[str, Any]
|
||||
|
||||
|
||||
class OAuthDeviceError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def request_device_code(
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
scope: str = SCOPE,
|
||||
timeout: float = 30.0,
|
||||
proxy: str | None = None,
|
||||
) -> DeviceCodeSession:
|
||||
status, body = _post_form(
|
||||
DEVICE_CODE_URL,
|
||||
{"client_id": client_id, "scope": scope},
|
||||
timeout=timeout,
|
||||
proxy=proxy,
|
||||
retries=2,
|
||||
retry_sleep=1.0,
|
||||
)
|
||||
if status != 200 or not isinstance(body, dict):
|
||||
raise OAuthDeviceError(f"device code request failed HTTP {status}: {body!r}")
|
||||
device_code = str(body.get("device_code") or "").strip()
|
||||
user_code = str(body.get("user_code") or "").strip()
|
||||
if not device_code or not user_code:
|
||||
raise OAuthDeviceError(f"device code response missing fields: {body}")
|
||||
vuri = str(body.get("verification_uri") or "https://accounts.x.ai/oauth2/device").strip()
|
||||
vcomplete = str(
|
||||
body.get("verification_uri_complete") or f"{vuri}?user_code={user_code}"
|
||||
).strip()
|
||||
expires_in = int(body.get("expires_in") or 1800)
|
||||
interval = max(int(body.get("interval") or 5), 1)
|
||||
return DeviceCodeSession(
|
||||
device_code=device_code,
|
||||
user_code=user_code,
|
||||
verification_uri=vuri,
|
||||
verification_uri_complete=vcomplete,
|
||||
expires_in=expires_in,
|
||||
interval=interval,
|
||||
raw=body,
|
||||
)
|
||||
|
||||
|
||||
def poll_device_token(
|
||||
device_code: str,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
interval: int = 5,
|
||||
expires_in: int = 1800,
|
||||
timeout: float = 30.0,
|
||||
log: LogFn | None = None,
|
||||
cancel: Callable[[], bool] | None = None,
|
||||
proxy: str | None = None,
|
||||
) -> TokenResult:
|
||||
"""Poll token endpoint until authorized or expired.
|
||||
|
||||
Transient proxy/TLS errors (Broken pipe, SSL EOF, timeouts) are retried
|
||||
until the device-code deadline so a successful browser consent is not
|
||||
wasted by a single flaky poll.
|
||||
"""
|
||||
log = log or _noop_log
|
||||
deadline = time.time() + max(expires_in - 5, 30)
|
||||
sleep_for = max(interval, 1)
|
||||
net_streak = 0
|
||||
max_net_streak = 20
|
||||
while time.time() < deadline:
|
||||
if cancel and cancel():
|
||||
raise OAuthDeviceError("cancelled")
|
||||
try:
|
||||
status, body = _post_form(
|
||||
TOKEN_URL,
|
||||
{
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
||||
"device_code": device_code,
|
||||
"client_id": client_id,
|
||||
},
|
||||
timeout=timeout,
|
||||
proxy=proxy,
|
||||
retries=2,
|
||||
retry_sleep=1.0,
|
||||
)
|
||||
net_streak = 0
|
||||
except BaseException as e: # noqa: BLE001
|
||||
if not _is_transient_net_error(e):
|
||||
raise
|
||||
net_streak += 1
|
||||
wait = min(sleep_for + min(net_streak, 5), 20)
|
||||
log(
|
||||
f"oauth poll network blip ({net_streak}/{max_net_streak}): {e} "
|
||||
f"— retry in {wait}s"
|
||||
)
|
||||
if net_streak >= max_net_streak:
|
||||
raise OAuthDeviceError(
|
||||
f"device auth aborted after {net_streak} network errors: {e}"
|
||||
) from e
|
||||
time.sleep(wait)
|
||||
continue
|
||||
if status == 200 and isinstance(body, dict) and body.get("access_token"):
|
||||
access = str(body["access_token"]).strip()
|
||||
refresh = str(body.get("refresh_token") or "").strip()
|
||||
if not refresh:
|
||||
raise OAuthDeviceError("token response missing refresh_token")
|
||||
return TokenResult(
|
||||
access_token=access,
|
||||
refresh_token=refresh,
|
||||
id_token=(str(body["id_token"]).strip() if body.get("id_token") else None),
|
||||
token_type=str(body.get("token_type") or "Bearer"),
|
||||
expires_in=int(body.get("expires_in") or 21600),
|
||||
raw=body,
|
||||
)
|
||||
err = ""
|
||||
desc = ""
|
||||
if isinstance(body, dict):
|
||||
err = str(body.get("error") or "")
|
||||
desc = str(body.get("error_description") or "")
|
||||
if err in ("authorization_pending", "slow_down"):
|
||||
if err == "slow_down":
|
||||
sleep_for = min(sleep_for + 5, 30)
|
||||
log(f"oauth poll: {err} (sleep {sleep_for}s)")
|
||||
time.sleep(sleep_for)
|
||||
continue
|
||||
if err in ("expired_token", "access_denied"):
|
||||
raise OAuthDeviceError(f"device auth failed: {err}: {desc}")
|
||||
if status == 400 and err:
|
||||
raise OAuthDeviceError(f"device auth token error: {err}: {desc or body}")
|
||||
# 5xx / empty / proxy HTML — treat as soft error and keep polling
|
||||
if status >= 500 or status in (502, 503, 504) or not isinstance(body, dict):
|
||||
net_streak += 1
|
||||
wait = min(sleep_for + 2, 20)
|
||||
log(f"oauth poll soft HTTP {status}: {body!r} — retry in {wait}s")
|
||||
if net_streak >= max_net_streak:
|
||||
raise OAuthDeviceError(
|
||||
f"device auth aborted after soft HTTP failures status={status}"
|
||||
)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
log(f"oauth poll unexpected HTTP {status}: {body!r}")
|
||||
time.sleep(sleep_for)
|
||||
raise OAuthDeviceError("device auth timed out waiting for user approval")
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Probe free Grok 4.5 via cli-chat-proxy with a CPA access_token."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import ssl
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
from .proxyutil import resolve_proxy
|
||||
from .schema import DEFAULT_BASE_URL, DEFAULT_CLIENT_HEADERS
|
||||
|
||||
|
||||
def _ssl_context() -> ssl.SSLContext | None:
|
||||
try:
|
||||
import certifi # type: ignore
|
||||
|
||||
return ssl.create_default_context(cafile=certifi.where())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _opener(proxy: str | None = None) -> urllib.request.OpenerDirector:
|
||||
p = resolve_proxy(proxy)
|
||||
handlers: list[Any] = []
|
||||
ctx = _ssl_context()
|
||||
if ctx is not None:
|
||||
handlers.append(urllib.request.HTTPSHandler(context=ctx))
|
||||
if p:
|
||||
handlers.append(urllib.request.ProxyHandler({"http": p, "https": p}))
|
||||
return urllib.request.build_opener(*handlers) if handlers else urllib.request.build_opener()
|
||||
|
||||
|
||||
def probe_models(
|
||||
access_token: str,
|
||||
*,
|
||||
base_url: str = DEFAULT_BASE_URL,
|
||||
timeout: float = 30.0,
|
||||
proxy: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
base = base_url.rstrip("/")
|
||||
url = f"{base}/models"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/json",
|
||||
**DEFAULT_CLIENT_HEADERS,
|
||||
}
|
||||
opener = _opener(proxy)
|
||||
req = urllib.request.Request(url, headers=headers, method="GET")
|
||||
try:
|
||||
with opener.open(req, timeout=timeout) as resp:
|
||||
body = json.loads(resp.read().decode("utf-8"))
|
||||
ids = [x.get("id") for x in body.get("data") or [] if isinstance(x, dict)]
|
||||
return {
|
||||
"ok": True,
|
||||
"status": getattr(resp, "status", 200),
|
||||
"model_ids": ids,
|
||||
"has_grok_45": any(i == "grok-4.5" for i in ids),
|
||||
}
|
||||
except urllib.error.HTTPError as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"status": e.code,
|
||||
"error": e.read().decode("utf-8", errors="replace")[:500],
|
||||
"model_ids": [],
|
||||
"has_grok_45": False,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {
|
||||
"ok": False,
|
||||
"status": 0,
|
||||
"error": str(e),
|
||||
"model_ids": [],
|
||||
"has_grok_45": False,
|
||||
}
|
||||
|
||||
|
||||
def probe_mini_response(
|
||||
access_token: str,
|
||||
*,
|
||||
base_url: str = DEFAULT_BASE_URL,
|
||||
timeout: float = 60.0,
|
||||
proxy: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
base = base_url.rstrip("/")
|
||||
url = f"{base}/responses"
|
||||
payload = {
|
||||
"model": "grok-4.5",
|
||||
"stream": False,
|
||||
"input": "Reply with exactly MINT_OK",
|
||||
"reasoning": {"effort": "low"},
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
**DEFAULT_CLIENT_HEADERS,
|
||||
}
|
||||
opener = _opener(proxy)
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with opener.open(req, timeout=timeout) as resp:
|
||||
body = json.loads(resp.read().decode("utf-8"))
|
||||
texts: list[str] = []
|
||||
for item in body.get("output") or []:
|
||||
if item.get("type") == "message":
|
||||
for c in item.get("content") or []:
|
||||
if c.get("type") == "output_text":
|
||||
texts.append(c.get("text") or "")
|
||||
return {
|
||||
"ok": True,
|
||||
"status": getattr(resp, "status", 200),
|
||||
"model": body.get("model"),
|
||||
"text": "\n".join(texts),
|
||||
"usage": body.get("usage"),
|
||||
}
|
||||
except urllib.error.HTTPError as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"status": e.code,
|
||||
"error": e.read().decode("utf-8", errors="replace")[:800],
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "status": 0, "error": str(e)}
|
||||
@@ -0,0 +1,281 @@
|
||||
"""Pure-HTTP SSO cookie → OIDC tokens (device flow, no browser).
|
||||
|
||||
Uses curl_cffi (Chrome TLS fingerprint) + SSO cookie to:
|
||||
1. Validate session on accounts.x.ai
|
||||
2. Request device code (stdlib / oauth_device)
|
||||
3. GET verification_uri_complete
|
||||
4. POST /oauth2/device/verify
|
||||
5. POST /oauth2/device/approve
|
||||
6. Poll token endpoint
|
||||
|
||||
On any failure raise ProtocolMintError so callers can fall back to browser mint.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .oauth_device import (
|
||||
CLIENT_ID,
|
||||
ISSUER,
|
||||
OAuthDeviceError,
|
||||
SCOPE,
|
||||
poll_device_token,
|
||||
request_device_code,
|
||||
)
|
||||
from .proxyutil import proxy_log_label, resolve_proxy, set_runtime_proxy
|
||||
|
||||
LogFn = Callable[[str], None]
|
||||
|
||||
VERIFY_URL = f"{ISSUER}/oauth2/device/verify"
|
||||
APPROVE_URL = f"{ISSUER}/oauth2/device/approve"
|
||||
|
||||
|
||||
class ProtocolMintError(RuntimeError):
|
||||
"""Protocol path failed; caller may fall back to browser mint."""
|
||||
|
||||
|
||||
def _noop_log(_: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def extract_sso_from_cookies(cookies: Any) -> str:
|
||||
"""Pull sso / sso-rw value from a cookie list/dict."""
|
||||
if not cookies:
|
||||
return ""
|
||||
if isinstance(cookies, str):
|
||||
return cookies.strip()
|
||||
if isinstance(cookies, dict):
|
||||
for name in ("sso", "sso-rw"):
|
||||
v = cookies.get(name)
|
||||
if v:
|
||||
return str(v).strip()
|
||||
return ""
|
||||
if isinstance(cookies, (list, tuple)):
|
||||
# Prefer bare "sso" over "sso-rw"
|
||||
found_rw = ""
|
||||
for c in cookies:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
name = str(c.get("name") or c.get("Name") or "")
|
||||
value = c.get("value") if "value" in c else c.get("Value")
|
||||
if not value:
|
||||
continue
|
||||
if name == "sso":
|
||||
return str(value).strip()
|
||||
if name == "sso-rw" and not found_rw:
|
||||
found_rw = str(value).strip()
|
||||
return found_rw
|
||||
return ""
|
||||
|
||||
|
||||
def _session(proxy: str | None, log: LogFn):
|
||||
try:
|
||||
from curl_cffi import requests as cf_requests
|
||||
except ImportError as e:
|
||||
raise ProtocolMintError(
|
||||
"curl_cffi not installed; cannot run protocol mint"
|
||||
) from e
|
||||
|
||||
s = cf_requests.Session()
|
||||
resolved = resolve_proxy(proxy)
|
||||
if resolved:
|
||||
s.proxies = {"http": resolved, "https": resolved}
|
||||
log(f"protocol proxy={proxy_log_label(resolved)}")
|
||||
return s
|
||||
|
||||
|
||||
def _set_sso_cookie(session: Any, sso_cookie: str) -> None:
|
||||
sso_cookie = (sso_cookie or "").strip()
|
||||
if not sso_cookie:
|
||||
raise ProtocolMintError("empty sso cookie")
|
||||
for domain in (".x.ai", "accounts.x.ai", "auth.x.ai", ".accounts.x.ai"):
|
||||
try:
|
||||
session.cookies.set("sso", sso_cookie, domain=domain)
|
||||
except Exception:
|
||||
try:
|
||||
session.cookies.set("sso", sso_cookie, domain=domain, path="/")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
session.cookies.set("sso-rw", sso_cookie, domain=domain)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _url_path(url: str) -> str:
|
||||
try:
|
||||
return urlparse(url or "").path or ""
|
||||
except Exception:
|
||||
return url or ""
|
||||
|
||||
|
||||
def mint_with_sso_protocol(
|
||||
*,
|
||||
sso_cookie: str,
|
||||
email: str = "",
|
||||
proxy: str | None = None,
|
||||
timeout: float = 30.0,
|
||||
poll_timeout_sec: float = 90.0,
|
||||
log: LogFn | None = None,
|
||||
cancel: Callable[[], bool] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""SSO cookie → OIDC token dict (access/refresh/id/expires_in).
|
||||
|
||||
Raises ProtocolMintError on failure.
|
||||
"""
|
||||
log = log or _noop_log
|
||||
sso_cookie = (sso_cookie or "").strip()
|
||||
if not sso_cookie:
|
||||
raise ProtocolMintError("missing sso cookie")
|
||||
|
||||
resolved = resolve_proxy(proxy)
|
||||
set_runtime_proxy(resolved or None)
|
||||
|
||||
session = _session(resolved or None, log)
|
||||
_set_sso_cookie(session, sso_cookie)
|
||||
|
||||
# 1) Validate SSO
|
||||
try:
|
||||
r = session.get(
|
||||
"https://accounts.x.ai/",
|
||||
impersonate="chrome",
|
||||
timeout=timeout,
|
||||
allow_redirects=True,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise ProtocolMintError(f"accounts.x.ai network error: {e}") from e
|
||||
|
||||
final_url = getattr(r, "url", "") or ""
|
||||
if "sign-in" in final_url or "sign-up" in final_url:
|
||||
raise ProtocolMintError(f"sso invalid (landed {final_url[:120]})")
|
||||
log(f"protocol sso valid url={final_url[:120]}")
|
||||
|
||||
# 2) Device code
|
||||
try:
|
||||
sess = request_device_code(proxy=resolved or None, timeout=timeout)
|
||||
except OAuthDeviceError as e:
|
||||
raise ProtocolMintError(f"device code: {e}") from e
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise ProtocolMintError(f"device code: {e}") from e
|
||||
log(f"protocol user_code={sess.user_code}")
|
||||
|
||||
if cancel and cancel():
|
||||
raise ProtocolMintError("cancelled")
|
||||
|
||||
# 3) Open verification URI (sets session state / CSRF)
|
||||
try:
|
||||
r = session.get(
|
||||
sess.verification_uri_complete,
|
||||
impersonate="chrome",
|
||||
timeout=timeout,
|
||||
allow_redirects=True,
|
||||
)
|
||||
log(f"protocol verify-uri status={getattr(r, 'status_code', '?')} url={getattr(r, 'url', '')[:140]}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise ProtocolMintError(f"verification_uri get failed: {e}") from e
|
||||
|
||||
if cancel and cancel():
|
||||
raise ProtocolMintError("cancelled")
|
||||
|
||||
# 4) POST device/verify
|
||||
try:
|
||||
r = session.post(
|
||||
VERIFY_URL,
|
||||
data={"user_code": sess.user_code},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
impersonate="chrome",
|
||||
timeout=timeout,
|
||||
allow_redirects=True,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise ProtocolMintError(f"device/verify exception: {e}") from e
|
||||
|
||||
verify_url = getattr(r, "url", "") or ""
|
||||
status = getattr(r, "status_code", 0)
|
||||
path = _url_path(verify_url)
|
||||
body_snip = ""
|
||||
try:
|
||||
body_snip = (r.text or "")[:200]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if "consent" not in verify_url and "consent" not in path:
|
||||
# Some flows land on consent via relative redirect body; accept soft signals
|
||||
soft_ok = (
|
||||
"consent" in (body_snip or "").lower()
|
||||
or "authorize grok build" in (body_snip or "").lower()
|
||||
or "授权 grok build" in (body_snip or "").lower()
|
||||
)
|
||||
if not soft_ok:
|
||||
raise ProtocolMintError(
|
||||
f"device/verify failed status={status} url={verify_url[:160]}"
|
||||
)
|
||||
log(f"protocol verify ok status={status} url={verify_url[:140]}")
|
||||
|
||||
if cancel and cancel():
|
||||
raise ProtocolMintError("cancelled")
|
||||
|
||||
# 5) POST device/approve
|
||||
try:
|
||||
r = session.post(
|
||||
APPROVE_URL,
|
||||
data={
|
||||
"user_code": sess.user_code,
|
||||
"action": "allow",
|
||||
"principal_type": "User",
|
||||
"principal_id": "",
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
impersonate="chrome",
|
||||
timeout=timeout,
|
||||
allow_redirects=True,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise ProtocolMintError(f"device/approve exception: {e}") from e
|
||||
|
||||
approve_url = getattr(r, "url", "") or ""
|
||||
status = getattr(r, "status_code", 0)
|
||||
if "done" not in approve_url and "device/done" not in _url_path(approve_url):
|
||||
# Accept success page markers in body
|
||||
try:
|
||||
text = r.text or ""
|
||||
except Exception:
|
||||
text = ""
|
||||
if "设备已授权" not in text and "device authorized" not in text.lower() and "done" not in text.lower():
|
||||
raise ProtocolMintError(
|
||||
f"device/approve failed status={status} url={approve_url[:160]}"
|
||||
)
|
||||
log(f"protocol approve ok status={status} url={approve_url[:140]}")
|
||||
|
||||
# 6) Poll tokens
|
||||
poll_expires = min(int(sess.expires_in), max(int(poll_timeout_sec), 30))
|
||||
try:
|
||||
tr = poll_device_token(
|
||||
sess.device_code,
|
||||
interval=max(int(sess.interval), 2),
|
||||
expires_in=poll_expires,
|
||||
timeout=timeout,
|
||||
log=log,
|
||||
cancel=cancel,
|
||||
proxy=resolved or None,
|
||||
)
|
||||
except OAuthDeviceError as e:
|
||||
raise ProtocolMintError(f"token poll: {e}") from e
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise ProtocolMintError(f"token poll: {e}") from e
|
||||
|
||||
log(
|
||||
f"protocol token ok expires_in={tr.expires_in}"
|
||||
+ (f" email={email}" if email else "")
|
||||
)
|
||||
return {
|
||||
"access_token": tr.access_token,
|
||||
"refresh_token": tr.refresh_token,
|
||||
"id_token": tr.id_token,
|
||||
"token_type": tr.token_type,
|
||||
"expires_in": tr.expires_in,
|
||||
"user_code": sess.user_code,
|
||||
"mint_method": "protocol",
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Resolve outbound proxy for CPA mint HTTP + browser.
|
||||
|
||||
Priority (highest first):
|
||||
1. explicit argument
|
||||
2. thread-local runtime pin (set_runtime_proxy)
|
||||
3. environment https_proxy / HTTPS_PROXY / http_proxy / HTTP_PROXY
|
||||
|
||||
Thread-local pin avoids cross-talk when multiple mint workers run with
|
||||
different proxies in the same process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from urllib.parse import urlparse
|
||||
|
||||
_thread = threading.local()
|
||||
|
||||
|
||||
def set_runtime_proxy(proxy: str | None) -> None:
|
||||
"""Pin proxy for the *current thread*. Empty clears pin."""
|
||||
p = (proxy or "").strip()
|
||||
_thread.proxy = p or None
|
||||
|
||||
|
||||
def get_runtime_proxy() -> str | None:
|
||||
return getattr(_thread, "proxy", None)
|
||||
|
||||
|
||||
def resolve_proxy(explicit: str | None = None) -> str:
|
||||
for cand in (
|
||||
(explicit or "").strip(),
|
||||
(get_runtime_proxy() or "").strip(),
|
||||
(os.environ.get("https_proxy") or "").strip(),
|
||||
(os.environ.get("HTTPS_PROXY") or "").strip(),
|
||||
(os.environ.get("http_proxy") or "").strip(),
|
||||
(os.environ.get("HTTP_PROXY") or "").strip(),
|
||||
):
|
||||
if cand:
|
||||
return cand
|
||||
return ""
|
||||
|
||||
|
||||
def proxy_for_chromium(proxy: str) -> str:
|
||||
"""Chromium --proxy-server cannot embed user:pass; host:port only."""
|
||||
p = (proxy or "").strip()
|
||||
if not p:
|
||||
return ""
|
||||
u = urlparse(p if "://" in p else f"http://{p}")
|
||||
host = u.hostname or ""
|
||||
if not host:
|
||||
return ""
|
||||
port = u.port or (443 if (u.scheme or "http") == "https" else 80)
|
||||
scheme = u.scheme or "http"
|
||||
return f"{scheme}://{host}:{port}"
|
||||
|
||||
|
||||
def proxy_log_label(proxy: str) -> str:
|
||||
"""Redact userinfo for logs."""
|
||||
p = (proxy or "").strip()
|
||||
if not p:
|
||||
return ""
|
||||
try:
|
||||
u = urlparse(p if "://" in p else f"http://{p}")
|
||||
host = u.hostname or "?"
|
||||
port = u.port or ""
|
||||
auth = "user:***@" if u.username else ""
|
||||
return f"{u.scheme or 'http'}://{auth}{host}{(':' + str(port)) if port else ''}"
|
||||
except Exception:
|
||||
return "(proxy)"
|
||||
@@ -0,0 +1,146 @@
|
||||
"""CPA xAI auth JSON schema aligned with CLIProxyAPI internal/auth/xai."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
# Must match CLIProxyAPI internal/auth/xai/types.go
|
||||
CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
|
||||
ISSUER = "https://auth.x.ai"
|
||||
DEFAULT_TOKEN_ENDPOINT = "https://auth.x.ai/oauth2/token"
|
||||
DEFAULT_REDIRECT_URI = "http://127.0.0.1:56121/callback"
|
||||
# Free Build promo path (NOT api.x.ai)
|
||||
DEFAULT_BASE_URL = "https://cli-chat-proxy.grok.com/v1"
|
||||
|
||||
DEFAULT_CLIENT_HEADERS: dict[str, str] = {
|
||||
"x-grok-client-version": "0.2.93",
|
||||
"x-xai-token-auth": "xai-grok-cli",
|
||||
"x-authenticateresponse": "authenticate-response",
|
||||
"x-grok-client-identifier": "grok-shell",
|
||||
"User-Agent": "grok-shell/0.2.93 (linux; x86_64)",
|
||||
}
|
||||
|
||||
|
||||
def _sanitize_file_segment(value: str) -> str:
|
||||
"""Mirror CPA CredentialFileName sanitize rules."""
|
||||
value = (value or "").strip()
|
||||
if not value:
|
||||
return ""
|
||||
out: list[str] = []
|
||||
for ch in value:
|
||||
if (
|
||||
("a" <= ch <= "z")
|
||||
or ("A" <= ch <= "Z")
|
||||
or ("0" <= ch <= "9")
|
||||
or ch in {"@", ".", "_", "-"}
|
||||
):
|
||||
out.append(ch)
|
||||
else:
|
||||
out.append("-")
|
||||
return "".join(out).strip("-")
|
||||
|
||||
|
||||
def credential_file_name(email: str = "", sub: str = "") -> str:
|
||||
"""Return CPA auth filename: xai-<email>.json."""
|
||||
email_s = _sanitize_file_segment(email)
|
||||
if email_s:
|
||||
return f"xai-{email_s}.json"
|
||||
sub_s = _sanitize_file_segment(sub)
|
||||
if sub_s:
|
||||
return f"xai-{sub_s}.json"
|
||||
ts = int(datetime.now(tz=timezone.utc).timestamp() * 1000)
|
||||
return f"xai-{ts}.json"
|
||||
|
||||
|
||||
def jwt_payload(token: str) -> dict[str, Any]:
|
||||
parts = token.split(".")
|
||||
if len(parts) < 2:
|
||||
raise ValueError("not a JWT")
|
||||
pad = "=" * (-len(parts[1]) % 4)
|
||||
return json.loads(base64.urlsafe_b64decode(parts[1] + pad))
|
||||
|
||||
|
||||
def expired_from_access_token(access_token: str) -> tuple[str, int, str]:
|
||||
"""Return (expired_rfc3339_z, expires_in, sub)."""
|
||||
pl = jwt_payload(access_token)
|
||||
exp = int(pl["exp"])
|
||||
iat = int(pl["iat"]) if pl.get("iat") is not None else exp - 21600
|
||||
expired = datetime.fromtimestamp(exp, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
sub = str(pl.get("sub") or pl.get("principal_id") or "").strip()
|
||||
return expired, max(exp - iat, 0), sub
|
||||
|
||||
|
||||
def build_cpa_xai_auth(
|
||||
*,
|
||||
email: str,
|
||||
access_token: str,
|
||||
refresh_token: str,
|
||||
sub: str | None = None,
|
||||
id_token: str | None = None,
|
||||
expires_in: int | None = None,
|
||||
expired: str | None = None,
|
||||
last_refresh: str | None = None,
|
||||
base_url: str = DEFAULT_BASE_URL,
|
||||
token_endpoint: str = DEFAULT_TOKEN_ENDPOINT,
|
||||
redirect_uri: str = DEFAULT_REDIRECT_URI,
|
||||
headers: dict[str, str] | None = None,
|
||||
disabled: bool = False,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a CPA-importable xAI OAuth auth object."""
|
||||
access_token = (access_token or "").strip()
|
||||
refresh_token = (refresh_token or "").strip()
|
||||
if not access_token:
|
||||
raise ValueError("access_token is required")
|
||||
if not refresh_token:
|
||||
raise ValueError("refresh_token is required (CPA cannot renew without it)")
|
||||
|
||||
try:
|
||||
exp_s, exp_in, sub_jwt = expired_from_access_token(access_token)
|
||||
except Exception:
|
||||
exp_s, exp_in, sub_jwt = "", 21600, ""
|
||||
|
||||
if not expired:
|
||||
expired = exp_s
|
||||
if expires_in is None:
|
||||
expires_in = exp_in or 21600
|
||||
if not sub:
|
||||
sub = sub_jwt
|
||||
if not last_refresh:
|
||||
last_refresh = datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
# Free promo must hit cli-chat-proxy; refuse silent api.x.ai default for free path.
|
||||
base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
||||
if not re.search(r"/v1$", base_url):
|
||||
# CPA joins baseURL + "/responses"
|
||||
if base_url.endswith("cli-chat-proxy.grok.com"):
|
||||
base_url = base_url + "/v1"
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"type": "xai",
|
||||
"auth_kind": "oauth",
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": int(expires_in),
|
||||
"expired": expired,
|
||||
"last_refresh": last_refresh,
|
||||
"email": (email or "").strip(),
|
||||
"sub": (sub or "").strip(),
|
||||
"base_url": base_url,
|
||||
"token_endpoint": token_endpoint,
|
||||
"redirect_uri": redirect_uri,
|
||||
"disabled": bool(disabled),
|
||||
"headers": dict(headers or DEFAULT_CLIENT_HEADERS),
|
||||
}
|
||||
if id_token:
|
||||
payload["id_token"] = id_token.strip()
|
||||
if extra:
|
||||
for k, v in extra.items():
|
||||
if k not in payload:
|
||||
payload[k] = v
|
||||
return payload
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Atomic write of CPA xAI auth files (mode 0600)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .schema import credential_file_name
|
||||
|
||||
|
||||
def write_cpa_xai_auth(
|
||||
auth_dir: str | Path,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
filename: str | None = None,
|
||||
) -> Path:
|
||||
"""Write payload to auth_dir/xai-<email>.json atomically. Returns final path."""
|
||||
auth_dir = Path(auth_dir).expanduser().resolve()
|
||||
auth_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not filename:
|
||||
filename = credential_file_name(
|
||||
str(payload.get("email") or ""),
|
||||
str(payload.get("sub") or ""),
|
||||
)
|
||||
if not filename.endswith(".json"):
|
||||
filename = filename + ".json"
|
||||
|
||||
dest = auth_dir / filename
|
||||
data = json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
|
||||
|
||||
fd, tmp_name = tempfile.mkstemp(prefix=".xai-", suffix=".tmp", dir=str(auth_dir))
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(data)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.chmod(tmp_name, 0o600)
|
||||
os.replace(tmp_name, dest)
|
||||
os.chmod(dest, 0o600)
|
||||
finally:
|
||||
if os.path.exists(tmp_name):
|
||||
try:
|
||||
os.unlink(tmp_name)
|
||||
except OSError:
|
||||
pass
|
||||
return dest
|
||||
Reference in new issue
Block a user