refactor: 移除 Python 后端源码,全面迁移至 Rust gateway 架构

- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/
- 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层
- 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构
- 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations)
- 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image
- 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
This commit is contained in:
fawney19
2026-04-03 16:26:16 +08:00
parent 8f26e1a31f
commit 1d9c77522a
868 changed files with 1735 additions and 2433 deletions

View File

@@ -0,0 +1,39 @@
"""
Provider 操作模块
提供对提供商的扩展操作支持:
- 多种鉴权方式API Key、登录、Cookie
- 可扩展的操作类型(余额查询、签到等)
"""
from src.services.provider_ops.registry import ArchitectureRegistry, get_registry
from src.services.provider_ops.service import ProviderOpsService
from src.services.provider_ops.types import (
ActionResult,
ActionStatus,
BalanceInfo,
CheckinInfo,
ConnectorAuthType,
ConnectorState,
ConnectorStatus,
ProviderActionType,
ProviderOpsConfig,
)
__all__ = [
# 服务
"ProviderOpsService",
# 注册表
"ArchitectureRegistry",
"get_registry",
# 类型
"ActionResult",
"ActionStatus",
"BalanceInfo",
"CheckinInfo",
"ConnectorAuthType",
"ConnectorState",
"ConnectorStatus",
"ProviderActionType",
"ProviderOpsConfig",
]

View File

@@ -0,0 +1,25 @@
"""
Provider 操作模块
"""
from src.services.provider_ops.actions.anyrouter_balance import AnyrouterBalanceAction
from src.services.provider_ops.actions.balance import BalanceAction
from src.services.provider_ops.actions.base import ProviderAction
from src.services.provider_ops.actions.checkin import CheckinAction
from src.services.provider_ops.actions.cubence_balance import CubenceBalanceAction
from src.services.provider_ops.actions.nekocode_balance import NekoCodeBalanceAction
from src.services.provider_ops.actions.new_api_balance import NewApiBalanceAction
from src.services.provider_ops.actions.sub2api_balance import Sub2ApiBalanceAction
from src.services.provider_ops.actions.yescode_balance import YesCodeBalanceAction
__all__ = [
"ProviderAction",
"BalanceAction",
"CheckinAction",
"NewApiBalanceAction",
"AnyrouterBalanceAction",
"CubenceBalanceAction",
"NekoCodeBalanceAction",
"Sub2ApiBalanceAction",
"YesCodeBalanceAction",
]

View File

@@ -0,0 +1,84 @@
"""
Anyrouter 余额查询操作(含自动签到)
"""
from typing import Any
import httpx
from src.core.logger import logger
from src.services.provider_ops.actions.balance import BalanceAction
from src.services.provider_ops.types import BalanceInfo
class AnyrouterBalanceAction(BalanceAction):
"""
Anyrouter 余额查询
特点:
- 查询余额前始终自动签到
- 签到端点为 /api/user/sign_in
- Cookie 失效时返回友好的错误提示
- quota 单位是 1/500000 美元(与 New API 相同)
"""
display_name = "查询余额(含自动签到)"
description = "查询账户余额,同时自动签到"
_cookie_auth = True
def _parse_balance(self, data: Any) -> BalanceInfo:
"""解析余额信息"""
user_data = data.get("data", {}) if isinstance(data, dict) else {}
quota_divisor = self.config.get("quota_divisor", 500000)
# 注意Anyrouter 中 quota 是剩余额度total_available不是总额度
raw_quota = self._to_float(user_data.get("quota"))
raw_used = self._to_float(user_data.get("used_quota"))
total_available = raw_quota / quota_divisor if raw_quota is not None else None
total_used = raw_used / quota_divisor if raw_used is not None else None
return self._create_balance_info(
total_available=total_available,
total_used=total_used,
currency=self.config.get("currency", "USD"),
)
async def _do_checkin(self, client: httpx.AsyncClient) -> dict[str, Any] | None:
"""
执行自动签到(始终执行)
Returns:
签到结果字典,包含 success 和 message 字段
"""
checkin_endpoint = self.config.get("checkin_endpoint", "/api/user/sign_in")
site = client.base_url.host or str(client.base_url)
try:
response = await client.post(checkin_endpoint)
try:
data = response.json()
success = data.get("success", False)
message = data.get("message", "")
if success:
logger.debug(f"[{site}] 签到成功: {message}")
return {"success": True, "message": message or "签到成功"}
else:
already_indicators = ["已签到", "已签", "今日已", "already"]
is_already = any(ind in message.lower() for ind in already_indicators)
if is_already:
logger.debug(f"[{site}] 今日已签到: {message}")
return {"success": None, "message": message or "今日已签到"}
else:
logger.debug(f"[{site}] 签到失败: {message}")
return {"success": False, "message": message or "签到失败"}
except Exception as e:
logger.debug(f"[{site}] 签到响应解析失败: {e}")
return {"success": False, "message": "响应解析失败"}
except Exception as e:
logger.debug(f"[{site}] 签到异常: {e}")
return {"success": False, "message": str(e)}

View File

@@ -0,0 +1,307 @@
"""
余额查询操作抽象基类
"""
import time
from abc import abstractmethod
from typing import Any
import httpx
from src.core.cache_service import CacheService
from src.core.logger import logger as _logger
from src.services.provider_ops.actions.base import ProviderAction
from src.services.provider_ops.types import (
ActionResult,
ActionStatus,
BalanceInfo,
ProviderActionType,
)
# 签到缓存 TTL6 小时)
_CHECKIN_CACHE_TTL = 6 * 3600
class BalanceAction(ProviderAction):
"""
余额查询操作抽象基类
子类必须实现 _parse_balance() 方法来处理特定平台的余额解析逻辑。
子类可选重写 _do_query_balance() 或 _do_checkin() 进行自定义。
如果子类使用 Cookie 认证,设置 _cookie_auth = True 可让 401/403 错误
显示 "Cookie 已失效" 而非 "认证失败"
"""
action_type = ProviderActionType.QUERY_BALANCE
display_name = "查询余额"
description = "查询账户余额信息"
default_cache_ttl = 86400 # 24 小时
# 子类设为 True 即可在 401/403 时显示 "Cookie 已失效" 消息
_cookie_auth: bool = False
async def execute(self, client: httpx.AsyncClient) -> ActionResult:
"""
执行余额查询(模板方法)
1. 先尝试签到(如果子类实现了 _do_checkin带缓存冷却
2. 执行余额查询
Args:
client: 已认证的 HTTP 客户端
Returns:
ActionResult其中 data 字段为 BalanceInfo
"""
# 先尝试签到(带缓存冷却)
checkin_result = await self._get_or_do_checkin(client)
# 执行余额查询
result = await self._do_query_balance(client)
# 将签到结果附加到 extra 字段
if checkin_result and result.data and hasattr(result.data, "extra"):
if result.data.extra is None:
result.data.extra = {}
# 处理 cookie_expired 标记
if checkin_result.get("cookie_expired"):
result.data.extra["cookie_expired"] = True
result.data.extra["cookie_expired_message"] = checkin_result.get("message", "")
result.status = ActionStatus.AUTH_EXPIRED
_logger.warning("Cookie 已失效: {}", checkin_result)
else:
result.data.extra["checkin_success"] = checkin_result.get("success")
result.data.extra["checkin_message"] = checkin_result.get("message", "")
_logger.debug("签到结果已附加到 extra: {}", checkin_result)
return result
async def _do_query_balance(self, client: httpx.AsyncClient) -> ActionResult:
"""
执行余额查询
默认实现处理通用的请求/响应/错误处理流程。
子类只需实现 _parse_balance() 即可。
如果查询逻辑不同(如并发调用多个接口),子类可重写此方法。
Args:
client: 已认证的 HTTP 客户端
Returns:
ActionResult其中 data 字段为 BalanceInfo
"""
endpoint = self.config.get("endpoint", "/api/user/self")
method = self.config.get("method", "GET")
start_time = time.time()
try:
response = await client.request(method, endpoint)
response_time_ms = int((time.time() - start_time) * 1000)
try:
data = response.json()
except Exception:
return self._make_error_result(
ActionStatus.PARSE_ERROR,
"响应不是有效的 JSON",
)
if response.status_code != 200:
return self._handle_http_error(response, data)
if data.get("success") is False:
message = data.get("message", "业务状态码表示失败")
return self._make_error_result(
ActionStatus.UNKNOWN_ERROR,
message,
raw_response=data,
)
balance = self._parse_balance(data)
return self._make_success_result(
data=balance,
response_time_ms=response_time_ms,
raw_response=data,
)
except httpx.TimeoutException:
return self._make_error_result(
ActionStatus.NETWORK_ERROR,
"请求超时",
retry_after_seconds=30,
)
except httpx.RequestError as e:
return self._make_error_result(
ActionStatus.NETWORK_ERROR,
f"网络错误: {str(e)}",
retry_after_seconds=30,
)
except Exception as e:
return self._make_error_result(
ActionStatus.UNKNOWN_ERROR,
f"未知错误: {str(e)}",
)
@abstractmethod
def _parse_balance(self, data: Any) -> BalanceInfo:
"""
解析余额数据(子类必须实现)
Args:
data: API 响应 JSON 数据
Returns:
BalanceInfo 对象
"""
pass
def _handle_http_error(
self, response: httpx.Response, raw_data: dict[str, Any] | None = None
) -> ActionResult:
"""
处理 HTTP 错误响应
Cookie 认证的子类设置 _cookie_auth = True 即可获得友好的错误提示,
无需再逐个重写此方法。
"""
status_code = response.status_code
if status_code == 401:
msg = "Cookie 已失效,请重新配置" if self._cookie_auth else "认证失败"
return self._make_error_result(ActionStatus.AUTH_FAILED, msg, raw_response=raw_data)
elif status_code == 403:
msg = "Cookie 已失效或无权限" if self._cookie_auth else "无权限访问"
return self._make_error_result(ActionStatus.AUTH_FAILED, msg, raw_response=raw_data)
elif status_code == 404:
return self._make_error_result(
ActionStatus.NOT_SUPPORTED, "功能未开放", raw_response=raw_data
)
elif status_code == 429:
retry_after = response.headers.get("Retry-After")
return self._make_error_result(
ActionStatus.RATE_LIMITED,
"请求频率限制",
retry_after_seconds=int(retry_after) if retry_after else 60,
raw_response=raw_data,
)
else:
return self._make_error_result(
ActionStatus.UNKNOWN_ERROR,
f"HTTP {status_code}: {response.reason_phrase}",
raw_response=raw_data,
)
async def _get_or_do_checkin(self, client: httpx.AsyncClient) -> dict[str, Any] | None:
"""
带缓存冷却的签到:优先返回缓存结果,未命中才实际执行签到。
缓存 key 使用 host同一站点多个 provider 只需签到一次TTL 6 小时。
签到失败或 cookie_expired 不写入缓存,允许下次重试。
"""
host = str(client.base_url.host or client.base_url.netloc or client.base_url)
cache_key = f"provider_ops:checkin:{host}"
# 检查缓存
try:
cached = await CacheService.get(cache_key)
if cached is not None:
_logger.debug("[{}] 签到缓存命中,跳过签到: {}", host, cached)
return cached
except Exception:
pass
# 缓存未命中,执行签到
result = await self._do_checkin(client)
# 签到成功或"已签到"时写入缓存;失败/cookie_expired 不缓存
if result is not None and not result.get("cookie_expired"):
success = result.get("success")
if success is True or success is None:
try:
await CacheService.set(cache_key, result, _CHECKIN_CACHE_TTL)
except Exception:
pass
return result
async def _do_checkin(self, client: httpx.AsyncClient) -> dict[str, Any] | None:
"""
执行签到(子类可选实现)
默认实现返回 None不签到
子类可重写此方法实现平台特定的签到逻辑。
Args:
client: 已认证的 HTTP 客户端
Returns:
签到结果字典 {"success": bool, "message": str},或 None 表示不签到
"""
return None
def _create_balance_info(
self,
total_granted: float | None = None,
total_used: float | None = None,
total_available: float | None = None,
currency: str = "USD",
extra: dict[str, Any] | None = None,
) -> BalanceInfo:
"""
创建余额信息对象
辅助方法,用于创建统一格式的 BalanceInfo。
如果只有部分数据,会尝试计算缺失的值。
Args:
total_granted: 总额度
total_used: 已用额度
total_available: 可用余额
currency: 货币单位
extra: 额外信息
Returns:
BalanceInfo 对象
"""
# 如果只有部分数据,尝试计算
if total_available is None and total_granted is not None and total_used is not None:
total_available = total_granted - total_used
if total_used is None and total_granted is not None and total_available is not None:
total_used = total_granted - total_available
if total_granted is None and total_used is not None and total_available is not None:
total_granted = total_used + total_available
return BalanceInfo(
total_granted=total_granted,
total_used=total_used,
total_available=total_available,
currency=currency,
extra=extra if extra is not None else {},
)
def _to_float(self, value: Any) -> float | None:
"""转换为浮点数"""
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
"""获取操作配置 schema子类可重写"""
return {
"type": "object",
"properties": {
"currency": {
"type": "string",
"title": "货币单位",
"default": "USD",
},
},
"required": [],
}

View File

@@ -0,0 +1,161 @@
"""
Provider 操作抽象基类
"""
from abc import ABC, abstractmethod
from typing import Any
import httpx
from src.services.provider_ops.types import (
ActionResult,
ActionStatus,
ProviderActionType,
)
class ProviderAction(ABC):
"""
提供商操作基类
定义具体的操作逻辑(如查询余额、签到等)。
"""
# 子类需要定义的类属性
action_type: ProviderActionType = ProviderActionType.CUSTOM
display_name: str = "Base Action"
description: str = ""
# 默认缓存时间(秒)
default_cache_ttl: int = 300
def __init__(self, config: dict[str, Any] | None = None):
"""
初始化操作
Args:
config: 操作配置
"""
self.config = config or {}
@abstractmethod
async def execute(self, client: httpx.AsyncClient) -> ActionResult:
"""
执行操作
Args:
client: 已认证的 HTTP 客户端
Returns:
操作结果
"""
pass
def _extract_field(self, data: Any, path: str | None) -> Any:
"""
从响应数据中提取字段
支持点号分隔的路径,如 "data.user.balance"
Args:
data: 响应数据
path: 字段路径
Returns:
提取的值,如果路径无效则返回 None
"""
if not path:
return None
current = data
for key in path.split("."):
if isinstance(current, dict):
current = current.get(key)
elif isinstance(current, list) and key.isdigit():
index = int(key)
current = current[index] if 0 <= index < len(current) else None
else:
return None
if current is None:
return None
return current
def _make_success_result(
self,
data: Any = None,
message: str | None = None,
response_time_ms: int | None = None,
raw_response: dict[str, Any] | None = None,
) -> ActionResult:
"""创建成功结果"""
return ActionResult(
status=ActionStatus.SUCCESS,
action_type=self.action_type,
data=data,
message=message,
response_time_ms=response_time_ms,
raw_response=raw_response,
cache_ttl_seconds=self.default_cache_ttl,
)
def _make_error_result(
self,
status: ActionStatus,
message: str | None = None,
retry_after_seconds: int | None = None,
raw_response: dict[str, Any] | None = None,
) -> ActionResult:
"""创建错误结果"""
return ActionResult(
status=status,
action_type=self.action_type,
message=message,
retry_after_seconds=retry_after_seconds,
raw_response=raw_response,
cache_ttl_seconds=0, # 错误不缓存
)
def _handle_http_error(
self, response: httpx.Response, raw_data: dict[str, Any] | None = None
) -> ActionResult:
"""处理 HTTP 错误响应"""
status_code = response.status_code
if status_code == 401:
return self._make_error_result(
ActionStatus.AUTH_FAILED, "认证失败", raw_response=raw_data
)
elif status_code == 403:
return self._make_error_result(
ActionStatus.AUTH_FAILED, "无权限访问", raw_response=raw_data
)
elif status_code == 404:
# 404 表示接口不存在,通常意味着该功能未开放
return self._make_error_result(
ActionStatus.NOT_SUPPORTED, "功能未开放", raw_response=raw_data
)
elif status_code == 429:
retry_after = response.headers.get("Retry-After")
return self._make_error_result(
ActionStatus.RATE_LIMITED,
"请求频率限制",
retry_after_seconds=int(retry_after) if retry_after else 60,
raw_response=raw_data,
)
else:
return self._make_error_result(
ActionStatus.UNKNOWN_ERROR,
f"HTTP {status_code}: {response.reason_phrase}",
raw_response=raw_data,
)
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
"""
获取操作配置 JSON Schema用于前端表单生成
子类应重写此方法
"""
return {"type": "object", "properties": {}, "required": []}

