| """共享 FastAPI 依赖:注入 UpstreamProvider + API key 校验 + 错误分类换号。 |
| |
| 每次请求 round-robin 取一个账号的 provider,用 :class:`_RetryingClient` 包一层: |
| 按 :class:`~app.account.FailReason` 分类失效 → ``mark_failed`` → 抛 503,下一次请求自动换号。 |
| v1 的 ``gateway_api_key`` 留空则不校验(无认证);详见 references/auth-and-errors.md。 |
| """ |
| from __future__ import annotations |
|
|
| import sys |
| import time |
| from collections.abc import AsyncIterator |
| from typing import Any |
|
|
| import httpx |
| from fastapi import Depends, HTTPException, Request |
| from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer |
|
|
| from app.account import Account, AccountPool, FailReason |
| from app.events import IREvent |
|
|
| _bearer = HTTPBearer(auto_error=False) |
|
|
| |
| _AUTH_HINTS = ("unauthorized", "invalid token", "not authenticated", "login required") |
| _BAN_HINTS = ("banned", "suspended", "disabled", "forbidden", "封禁", "封号") |
| _QUOTA_HINTS = ("quota", "limit reached", "insufficient", "credit", "额度", "配额", "余额不足") |
| _CF_HINTS = ("cloudflare", "captcha", "turnstile", "challenge", "验证码") |
|
|
|
|
| def classify_failure(exc: BaseException) -> FailReason | None: |
| """把上游异常映射成 FailReason(默认按 HTTP 状态码 + body 关键词;按目标站定制)。 |
| |
| 返回 None 表示非账号级失效(不换号,原样抛出)。 |
| """ |
| status: int | None = None |
| body = "" |
| if isinstance(exc, httpx.HTTPStatusError): |
| status = exc.response.status_code |
| try: |
| body = exc.response.text.lower() |
| except Exception: |
| body = "" |
| text = f"{body} {str(exc).lower()}" |
| |
| |
| if "model_tier_required" in text: |
| return None |
| if status in (401, 403) or any(h in text for h in _AUTH_HINTS): |
| return FailReason.AUTH_FAILED |
| |
| |
| |
| if status == 402 or "insufficient_balance" in text: |
| return FailReason.INSUFFICIENT_BALANCE |
| if status == 429 or any(h in text for h in _QUOTA_HINTS): |
| return FailReason.QUOTA_EXHAUSTED |
| if status == 451 or any(h in text for h in _CF_HINTS): |
| return FailReason.CF_CHALLENGE |
| if any(h in text for h in _BAN_HINTS): |
| return FailReason.BANNED |
| return None |
|
|
|
|
| class _RetryingClient: |
| """duck-type UpstreamProvider:包装 stream,失效时自动换下一个账号重试。 |
| |
| 失败分类(AUTH_FAILED / QUOTA_EXHAUSTED / CF_CHALLENGE / BANNED)→ ``mark_failed`` |
| 标记当前账号 → 内部取下一个可用账号继续重试,最多 ``max_switches`` 次。 |
| **请求内自动换号**:opencode 等客户端收到 503 不重试,单请求内坏号应自动跳过, |
| 而非把 503 抛给客户端。全部换完仍失败才抛 503。 |
| 流式已 yield 部分内容后重试会重复输出,故仅在**尚未产出任何事件**时换号重试。 |
| """ |
|
|
| def __init__(self, pool: AccountPool, account: Account, underlying: Any, |
| providers: dict[str, Any] | None = None) -> None: |
| self._pool = pool |
| self._account = account |
| self._underlying = underlying |
| |
| self._providers = providers if providers is not None else {} |
| |
| self.max_switches = 5 |
|
|
| def _classify(self, exc: BaseException) -> FailReason | None: |
| """优先上游 AuthProvider 分类;未分类再回退通用逻辑。""" |
| |
| |
| |
| if type(exc).__name__ == "FenceError": |
| return FailReason.QUOTA_EXHAUSTED |
| auth = getattr(self._underlying, "_auth", None) |
| reason = auth.classify_failure(exc) if auth is not None else None |
| if reason is None: |
| reason = classify_failure(exc) |
| return reason |
|
|
| def _switch_account(self) -> Any: |
| """取下一个可用账号的 provider;内部抛 RuntimeError(转 503)。""" |
| acc = self._pool.next() |
| provider = self._providers.get(acc.name) |
| if provider is None: |
| raise RuntimeError(f"account provider not found: {acc.name}") |
| self._account = acc |
| self._underlying = provider |
| return acc |
|
|
| async def _maybe_delete_dead(self, acc: Account) -> bool: |
| """账号失败后查一次额度:<=0 或查不到(token 死)→ 从池删除账号文件。 |
| |
| 用户 2026-08-08 要求:失败号不该留在池里继续被撞。查询走 auth.check_balance, |
| 只读 GET(1s 内),失败返回 None(token 失效/网络错)→ 同样删除(死号)。 |
| 返回 True=已删除(调用方应跳过 mark_failed,避免 _save 把账号写回);False=保留。 |
| """ |
| try: |
| auth = getattr(self._underlying, "_auth", None) |
| if auth is None or not hasattr(auth, "check_balance"): |
| return False |
| balance = await auth.check_balance() |
| if balance is None or balance < 0: |
| |
| return False |
| if balance > 0: |
| return False |
| self._pool.remove(acc.name) |
| print( |
| f"[deps] account {acc.name} deleted (balance={balance}, " |
| f"reason={acc.fail_reason})", |
| file=sys.stderr, |
| ) |
| return True |
| except Exception: |
| return False |
|
|
| def switch_account_and_mark_failed(self) -> Any: |
| """供 images adapter 在 fence 后手动换号:标记当前账号冷却并换下一个。 |
| |
| FenceError 在 adapter 层(_collect)抛出,发生在本类 stream() 的 try 块之外, |
| stream() 捕获不到。images 路由捕获 FenceError 后调用本方法换号重试。""" |
| self._pool.mark_failed(self._account, FailReason.QUOTA_EXHAUSTED) |
| print( |
| f"[deps] account {self._account.name} fenced (image); " |
| f"switching to next account", |
| file=sys.stderr, |
| ) |
| return self._switch_account() |
|
|
| async def stream(self, *args: Any, **kwargs: Any) -> AsyncIterator[IREvent]: |
| last_reason: FailReason | None = None |
| last_exc: BaseException | None = None |
| for _ in range(self.max_switches): |
| emitted = False |
| try: |
| async for ir in self._underlying.stream(*args, **kwargs): |
| emitted = True |
| yield ir |
| return |
| except Exception as e: |
| reason = self._classify(e) |
| if reason is None: |
| |
| raise |
| last_reason = reason |
| last_exc = e |
| |
| |
| |
| |
| if not await self._maybe_delete_dead(self._account): |
| self._pool.mark_failed(self._account, reason) |
| if emitted: |
| |
| raise HTTPException( |
| status_code=503, |
| detail=f"account failed ({reason.value}) mid-stream; retry request to switch account", |
| ) from e |
| print( |
| f"[deps] account {self._account.name} failed ({reason.value}); " |
| f"switching to next account (attempt {_ + 1}/{self.max_switches})", |
| file=sys.stderr, |
| ) |
| try: |
| self._switch_account() |
| except RuntimeError as switch_exc: |
| raise HTTPException(status_code=503, detail=str(switch_exc)) from switch_exc |
| |
| raise HTTPException( |
| status_code=503, |
| detail=f"all accounts failed ({last_reason}); retry request to switch account", |
| ) from last_exc |
|
|
|
|
| def get_client(request: Request) -> _RetryingClient: |
| """round-robin 取一个账号,返回其 _RetryingClient 包装。""" |
| st = request.app.state |
| pool: AccountPool = st.pool |
| providers: dict[str, Any] = st.providers |
| try: |
| acc = pool.next() |
| except RuntimeError as e: |
| |
| raise HTTPException(status_code=503, detail=str(e)) from e |
| return _RetryingClient(pool, acc, providers[acc.name], providers=providers) |
|
|
|
|
| def get_image_client(request: Request) -> _RetryingClient: |
| """生图专用取号:只在"未失败过或冷却已到期"的可用账号里 round-robin。 |
| |
| 2026-08-08 实测:生图撞到 402(0分,INSUFFICIENT_BALANCE 已 disabled 踢出)或 |
| fence(账号级冷却)的号,要等上游把整轮输出完才返回(~2 分钟),再换号重试, |
| 一趟白等 6-8 分钟——cherry 里就是"一直加载"。干净号(d7yoa12=100 等)13 秒 |
| 出图。冷却中的 fence 号到期会自动回到候选(QUOTA_EXHAUSTED 可恢复)。 |
| |
| 2026-08-08 改图修复:改图/生图共用本取号逻辑。改图的 input_images 用**全局最近 |
| 生成的签名 URL**(openai_images.py 的 _RECENT_URLS_FILE,跨账号共享——实测 URL |
| 不绑账号会话,任意有额度账号都能拿它改图),故此处统一 round-robin 即可, |
| 不需要为改图特判账号。 |
| """ |
| st = request.app.state |
| pool: AccountPool = st.pool |
| providers: dict[str, Any] = st.providers |
| candidates = [ |
| a for a in pool.all() |
| if not a.disabled |
| and not (a.cooldown_until and a.cooldown_until > time.time()) |
| ] |
| if not candidates: |
| try: |
| acc = pool.next() |
| except RuntimeError as e: |
| raise HTTPException(status_code=503, detail=str(e)) from e |
| return _RetryingClient(pool, acc, providers[acc.name], providers=providers) |
| acc = candidates[getattr(pool, "_idx", 0) % len(candidates)] |
| return _RetryingClient(pool, acc, providers[acc.name], providers=providers) |
|
|
|
|
| def verify_api_key( |
| request: Request, |
| cred: HTTPAuthorizationCredentials | None = Depends(_bearer), |
| ) -> None: |
| """v1 gateway key 校验;未配置任何 key 则放行(无认证)。每个 /v1 router 都应 Depends。 |
| |
| 校验集合 = 面板生成的多 key 库(app.state.api_keys)+ config 兼容单 key |
| (gateway_api_key)。任一匹配即放行 —— 老客户端 key 无需迁移。 |
| """ |
| settings = request.app.state.settings |
| legacy = settings.gateway_api_key |
| keys = getattr(request.app.state, "api_keys", None) |
| if not legacy and not keys: |
| return |
| if cred is None or not _is_api_key_valid(cred.credentials, legacy, keys): |
| raise HTTPException(status_code=401, detail="invalid api key") |
|
|
|
|
| def _is_api_key_valid(provided: str | None, legacy: str, keys: Any) -> bool: |
| from app.api_keys import is_valid_key |
|
|
| key_list = keys if isinstance(keys, list) else [] |
| return is_valid_key(provided, key_list, legacy=legacy) |
|
|