feat: 添加 NekoCode 中转站架构支持

- 使用 Cookie 认证(session)
- 支持余额查询和每日配额显示
- 显示订阅状态和有效期
This commit is contained in:
fawney19
2026-01-28 23:51:06 +08:00
parent 6b34a7e3f5
commit b4bbf4eeb1
7 changed files with 631 additions and 1 deletions

View File

@@ -24,13 +24,14 @@
import type { AuthTemplate, AuthTemplateRegistry } from './types' import type { AuthTemplate, AuthTemplateRegistry } from './types'
import { anyrouterTemplate } from './anyrouter' import { anyrouterTemplate } from './anyrouter'
import { cubenceTemplate } from './cubence' import { cubenceTemplate } from './cubence'
import { nekocodeTemplate } from './nekocode'
import { newApiTemplate } from './new-api' import { newApiTemplate } from './new-api'
import { yescodeTemplate } from './yescode' import { yescodeTemplate } from './yescode'
// ==================== 模板注册 ==================== // ==================== 模板注册 ====================
// 在这里添加新模板 // 在这里添加新模板
const templates: AuthTemplate[] = [newApiTemplate, anyrouterTemplate, cubenceTemplate, yescodeTemplate] const templates: AuthTemplate[] = [newApiTemplate, anyrouterTemplate, cubenceTemplate, nekocodeTemplate, yescodeTemplate]
// ==================== 注册表实现 ==================== // ==================== 注册表实现 ====================
@@ -68,5 +69,6 @@ export const authTemplateRegistry: AuthTemplateRegistry = {
export * from './types' export * from './types'
export { anyrouterTemplate } from './anyrouter' export { anyrouterTemplate } from './anyrouter'
export { cubenceTemplate } from './cubence' export { cubenceTemplate } from './cubence'
export { nekocodeTemplate } from './nekocode'
export { newApiTemplate } from './new-api' export { newApiTemplate } from './new-api'
export { yescodeTemplate } from './yescode' export { yescodeTemplate } from './yescode'

View File

@@ -0,0 +1,148 @@
/**
* NekoCode 认证模板
*
* 适用于 NekoCode 中转站:
* - 使用 Cookie 认证session
* - 显示余额、每日配额、订阅状态
*/
import type { AuthTemplate, AuthTemplateFieldGroup, BalanceExtraItem } from './types'
import type { SaveConfigRequest } from '@/api/providerOps'
import { PROXY_FIELD_GROUP, buildProxyUrl, parseProxyUrl } from './types'
export const nekocodeTemplate: AuthTemplate = {
id: 'nekocode',
name: 'NekoCode',
description: '适用于 NekoCode 中转站,使用 Cookie 认证',
getFields(providerWebsite?: string): AuthTemplateFieldGroup[] {
return [
{
fields: [
{
key: 'base_url',
label: '站点地址',
type: 'text',
placeholder: providerWebsite || 'https://nekocode.ai',
required: !providerWebsite,
helpText: '通常为 https://nekocode.ai',
},
{
key: 'session_cookie',
label: 'Cookie',
type: 'password',
placeholder: 'session=MTc2OTYx...',
required: true,
sensitive: true,
helpText: '从浏览器开发者工具复制 session Cookie 值',
},
],
},
PROXY_FIELD_GROUP,
]
},
buildRequest(formData: Record<string, any>, providerWebsite?: string): SaveConfigRequest {
const baseUrl = formData.base_url || providerWebsite || ''
return {
architecture_id: 'nekocode',
base_url: baseUrl,
connector: {
auth_type: 'cookie',
config: {
proxy: buildProxyUrl(formData),
},
credentials: {
session_cookie: formData.session_cookie,
},
},
actions: {},
schedule: {},
}
},
parseConfig(config: any): Record<string, any> {
const proxyData = parseProxyUrl(config?.connector?.config?.proxy)
return {
base_url: config?.base_url || '',
session_cookie: config?.connector?.credentials?.session_cookie || '',
...proxyData,
}
},
validate(formData: Record<string, any>): string | null {
if (!formData.session_cookie?.trim()) {
return '请填写 Session Cookie'
}
return null
},
formatQuota(quota: number): string {
// NekoCode 的余额单位是美元
if (quota >= 1) {
return `$${quota.toFixed(2)}`
}
return `$${quota.toFixed(4)}`
},
formatBalanceExtra(extra: Record<string, any>): BalanceExtraItem[] {
const items: BalanceExtraItem[] = []
// 每日配额(天卡)- 显示进度条和倒计时
if (extra.daily_quota_limit !== undefined && extra.daily_remaining_quota !== undefined) {
const limit = Number(extra.daily_quota_limit)
const remaining = Number(extra.daily_remaining_quota)
const percent = limit > 0 ? Math.round((remaining / limit) * 100) : 0
// 计算刷新时间戳
let resetsAt: number | undefined
if (extra.effective_start_date) {
try {
// effective_start_date 是订阅开始时间,每日配额在每天的这个时间刷新
const startDate = new Date(extra.effective_start_date)
const now = new Date()
// 找到下一个刷新时间点(今天或明天的同一时间)
const todayReset = new Date(now)
todayReset.setHours(startDate.getHours(), startDate.getMinutes(), startDate.getSeconds(), 0)
if (todayReset <= now) {
// 已过今天的刷新时间,设为明天
todayReset.setDate(todayReset.getDate() + 1)
}
resetsAt = Math.floor(todayReset.getTime() / 1000)
} catch {
// 忽略解析错误
}
}
items.push({
label: '天',
value: `${percent}%`,
percent,
resetsAt,
})
}
// 套餐到期时间 - 显示倒计时
if (extra.effective_end_date) {
try {
const endDate = new Date(extra.effective_end_date)
const now = new Date()
const daysLeft = Math.ceil((endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
const resetsAt = Math.floor(endDate.getTime() / 1000)
const percent = Math.min(100, Math.max(0, Math.round((daysLeft / 30) * 100)))
items.push({
label: '月',
value: `${percent}%`,
percent,
resetsAt,
})
} catch {
// 忽略解析错误
}
}
return items
},
}

View File

@@ -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.base import ProviderAction
from src.services.provider_ops.actions.checkin import CheckinAction 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.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.new_api_balance import NewApiBalanceAction
from src.services.provider_ops.actions.yescode_balance import YesCodeBalanceAction from src.services.provider_ops.actions.yescode_balance import YesCodeBalanceAction
@@ -17,5 +18,6 @@ __all__ = [
"NewApiBalanceAction", "NewApiBalanceAction",
"AnyrouterBalanceAction", "AnyrouterBalanceAction",
"CubenceBalanceAction", "CubenceBalanceAction",
"NekoCodeBalanceAction",
"YesCodeBalanceAction", "YesCodeBalanceAction",
] ]

View 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": [],
}

View File

@@ -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.anyrouter import AnyrouterArchitecture
from src.services.provider_ops.architectures.cubence import CubenceArchitecture 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.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.new_api import NewApiArchitecture
from src.services.provider_ops.architectures.one_api import OneApiArchitecture from src.services.provider_ops.architectures.one_api import OneApiArchitecture
from src.services.provider_ops.architectures.yescode import YesCodeArchitecture from src.services.provider_ops.architectures.yescode import YesCodeArchitecture
@@ -21,6 +22,7 @@ __all__ = [
"AnyrouterArchitecture", "AnyrouterArchitecture",
"CubenceArchitecture", "CubenceArchitecture",
"GenericApiArchitecture", "GenericApiArchitecture",
"NekoCodeArchitecture",
"NewApiArchitecture", "NewApiArchitecture",
"OneApiArchitecture", "OneApiArchitecture",
"YesCodeArchitecture", "YesCodeArchitecture",

View 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,
)

View File

@@ -12,6 +12,7 @@ from src.services.provider_ops.architectures import (
AnyrouterArchitecture, AnyrouterArchitecture,
CubenceArchitecture, CubenceArchitecture,
GenericApiArchitecture, GenericApiArchitecture,
NekoCodeArchitecture,
NewApiArchitecture, NewApiArchitecture,
OneApiArchitecture, OneApiArchitecture,
ProviderArchitecture, ProviderArchitecture,
@@ -54,6 +55,7 @@ class ArchitectureRegistry:
AnyrouterArchitecture, AnyrouterArchitecture,
CubenceArchitecture, CubenceArchitecture,
GenericApiArchitecture, GenericApiArchitecture,
NekoCodeArchitecture,
NewApiArchitecture, NewApiArchitecture,
OneApiArchitecture, OneApiArchitecture,
YesCodeArchitecture, YesCodeArchitecture,