| """anuma.ai AuthProvider:Privy JWT(authorization: Bearer)+ 平台头。 |
| |
| 凭据(account/<name>.json): |
| - ``privy_access_token``:Privy **identity token**(上游 portal.anuma.ai 的 |
| authorization: Bearer;~1h 过期,exp - iat = 3600s) |
| - ``privy_pat``:Privy **persisted access token**(``privy:pat``,仅刷新端点用; |
| 刷新时 Bearer 必须用 pat,identity token 会被拒) |
| - ``privy_app_id``:Privy 应用 ID(JWT aud,如 cmjrfihuc03h8l10ca0bi9o2y) |
| - ``privy_refresh_token``:Privy refresh token(一次性,刷新后轮换,自动写回) |
| - ``privy_cf_cookie``:浏览器 auth.privy.io 的 Cloudflare clearance cookie 串 |
| (httpOnly,从 DevTools Network 请求头复制;绑定本机 IP 与 Chrome UA, |
| 过期需重新抓取——见 README「自动刷新」节) |
| - ``conversation_id``:会话 UUID(x-conversation-id 头;首次生图自动生成并持久化, |
| 后续复用同一会话——对齐网页「同一会话连续生图」行为) |
| |
| 过期处理:JWT 到期前 ``token_refresh_margin`` 秒主动刷新 |
| (``POST auth.privy.io/api/v1/sessions``,实测于 2026-08-01: |
| 需带 origin/referer/user-agent/cf cookie + **Bearer pat**(identity token 会报 |
| missing_or_invalid_token);刷新返回新 pat + 新 id_token + 新 rt, |
| **新 rt 必须写回账号文件**,Privy 的 refresh token 一次性轮换)。 |
| 刷新失败时抛明确错误(不判死账号),提示重新登录更新凭据。 |
| """ |
| from __future__ import annotations |
|
|
| import base64 |
| import json |
| import time |
| from pathlib import Path |
| from typing import Any |
|
|
| import httpx |
|
|
| from app.account import FailReason |
| from app.upstream.base import AuthProvider |
|
|
| APP_VERSION = "41cf240" |
|
|
| |
| |
| _CHROME_UA = ( |
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " |
| "(KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36" |
| ) |
| _PRIVY_REFRESH_URL = "https://auth.privy.io/api/v1/sessions" |
|
|
|
|
| def _decode_jwt_payload(token: str) -> dict[str, Any]: |
| """解析 JWT payload(不验签,仅取过期时间等元数据)。""" |
| try: |
| payload = token.split(".")[1] |
| payload += "=" * (-len(payload) % 4) |
| return json.loads(base64.urlsafe_b64decode(payload)) |
| except Exception: |
| return {} |
|
|
|
|
| class DefaultAuthProvider(AuthProvider): |
| def __init__(self, account, settings, http_client, account_file: Path | None = None) -> None: |
| self._account = account |
| self._settings = settings |
| self._http: httpx.AsyncClient = http_client |
| self._account_file = account_file |
|
|
| async def get_auth(self) -> dict[str, str]: |
| |
| |
| |
| token = str( |
| getattr(self._account, "privy_identity_token", "") |
| or self._account.privy_access_token |
| or "" |
| ) |
| if not token: |
| raise RuntimeError("account missing privy_access_token (identity token)") |
| exp = _decode_jwt_payload(token).get("exp") or 0 |
| now = time.time() |
| margin = float(getattr(self._settings, "token_refresh_margin", 300)) |
| if exp and exp - now <= margin: |
| refreshed = await self._try_refresh() |
| if refreshed: |
| token = refreshed |
| elif exp <= now: |
| |
| |
| |
| |
| raise RuntimeError( |
| f"privy access token expired (exp={int(exp)}); re-login and update " |
| "privy_access_token in account/<name>.json" |
| ) |
| |
| |
| conv_id = str(getattr(self._account, "conversation_id", "") or "") |
| headers: dict[str, str] = { |
| "authorization": f"Bearer {token}", |
| |
| |
| "user-agent": _CHROME_UA, |
| |
| |
| "origin": "https://chat.anuma.ai", |
| "referer": "https://chat.anuma.ai/", |
| "x-anuma-feature": "chat", |
| "x-anuma-platform": "web", |
| "x-anuma-app-version": APP_VERSION, |
| "x-privacy-mode": "standard", |
| "x-anuma-preprocessors-enabled": "true", |
| |
| |
| **({"x-conversation-id": conv_id} if conv_id else {}), |
| } |
| |
| |
| |
| cf_cookie = str(getattr(self._account, "privy_cf_cookie", "") or "") |
| if cf_cookie: |
| headers["cookie"] = cf_cookie |
| return headers |
|
|
| async def _try_refresh(self) -> str | None: |
| """用 Privy refresh token 换新 access token(实测端点 auth.privy.io/api/v1/sessions)。 |
| |
| 请求需复刻浏览器特征:origin/referer 指向 anuma、Chrome UA、cf_clearance cookie |
| (绑定 UA+IP)、Bearer 旧 pat(过期也带上,服务器只校验会话归属)。 |
| 响应返回新 pat + **新 rt**(一次性轮换)→ 两者都写回账号文件。 |
| """ |
| refresh = str(getattr(self._account, "privy_refresh_token", "") or "") |
| app_id = str(getattr(self._account, "privy_app_id", "") or "") |
| |
| |
| pat = str(getattr(self._account, "privy_pat", "") or "") |
| if not refresh or not app_id or not pat: |
| return None |
| headers: dict[str, str] = { |
| "content-type": "application/json", |
| "privy-app-id": app_id, |
| "authorization": f"Bearer {pat}", |
| "origin": "https://chat.anuma.ai", |
| "referer": "https://chat.anuma.ai/", |
| "user-agent": _CHROME_UA, |
| "privy-client": "react-auth:3.14.1", |
| } |
| cf_cookie = str(getattr(self._account, "privy_cf_cookie", "") or "") |
| if cf_cookie: |
| headers["cookie"] = cf_cookie |
| try: |
| resp = await self._http.post( |
| _PRIVY_REFRESH_URL, json={"refresh_token": refresh}, headers=headers, |
| ) |
| if resp.status_code == 200: |
| data = resp.json() |
| |
| |
| new_pat = data.get("privy_access_token") or "" |
| new_id = data.get("identity_token") or "" |
| if new_pat: |
| self._account.privy_pat = new_pat |
| if new_id: |
| self._account.privy_identity_token = new_id |
| new_refresh = data.get("refresh_token") or "" |
| if new_refresh: |
| |
| self._account.privy_refresh_token = new_refresh |
| if new_pat or new_id: |
| self._persist() |
| return new_id or new_pat |
| except Exception: |
| pass |
| return None |
|
|
| def _persist(self) -> None: |
| """把内存 account 状态(含轮换后的 token)原子写回账号文件。失败不阻断请求。""" |
| if self._account_file is None: |
| return |
| try: |
| self._account_file.parent.mkdir(parents=True, exist_ok=True) |
| tmp = self._account_file.with_suffix(".json.tmp") |
| tmp.write_text( |
| json.dumps(self._account.model_dump(mode="json"), ensure_ascii=False, indent=2), |
| encoding="utf-8", |
| ) |
| tmp.replace(self._account_file) |
| except Exception: |
| pass |
|
|
| def is_auth_failure(self, exc: BaseException) -> bool: |
| if isinstance(exc, httpx.HTTPStatusError): |
| status = exc.response.status_code |
| if status in (401, 403): |
| body = exc.response.text.lower() |
| |
| |
| if "model_tier_required" in body: |
| return False |
| |
| if status == 401 or "authorization_error" in body or "invalid token" in body: |
| return True |
| return False |
|
|
| async def check_balance(self) -> int | None: |
| """查账号可用额度;返回 None = 查不到(无 token/401/网络错/未配置 http)。 |
| |
| 2026-08-08:GET /api/v1/credits/balance 实测返回 |
| {available_credits, lifetime_credits, subscription_tier, ...}; |
| token 失效 → 401 invalid or expired token。仅返回**起效**额度,供 |
| 失败账号判定是否<=0 删除。超时 15s,失败不抛(None 即可删)。 |
| |
| token 复用 ``get_auth()``(含自动刷新)——静态存储的 identity token 1h 即过期, |
| 直接读字段查余额几乎必失败;走 get_auth 才能拿到刷新后的有效 token。 |
| """ |
| try: |
| if self._http is None: |
| return -1 |
| |
| token = "" |
| try: |
| headers = await self.get_auth() |
| auth_val = headers.get("authorization", "") |
| if auth_val.lower().startswith("bearer "): |
| token = auth_val[7:] |
| except Exception: |
| token = "" |
| if not token: |
| token = str( |
| getattr(self._account, "privy_identity_token", "") |
| or self._account.privy_access_token or "" |
| ) |
| if not token: |
| return -1 |
| r = await self._http.get( |
| "https://portal.anuma.ai/api/v1/credits/balance", |
| headers={ |
| "authorization": f"Bearer {token}", |
| "user-agent": _CHROME_UA, |
| "origin": "https://chat.anuma.ai", |
| "referer": "https://chat.anuma.ai/", |
| "x-anuma-feature": "chat", |
| "x-anuma-platform": "web", |
| "x-anuma-app-version": APP_VERSION, |
| "x-privacy-mode": "standard", |
| "x-anuma-preprocessors-enabled": "true", |
| }, |
| timeout=15.0, |
| ) |
| if r.status_code != 200: |
| return None |
| return int((r.json().get("available_credits") or 0)) |
| except Exception: |
| return None |
|
|
| def classify_failure(self, exc: BaseException) -> FailReason | None: |
| if self.is_auth_failure(exc): |
| return FailReason.AUTH_FAILED |
| if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code == 429: |
| return FailReason.QUOTA_EXHAUSTED |
| return None |
|
|