mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: 添加 NekoCode 中转站架构支持
- 使用 Cookie 认证(session) - 支持余额查询和每日配额显示 - 显示订阅状态和有效期
This commit is contained in:
@@ -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'
|
||||||
|
|||||||
148
frontend/src/features/providers/auth-templates/nekocode.ts
Normal file
148
frontend/src/features/providers/auth-templates/nekocode.ts
Normal 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
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
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.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",
|
||||||
|
|||||||
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,
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user