View File

@@ -0,0 +1,80 @@
"""
签到操作抽象基类
"""
from abc import abstractmethod
from typing import Any
import httpx
from src.services.provider_ops.actions.base import ProviderAction
from src.services.provider_ops.types import (
ActionResult,
CheckinInfo,
ProviderActionType,
)
class CheckinAction(ProviderAction):
"""
签到操作抽象基类
子类必须实现 execute() 方法来处理特定平台的签到逻辑。
"""
action_type = ProviderActionType.CHECKIN
display_name = "签到"
description = "每日签到领取额度"
default_cache_ttl = 3600 # 签到结果缓存 1 小时
@abstractmethod
async def execute(self, client: httpx.AsyncClient) -> ActionResult:
"""
执行签到
子类必须实现此方法。
Args:
client: 已认证的 HTTP 客户端
Returns:
ActionResult其中 data 字段为 CheckinInfo
"""
pass
def _create_checkin_info(
self,
reward: float | None = None,
streak_days: int | None = None,
message: str | None = None,
extra: dict[str, Any] | None = None,
) -> CheckinInfo:
"""
创建签到信息对象
辅助方法,用于创建统一格式的 CheckinInfo。
Args:
reward: 签到奖励额度
streak_days: 连续签到天数
message: 签到消息
extra: 额外信息
Returns:
CheckinInfo 对象
"""
return CheckinInfo(
reward=reward,
streak_days=streak_days,
message=message,
extra=extra if extra is not None else {},
)
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
"""获取操作配置 schema子类可重写"""
return {
"type": "object",
"properties": {},
"required": [],
}

View File

@@ -0,0 +1,74 @@
"""
Cubence 余额查询操作
"""
from typing import Any
from src.services.provider_ops.actions.balance import BalanceAction
from src.services.provider_ops.types import BalanceInfo
class CubenceBalanceAction(BalanceAction):
"""
Cubence 专用余额查询
特点:
- 余额单位直接是美元
- 支持窗口限额查询5小时/每周)
- Cookie 失效时返回友好的错误提示
"""
display_name = "查询余额(含窗口限额)"
description = "查询账户余额和窗口限额信息"
_cookie_auth = True
def _parse_balance(self, data: Any) -> BalanceInfo:
"""解析 Cubence 余额信息"""
# Cubence 响应格式data.balance 和 data.subscription_limits
response_data = data.get("data", {}) if isinstance(data, dict) else {}
balance_data = response_data.get("balance", {})
subscription_limits = response_data.get("subscription_limits", {})
# 余额信息(单位直接是美元)
total_available = balance_data.get("total_balance_dollar")
normal_balance = balance_data.get("normal_balance_dollar")
subscription_balance = balance_data.get("subscription_balance_dollar")
charity_balance = balance_data.get("charity_balance_dollar")
# 窗口限额信息
extra: dict[str, Any] = {}
# 5小时窗口限额
five_hour = subscription_limits.get("five_hour", {})
if five_hour:
extra["five_hour_limit"] = {
"limit": five_hour.get("limit"),
"used": five_hour.get("used"),
"remaining": five_hour.get("remaining"),
"resets_at": five_hour.get("resets_at"),
}
# 每周窗口限额
weekly = subscription_limits.get("weekly", {})
if weekly:
extra["weekly_limit"] = {
"limit": weekly.get("limit"),
"used": weekly.get("used"),
"remaining": weekly.get("remaining"),
"resets_at": weekly.get("resets_at"),
}
# 余额组成
if normal_balance is not None:
extra["normal_balance"] = normal_balance
if subscription_balance is not None:
extra["subscription_balance"] = subscription_balance
if charity_balance is not None:
extra["charity_balance"] = charity_balance
return self._create_balance_info(
total_available=total_available,
currency=self.config.get("currency", "USD"),
extra=extra if extra else None,
)

View File

@@ -0,0 +1,119 @@
"""
NekoCode 余额查询操作
"""
from datetime import datetime
from typing import Any
from src.core.logger import logger
from src.services.provider_ops.actions.balance import BalanceAction
from src.services.provider_ops.types import BalanceInfo
class NekoCodeBalanceAction(BalanceAction):
"""
NekoCode 余额查询
特点:
- 查询余额和订阅信息
- 显示每日配额限制和剩余
- 显示订阅状态和有效期
- 余额单位为积分
"""
display_name = "查询余额"
description = "查询 NekoCode 账户余额和订阅信息"
_cookie_auth = True
def _parse_balance(self, data: Any) -> BalanceInfo:
"""解析余额信息"""
response_data = data.get("data", {}) if isinstance(data, dict) else {}
subscription = response_data.get("subscription", {})
# 解析余额(积分)
balance = self._to_float(response_data.get("balance"))
# 解析每日配额
daily_quota_limit = self._to_float(subscription.get("daily_quota_limit"))
daily_remaining_quota = self._to_float(subscription.get("daily_remaining_quota"))
# 计算每日已用配额
daily_used = None
if daily_quota_limit is not None and daily_remaining_quota is not None:
daily_used = daily_quota_limit - daily_remaining_quota
# 解析订阅信息
plan_name = subscription.get("plan_name")
status = subscription.get("status")
effective_start_date = subscription.get("effective_start_date")
effective_end_date = subscription.get("effective_end_date")
# 解析日期
expires_at = None
refresh_at = None
if effective_end_date:
try:
expires_at = datetime.fromisoformat(effective_end_date)
except ValueError as e:
logger.debug(f"解析 effective_end_date 失败: {e}")
if effective_start_date:
try:
refresh_at = datetime.fromisoformat(effective_start_date)
except ValueError as e:
logger.debug(f"解析 effective_start_date 失败: {e}")
# 构建 extra 信息
extra: dict[str, Any] = {
"plan_name": plan_name,
"subscription_status": status,
"daily_quota_limit": daily_quota_limit,
"daily_remaining_quota": daily_remaining_quota,
"daily_used_quota": daily_used,
"effective_start_date": effective_start_date,
"effective_end_date": effective_end_date,
}
# 添加刷新时间信息
if refresh_at:
extra["refresh_at"] = refresh_at.isoformat()
extra["refresh_at_display"] = refresh_at.strftime("%Y-%m-%d %H:%M:%S")
# 添加月度统计
month_data = response_data.get("month", {})
if month_data:
extra["month_stats"] = {
"total_input_tokens": month_data.get("total_input_tokens"),
"total_output_tokens": month_data.get("total_output_tokens"),
"total_quota": month_data.get("total_quota"),
"total_requests": month_data.get("total_requests"),
}
# 添加今日统计
today_data = response_data.get("today", {})
if today_data:
extra["today_stats"] = today_data.get("stats", [])
return self._create_balance_info(
total_available=balance,
total_granted=daily_quota_limit, # 每日配额作为总额度
total_used=daily_used, # 每日已用
currency="USD", # NekoCode 使用美元单位
extra=extra,
)
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
"""获取操作配置 schema"""
return {
"type": "object",
"properties": {
"endpoint": {
"type": "string",
"title": "API 端点",
"default": "/api/usage/summary",
},
},
"required": [],
}

View File

@@ -0,0 +1,176 @@
"""
New API 余额查询操作
"""
from typing import Any
import httpx
from src.core.logger import logger
from src.services.provider_ops.actions.balance import BalanceAction
from src.services.provider_ops.types import BalanceInfo
class NewApiBalanceAction(BalanceAction):
"""
New API 风格的余额查询
特点:
- 使用 /api/user/self 端点
- quota 单位是 1/500000 美元
- 支持查询前自动签到(通过基类的模板方法)
"""
display_name = "查询余额"
description = "查询 New API 账户余额信息"
def _parse_balance(
self,
data: Any,
) -> BalanceInfo:
"""解析 New API 余额信息"""
# New API 响应格式: {"success": true, "data": {...}}
user_data = data.get("data", {}) if isinstance(data, dict) else {}
# 获取 quota 除数(默认 500000New API 的标准)
quota_divisor = self.config.get("quota_divisor", 500000)
# 提取原始值
# 注意New API 中 quota 是剩余额度total_available不是总额度
raw_quota = self._to_float(user_data.get("quota"))
raw_used = self._to_float(user_data.get("used_quota"))
# 转换为美元
total_available = raw_quota / quota_divisor if raw_quota is not None else None
total_used = raw_used / quota_divisor if raw_used is not None else None
return self._create_balance_info(
total_available=total_available,
total_used=total_used,
currency=self.config.get("currency", "USD"),
)
async def _do_checkin(self, client: httpx.AsyncClient) -> dict[str, Any] | None:
"""
执行签到(静默,不抛出异常)
New API 签到通常需要认证Cookie 或 API Key + New-Api-User
失败时仅记录日志,不影响余额查询。
Returns:
签到结果字典,包含 success 和 message 字段;
如果功能未开放返回 None
如果 Cookie 失效返回 {"cookie_expired": True}
"""
site = client.base_url.host or str(client.base_url)
checkin_endpoint = self.config.get("checkin_endpoint", "/api/user/checkin")
# 检查是否配置了 Cookie通过 service 层注入的 _has_cookie 标志)
has_cookie = self.config.get("_has_cookie", False)
if not has_cookie:
logger.debug(f"[{site}] 未配置 Cookie尝试使用 API Key 认证签到")
try:
response = await client.post(checkin_endpoint)
# 404 表示签到功能未开放
if response.status_code == 404:
logger.debug(f"[{site}] 签到功能未开放")
return None
# 401/403 通常表示未授权Cookie 模式下大多意味着 Cookie 已失效
if response.status_code in (401, 403):
# 只有在明确配置了 Cookie 的情况下,才标记为 Cookie 失效。
# API Key 模式下的 401/403 更可能表示该站点不支持该认证方式签到。
if has_cookie:
logger.warning(f"[{site}] Cookie 已失效(签到返回 {response.status_code}")
return {"cookie_expired": True, "message": "Cookie 已失效"}
logger.debug(
f"[{site}] 签到认证失败({response.status_code}),跳过签到结果上报"
)
return None
try:
data = response.json()
message = data.get("message", "")
success = data.get("success", False)
message_lower = str(message).lower()
if success:
logger.debug(f"[{site}] 签到成功: {message}")
return {"success": True, "message": message or "签到成功"}
else:
# 检查是否是"已签到"的情况
already_indicators = ["already", "已签到", "已经签到", "今日已签", "重复签到"]
is_already = any(ind.lower() in message_lower for ind in already_indicators)
if is_already:
logger.debug(f"[{site}] 今日已签到: {message}")
return {"success": None, "message": message or "今日已签到"}
# 检查是否是认证失败(未登录、无权限、验证码等)
auth_fail_indicators = [
"未登录",
"请登录",
"login",
"unauthorized",
"无权限",
"权限不足",
"turnstile",
"captcha",
"验证码", # 需要人机验证
]
is_auth_fail = any(ind.lower() in message_lower for ind in auth_fail_indicators)
if is_auth_fail:
# Cookie 模式下,这类提示通常意味着 Cookie 已失效或需要重新登录。
if has_cookie:
logger.warning(f"[{site}] Cookie 已失效(签到认证失败): {message}")
return {"cookie_expired": True, "message": message or "Cookie 已失效"}
# API Key 模式下,站点可能不支持该认证方式签到;保持静默不影响余额查询。
logger.debug(f"[{site}] 签到认证失败API Key 模式),跳过签到: {message}")
return None
# 其他失败情况
logger.debug(f"[{site}] 签到失败: {message}")
return {"success": False, "message": message or "签到失败"}
except Exception as e:
logger.debug(f"[{site}] 签到响应解析失败: {e}")
return {"success": False, "message": "响应解析失败"}
except Exception as e:
# 签到失败不影响余额查询
logger.debug(f"[{site}] 签到请求失败(不影响余额查询): {e}")
return None
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
"""获取操作配置 schema"""
return {
"type": "object",
"properties": {
"endpoint": {
"type": "string",
"title": "API 路径",
"description": "余额查询 API 路径",
"default": "/api/user/self",
},
"method": {
"type": "string",
"title": "请求方法",
"enum": ["GET", "POST"],
"default": "GET",
},
"quota_divisor": {
"type": "number",
"title": "额度除数",
"description": "将原始额度值转换为美元的除数",
"default": 500000,
},
"currency": {
"type": "string",
"title": "货币单位",
"default": "USD",
},
},
"required": [],
}

View File

