mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
feat: 添加 NekoCode 中转站架构支持
- 使用 Cookie 认证(session) - 支持余额查询和每日配额显示 - 显示订阅状态和有效期
This commit is contained in:
@@ -7,6 +7,7 @@ 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.yescode_balance import YesCodeBalanceAction
|
||||
|
||||
@@ -17,5 +18,6 @@ __all__ = [
|
||||
"NewApiBalanceAction",
|
||||
"AnyrouterBalanceAction",
|
||||
"CubenceBalanceAction",
|
||||
"NekoCodeBalanceAction",
|
||||
"YesCodeBalanceAction",
|
||||
]
|
||||
|
||||
193
src/services/provider_ops/actions/nekocode_balance.py
Normal file
193
src/services/provider_ops/actions/nekocode_balance.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
NekoCode 余额查询操作
|
||||
"""
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.services.provider_ops.actions.balance import BalanceAction
|
||||
from src.services.provider_ops.types import ActionResult, ActionStatus, BalanceInfo
|
||||
|
||||
|
||||
class NekoCodeBalanceAction(BalanceAction):
|
||||
"""
|
||||
NekoCode 余额查询
|
||||
|
||||
特点:
|
||||
- 查询余额和订阅信息
|
||||
- 显示每日配额限制和剩余
|
||||
- 显示订阅状态和有效期
|
||||
- 余额单位为积分
|
||||
"""
|
||||
|
||||
display_name = "查询余额"
|
||||
description = "查询 NekoCode 账户余额和订阅信息"
|
||||
|
||||
async def _do_query_balance(self, client: httpx.AsyncClient) -> ActionResult:
|
||||
"""执行余额查询"""
|
||||
endpoint = self.config.get("endpoint", "/api/usage/summary")
|
||||
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)}",
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
def _handle_http_error(
|
||||
self, response: httpx.Response, raw_data: Optional[Dict[str, Any]] = None
|
||||
) -> ActionResult:
|
||||
"""处理 HTTP 错误响应"""
|
||||
status_code = response.status_code
|
||||
|
||||
if status_code == 401:
|
||||
return self._make_error_result(
|
||||
ActionStatus.AUTH_FAILED, "Cookie 已失效,请重新配置", raw_response=raw_data
|
||||
)
|
||||
elif status_code == 403:
|
||||
return self._make_error_result(
|
||||
ActionStatus.AUTH_FAILED, "Cookie 已失效或无权限", raw_response=raw_data
|
||||
)
|
||||
|
||||
return super()._handle_http_error(response, raw_data)
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> Dict[str, Any]:
|
||||
"""获取操作配置 schema"""
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"endpoint": {
|
||||
"type": "string",
|
||||
"title": "API 端点",
|
||||
"default": "/api/usage/summary",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
@@ -10,6 +10,7 @@ from src.services.provider_ops.architectures.base import (
|
||||
from src.services.provider_ops.architectures.anyrouter import AnyrouterArchitecture
|
||||
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.one_api import OneApiArchitecture
|
||||
from src.services.provider_ops.architectures.yescode import YesCodeArchitecture
|
||||
@@ -21,6 +22,7 @@ __all__ = [
|
||||
"AnyrouterArchitecture",
|
||||
"CubenceArchitecture",
|
||||
"GenericApiArchitecture",
|
||||
"NekoCodeArchitecture",
|
||||
"NewApiArchitecture",
|
||||
"OneApiArchitecture",
|
||||
"YesCodeArchitecture",
|
||||
|
||||
281
src/services/provider_ops/architectures/nekocode.py
Normal file
281
src/services/provider_ops/architectures/nekocode.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""
|
||||
NekoCode 架构
|
||||
|
||||
针对 NekoCode 中转站的预设配置,使用 Cookie 认证。
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Type
|
||||
|
||||
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.utils.ssl_utils import get_ssl_context
|
||||
|
||||
|
||||
def _extract_session_from_cookie(cookie_string: str) -> str:
|
||||
"""
|
||||
从完整的 Cookie 字符串中提取 session 值
|
||||
|
||||
支持两种输入格式:
|
||||
1. 完整 Cookie: "session=xxx; other=xxx; ..."
|
||||
2. 仅 session 值: "MTc2OTYx..."
|
||||
|
||||
Args:
|
||||
cookie_string: Cookie 字符串或 session 值
|
||||
|
||||
Returns:
|
||||
session cookie 的值
|
||||
"""
|
||||
# 如果包含 "session=",说明是完整 Cookie 字符串
|
||||
if "session=" in cookie_string:
|
||||
# 解析 Cookie 字符串
|
||||
for part in cookie_string.split(";"):
|
||||
part = part.strip()
|
||||
if part.startswith("session="):
|
||||
return part[8:] # 去掉 "session=" 前缀
|
||||
# 否则认为直接是 session 值
|
||||
return cookie_string.strip()
|
||||
|
||||
|
||||
class NekoCodeConnector(ProviderConnector):
|
||||
"""
|
||||
NekoCode 专用连接器
|
||||
|
||||
特点:
|
||||
- 使用 Cookie 认证(session)
|
||||
"""
|
||||
|
||||
auth_type = ConnectorAuthType.COOKIE
|
||||
display_name = "NekoCode Cookie"
|
||||
|
||||
def __init__(self, base_url: str, config: Optional[Dict[str, Any]] = None):
|
||||
super().__init__(base_url, config)
|
||||
self._session_cookie: Optional[str] = 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_session_from_cookie(session_cookie)
|
||||
|
||||
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": {
|
||||
"session_cookie": {
|
||||
"type": "string",
|
||||
"title": "Session Cookie",
|
||||
"description": "从浏览器复制的 session Cookie 值",
|
||||
},
|
||||
},
|
||||
"required": ["session_cookie"],
|
||||
}
|
||||
|
||||
|
||||
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_session_from_cookie(cookie_input)
|
||||
headers["Cookie"] = f"session={session_value}"
|
||||
|
||||
# 构建 client 参数
|
||||
client_kwargs: Dict[str, Any] = {
|
||||
"timeout": 10,
|
||||
"verify": get_ssl_context(),
|
||||
}
|
||||
proxy = config.get("proxy")
|
||||
if 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(f"获取 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_session_from_cookie(cookie_input)
|
||||
headers["Cookie"] = f"session={session_value}"
|
||||
|
||||
return headers
|
||||
|
||||
def parse_verify_response(
|
||||
self,
|
||||
status_code: int,
|
||||
data: Dict[str, Any],
|
||||
) -> VerifyResult:
|
||||
"""解析 NekoCode 验证响应(/api/user/self + _usage_summary)"""
|
||||
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}")
|
||||
|
||||
# NekoCode 响应格式: {"success": true, "data": {...}}
|
||||
if not data.get("success"):
|
||||
message = data.get("message", "验证失败")
|
||||
return VerifyResult(success=False, message=message)
|
||||
|
||||
user_data = data.get("data", {})
|
||||
|
||||
# 转换余额字符串为数字
|
||||
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 = data.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,
|
||||
)
|
||||
@@ -12,6 +12,7 @@ from src.services.provider_ops.architectures import (
|
||||
AnyrouterArchitecture,
|
||||
CubenceArchitecture,
|
||||
GenericApiArchitecture,
|
||||
NekoCodeArchitecture,
|
||||
NewApiArchitecture,
|
||||
OneApiArchitecture,
|
||||
ProviderArchitecture,
|
||||
@@ -54,6 +55,7 @@ class ArchitectureRegistry:
|
||||
AnyrouterArchitecture,
|
||||
CubenceArchitecture,
|
||||
GenericApiArchitecture,
|
||||
NekoCodeArchitecture,
|
||||
NewApiArchitecture,
|
||||
OneApiArchitecture,
|
||||
YesCodeArchitecture,
|
||||
|
||||
Reference in New Issue
Block a user