@@ -0,0 +1,145 @@
"""
Sub2API 余额查询操作
"""
import asyncio
import time
from typing import Any, cast
import httpx
from src.services.provider_ops.actions.balance import BalanceAction
from src.services.provider_ops.types import ActionResult, ActionStatus, BalanceInfo
class Sub2ApiBalanceAction(BalanceAction):
"""
Sub2API 余额查询
特点:
- 并发调用 /api/v1/auth/me 和 /api/v1/subscriptions/summary
- auth/me 提供基础余额balance + points
- subscriptions/summary 提供订阅详情(各订阅的日/周/月用量和额度)
- 响应格式: {"code": 0, "message": "success", "data": {...}}
"""
display_name = "查询余额"
description = "查询 Sub2API 账户余额和订阅信息"
def _parse_balance(self, data: Any) -> BalanceInfo:
"""本类完全重写了 _do_query_balance绕过基类默认流程故此方法不会被调用"""
raise NotImplementedError(
"Sub2API 重写了 _do_query_balance不走基类的 _parse_balance 路径"
)
async def _do_query_balance(self, client: httpx.AsyncClient) -> ActionResult:
"""并发查询 auth/me 和 subscriptions/summary"""
start_time = time.time()
me_endpoint = self.config.get("endpoint", "/api/v1/auth/me?timezone=Asia/Shanghai")
sub_endpoint = self.config.get("subscription_endpoint", "/api/v1/subscriptions/summary")
try:
me_resp, sub_resp = cast(
tuple[httpx.Response | BaseException, httpx.Response | BaseException],
await asyncio.gather(
client.get(me_endpoint),
client.get(sub_endpoint),
return_exceptions=True,
),
)
response_time_ms = int((time.time() - start_time) * 1000)
# 解析 auth/me
me_data: dict[str, Any] = {}
me_ok = False
if isinstance(me_resp, httpx.Response):
if me_resp.status_code in (401, 403):
return self._make_error_result(
ActionStatus.AUTH_FAILED, "认证失败,请检查凭据配置"
)
if me_resp.status_code == 200:
try:
me_json = me_resp.json()
if me_json.get("code") == 0:
me_data = me_json.get("data", {})
me_ok = True
except Exception:
pass
if not me_ok:
return self._make_error_result(ActionStatus.UNKNOWN_ERROR, "查询用户信息失败")
# 基础余额
balance = self._to_float(me_data.get("balance")) or 0.0
points = self._to_float(me_data.get("points")) or 0.0
total_available = balance + points
extra: dict[str, Any] = {
"balance": balance,
"points": points,
}
# 解析 subscriptions/summary可选失败不影响主流程
if isinstance(sub_resp, httpx.Response) and sub_resp.status_code == 200:
try:
sub_json = sub_resp.json()
if sub_json.get("code") == 0:
summary = sub_json.get("data", {})
extra["active_subscriptions"] = summary.get("active_count", 0)
extra["total_used_usd"] = summary.get("total_used_usd", 0)
extra["subscriptions"] = self._parse_subscriptions(
summary.get("subscriptions", [])
)
except Exception:
pass
balance_info = self._create_balance_info(
total_available=total_available,
currency="USD",
extra=extra,
)
return self._make_success_result(
data=balance_info,
response_time_ms=response_time_ms,
raw_response={"me": me_data},
)
except httpx.TimeoutException:
return self._make_error_result(
ActionStatus.NETWORK_ERROR, "请求超时", retry_after_seconds=30
)
except httpx.RequestError as e:
return self._make_error_result(
ActionStatus.NETWORK_ERROR, f"网络错误: {e}", retry_after_seconds=30
)
except Exception as e:
return self._make_error_result(ActionStatus.UNKNOWN_ERROR, f"未知错误: {e}")
@staticmethod
def _parse_subscriptions(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""将订阅列表精简为前端需要的字段"""
result = []
for item in items:
sub: dict[str, Any] = {
"group_name": item.get("group_name", ""),
"status": item.get("status", ""),
}
# 只保留非 None 的限额字段0 是有效值,表示未使用/无限额)
for field in (
"daily_used_usd",
"daily_limit_usd",
"weekly_used_usd",
"weekly_limit_usd",
"monthly_used_usd",
"monthly_limit_usd",
):
val = item.get(field)
if val is not None:
sub[field] = val
expires_at = item.get("expires_at")
if expires_at:
sub["expires_at"] = expires_at
result.append(sub)
return result

View File

@@ -0,0 +1,233 @@
"""
YesCode 余额查询操作
"""
import asyncio
from datetime import datetime, timedelta
from typing import Any, cast
import httpx
from src.services.provider_ops.actions.balance import BalanceAction
from src.services.provider_ops.types import ActionResult, ActionStatus, BalanceInfo
async def fetch_yescode_combined_data(
client: httpx.AsyncClient,
base_url: str,
) -> dict[str, Any]:
"""
获取 YesCode 合并数据balance + profile
使用传入的 client 并发调用两个接口:
- /api/v1/user/balance: 准确的 weekly_spent_balance
- /api/v1/auth/profile: 用户信息、重置时间
Args:
client: 已配置好认证的 HTTP 客户端
base_url: API 基础地址
Returns:
合并后的数据字典
"""
base_url = base_url.rstrip("/")
result: dict[str, Any] = {}
# 并发调用两个接口
balance_task = client.get(f"{base_url}/api/v1/user/balance")
profile_task = client.get(f"{base_url}/api/v1/auth/profile")
balance_resp, profile_resp = cast(
tuple[httpx.Response | BaseException, httpx.Response | BaseException],
await asyncio.gather(balance_task, profile_task, return_exceptions=True),
)
# 解析 balance 接口
balance_data: dict[str, Any] = {}
if isinstance(balance_resp, httpx.Response) and balance_resp.status_code == 200:
try:
balance_data = balance_resp.json()
result["_balance_data"] = balance_data
except Exception:
pass
# 解析 profile 接口
profile_data: dict[str, Any] = {}
if isinstance(profile_resp, httpx.Response) and profile_resp.status_code == 200:
try:
profile_data = profile_resp.json()
result["_profile_data"] = profile_data
except Exception:
pass
# 合并数据balance 接口优先(更准确的余额数据)
result["pay_as_you_go_balance"] = balance_data.get(
"pay_as_you_go_balance", profile_data.get("pay_as_you_go_balance", 0)
)
result["subscription_balance"] = balance_data.get(
"subscription_balance", profile_data.get("subscription_balance", 0)
)
result["weekly_limit"] = balance_data.get("weekly_limit") or (
profile_data.get("subscription_plan") or {}
).get("weekly_limit")
result["weekly_spent_balance"] = balance_data.get(
"weekly_spent_balance", profile_data.get("current_week_spend", 0)
)
# 用户信息(仅 profile 有)
result["username"] = profile_data.get("username")
result["email"] = profile_data.get("email")
# 重置时间(仅 profile 有)
result["last_week_reset"] = profile_data.get("last_week_reset")
result["last_daily_balance_add"] = profile_data.get("last_daily_balance_add")
# subscription_plan仅 profile 有)
result["subscription_plan"] = profile_data.get("subscription_plan")
return result
def parse_yescode_balance_extra(data: dict[str, Any]) -> dict[str, Any]:
"""
解析 YesCode 余额额外信息
Args:
data: 合并后的数据(来自 fetch_yescode_combined_data 或单独接口)
Returns:
统一格式的 extra 字典
"""
extra: dict[str, Any] = {}
pay_as_you_go = data.get("pay_as_you_go_balance", 0)
subscription = data.get("subscription_balance", 0)
extra["pay_as_you_go_balance"] = pay_as_you_go
# 每日额度上限
plan = data.get("subscription_plan") or {}
daily_balance = plan.get("daily_balance", subscription)
# 周限额
weekly_limit = data.get("weekly_limit") or plan.get("weekly_limit")
weekly_spent = data.get("weekly_spent_balance", 0)
# 映射为统一字段
extra["daily_limit"] = daily_balance
if weekly_limit is not None:
extra["weekly_limit"] = weekly_limit
extra["weekly_spent"] = weekly_spent
# 计算重置时间
last_week_reset = data.get("last_week_reset")
if last_week_reset:
try:
if isinstance(last_week_reset, str):
reset_dt = datetime.fromisoformat(last_week_reset.replace("Z", "+00:00"))
next_reset = reset_dt + timedelta(days=7)
extra["weekly_resets_at"] = int(next_reset.timestamp())
except Exception:
pass
last_daily_add = data.get("last_daily_balance_add")
if last_daily_add:
try:
if isinstance(last_daily_add, str):
add_dt = datetime.fromisoformat(last_daily_add.replace("Z", "+00:00"))
next_daily = add_dt + timedelta(days=1)
extra["daily_resets_at"] = int(next_daily.timestamp())
except Exception:
pass
# 计算实际可用余额
if weekly_limit is not None:
weekly_remaining = max(0, weekly_limit - weekly_spent)
subscription_available = min(subscription, weekly_remaining)
extra["daily_spent"] = daily_balance - min(daily_balance, subscription_available)
else:
subscription_available = subscription
extra["daily_spent"] = max(0, daily_balance - subscription)
extra["_subscription_available"] = subscription_available
extra["_total_available"] = pay_as_you_go + subscription_available
return extra
class YesCodeBalanceAction(BalanceAction):
"""
YesCode 专用余额查询
特点:
- 余额单位直接是美元
- 支持每周限额查询
- 同时调用 balance 和 profile 接口获取完整数据
- Cookie 失效时返回友好的错误提示
"""
display_name = "查询余额(含每周限额)"
description = "查询账户余额和每周限额信息"
_cookie_auth = True
def _parse_balance(self, data: Any) -> BalanceInfo:
"""YesCode 不使用基类的 _do_query_balance此方法不会被调用"""
raise NotImplementedError("YesCode 使用自定义 _do_query_balance")
async def _do_query_balance(self, client: httpx.AsyncClient) -> ActionResult:
"""执行余额查询(实现抽象方法,复用 client 调用两个接口获取完整数据)"""
import time
start_time = time.time()
base_url = str(client.base_url).rstrip("/")
try:
# 复用传入的 client 获取合并数据
combined_data = await fetch_yescode_combined_data(client, base_url)
response_time_ms = int((time.time() - start_time) * 1000)
# 检查是否至少有一个接口成功
if "_balance_data" not in combined_data and "_profile_data" not in combined_data:
return self._make_error_result(
ActionStatus.AUTH_FAILED,
"Cookie 已失效,请重新配置",
)
# 使用公共函数解析余额
extra = parse_yescode_balance_extra(combined_data)
total_available = extra.pop("_total_available", 0)
extra.pop("_subscription_available", None)
balance = BalanceInfo(
total_granted=None,
total_used=None,
total_available=total_available,
currency=self.config.get("currency", "USD"),
extra=extra if extra else {},
)
return self._make_success_result(
data=balance,
response_time_ms=response_time_ms,
raw_response=combined_data,
)
except httpx.TimeoutException:
return self._make_error_result(
ActionStatus.NETWORK_ERROR,
"请求超时",
retry_after_seconds=30,
)
except httpx.RequestError as e:
return self._make_error_result(
ActionStatus.NETWORK_ERROR,
f"网络错误: {str(e)}",
retry_after_seconds=30,
)
except Exception as e:
return self._make_error_result(
ActionStatus.UNKNOWN_ERROR,
f"未知错误: {str(e)}",
)

View File

@@ -0,0 +1,29 @@
"""
Provider 架构模块
"""
from src.services.provider_ops.architectures.anyrouter import AnyrouterArchitecture
from src.services.provider_ops.architectures.base import (
ProviderArchitecture,
ProviderConnector,
VerifyResult,
)
from src.services.provider_ops.architectures.cubence import CubenceArchitecture
from src.services.provider_ops.architectures.generic_api import GenericApiArchitecture
from src.services.provider_ops.architectures.nekocode import NekoCodeArchitecture
from src.services.provider_ops.architectures.new_api import NewApiArchitecture
from src.services.provider_ops.architectures.sub2api import Sub2ApiArchitecture
from src.services.provider_ops.architectures.yescode import YesCodeArchitecture
__all__ = [
"ProviderArchitecture",
"ProviderConnector",
"VerifyResult",
"AnyrouterArchitecture",
"CubenceArchitecture",
"GenericApiArchitecture",
"NekoCodeArchitecture",
"NewApiArchitecture",
"Sub2ApiArchitecture",
"YesCodeArchitecture",
]

View File

@@ -0,0 +1,469 @@
"""
Anyrouter 架构
针对 Anyrouter 中转站的预设配置,自动处理 acw_sc__v2 反爬 Cookie。
"""
import base64
import re
from typing import Any
import httpx
from src.core.logger import logger
from src.services.provider_ops.actions import (
AnyrouterBalanceAction,
ProviderAction,
)
from src.services.provider_ops.architectures.base import (
ProviderArchitecture,
ProviderConnector,
)
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
from src.services.provider_ops.utils import extract_cookie_value
from src.utils.ssl_utils import get_ssl_context
# acw_sc__v2 算法常量
_XOR_KEY = "3000176000856006061501533003690027800375"
_UNSBOX_TABLE = [
0xF,
0x23,
0x1D,
0x18,
0x21,
0x10,
0x1,
0x26,
0xA,
0x9,
0x13,
0x1F,
0x28,
0x1B,
0x16,
0x17,
0x19,
0xD,
0x6,
0xB,
0x27,
0x12,
0x14,
0x8,
0xE,
0x15,
0x20,
0x1A,
0x2,
0x1E,
0x7,
0x4,
0x11,
0x5,
0x3,
0x1C,
0x22,
0x25,
0xC,
0x24,
]
def _compute_acw_sc_v2(arg1: str) -> str:
"""
计算 acw_sc__v2 Cookie 值
Args:
arg1: 从 HTML 中提取的 40 位十六进制字符串
Returns:
计算后的 Cookie 值
"""
# Step 1: unsbox - 根据置换表重排字符
unsboxed = "".join(arg1[i - 1] for i in _UNSBOX_TABLE)
# Step 2: hexXor - 与密钥逐字节异或
result = ""
for i in range(0, 40, 2):
a = int(unsboxed[i : i + 2], 16)
b = int(_XOR_KEY[i : i + 2], 16)
xored = format(a ^ b, "02x")
result += xored
return result
def _parse_session_user_id(cookie_input: str) -> tuple[str | None, str | None]:
"""
从 session cookie 中解析用户 ID 和用户名
Anyrouter 的 session cookie 结构:
base64(timestamp|gob_base64|signature)
gob 数据中包含:
- id: 内部数字 ID (gob 编码的整数)
- username: 用户名
- role, status, group 等
Args:
cookie_input: Cookie 字符串或 session 值
Returns:
(user_id, username) 元组,解析失败则返回 (None, None)
"""
try:
# 先提取 session 值
session_cookie = extract_cookie_value(cookie_input, "session")
# 1. URL-safe base64 解码外层
padding = 4 - len(session_cookie) % 4
if padding != 4:
session_cookie += "=" * padding
decoded = base64.urlsafe_b64decode(session_cookie)
text = decoded.decode("utf-8", errors="replace")
# 2. 分割: timestamp|gob_base64|signature
parts = text.split("|")
if len(parts) < 2:
return None, None
# 3. 解码 gob 数据 (第二层 base64)
gob_b64 = parts[1]
padding2 = 4 - len(gob_b64) % 4
if padding2 != 4:
gob_b64 += "=" * padding2
gob_data = base64.urlsafe_b64decode(gob_b64)
# 4. 从 gob 数据中解析 id 字段
# 查找 "\x02id\x03int" 模式,后面跟着 gob 编码的整数
id_pattern = b"\x02id\x03int"
id_idx = gob_data.find(id_pattern)
user_id = None
if id_idx != -1:
# 跳过 "\x02id\x03int" (7字节) 和类型标记 (2字节)
value_start = id_idx + 7 + 2
if value_start < len(gob_data):
# 读取第一个字节,检查是否是 00正数标记
first_byte = gob_data[value_start]
if first_byte == 0:
# 下一个字节是长度标记
marker = gob_data[value_start + 1]
if marker >= 0x80:
# 负的表示长度: 256 - marker = 字节数
length = 256 - marker
if value_start + 2 + length <= len(gob_data):
# 读取 length 字节,大端序转整数
val = int.from_bytes(
gob_data[value_start + 2 : value_start + 2 + length],
"big",
)
# gob zigzag 解码:正整数用 2*n 编码
user_id = str(val >> 1)
# 5. 从 gob 数据中提取用户名
username = None
# 查找 username 字段后的值
# 格式: \x08username\x06string\x0c\x10\x00\x0elinuxdo_129083
# 其中 \x0e (14) 是用户名的长度
username_pattern = b"\x08username\x06string"
username_idx = gob_data.find(username_pattern)
if username_idx != -1:
# 跳过模式本身 (17字节) 和 \x0c\x10\x00 (3字节)
# 第 4 个字节是长度
length_pos = username_idx + len(username_pattern) + 3
if length_pos < len(gob_data):
length_byte = gob_data[length_pos]
value_start = length_pos + 1
if length_byte < 128 and value_start + length_byte <= len(gob_data):
username = gob_data[value_start : value_start + length_byte].decode(
"utf-8", errors="ignore"
)
return user_id, username
except Exception as e:
logger.debug(f"解析 Anyrouter session cookie 失败: {e}")
return None, None
async def _get_acw_cookie(
base_url: str,
timeout: float = 10,
proxy: str | httpx.Proxy | None = None,
tunnel_node_id: str | None = None,
) -> str | None:
"""
获取 acw_sc__v2 Cookie
首先请求目标 URL如果返回包含 arg1 的反爬页面,则计算 Cookie 值。
Args:
base_url: 目标站点 URL
timeout: 请求超时时间
proxy: 代理地址
tunnel_node_id: tunnel 模式节点 ID优先于 proxy
Returns:
Cookie 字符串 (acw_sc__v2=xxx),如果不需要或获取失败则返回 None
"""
try:
# 构建 client 参数
client_kwargs: dict[str, Any] = {
"timeout": timeout,
"verify": get_ssl_context(),
}
if tunnel_node_id:
from src.services.proxy_node.tunnel_transport import create_tunnel_transport
client_kwargs["transport"] = create_tunnel_transport(tunnel_node_id, timeout=timeout)
elif proxy:
client_kwargs["proxy"] = proxy
logger.debug(f"获取 acw_sc__v2 Cookie 使用代理: {proxy}")
async with httpx.AsyncClient(**client_kwargs) as client:
resp = await client.get(
base_url,
headers={
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
),
},
follow_redirects=False,
)
# 尝试从响应中提取 arg1
match = re.search(r"var\s+arg1\s*=\s*'([0-9a-fA-F]{40})'", resp.text)
if not match:
# 没有反爬页面,不需要 Cookie
return None
cookie_value = _compute_acw_sc_v2(match.group(1))
return f"acw_sc__v2={cookie_value}"
except Exception as e:
logger.debug(f"获取 acw_sc__v2 Cookie 失败: {e}")
return None
class AnyrouterConnector(ProviderConnector):
"""
Anyrouter 专用连接器
特点:
- 使用 Cookie 认证session
- 自动补充 acw_sc__v2 反爬 Cookie
- 自动解析 user_id 用于 New-Api-User header
"""
auth_type = ConnectorAuthType.COOKIE
display_name = "Anyrouter Cookie"
def __init__(self, base_url: str, config: dict[str, Any] | None = None):
super().__init__(base_url, config)
self._session_cookie: str | None = None
self._acw_cookie: str | None = None
self._user_id: str | None = None
async def connect(self, credentials: dict[str, Any]) -> bool:
"""建立连接"""
session_cookie = credentials.get("session_cookie")
if not session_cookie:
self._set_error("Session Cookie 不能为空")
return False
# 提取纯 session 值(支持完整 Cookie 字符串或仅 session 值)
self._session_cookie = extract_cookie_value(session_cookie, "session")
# 解析 user_id
self._user_id, _ = _parse_session_user_id(session_cookie)
# 尝试获取反爬 Cookie使用配置中的代理
self._acw_cookie = await _get_acw_cookie(
self.base_url, proxy=self._proxy, tunnel_node_id=self._tunnel_node_id
)
self._set_connected()
return True
async def disconnect(self) -> None:
"""断开连接"""
self._session_cookie = None
self._acw_cookie = None
self._user_id = None
self._set_disconnected()
async def is_authenticated(self) -> bool:
"""检查是否已认证"""
return self._session_cookie is not None
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
"""为请求应用认证信息"""
cookies = []
# 添加反爬 Cookie
if self._acw_cookie:
cookies.append(self._acw_cookie)
# 添加 session Cookie
if self._session_cookie:
cookies.append(f"session={self._session_cookie}")
if cookies:
request.headers["Cookie"] = "; ".join(cookies)
# 添加 New-Api-User header
if self._user_id:
request.headers["New-Api-User"] = self._user_id
return request
@classmethod
def get_credentials_schema(cls) -> dict[str, Any]:
"""获取凭据配置 schema"""
return {
"type": "object",
"properties": {
"base_url": {
"type": "string",
"title": "站点地址",
"description": "API 基础地址",
"x-default-value": "https://anyrouter.top",
},
"session_cookie": {
"type": "string",
"title": "Session Cookie",
"description": "从浏览器复制的 session Cookie 值",
"x-sensitive": True,
"x-input-type": "password",
},
},
"required": ["session_cookie"],
"x-field-groups": [
{"fields": ["base_url"]},
{"fields": ["session_cookie"]},
],
"x-auth-type": "cookie",
"x-default-base-url": "https://anyrouter.top",
"x-validation": [
{
"type": "required",
"fields": ["session_cookie"],
"message": "请填写 Session Cookie",
},
],
"x-quota-divisor": 500000,
"x-currency": "USD",
}
class AnyrouterArchitecture(ProviderArchitecture):
"""
Anyrouter 架构预设
针对 Anyrouter 中转站优化的预设配置。
特点:
- 使用 Cookie 认证session
- 自动处理 acw_sc__v2 反爬 Cookie
- 验证端点: /api/user/self
- quota 单位是 1/500000 美元
"""
architecture_id = "anyrouter"
display_name = "Anyrouter"
description = "Anyrouter 中转站预设配置,使用 Cookie 认证"
supported_connectors: list[type[ProviderConnector]] = [
AnyrouterConnector,
]
supported_actions: list[type[ProviderAction]] = [AnyrouterBalanceAction]
default_action_configs: dict[ProviderActionType, dict[str, Any]] = {
ProviderActionType.QUERY_BALANCE: {
"endpoint": "/api/user/self",
"method": "GET",
"quota_divisor": 500000, # 与 New API 相同
"checkin_endpoint": "/api/user/sign_in", # 自动签到端点
},
}
def get_credentials_schema(self) -> dict[str, Any]:
"""Anyrouter 使用 session_cookie 认证"""
return AnyrouterConnector.get_credentials_schema()
def get_verify_endpoint(self) -> str:
"""验证端点"""
return "/api/user/self"
async def prepare_verify_config(
self,
base_url: str,
config: dict[str, Any],
credentials: dict[str, Any],
) -> dict[str, Any]:
"""
验证前获取 acw_sc__v2 Cookie
Args:
base_url: API 基础地址
config: 连接器配置
credentials: 凭据信息
Returns:
包含 acw_cookie 的配置
"""
# 从 config 获取代理配置(支持 proxy_node_id、tunnel 和旧的 proxy URL
from src.services.proxy_node.resolver import resolve_ops_proxy_config_async
proxy, tunnel_node_id = await resolve_ops_proxy_config_async(config)
acw_cookie = await _get_acw_cookie(base_url, proxy=proxy, tunnel_node_id=tunnel_node_id)
if acw_cookie:
return {"acw_cookie": acw_cookie}
return {}
def build_verify_headers(
self,
config: dict[str, Any],
credentials: dict[str, Any],
) -> dict[str, str]:
"""
构建 Anyrouter 的验证请求 Headers
使用 Cookie 认证,不使用 Authorization。
同时添加 New-Api-User header。
"""
headers: dict[str, str] = {}
cookies = []
# 添加反爬 Cookie
acw_cookie = config.get("acw_cookie")
if acw_cookie:
cookies.append(acw_cookie)
# 添加 session Cookie
cookie_input = credentials.get("session_cookie")
if cookie_input:
# 提取 session 值(支持完整 Cookie 字符串或仅 session 值)
session_value = extract_cookie_value(cookie_input, "session")
cookies.append(f"session={session_value}")
# 从 session 解析 user_id 并添加 New-Api-User header
user_id, _ = _parse_session_user_id(cookie_input)
if user_id:
headers["New-Api-User"] = user_id
if cookies:
headers["Cookie"] = "; ".join(cookies)
return headers
def _auth_fail_message(self, status_code: int) -> str:
"""Cookie 认证的错误消息"""
if status_code == 401:
return "Cookie 已失效,请重新配置"
return "Cookie 已失效或无权限"

View File

@@ -0,0 +1,550 @@
"""
Provider 架构抽象基类
"""
from abc import ABC, abstractmethod
from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
import httpx
from src.services.provider_ops.actions.base import ProviderAction
from src.services.provider_ops.types import (
ConnectorAuthType,
ConnectorState,
ConnectorStatus,
ProviderActionType,
)
from src.utils.ssl_utils import get_ssl_context
# ==================== 连接器基类 ====================
class ProviderConnector(ABC):
"""
提供商连接器基类
负责建立与提供商的认证连接,管理凭据状态。
每个架构应在自己的文件中实现对应的连接器子类。
"""
# 子类需要定义的类属性
auth_type: ConnectorAuthType = ConnectorAuthType.NONE
display_name: str = "Base Connector"
def __init__(self, base_url: str, config: dict[str, Any] | None = None):
"""
初始化连接器
Args:
base_url: 提供商 API 基础 URL
config: 连接器配置
"""
self.base_url = base_url.rstrip("/")
self.config = config or {}
self._status = ConnectorStatus.DISCONNECTED
self._connected_at: datetime | None = None
self._expires_at: datetime | None = None
self._last_error: str | None = None
# 代理配置(支持 proxy_node_id、tunnel 和旧的 proxy URL
from src.services.proxy_node.resolver import resolve_ops_proxy_config
self._proxy: str | httpx.Proxy | None
self._tunnel_node_id: str | None
self._proxy, self._tunnel_node_id = resolve_ops_proxy_config(self.config)
# HTTP 客户端配置
self._timeout = self.config.get("timeout", 30)
self._headers: dict[str, str] = {}
# 凭据更新回调Token Rotation 等场景需要持久化新凭据)
self._on_credentials_updated: Callable[[dict[str, Any]], None] | None = None
@abstractmethod
async def connect(self, credentials: dict[str, Any]) -> bool:
"""
建立认证连接
Args:
credentials: 凭据信息如用户名密码、API Key 等)
Returns:
是否连接成功
"""
pass
@abstractmethod
async def disconnect(self) -> None:
"""断开连接,清理状态"""
pass
@abstractmethod
async def is_authenticated(self) -> bool:
"""检查当前是否已认证"""
pass
@abstractmethod
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
"""
为请求应用认证信息
Args:
request: 原始请求
Returns:
添加认证信息后的请求
"""
pass
async def refresh_auth(self, credentials: dict[str, Any]) -> bool:
"""
刷新认证(如 Token 过期)
默认实现:重新连接
Args:
credentials: 凭据信息
Returns:
是否刷新成功
"""
return await self.connect(credentials)
@asynccontextmanager
async def get_client(self) -> AsyncIterator[httpx.AsyncClient]:
"""
获取已认证的 HTTP 客户端
使用 context manager 确保资源正确释放。
tunnel 模式下使用 TunnelTransport 替代 proxy transport。
Yields:
已配置认证信息的 AsyncClient
"""
transport = None
if self._tunnel_node_id:
from src.services.proxy_node.tunnel_transport import create_tunnel_transport
transport = create_tunnel_transport(self._tunnel_node_id, timeout=self._timeout)
elif self._proxy:
transport = httpx.AsyncHTTPTransport(proxy=self._proxy)
async with httpx.AsyncClient(
base_url=self.base_url,
timeout=self._timeout,
transport=transport,
event_hooks={"request": [self._auth_hook]},
verify=get_ssl_context(),
) as client:
yield client
async def _auth_hook(self, request: httpx.Request) -> None:
"""请求钩子:应用认证信息"""
self._apply_auth(request)
def get_state(self) -> ConnectorState:
"""获取连接器当前状态"""
return ConnectorState(
status=self._status,
auth_type=self.auth_type,
connected_at=self._connected_at,
expires_at=self._expires_at,
last_error=self._last_error,
)
def _set_connected(self, expires_at: datetime | None = None) -> None:
"""设置为已连接状态"""
self._status = ConnectorStatus.CONNECTED
self._connected_at = datetime.now(timezone.utc)
self._expires_at = expires_at
self._last_error = None
def _set_error(self, error: str) -> None:
"""设置错误状态"""
self._status = ConnectorStatus.ERROR
self._last_error = error
def _set_disconnected(self) -> None:
"""设置为断开状态"""
self._status = ConnectorStatus.DISCONNECTED
self._connected_at = None
self._expires_at = None
@classmethod
def get_credentials_schema(cls) -> dict[str, Any]:
"""
获取凭据配置 JSON Schema用于前端表单生成
子类应重写此方法
"""
return {"type": "object", "properties": {}, "required": []}
# ==================== 验证结果 ====================
@dataclass
class VerifyResult:
"""认证验证结果"""
success: bool
message: str | None = None
username: str | None = None
display_name: str | None = None
email: str | None = None
quota: float | None = None
used_quota: float | None = None
request_count: int | None = None
extra: dict[str, Any] | None = None
def to_dict(self) -> dict[str, Any]:
"""转换为字典"""
if not self.success:
return {"success": False, "message": self.message}
return {
"success": True,
"data": {
"username": self.username,
"display_name": self.display_name or self.username,
"email": self.email,
"quota": self.quota,
"used_quota": self.used_quota,
"request_count": self.request_count,
"extra": self.extra or {},
},
}
# ==================== 架构基类 ====================
class ProviderArchitecture(ABC):
"""
提供商架构抽象基类
架构 = Connector鉴权方式 + Actions支持的操作
一个架构可以被多个 Provider 复用。
例如generic_api 架构可用于各种中转站。
## 添加新认证模板的步骤
1. 在 architectures/ 目录创建新文件
2. 继承 ProviderArchitecture 和 ProviderConnector
3. 定义类属性architecture_id, display_name, description
4. 实现连接器子类和架构类
5. 实现认证相关的抽象方法:
- get_credentials_schema(): 返回凭据字段定义
- get_verify_endpoint(): 返回验证端点
- build_verify_headers(): 构建验证请求 headers
- parse_verify_response(): 解析验证响应
6. 在 registry.py 的 _register_builtin_architectures() 中注册
"""
# 子类需要定义的类属性
architecture_id: str = ""
display_name: str = ""
description: str = ""
# 设为 True 时不在架构列表 API 中返回(内部使用的架构)
hidden: bool = False
# 支持的 Connector 类型列表(按优先级排序)
supported_connectors: list[type[ProviderConnector]] = []
# 支持的 Action 类型列表
supported_actions: list[type[ProviderAction]] = []
# 默认操作配置
default_action_configs: dict[ProviderActionType, dict[str, Any]] = {}
def __init__(self, config: dict[str, Any] | None = None):
"""
初始化架构
Args:
config: 架构配置
"""
self.config = config or {}
# ==================== 认证验证相关方法 ====================
@abstractmethod
def get_credentials_schema(self) -> dict[str, Any]:
"""
获取凭据字段定义JSON Schema 格式)
子类必须实现此方法定义需要的凭据字段。
Returns:
JSON Schema 格式的字段定义
"""
pass
@abstractmethod
def get_verify_endpoint(self) -> str:
"""
获取认证验证端点
子类必须实现此方法返回验证端点。
Returns:
验证端点路径(如 /api/user/self, /api/v1/auth/profile
"""
pass
@abstractmethod
def build_verify_headers(
self,
config: dict[str, Any],
credentials: dict[str, Any],
) -> dict[str, str]:
"""
构建认证验证请求的 Headers
子类必须实现此方法构建认证 Headers。
Args:
config: 连接器配置(可能包含 prepare_verify_config 返回的额外配置)
credentials: 凭据信息
Returns:
Headers 字典
"""
pass
def parse_verify_response(
self,
status_code: int,
data: dict[str, Any],
) -> VerifyResult:
"""
解析认证验证响应
默认实现处理通用的 {"success": bool, "data": {...}} 格式。
子类可重写 _auth_fail_message() 和 _build_verify_result() 进行自定义。
Args:
status_code: HTTP 状态码
data: 响应 JSON 数据
Returns:
验证结果
"""
if status_code == 401:
return VerifyResult(success=False, message=self._auth_fail_message(401))
if status_code == 403:
return VerifyResult(success=False, message=self._auth_fail_message(403))
if status_code != 200:
return VerifyResult(success=False, message=f"验证失败HTTP {status_code}")
# 解析通用响应格式
if data.get("success") is True and "data" in data:
user_data = data["data"]
elif data.get("success") is False:
message = data.get("message", "验证失败")
return VerifyResult(success=False, message=message)
else:
user_data = data
return self._build_verify_result(user_data, data)
def _auth_fail_message(self, status_code: int) -> str:
"""
获取认证失败消息
子类可重写以提供自定义消息(如 Cookie 认证场景)。
"""
if status_code == 401:
return "认证失败:无效的凭据"
return "认证失败:权限不足"
def _build_verify_result(
self, user_data: dict[str, Any], raw_data: dict[str, Any] | None = None
) -> VerifyResult:
"""
从用户数据构建验证结果
默认实现提取 username, display_name, email, quota, used_quota, request_count。
子类可重写以自定义字段提取。
"""
known_fields = (
"username",
"display_name",
"email",
"quota",
"used_quota",
"request_count",
)
return VerifyResult(
success=True,
username=user_data.get("username"),
display_name=user_data.get("display_name") or user_data.get("username"),
email=user_data.get("email"),
quota=user_data.get("quota"),
used_quota=user_data.get("used_quota"),
request_count=user_data.get("request_count"),
extra={k: v for k, v in user_data.items() if k not in known_fields},
)
# ==================== 可选的钩子方法 ====================
async def prepare_verify_config(
self,
base_url: str,
config: dict[str, Any],
credentials: dict[str, Any],
) -> dict[str, Any] | tuple[dict[str, Any], dict[str, Any]]:
"""
验证前的异步预处理(可选)
子类可重写以执行异步操作(如获取动态 Cookie、登录获取 Token
Args:
base_url: API 基础地址
config: 连接器配置
credentials: 凭据信息
Returns:
- dict: 额外配置(会与原 config 合并传递给 build_verify_headers
- tuple[dict, dict]: (额外配置, 需持久化的凭据更新)
当预处理过程中凭据发生变更时(如 Token Rotation
通过第二个 dict 显式返回需要持久化的字段。
"""
return {}
# ==================== 连接器和操作相关方法 ====================
def get_connector(
self,
base_url: str,
auth_type: ConnectorAuthType | None = None,
config: dict[str, Any] | None = None,
) -> ProviderConnector:
"""
获取连接器实例
Args:
base_url: 提供商 API 基础 URL
auth_type: 指定的认证类型None 则使用默认
config: 连接器配置
Returns:
连接器实例
Raises:
ValueError: 不支持的认证类型
"""
if not self.supported_connectors:
raise ValueError(f"架构 {self.architecture_id} 未配置支持的连接器")
# 查找匹配的连接器
connector_cls: type[ProviderConnector] | None = None
if auth_type:
for cls in self.supported_connectors:
if cls.auth_type == auth_type:
connector_cls = cls
break
if not connector_cls:
supported = [c.auth_type.value for c in self.supported_connectors]
raise ValueError(
f"架构 {self.architecture_id} 不支持 {auth_type.value} 认证,"
f"支持的类型: {supported}"
)
else:
# 使用第一个(默认)连接器
connector_cls = self.supported_connectors[0]
return connector_cls(base_url, config)
def get_action(
self,
action_type: ProviderActionType,
config: dict[str, Any] | None = None,
) -> ProviderAction:
"""
获取操作实例
Args:
action_type: 操作类型
config: 操作配置(会与默认配置合并)
Returns:
操作实例
Raises:
ValueError: 不支持的操作类型
"""
action_cls: type[ProviderAction] | None = None
for cls in self.supported_actions:
if cls.action_type == action_type:
action_cls = cls
break
if not action_cls:
supported = [a.action_type.value for a in self.supported_actions]
raise ValueError(
f"架构 {self.architecture_id} 不支持 {action_type.value} 操作,"
f"支持的操作: {supported}"
)
# 合并默认配置和用户配置
merged_config = dict(self.default_action_configs.get(action_type, {}))
if config:
merged_config.update(config)
return action_cls(merged_config)
def supports_action(self, action_type: ProviderActionType) -> bool:
"""检查是否支持指定操作"""
return any(a.action_type == action_type for a in self.supported_actions)
def supports_auth_type(self, auth_type: ConnectorAuthType) -> bool:
"""检查是否支持指定认证类型"""
return any(c.auth_type == auth_type for c in self.supported_connectors)
def get_supported_auth_types(self) -> list[ConnectorAuthType]:
"""获取支持的认证类型列表"""
return [c.auth_type for c in self.supported_connectors]
def get_supported_action_types(self) -> list[ProviderActionType]:
"""获取支持的操作类型列表"""
return [a.action_type for a in self.supported_actions]
def to_dict(self) -> dict[str, Any]:
"""转换为字典(用于 API 响应)"""
return {
"architecture_id": self.architecture_id,
"display_name": self.display_name,
"description": self.description,
"credentials_schema": self.get_credentials_schema(),
"verify_endpoint": self.get_verify_endpoint(),
"supported_auth_types": [
{
"type": c.auth_type.value,
"display_name": c.display_name,
"credentials_schema": c.get_credentials_schema(),
}
for c in self.supported_connectors
],
"supported_actions": [
{
"type": a.action_type.value,
"display_name": a.display_name,
"description": a.description,
"config_schema": a.get_config_schema(),
}
for a in self.supported_actions
],
"default_connector": (
self.supported_connectors[0].auth_type.value if self.supported_connectors else None
),
}

View File

@@ -0,0 +1,228 @@
"""
Cubence 架构
针对 Cubence 中转站的预设配置。
"""
from typing import Any
import httpx
from src.services.provider_ops.actions import ProviderAction
from src.services.provider_ops.actions.cubence_balance import CubenceBalanceAction
from src.services.provider_ops.architectures.base import (
ProviderArchitecture,
ProviderConnector,
VerifyResult,
)
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
from src.services.provider_ops.utils import extract_cookie_value
class CubenceConnector(ProviderConnector):
"""
Cubence 专用连接器
特点:
- 使用 Cookie 认证token JWT
"""
auth_type = ConnectorAuthType.COOKIE
display_name = "Cubence Cookie"
def __init__(self, base_url: str, config: dict[str, Any] | None = None):
super().__init__(base_url, config)
self._token_cookie: str | None = None
async def connect(self, credentials: dict[str, Any]) -> bool:
"""建立连接"""
token_cookie = credentials.get("token_cookie")
if not token_cookie:
self._set_error("Token Cookie 不能为空")
return False
# 提取纯 token 值
self._token_cookie = extract_cookie_value(token_cookie, "token")
self._set_connected()
return True
async def disconnect(self) -> None:
"""断开连接"""
self._token_cookie = None
self._set_disconnected()
async def is_authenticated(self) -> bool:
"""检查是否已认证"""
return self._token_cookie is not None
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
"""为请求应用认证信息"""
if self._token_cookie:
request.headers["Cookie"] = f"token={self._token_cookie}"
return request
@classmethod
def get_credentials_schema(cls) -> dict[str, Any]:
"""获取凭据配置 schema"""
return {
"type": "object",
"properties": {
"base_url": {
"type": "string",
"title": "站点地址",
"description": "API 基础地址",
"x-default-value": "https://cubence.com",
},
"token_cookie": {
"type": "string",
"title": "Token Cookie",
"description": "从浏览器复制的 token Cookie 值JWT 格式)",
"x-sensitive": True,
"x-input-type": "password",
},
},
"required": ["token_cookie"],
"x-field-groups": [
{"fields": ["base_url"]},
{"fields": ["token_cookie"]},
],
"x-auth-type": "cookie",
"x-default-base-url": "https://cubence.com",
"x-validation": [
{
"type": "required",
"fields": ["token_cookie"],
"message": "请填写 Token Cookie",
},
],
"x-quota-divisor": None,
"x-currency": "USD",
"x-balance-extra-format": [
{
"label": "5h",
"type": "window_limit",
"source": "five_hour_limit",
"unit_divisor": 1000000,
},
{
"label": "",
"type": "window_limit",
"source": "weekly_limit",
"unit_divisor": 1000000,
},
],
}
class CubenceArchitecture(ProviderArchitecture):
"""
Cubence 架构预设
针对 Cubence 中转站优化的预设配置。
特点:
- 使用 Cookie 认证token JWT
- 验证端点: /api/v1/dashboard/overview
- 余额单位直接是美元
- 支持窗口限额5小时/每周)
"""
architecture_id = "cubence"
display_name = "Cubence"
description = "Cubence 中转站预设配置,使用 Cookie 认证"
supported_connectors: list[type[ProviderConnector]] = [
CubenceConnector,
]
supported_actions: list[type[ProviderAction]] = [
CubenceBalanceAction,
]
default_action_configs: dict[ProviderActionType, dict[str, Any]] = {
ProviderActionType.QUERY_BALANCE: {
"endpoint": "/api/v1/dashboard/overview",
"method": "GET",
"response_mapping": {
"total_available": "data.balance.total_balance_dollar",
},
},
}
def get_credentials_schema(self) -> dict[str, Any]:
"""Cubence 使用 token_cookie 认证"""
return CubenceConnector.get_credentials_schema()
def get_verify_endpoint(self) -> str:
"""验证端点"""
return "/api/v1/dashboard/overview"
def build_verify_headers(
self,
config: dict[str, Any],
credentials: dict[str, Any],
) -> dict[str, str]:
"""
构建 Cubence 的验证请求 Headers
使用 Cookie 认证,不使用 Authorization。
"""
headers: dict[str, str] = {}
# 添加 token Cookie
cookie_input = credentials.get("token_cookie")
if cookie_input:
token_value = extract_cookie_value(cookie_input, "token")
headers["Cookie"] = f"token={token_value}"
return headers
def _auth_fail_message(self, status_code: int) -> str:
"""Cookie 认证的错误消息"""
if status_code == 401:
return "Cookie 已失效,请重新配置"
return "Cookie 已失效或无权限"
def _build_verify_result(
self, user_data: dict[str, Any], raw_data: dict[str, Any] | None = None
) -> VerifyResult:
"""Cubence 自定义字段提取user/balance/subscription_limits"""
user_info = user_data.get("user", {})
balance_info = user_data.get("balance", {})
subscription_limits = user_data.get("subscription_limits", {})
# 构建 extra 信息,包含窗口限额
extra: dict[str, Any] = {
"role": user_info.get("role"),
"invite_code": user_info.get("invite_code"),
}
# 5小时窗口限额
five_hour = subscription_limits.get("five_hour", {})
if five_hour:
extra["five_hour_limit"] = {
"limit": five_hour.get("limit"),
"used": five_hour.get("used"),
"remaining": five_hour.get("remaining"),
"resets_at": five_hour.get("resets_at"),
}
# 每周窗口限额
weekly = subscription_limits.get("weekly", {})
if weekly:
extra["weekly_limit"] = {
"limit": weekly.get("limit"),
"used": weekly.get("used"),
"remaining": weekly.get("remaining"),
"resets_at": weekly.get("resets_at"),
}
return VerifyResult(
success=True,
username=user_info.get("username"),
display_name=user_info.get("username"),
quota=balance_info.get("total_balance_dollar"),
extra=extra,
)

View File

@@ -0,0 +1,204 @@
"""
通用 API 架构
支持各种中转站的可配置架构。
## 添加新认证模板示例
如需添加新的中转站模板(如 MyApi参考以下步骤
1. 在 architectures/ 目录创建新文件,如 my_api.py
from src.services.provider_ops.architectures.base import ProviderArchitecture
from src.services.provider_ops.connectors.base import ProviderConnector
class MyApiConnector(ProviderConnector):
# 实现自己的连接器
pass
class MyApiArchitecture(ProviderArchitecture):
architecture_id = "my_api"
display_name = "My API"
description = "My API 风格中转站"
supported_connectors = [MyApiConnector]
supported_actions = [BalanceAction]
# 如果需要特殊的认证 headers重写此方法
def build_verify_headers(self, config, credentials):
headers = super().build_verify_headers(config, credentials)
if "custom_field" in credentials:
headers["X-Custom-Header"] = credentials["custom_field"]
return headers
2. 在 registry.py 的 _register_builtin_architectures() 中注册:
from .my_api import MyApiArchitecture
builtin = [..., MyApiArchitecture]
3. 在前端 auth-templates/ 添加对应的模板定义
"""
from typing import Any
import httpx
from src.services.provider_ops.actions import (
NewApiBalanceAction,
ProviderAction,
)
from src.services.provider_ops.architectures.base import (
ProviderArchitecture,
ProviderConnector,
)
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
class GenericApiKeyConnector(ProviderConnector):
"""
通用 API Key 连接器
支持多种 API Key 传递方式:
- Bearer Token (Authorization: Bearer xxx)
- Custom Header (X-API-Key: xxx)
"""
auth_type = ConnectorAuthType.API_KEY
display_name = "API Key"
def __init__(self, base_url: str, config: dict[str, Any] | None = None):
super().__init__(base_url, config)
self._api_key: str | None = None
# 支持配置认证方式
self._auth_method = self.config.get("auth_method", "bearer")
self._header_name = self.config.get("header_name", "Authorization")
async def connect(self, credentials: dict[str, Any]) -> bool:
"""建立连接"""
api_key = credentials.get("api_key")
if not api_key:
self._set_error("API Key 不能为空")
return False
self._api_key = api_key
self._set_connected()
return True
async def disconnect(self) -> None:
"""断开连接"""
self._api_key = None
self._set_disconnected()
async def is_authenticated(self) -> bool:
"""检查是否已认证"""
return self._api_key is not None
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
"""为请求应用认证信息"""
if not self._api_key:
return request
if self._auth_method == "bearer":
request.headers["Authorization"] = f"Bearer {self._api_key}"
elif self._auth_method == "header":
request.headers[self._header_name] = self._api_key
return request
@classmethod
def get_credentials_schema(cls) -> dict[str, Any]:
"""获取凭据配置 schema"""
return {
"type": "object",
"properties": {
"base_url": {
"type": "string",
"title": "站点地址",
"description": "API 基础地址",
},
"api_key": {
"type": "string",
"title": "API Key",
"description": "提供商的 API Key",
"x-sensitive": True,
"x-input-type": "password",
},
},
"required": ["api_key"],
"x-field-groups": [
{"fields": ["base_url"]},
{"fields": ["api_key"]},
],
"x-auth-type": "api_key",
"x-auth-method": "bearer",
"x-validation": [
{
"type": "required",
"fields": ["api_key"],
"message": "请填写 API Key",
},
],
"x-quota-divisor": 500000,
"x-currency": "USD",
}
class GenericApiArchitecture(ProviderArchitecture):
"""
通用 API 架构
适用于各种中转站,支持所有认证方式和操作类型。
用户可以完全自定义 endpoint 和响应映射。
这是"自定义"模板对应的后端架构。
"""
architecture_id = "generic_api"
display_name = "通用 API"
description = "可配置的通用 API 架构,适用于各种中转站"
hidden = True
supported_connectors: list[type[ProviderConnector]] = [
GenericApiKeyConnector,
]
supported_actions: list[type[ProviderAction]] = [NewApiBalanceAction]
# 默认操作配置(可被用户配置覆盖)
default_action_configs: dict[ProviderActionType, dict[str, Any]] = {
ProviderActionType.QUERY_BALANCE: {
"endpoint": "/api/user/balance",
"method": "GET",
},
ProviderActionType.CHECKIN: {
"endpoint": "/api/user/checkin",
"method": "POST",
},
}
def get_credentials_schema(self) -> dict[str, Any]:
"""通用架构只需要 api_key"""
return GenericApiKeyConnector.get_credentials_schema()
def get_verify_endpoint(self) -> str:
"""通用架构验证端点"""
return "/api/user/self"
def build_verify_headers(
self,
config: dict[str, Any],
credentials: dict[str, Any],
) -> dict[str, str]:
"""构建通用 API 的验证请求 Headers"""
headers: dict[str, str] = {}
api_key = credentials.get("api_key", "")
if api_key:
auth_method = config.get("auth_method", "bearer")
if auth_method == "bearer":
headers["Authorization"] = f"Bearer {api_key}"
elif auth_method == "header":
header_name = config.get("header_name", "X-API-Key")
headers[header_name] = api_key
return headers

View File

@@ -0,0 +1,289 @@
"""
NekoCode 架构
针对 NekoCode 中转站的预设配置,使用 Cookie 认证。
"""
from typing import Any
import httpx
from src.core.logger import logger
from src.services.provider_ops.actions import ProviderAction
from src.services.provider_ops.actions.nekocode_balance import NekoCodeBalanceAction
from src.services.provider_ops.architectures.base import (
ProviderArchitecture,
ProviderConnector,
VerifyResult,
)
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
from src.services.provider_ops.utils import extract_cookie_value
from src.utils.ssl_utils import get_ssl_context
class NekoCodeConnector(ProviderConnector):
"""
NekoCode 专用连接器
特点:
- 使用 Cookie 认证session
"""
auth_type = ConnectorAuthType.COOKIE
display_name = "NekoCode Cookie"
def __init__(self, base_url: str, config: dict[str, Any] | None = None):
super().__init__(base_url, config)
self._session_cookie: str | None = None
async def connect(self, credentials: dict[str, Any]) -> bool:
"""建立连接"""
session_cookie = credentials.get("session_cookie")
if not session_cookie:
self._set_error("Session Cookie 不能为空")
return False
# 提取纯 session 值(支持完整 Cookie 字符串或仅 session 值)
self._session_cookie = extract_cookie_value(session_cookie, "session")
self._set_connected()
return True
async def disconnect(self) -> None:
"""断开连接"""
self._session_cookie = None
self._set_disconnected()
async def is_authenticated(self) -> bool:
"""检查是否已认证"""
return self._session_cookie is not None
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
"""为请求应用认证信息"""
if self._session_cookie:
request.headers["Cookie"] = f"session={self._session_cookie}"
return request
@classmethod
def get_credentials_schema(cls) -> dict[str, Any]:
"""获取凭据配置 schema"""
return {
"type": "object",
"properties": {
"base_url": {
"type": "string",
"title": "站点地址",
"description": "API 基础地址",
"x-default-value": "https://nekocode.ai",
},
"session_cookie": {
"type": "string",
"title": "Session Cookie",
"description": "从浏览器复制的 session Cookie 值",
"x-sensitive": True,
"x-input-type": "password",
},
},
"required": ["session_cookie"],
"x-field-groups": [
{"fields": ["base_url"]},
{"fields": ["session_cookie"]},
],
"x-auth-type": "cookie",
"x-default-base-url": "https://nekocode.ai",
"x-validation": [
{
"type": "required",
"fields": ["session_cookie"],
"message": "请填写 Session Cookie",
},
],
"x-quota-divisor": None,
"x-currency": "USD",
"x-balance-extra-format": [
{
"label": "",
"type": "daily_quota",
"source_limit": "daily_quota_limit",
"source_remaining": "daily_remaining_quota",
"source_start_date": "effective_start_date",
},
{
"label": "",
"type": "monthly_expiry",
"source_end_date": "effective_end_date",
},
],
}
class NekoCodeArchitecture(ProviderArchitecture):
"""
NekoCode 架构预设
针对 NekoCode 中转站优化的预设配置。
特点:
- 使用 Cookie 认证session
- 验证端点: /api/usage/summary
- 显示余额、每日配额、订阅状态
"""
architecture_id = "nekocode"
display_name = "NekoCode"
description = "NekoCode 中转站预设配置,使用 Cookie 认证"
supported_connectors: list[type[ProviderConnector]] = [
NekoCodeConnector,
]
supported_actions: list[type[ProviderAction]] = [NekoCodeBalanceAction]
default_action_configs: dict[ProviderActionType, dict[str, Any]] = {
ProviderActionType.QUERY_BALANCE: {
"endpoint": "/api/usage/summary",
"method": "GET",
},
}
def get_credentials_schema(self) -> dict[str, Any]:
"""NekoCode 使用 session_cookie 认证"""
return NekoCodeConnector.get_credentials_schema()
def get_verify_endpoint(self) -> str:
"""验证端点"""
return "/api/user/self"
async def prepare_verify_config(
self,
base_url: str,
config: dict[str, Any],
credentials: dict[str, Any],
) -> dict[str, Any]:
"""
验证前获取 /api/usage/summary 数据(用于显示天卡信息)
Args:
base_url: API 基础地址
config: 连接器配置
credentials: 凭据信息
Returns:
包含 _usage_summary 的配置(会被合并到验证响应中)
"""
try:
# 构建请求头
headers: dict[str, str] = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
),
}
# 添加 Cookie
cookie_input = credentials.get("session_cookie")
if cookie_input:
session_value = extract_cookie_value(cookie_input, "session")
headers["Cookie"] = f"session={session_value}"
# 构建 client 参数
client_kwargs: dict[str, Any] = {
"timeout": 10,
"verify": get_ssl_context(),
}
from src.services.proxy_node.resolver import resolve_ops_proxy_config_async
proxy, tunnel_node_id = await resolve_ops_proxy_config_async(config)
if tunnel_node_id:
from src.services.proxy_node.tunnel_transport import create_tunnel_transport
client_kwargs["transport"] = create_tunnel_transport(tunnel_node_id, timeout=10.0)
elif proxy:
client_kwargs["proxy"] = proxy
async with httpx.AsyncClient(**client_kwargs) as client:
resp = await client.get(
f"{base_url.rstrip('/')}/api/usage/summary",
headers=headers,
)
if resp.status_code == 200:
data = resp.json()
if data.get("success"):
return {"_usage_summary": data.get("data", {})}
except Exception as e:
logger.debug("获取 NekoCode usage summary 失败: {}", e)
return {}
def build_verify_headers(
self,
config: dict[str, Any],
credentials: dict[str, Any],
) -> dict[str, str]:
"""
构建 NekoCode 的验证请求 Headers
使用 Cookie 认证,不使用 Authorization。
"""
headers: dict[str, str] = {}
# 添加 session Cookie
cookie_input = credentials.get("session_cookie")
if cookie_input:
# 提取 session 值(支持完整 Cookie 字符串或仅 session 值)
session_value = extract_cookie_value(cookie_input, "session")
headers["Cookie"] = f"session={session_value}"
return headers
def _auth_fail_message(self, status_code: int) -> str:
"""Cookie 认证的错误消息"""
if status_code == 401:
return "Cookie 已失效,请重新配置"
return "Cookie 已失效或无权限"
def _build_verify_result(
self, user_data: dict[str, Any], raw_data: dict[str, Any] | None = None
) -> VerifyResult:
"""NekoCode 自定义字段提取(合并 _usage_summary 天卡数据)"""
# 转换余额字符串为数字
balance = user_data.get("balance")
try:
quota = float(balance) if balance else None
except (TypeError, ValueError):
quota = None
# 从 prepare_verify_config 获取的 _usage_summary 数据(天卡信息)
extra: dict[str, Any] = {}
usage_summary = (raw_data or {}).get("_usage_summary", {})
subscription = usage_summary.get("subscription", {})
if subscription:
daily_limit = subscription.get("daily_quota_limit")
daily_remaining = subscription.get("daily_remaining_quota")
try:
extra["daily_quota_limit"] = float(daily_limit) if daily_limit else None
except (TypeError, ValueError):
pass
try:
extra["daily_remaining_quota"] = float(daily_remaining) if daily_remaining else None
except (TypeError, ValueError):
pass
extra["plan_name"] = subscription.get("plan_name")
extra["subscription_status"] = subscription.get("status")
extra["effective_start_date"] = subscription.get("effective_start_date")
extra["effective_end_date"] = subscription.get("effective_end_date")
return VerifyResult(
success=True,
username=user_data.get("username"),
display_name=user_data.get("display_name") or user_data.get("username"),
email=user_data.get("email"),
quota=quota,
extra=extra if extra else None,
)

View File

@@ -0,0 +1,232 @@
"""
New API 架构
针对 New API 风格的中转站优化的预设配置。
"""
from typing import Any
import httpx
from src.core.api_format.headers import BROWSER_FINGERPRINT_HEADERS
from src.services.provider_ops.actions import (
NewApiBalanceAction,
ProviderAction,
)
from src.services.provider_ops.architectures.base import (
ProviderArchitecture,
ProviderConnector,
)
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
class NewApiConnector(ProviderConnector):
"""
New API 专用连接器
特点:
- 使用 Bearer Token 认证
- 需要 New-Api-User Header 传递用户 ID
"""
auth_type = ConnectorAuthType.API_KEY
display_name = "New API Key"
def __init__(self, base_url: str, config: dict[str, Any] | None = None):
super().__init__(base_url, config)
self._api_key: str | None = None
self._user_id: str | None = None
self._cookie: str | None = None
async def connect(self, credentials: dict[str, Any]) -> bool:
"""建立连接"""
api_key = credentials.get("api_key")
cookie = credentials.get("cookie")
user_id = credentials.get("user_id")
# api_key 和 cookie 至少需要一个
if not api_key and not cookie:
self._set_error("访问令牌和 Cookie 至少需要填写一个")
return False
# 使用 api_key 时必须提供 user_id使用 cookie 时 user_id 可选
if api_key and not cookie and not user_id:
self._set_error("使用访问令牌时,用户 ID 不能为空")
return False
self._api_key = api_key
self._user_id = str(user_id) if user_id else None
self._cookie = cookie
self._set_connected()
return True
async def disconnect(self) -> None:
"""断开连接"""
self._api_key = None
self._user_id = None
self._cookie = None
self._set_disconnected()
async def is_authenticated(self) -> bool:
"""检查是否已认证"""
# 有 cookie 就行,或者有 api_key + user_id
if self._cookie:
return True
return self._api_key is not None and self._user_id is not None
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
"""为请求应用认证信息"""
# 添加浏览器指纹 Headers 以绕过 Cloudflare 等防护
for key, value in BROWSER_FINGERPRINT_HEADERS.items():
request.headers.setdefault(key, value)
if self._api_key:
request.headers["Authorization"] = f"Bearer {self._api_key}"
if self._user_id:
request.headers["New-Api-User"] = self._user_id
if self._cookie:
request.headers["Cookie"] = self._cookie
return request
@classmethod
def get_credentials_schema(cls) -> dict[str, Any]:
"""获取凭据配置 schema"""
return {
"type": "object",
"properties": {
"base_url": {
"type": "string",
"title": "站点地址",
"description": "API 基础地址",
},
"api_key": {
"type": "string",
"title": "访问令牌 (API Key)",
"description": "New API 的访问令牌,与 Cookie 二选一",
"x-sensitive": True,
"x-input-type": "password",
},
"user_id": {
"type": "string",
"title": "用户 ID",
"description": "使用访问令牌时必填,使用 Cookie 时可选",
},
"cookie": {
"type": "string",
"title": "Cookie",
"description": "用于 Cookie 认证,与访问令牌二选一",
"x-sensitive": True,
"x-input-type": "password",
},
},
"required": [],
"x-field-groups": [
{"fields": ["base_url"]},
{
"fields": ["cookie"],
"x-help": "从浏览器开发者工具复制完整 Cookie",
},
{
"layout": "inline",
"fields": ["api_key", "user_id"],
"x-flex": {"api_key": 3, "user_id": 1},
},
],
"x-auth-type": "api_key",
"x-auth-method": "bearer",
"x-validation": [
{
"type": "any_required",
"fields": ["api_key", "cookie"],
"message": "访问令牌和 Cookie 至少需要填写一个",
},
{
"type": "conditional_required",
"if": "api_key",
"unless": "cookie",
"then": ["user_id"],
"message": "使用访问令牌时,用户 ID 不能为空",
},
],
"x-quota-divisor": 500000,
"x-currency": "USD",
"x-field-hooks": {
"cookie": {
"action": "parse_new_api_user_id",
"target": "user_id",
},
},
}
class NewApiArchitecture(ProviderArchitecture):
"""
New API 架构预设
针对 New API 风格的中转站优化的预设配置。
特点:
- 使用 Bearer Token 认证
- 需要 New-Api-User Header 传递用户 ID
- 验证端点: /api/user/self
- quota 单位通常是 1/500000 美元
"""
architecture_id = "new_api"
display_name = "New API"
description = "New API 风格中转站的预设配置"
supported_connectors: list[type[ProviderConnector]] = [
NewApiConnector,
]
supported_actions: list[type[ProviderAction]] = [
NewApiBalanceAction,
]
default_action_configs: dict[ProviderActionType, dict[str, Any]] = {
ProviderActionType.QUERY_BALANCE: {
"endpoint": "/api/user/self",
"method": "GET",
"quota_divisor": 500000, # New API 的 quota 单位是 1/500000 美元
"checkin_endpoint": "/api/user/checkin", # 签到端点
},
}
def get_credentials_schema(self) -> dict[str, Any]:
"""New API 需要 api_key 和 user_id"""
return NewApiConnector.get_credentials_schema()
def get_verify_endpoint(self) -> str:
"""New API 验证端点"""
return "/api/user/self"
def build_verify_headers(
self,
config: dict[str, Any],
credentials: dict[str, Any],
) -> dict[str, str]:
"""
构建 New API 的验证请求 Headers
New API 特有:需要 New-Api-User Header 传递用户 ID
"""
# 以浏览器指纹 Headers 为基础,绕过 Cloudflare 等防护
headers: dict[str, str] = {**BROWSER_FINGERPRINT_HEADERS}
# Bearer Token 认证
api_key = credentials.get("api_key", "")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
# New API 特有的 header
user_id = credentials.get("user_id", "")
if user_id:
headers["New-Api-User"] = str(user_id)
# 可选的 Cookie
cookie = credentials.get("cookie", "")
if cookie:
headers["Cookie"] = cookie
return headers

View File

@@ -0,0 +1,565 @@
"""
Sub2API 架构
针对 Sub2API 风格的中转站优化的预设配置。
支持两种认证方式:
1. 账号密码登录(自动获取 JWT过期自动刷新refresh 失败自动重新登录)
2. Refresh Token从浏览器 localStorage 获取,自动续期,适合 OAuth 用户)
"""
import time
from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager
from typing import Any
import httpx
from src.core.logger import logger
from src.services.provider_ops.actions import ProviderAction
from src.services.provider_ops.actions.sub2api_balance import Sub2ApiBalanceAction
from src.services.provider_ops.architectures.base import (
ProviderArchitecture,
ProviderConnector,
VerifyResult,
)
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
from src.utils.ssl_utils import get_ssl_context
def _calc_expires_at(token_data: dict[str, Any]) -> float:
"""从 token 响应数据计算过期时间(秒级时间戳,提前 60s"""
token_expires_at = token_data.get("token_expires_at")
if token_expires_at is not None:
# Sub2API 返回毫秒级绝对时间戳
return token_expires_at / 1000 - 60
expires_in = token_data.get("expires_in", 900)
return time.time() + expires_in - 60
async def _do_login(
client: httpx.AsyncClient,
email: str,
password: str,
) -> dict[str, Any]:
"""调用 Sub2API 登录接口,返回 token_data。失败抛 ValueError。"""
resp = await client.post(
"/api/v1/auth/login",
json={"email": email, "password": password},
)
data = resp.json()
if resp.status_code != 200 or data.get("code", -1) != 0:
raise ValueError(data.get("message", f"登录失败 (HTTP {resp.status_code})"))
return data.get("data", {})
async def _do_refresh(
client: httpx.AsyncClient,
refresh_token: str,
) -> dict[str, Any]:
"""调用 Sub2API refresh 接口,返回 token_data。失败抛 ValueError。"""
resp = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": refresh_token},
)
data = resp.json()
if resp.status_code != 200 or data.get("code", -1) != 0:
raise ValueError(data.get("message", "Refresh Token 无效或已过期"))
return data.get("data", {})
def _collect_updated_credentials(
token_data: dict[str, Any],
old_refresh_token: str | None = None,
) -> dict[str, Any]:
"""从 token 响应中提取需要持久化的凭据变更"""
updated: dict[str, Any] = {}
new_refresh_token = token_data.get("refresh_token")
if new_refresh_token and new_refresh_token != old_refresh_token:
updated["refresh_token"] = new_refresh_token
access_token = token_data.get("access_token")
if access_token:
updated["_cached_access_token"] = access_token
updated["_cached_token_expires_at"] = _calc_expires_at(token_data)
return updated
class _Sub2ApiTokenMixin:
"""Sub2API JWT token 管理公共逻辑
与 ProviderConnector 配合使用MRO 中由 ProviderConnector 提供实际属性初始化)。
以下类型注解声明 mixin 依赖的协议属性,不会创建新的实例属性。
"""
# Mixin 自身管理的 token 状态(提供默认值防止子类遗漏初始化)
_access_token: str | None = None
_refresh_token: str | None = None
_token_expires_at: float = 0
# 以下属性由 ProviderConnector.__init__ 初始化,仅作协议声明
_on_credentials_updated: Callable[[dict[str, Any]], None] | None
base_url: str
_timeout: int | float
_proxy: str | httpx.Proxy | None
_tunnel_node_id: str | None
@asynccontextmanager
async def _get_raw_client(self) -> AsyncIterator[httpx.AsyncClient]:
"""获取不带 auth hook 的裸 HTTP 客户端(用于登录/刷新 token"""
transport = None
if self._tunnel_node_id:
from src.services.proxy_node.tunnel_transport import create_tunnel_transport
transport = create_tunnel_transport(self._tunnel_node_id, timeout=self._timeout)
elif self._proxy:
transport = httpx.AsyncHTTPTransport(proxy=self._proxy)
async with httpx.AsyncClient(
base_url=self.base_url,
timeout=self._timeout,
transport=transport,
verify=get_ssl_context(),
) as client:
yield client
def _update_tokens(self, token_data: dict[str, Any]) -> None:
"""更新实例 token 状态并通过回调持久化变更"""
old_refresh_token = self._refresh_token
self._access_token = token_data.get("access_token")
self._refresh_token = token_data.get("refresh_token", self._refresh_token)
self._token_expires_at = _calc_expires_at(token_data)
if self._on_credentials_updated:
updated = _collect_updated_credentials(token_data, old_refresh_token)
if updated:
self._on_credentials_updated(updated)
async def _refresh(self) -> bool:
"""使用 refresh_token 续期"""
if not self._refresh_token:
return False
try:
async with self._get_raw_client() as client:
token_data = await _do_refresh(client, self._refresh_token)
self._update_tokens(token_data)
logger.debug("Sub2API token 续期成功")
return True
except ValueError as e:
logger.warning("Sub2API refresh_token 续期失败: {}", e)
return False
except Exception as e:
logger.warning("Sub2API refresh_token 续期异常: {}", e)
return False
class Sub2ApiConnector(_Sub2ApiTokenMixin, ProviderConnector):
"""
Sub2API 连接器(账号密码模式)
使用 email + password 登录获取 JWT Token支持自动刷新
- 登录后获取 access_token + refresh_token
- access_token 过期前自动使用 refresh_token 续期
- refresh_token 也过期时自动重新登录
"""
auth_type = ConnectorAuthType.SESSION_LOGIN
display_name = "账号密码"
def __init__(self, base_url: str, config: dict[str, Any] | None = None):
super().__init__(base_url, config)
self._access_token: str | None = None
self._refresh_token: str | None = None
self._token_expires_at: float = 0
self._email: str | None = None
self._password: str | None = None
async def connect(self, credentials: dict[str, Any]) -> bool:
email = credentials.get("email", "").strip()
password = credentials.get("password", "").strip()
if not email or not password:
self._set_error("邮箱和密码不能为空")
return False
self._email = email
self._password = password
# 如果有缓存的 access_token 且未过期,直接复用,避免不必要的登录
cached_access_token = credentials.get("_cached_access_token", "")
cached_expires_at = credentials.get("_cached_token_expires_at", 0)
if cached_access_token and time.time() < cached_expires_at:
self._access_token = cached_access_token
self._token_expires_at = cached_expires_at
# 恢复 refresh_token 以便 access_token 过期后可刷新而非重新登录
self._refresh_token = credentials.get("refresh_token", "").strip() or None
self._set_connected()
return True
return await self._login()
async def _login(self) -> bool:
"""使用 email + password 登录获取 token pair"""
try:
async with self._get_raw_client() as client:
token_data = await _do_login(client, self._email or "", self._password or "")
self._update_tokens(token_data)
self._set_connected()
logger.debug(
"Sub2API 登录成功: {}", self._email[:3] + "***" if self._email else "N/A"
)
return True
except ValueError as e:
self._set_error(str(e))
return False
except httpx.TimeoutException:
self._set_error("登录请求超时")
return False
except httpx.RequestError as e:
self._set_error(f"登录网络错误: {e}")
return False
except Exception as e:
self._set_error(f"登录失败: {e}")
return False
async def _ensure_token(self) -> None:
"""确保 access_token 有效,过期则自动刷新或重新登录"""
if self._access_token and time.time() < self._token_expires_at:
return
if self._refresh_token and await self._refresh():
return
logger.info("Sub2API token 已过期,尝试重新登录")
if not await self._login():
logger.error("Sub2API 重新登录失败: {}", self._last_error)
async def disconnect(self) -> None:
self._access_token = None
self._refresh_token = None
self._token_expires_at = 0
self._email = None
self._password = None
self._set_disconnected()
async def is_authenticated(self) -> bool:
if not self._access_token:
return False
return self._refresh_token is not None or self._email is not None
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
if self._access_token:
request.headers["Authorization"] = f"Bearer {self._access_token}"
return request
async def _auth_hook(self, request: httpx.Request) -> None:
await self._ensure_token()
self._apply_auth(request)
async def refresh_auth(self, credentials: dict[str, Any]) -> bool:
if await self._refresh():
return True
self._email = credentials.get("email", self._email)
self._password = credentials.get("password", self._password)
return await self._login()
@classmethod
def get_credentials_schema(cls) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"base_url": {
"type": "string",
"title": "站点地址",
"description": "API 基础地址",
},
"email": {
"type": "string",
"title": "邮箱",
"description": "Sub2API 登录邮箱",
},
"password": {
"type": "string",
"title": "密码",
"description": "Sub2API 登录密码",
"x-sensitive": True,
"x-input-type": "password",
},
},
"required": ["email", "password"],
"x-field-groups": [
{"fields": ["base_url"]},
{"fields": ["email"]},
{"fields": ["password"]},
],
"x-auth-type": "session_login",
"x-auth-method": "jwt",
"x-validation": [
{
"type": "required",
"fields": ["email", "password"],
"message": "请填写邮箱和密码",
},
],
}
class Sub2ApiRefreshTokenConnector(_Sub2ApiTokenMixin, ProviderConnector):
"""
Sub2API 连接器Refresh Token 模式)
适合 OAuth 登录用户(如 LinuxDo从浏览器 localStorage 获取 refresh_token。
- 首次连接时用 refresh_token 换取 access_token
- access_token 过期前自动续期
- refresh_token 过期后需手动更新(无法自动重新登录)
"""
auth_type = ConnectorAuthType.API_KEY
display_name = "Refresh Token"
def __init__(self, base_url: str, config: dict[str, Any] | None = None):
super().__init__(base_url, config)
self._access_token: str | None = None
self._refresh_token: str | None = None
self._token_expires_at: float = 0
async def connect(self, credentials: dict[str, Any]) -> bool:
refresh_token = credentials.get("refresh_token", "").strip()
if not refresh_token:
self._set_error("请填写 Refresh Token")
return False
self._refresh_token = refresh_token
# 如果有缓存的 access_token 且未过期,直接复用,不消耗 refresh_token
cached_access_token = credentials.get("_cached_access_token", "")
cached_expires_at = credentials.get("_cached_token_expires_at", 0)
if cached_access_token and time.time() < cached_expires_at:
self._access_token = cached_access_token
self._token_expires_at = cached_expires_at
self._set_connected()
return True
# 首次连接或 access_token 已过期,用 refresh_token 换取
if not await self._refresh():
self._refresh_token = None # 清理,避免残留无效状态
self._set_error("Refresh Token 无效或已过期")
return False
self._set_connected()
return True
async def _ensure_token(self) -> None:
"""确保 access_token 有效,有 refresh_token 时自动续期"""
if self._access_token and time.time() < self._token_expires_at:
return
if self._refresh_token:
await self._refresh()
async def disconnect(self) -> None:
self._access_token = None
self._refresh_token = None
self._token_expires_at = 0
self._set_disconnected()
async def is_authenticated(self) -> bool:
return self._refresh_token is not None
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
if self._access_token:
request.headers["Authorization"] = f"Bearer {self._access_token}"
return request
async def _auth_hook(self, request: httpx.Request) -> None:
await self._ensure_token()
self._apply_auth(request)
async def refresh_auth(self, credentials: dict[str, Any]) -> bool:
if self._refresh_token and await self._refresh():
return True
return await self.connect(credentials)
@classmethod
def get_credentials_schema(cls) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"base_url": {
"type": "string",
"title": "站点地址",
"description": "API 基础地址",
},
"refresh_token": {
"type": "string",
"title": "Refresh Token",
"description": ("从浏览器 F12 > Application > Local Storage 获取"),
"x-sensitive": True,
"x-input-type": "password",
"x-help": "浏览器控制台执行 localStorage.getItem('refresh_token') 获取",
},
},
"required": ["refresh_token"],
"x-field-groups": [
{"fields": ["base_url"]},
{"fields": ["refresh_token"]},
],
"x-auth-type": "api_key",
"x-auth-method": "bearer",
"x-validation": [
{
"type": "required",
"fields": ["refresh_token"],
"message": "请填写 Refresh Token",
},
],
}
class Sub2ApiArchitecture(ProviderArchitecture):
"""
Sub2API 架构
特点:
- 支持两种认证方式:账号密码 / Refresh Token
- 验证端点: /api/v1/auth/me
- balance 为充值余额points 为赠送余额
- 余额查询同时获取订阅概览信息
"""
architecture_id = "sub2api"
display_name = "Sub2API"
description = "Sub2API 风格中转站的预设配置"
supported_connectors: list[type[ProviderConnector]] = [
Sub2ApiConnector,
Sub2ApiRefreshTokenConnector,
]
supported_actions: list[type[ProviderAction]] = [Sub2ApiBalanceAction]
default_action_configs: dict[ProviderActionType, dict[str, Any]] = {
ProviderActionType.QUERY_BALANCE: {
"endpoint": "/api/v1/auth/me?timezone=Asia/Shanghai",
"subscription_endpoint": "/api/v1/subscriptions/summary",
"method": "GET",
},
}
def get_credentials_schema(self) -> dict[str, Any]:
return Sub2ApiConnector.get_credentials_schema()
def get_verify_endpoint(self) -> str:
return "/api/v1/auth/me?timezone=Asia/Shanghai"
def build_verify_headers(
self,
config: dict[str, Any],
credentials: dict[str, Any],
) -> dict[str, str]:
headers: dict[str, str] = {}
access_token = credentials.get("_access_token", "")
if access_token:
headers["Authorization"] = f"Bearer {access_token}"
return headers
async def prepare_verify_config(
self,
base_url: str,
config: dict[str, Any],
credentials: dict[str, Any],
) -> tuple[dict[str, Any], dict[str, Any]]:
"""
验证前预处理:根据凭据类型选择登录方式
- 有 email + password -> 账号密码登录
- 有 refresh_token -> 用 refresh_token 换 access_token
Returns:
(extra_config, updated_credentials):
extra_config 为空updated_credentials 包含需持久化的凭据变更
Token Rotation 后的新 refresh_token、缓存的 access_token 等)
"""
base_url = base_url.rstrip("/")
from src.services.proxy_node.resolver import resolve_ops_proxy_config_async
proxy, tunnel_node_id = await resolve_ops_proxy_config_async(config)
client_kwargs: dict[str, Any] = {
"base_url": base_url,
"timeout": 30.0,
"verify": get_ssl_context(),
}
if tunnel_node_id:
from src.services.proxy_node.tunnel_transport import create_tunnel_transport
client_kwargs["transport"] = create_tunnel_transport(tunnel_node_id, timeout=30.0)
elif proxy:
client_kwargs["proxy"] = proxy
email = credentials.get("email", "").strip()
password = credentials.get("password", "").strip()
refresh_token = credentials.get("refresh_token", "").strip()
try:
if email and password:
async with httpx.AsyncClient(**client_kwargs) as client:
token_data = await _do_login(client, email, password)
elif refresh_token:
async with httpx.AsyncClient(**client_kwargs) as client:
token_data = await _do_refresh(client, refresh_token)
else:
raise ValueError("请填写账号密码或 Refresh Token")
except ValueError:
raise
except Exception as e:
raise ValueError(f"验证失败: {e}") from e
access_token = token_data.get("access_token", "")
credentials["_access_token"] = access_token
updated_credentials = _collect_updated_credentials(
token_data, old_refresh_token=refresh_token or None
)
return {}, updated_credentials
def parse_verify_response(
self,
status_code: int,
data: dict[str, Any],
) -> VerifyResult:
if status_code == 401:
return VerifyResult(success=False, message=self._auth_fail_message(401))
if status_code == 403:
return VerifyResult(success=False, message=self._auth_fail_message(403))
if status_code != 200:
return VerifyResult(success=False, message=f"验证失败HTTP {status_code}")
code = data.get("code")
if code != 0:
message = data.get("message", "验证失败")
return VerifyResult(success=False, message=message)
user_data = data.get("data", {})
return self._build_verify_result(user_data, data)
def _build_verify_result(
self, user_data: dict[str, Any], raw_data: dict[str, Any] | None = None
) -> VerifyResult:
balance = float(user_data.get("balance") or 0)
points = float(user_data.get("points") or 0)
return VerifyResult(
success=True,
username=user_data.get("username") or user_data.get("email"),
display_name=user_data.get("username") or user_data.get("email"),
email=user_data.get("email"),
quota=balance + points,
extra={
"balance": balance,
"points": points,
"status": user_data.get("status"),
"concurrency": user_data.get("concurrency"),
},
)

View File

@@ -0,0 +1,344 @@
"""
YesCode 架构
针对 YesCode 中转站的预设配置。
"""
from typing import Any
import httpx
from src.services.provider_ops.actions import ProviderAction
from src.services.provider_ops.actions.yescode_balance import (
YesCodeBalanceAction,
fetch_yescode_combined_data,
parse_yescode_balance_extra,
)
from src.services.provider_ops.architectures.base import (
ProviderArchitecture,
ProviderConnector,
VerifyResult,
)
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
from src.utils.ssl_utils import get_ssl_context
def _extract_cookies(cookie_string: str) -> dict[str, str]:
"""
从完整的 Cookie 字符串中提取 yescode_auth 和 yescode_csrf
Args:
cookie_string: Cookie 字符串
Returns:
包含 yescode_auth 和 yescode_csrf 的字典
"""
result: dict[str, str] = {}
for part in cookie_string.split(";"):
part = part.strip()
if "=" in part:
key, value = part.split("=", 1)
key = key.strip()
if key in ("yescode_auth", "yescode_csrf"):
result[key] = value.strip()
return result
def _build_cookie_header(cookie_string: str) -> str:
"""
从输入的 Cookie 字符串构建请求用的 Cookie header
支持两种输入格式:
1. 完整 Cookie: "yescode_auth=xxx; yescode_csrf=yyy"
2. 仅 auth 值: "eyJhbGciOiJI..."
Args:
cookie_string: Cookie 字符串或 auth 值
Returns:
Cookie header 值
"""
# 如果包含 "yescode_auth=",说明是完整 Cookie 字符串
if "yescode_auth=" in cookie_string:
cookies = _extract_cookies(cookie_string)
parts = []
if "yescode_auth" in cookies:
parts.append(f"yescode_auth={cookies['yescode_auth']}")
if "yescode_csrf" in cookies:
parts.append(f"yescode_csrf={cookies['yescode_csrf']}")
return "; ".join(parts)
# 否则认为直接是 auth 值
return f"yescode_auth={cookie_string.strip()}"
class YesCodeConnector(ProviderConnector):
"""
YesCode 专用连接器
特点:
- 使用 Cookie 认证yescode_auth JWT + yescode_csrf
"""
auth_type = ConnectorAuthType.COOKIE
display_name = "YesCode Cookie"
def __init__(self, base_url: str, config: dict[str, Any] | None = None):
super().__init__(base_url, config)
self._auth_cookie: str | None = None
async def connect(self, credentials: dict[str, Any]) -> bool:
"""建立连接"""
auth_cookie = credentials.get("auth_cookie")
if not auth_cookie:
self._set_error("Auth Cookie 不能为空")
return False
# 构建 Cookie header
self._auth_cookie = _build_cookie_header(auth_cookie)
self._set_connected()
return True
async def disconnect(self) -> None:
"""断开连接"""
self._auth_cookie = None
self._set_disconnected()
async def is_authenticated(self) -> bool:
"""检查是否已认证"""
return self._auth_cookie is not None
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
"""为请求应用认证信息"""
if self._auth_cookie:
request.headers["Cookie"] = self._auth_cookie
return request
@classmethod
def get_credentials_schema(cls) -> dict[str, Any]:
"""获取凭据配置 schema"""
return {
"type": "object",
"properties": {
"base_url": {
"type": "string",
"title": "站点地址",
"description": "API 基础地址",
"x-default-value": "https://co.yes.vg",
},
"auth_cookie": {
"type": "string",
"title": "Auth Cookie",
"description": "从浏览器复制的 Cookie包含 yescode_auth 和 yescode_csrf",
"x-sensitive": True,
"x-input-type": "password",
},
},
"required": ["auth_cookie"],
"x-field-groups": [
{"fields": ["base_url"]},
{"fields": ["auth_cookie"]},
],
"x-auth-type": "cookie",
"x-default-base-url": "https://co.yes.vg",
"x-validation": [
{
"type": "required",
"fields": ["auth_cookie"],
"message": "请填写 Auth Cookie",
},
],
"x-quota-divisor": None,
"x-currency": "USD",
"x-balance-extra-format": [
{
"label": "",
"type": "weekly_spent",
"source_limit": "daily_limit",
"source_spent": "daily_spent",
"source_resets_at": "daily_resets_at",
},
{
"label": "",
"type": "weekly_spent",
"source_limit": "weekly_limit",
"source_spent": "weekly_spent",
"source_resets_at": "weekly_resets_at",
},
],
}
class YesCodeArchitecture(ProviderArchitecture):
"""
YesCode 架构预设
针对 YesCode 中转站优化的预设配置。
特点:
- 使用 Cookie 认证yescode_auth JWT + yescode_csrf
- 验证端点: /api/v1/user/balance
- 余额单位直接是美元
- 支持每周限额查询
"""
architecture_id = "yescode"
display_name = "YesCode"
description = "YesCode 中转站预设配置,使用 Cookie 认证"
supported_connectors: list[type[ProviderConnector]] = [
YesCodeConnector,
]
supported_actions: list[type[ProviderAction]] = [
YesCodeBalanceAction,
]
default_action_configs: dict[ProviderActionType, dict[str, Any]] = {
ProviderActionType.QUERY_BALANCE: {
"endpoint": "/api/v1/user/balance",
"method": "GET",
"response_mapping": {
"total_available": "total_balance",
},
},
}
def get_credentials_schema(self) -> dict[str, Any]:
"""YesCode 使用 auth_cookie 认证"""
return YesCodeConnector.get_credentials_schema()
def get_verify_endpoint(self) -> str:
"""验证端点 - 使用 profile 接口获取完整信息"""
return "/api/v1/auth/profile"
async def prepare_verify_config(
self,
base_url: str,
config: dict[str, Any],
credentials: dict[str, Any],
) -> dict[str, Any]:
"""
预获取合并数据balance + profile
验证时并发调用两个接口获取完整数据。
"""
extra_config: dict[str, Any] = {}
cookie_input = credentials.get("auth_cookie")
if not cookie_input:
return extra_config
cookie_header = _build_cookie_header(cookie_input)
# 获取代理配置(支持 proxy_node_id、tunnel 和旧的 proxy URL
from src.services.proxy_node.resolver import resolve_ops_proxy_config_async
proxy, tunnel_node_id = await resolve_ops_proxy_config_async(config)
try:
# 构建 client 参数
client_kwargs: dict[str, Any] = {
"headers": {"Cookie": cookie_header},
"timeout": 10.0,
"verify": get_ssl_context(),
}
if tunnel_node_id:
from src.services.proxy_node.tunnel_transport import create_tunnel_transport
client_kwargs["transport"] = create_tunnel_transport(tunnel_node_id, timeout=10.0)
elif proxy:
client_kwargs["proxy"] = proxy
# 创建临时 client 获取合并数据
async with httpx.AsyncClient(**client_kwargs) as client:
combined_data = await fetch_yescode_combined_data(client, base_url)
extra_config["_combined_data"] = combined_data
except Exception:
# 如果调用失败,不影响验证流程(会回退到单独调用 profile
pass
return extra_config
def build_verify_headers(
self,
config: dict[str, Any],
credentials: dict[str, Any],
) -> dict[str, str]:
"""
构建 YesCode 的验证请求 Headers
使用 Cookie 认证,不使用 Authorization。
"""
headers: dict[str, str] = {}
# 添加 Cookie
cookie_input = credentials.get("auth_cookie")
if cookie_input:
headers["Cookie"] = _build_cookie_header(cookie_input)
return headers
def parse_verify_response(
self,
status_code: int,
data: dict[str, Any],
) -> VerifyResult:
"""解析 YesCode 验证响应(使用预获取的合并数据)"""
if status_code == 401:
return VerifyResult(success=False, message="Cookie 已失效,请重新配置")
if status_code == 403:
return VerifyResult(success=False, message="Cookie 已失效或无权限")
if status_code != 200:
return VerifyResult(success=False, message=f"验证失败HTTP {status_code}")
# 优先使用预获取的合并数据(包含 balance + profile
combined_data = data.get("_combined_data")
if combined_data:
# 检查是否有有效数据
if "_profile_data" not in combined_data and "_balance_data" not in combined_data:
return VerifyResult(success=False, message="Cookie 已失效,请重新配置")
# 使用公共函数解析余额
extra = parse_yescode_balance_extra(combined_data)
total_available = extra.pop("_total_available", 0)
extra.pop("_subscription_available", None)
return VerifyResult(
success=True,
username=combined_data.get("username"),
display_name=combined_data.get("username"),
email=combined_data.get("email"),
quota=total_available,
extra=extra if extra else None,
)
# 回退:仅使用 profile 数据(旧逻辑,当 prepare_verify_config 失败时)
if "username" not in data:
return VerifyResult(success=False, message="响应格式无效")
# 构造兼容格式供公共函数使用
compat_data = {
"pay_as_you_go_balance": data.get("pay_as_you_go_balance", 0),
"subscription_balance": data.get("subscription_balance", 0),
"weekly_spent_balance": data.get("current_week_spend", 0),
"subscription_plan": data.get("subscription_plan"),
"last_week_reset": data.get("last_week_reset"),
"last_daily_balance_add": data.get("last_daily_balance_add"),
}
extra = parse_yescode_balance_extra(compat_data)
total_available = extra.pop("_total_available", 0)
extra.pop("_subscription_available", None)
return VerifyResult(
success=True,
username=data.get("username"),
display_name=data.get("username"),
email=data.get("email"),
quota=total_available,
extra=extra if extra else None,
)

View File

@@ -0,0 +1,149 @@
"""
架构注册表
管理所有可用的 Provider 架构。
"""
from __future__ import annotations
import threading
from src.core.logger import logger
from src.services.provider_ops.architectures import (
AnyrouterArchitecture,
CubenceArchitecture,
GenericApiArchitecture,
NekoCodeArchitecture,
NewApiArchitecture,
ProviderArchitecture,
Sub2ApiArchitecture,
YesCodeArchitecture,
)
class ArchitectureRegistry:
"""
架构注册表
单例模式,管理所有可用的 Provider 架构。
"""
_instance: ArchitectureRegistry | None = None
_lock = threading.Lock()
_initialized: bool = False
def __new__(cls) -> ArchitectureRegistry:
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self) -> None:
if self._initialized:
return
self._architectures: dict[str, ProviderArchitecture] = {}
self._initialized = True
# 注册内置架构
self._register_builtin_architectures()
def _register_builtin_architectures(self) -> None:
"""注册内置架构"""
builtin: list[type[ProviderArchitecture]] = [
AnyrouterArchitecture,
CubenceArchitecture,
GenericApiArchitecture,
NekoCodeArchitecture,
NewApiArchitecture,
Sub2ApiArchitecture,
YesCodeArchitecture,
]
for arch_cls in builtin:
self.register(arch_cls(), _quiet=True)
logger.debug(f"内置架构注册完成: {', '.join(self._architectures.keys())}")
def register(self, architecture: ProviderArchitecture, *, _quiet: bool = False) -> None:
"""
注册架构
Args:
architecture: 架构实例
_quiet: 内部参数,批量注册时抑制逐条日志
"""
if architecture.architecture_id in self._architectures:
logger.warning(f"架构 {architecture.architecture_id} 已存在,将被覆盖")
self._architectures[architecture.architecture_id] = architecture
if not _quiet:
logger.debug(f"注册架构: {architecture.architecture_id}")
def unregister(self, architecture_id: str) -> bool:
"""
注销架构
Args:
architecture_id: 架构 ID
Returns:
是否成功注销
"""
if architecture_id in self._architectures:
del self._architectures[architecture_id]
return True
return False
def get(self, architecture_id: str) -> ProviderArchitecture | None:
"""
获取架构
Args:
architecture_id: 架构 ID
Returns:
架构实例,不存在则返回 None
"""
return self._architectures.get(architecture_id)
def get_or_default(self, architecture_id: str | None = None) -> ProviderArchitecture:
"""
获取架构,如果不存在则返回默认架构
Args:
architecture_id: 架构 ID
Returns:
架构实例
"""
if architecture_id and architecture_id in self._architectures:
return self._architectures[architecture_id]
# 返回默认架构generic_api
return self._architectures.get("generic_api", GenericApiArchitecture())
def list_all(self) -> list[ProviderArchitecture]:
"""获取所有已注册的架构"""
return list(self._architectures.values())
def list_ids(self) -> list[str]:
"""获取所有已注册的架构 ID"""
return list(self._architectures.keys())
def to_dict_list(self) -> list[dict]:
"""获取所有架构的字典表示(用于 API 响应,隐藏 hidden 架构)"""
return [arch.to_dict() for arch in self._architectures.values() if not arch.hidden]
# 全局注册表实例
_registry: ArchitectureRegistry | None = None
def get_registry() -> ArchitectureRegistry:
"""获取全局注册表实例"""
global _registry
if _registry is None:
_registry = ArchitectureRegistry()
return _registry

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,177 @@
"""
Provider 操作模块类型定义
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Any
SENSITIVE_CREDENTIAL_FIELDS = frozenset(
{
"api_key",
"password",
"refresh_token",
"session_token",
"session_cookie",
"token_cookie",
"auth_cookie",
"cookie_string",
"cookie",
}
)
class ConnectorAuthType(str, Enum):
"""连接器认证类型"""
API_KEY = "api_key" # API Key 直接认证
SESSION_LOGIN = "session_login" # 用户名密码登录获取 Session
OAUTH = "oauth" # OAuth 流程
COOKIE = "cookie" # 直接使用 Cookie
NONE = "none" # 无需认证
class ProviderActionType(str, Enum):
"""提供商操作类型"""
QUERY_BALANCE = "query_balance" # 查询余额
CHECKIN = "checkin" # 签到
CLAIM_QUOTA = "claim_quota" # 领取额度
REFRESH_TOKEN = "refresh_token" # 刷新 Token
GET_USAGE = "get_usage" # 获取使用记录
GET_MODELS = "get_models" # 获取可用模型列表
CUSTOM = "custom" # 自定义操作
class ActionStatus(str, Enum):
"""操作执行状态"""
SUCCESS = "success" # 成功
PENDING = "pending" # 处理中(异步任务已触发,尚未完成)
AUTH_FAILED = "auth_failed" # 认证失败
AUTH_EXPIRED = "auth_expired" # 认证过期
RATE_LIMITED = "rate_limited" # 频率限制
NETWORK_ERROR = "network_error" # 网络错误
PARSE_ERROR = "parse_error" # 响应解析错误
NOT_CONFIGURED = "not_configured" # 未配置
NOT_SUPPORTED = "not_supported" # 不支持
ALREADY_DONE = "already_done" # 已完成(如今日已签到)
UNKNOWN_ERROR = "unknown_error" # 未知错误
class ConnectorStatus(str, Enum):
"""连接器状态"""
DISCONNECTED = "disconnected" # 未连接
CONNECTING = "connecting" # 连接中
CONNECTED = "connected" # 已连接
EXPIRED = "expired" # 已过期
ERROR = "error" # 错误
@dataclass
class BalanceInfo:
"""余额信息"""
total_granted: float | None = None # 总授予额度
total_used: float | None = None # 已使用额度
total_available: float | None = None # 可用余额
expires_at: datetime | None = None # 过期时间
currency: str = "USD" # 货币单位
extra: dict[str, Any] = field(default_factory=dict) # 额外信息
@dataclass
class CheckinInfo:
"""签到信息"""
reward: float | None = None # 奖励额度
streak_days: int | None = None # 连续签到天数
next_reward: float | None = None # 下次奖励
message: str | None = None # 签到消息
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class ActionResult:
"""操作执行结果"""
status: ActionStatus
action_type: ProviderActionType
data: Any | None = None # 操作返回的数据(如 BalanceInfo, CheckinInfo
message: str | None = None # 消息
executed_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
response_time_ms: int | None = None # 响应时间(毫秒)
raw_response: dict[str, Any] | None = None # 原始响应(调试用)
cache_ttl_seconds: int = 300 # 建议缓存时间
retry_after_seconds: int | None = None # 失败后重试间隔
@property
def is_success(self) -> bool:
return self.status == ActionStatus.SUCCESS
@dataclass
class ConnectorState:
"""连接器状态信息"""
status: ConnectorStatus
auth_type: ConnectorAuthType
connected_at: datetime | None = None
expires_at: datetime | None = None
last_error: str | None = None
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class ProviderOpsConfig:
"""Provider 操作配置(存储在 Provider.config['provider_ops'] 中)"""
architecture_id: str = "generic_api"
base_url: str | None = None # API 基础地址
# 连接器配置
connector_auth_type: ConnectorAuthType = ConnectorAuthType.API_KEY
connector_config: dict[str, Any] = field(default_factory=dict)
connector_credentials: dict[str, Any] = field(default_factory=dict) # 加密存储
# 操作配置
actions: dict[str, dict[str, Any]] = field(default_factory=dict)
# 定时任务配置
schedule: dict[str, str] = field(default_factory=dict) # {action_type: cron_expression}
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> ProviderOpsConfig:
"""从字典创建配置"""
if not data:
return cls()
return cls(
architecture_id=data.get("architecture_id", "generic_api"),
base_url=data.get("base_url"),
connector_auth_type=ConnectorAuthType(
data.get("connector", {}).get("auth_type", "api_key")
),
connector_config=data.get("connector", {}).get("config", {}),
connector_credentials=data.get("connector", {}).get("credentials", {}),
actions=data.get("actions", {}),
schedule=data.get("schedule", {}),
)
def to_dict(self) -> dict[str, Any]:
"""转换为字典(用于存储)"""
return {
"architecture_id": self.architecture_id,
"base_url": self.base_url,
"connector": {
"auth_type": self.connector_auth_type.value,
"config": self.connector_config,
"credentials": self.connector_credentials,
},
"actions": self.actions,
"schedule": self.schedule,
}

View File

@@ -0,0 +1,26 @@
"""
Provider Ops 通用工具函数
"""
def extract_cookie_value(cookie_string: str, key: str) -> str:
"""
从 Cookie 字符串中提取指定 key 的值
支持两种输入格式:
1. 完整 Cookie: "key=xxx; other=yyy; ..."
2. 仅值: "MTc2ODc4..."
Args:
cookie_string: Cookie 字符串或直接的值
key: 要提取的 Cookie key
Returns:
对应的值
"""
if f"{key}=" in cookie_string:
for part in cookie_string.split(";"):
part = part.strip()
if part.startswith(f"{key}="):
return part[len(key) + 1 :]
return cookie_string.strip()