feat: ProxyNode 代理节点管理系统与 OpenAI Responses API 解析增强

ProxyNode 系统:新增 aether-proxy(Rust)海外 VPS 代理组件,后端实现节点注册/心跳/
HMAC 认证/健康检测调度器/模块化集成,前端新增代理节点管理页面。ProxyConfig 支持
node_id 模式,http_client 支持 HMAC 签名代理 URL 构建与 TTL 缓存。

OpenAI CLI 解析器:适配 Responses API 格式,支持 input_tokens/output_tokens 提取、
output[].content[].text 文本解析、response.completed 流式事件 usage 嵌套结构。
This commit is contained in:
fawney19
2026-02-07 12:24:42 +08:00
parent 62f852b851
commit 1180634269
40 changed files with 4761 additions and 11 deletions

View File

@@ -44,5 +44,6 @@ router.include_router(video_tasks_router)
# 注意:以下路由已迁移到模块系统,由 ModuleRegistry 动态注册
# - ldap_router: 当 LDAP_AVAILABLE=true 时注册
# - management_tokens_router: 当 MANAGEMENT_TOKENS_AVAILABLE=true 时注册
# - proxy_nodes_router: 当 PROXY_NODES_AVAILABLE=true 时注册
__all__ = ["router"]

View File

@@ -0,0 +1,5 @@
"""Proxy node admin routes export."""
from .routes import router
__all__ = ["router"]

View File

@@ -0,0 +1,304 @@
"""管理员代理节点ProxyNode管理端点
用于 aether-proxy 在 VPS 上注册、心跳、注销节点,以及管理员查看/删除节点记录。
"""
from __future__ import annotations
import ipaddress
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, Field, ValidationError, field_validator
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.core.exceptions import InvalidRequestException, NotFoundException
from src.database import get_db
from src.models.database import ProxyNode, ProxyNodeStatus
router = APIRouter(prefix="/api/admin/proxy-nodes", tags=["Admin - Proxy Nodes"])
pipeline = ApiRequestPipeline()
def _node_to_dict(node: ProxyNode) -> dict[str, Any]:
return {
"id": node.id,
"name": node.name,
"ip": node.ip,
"port": node.port,
"region": node.region,
"status": node.status.value if node.status else None,
"registered_by": node.registered_by,
"last_heartbeat_at": node.last_heartbeat_at,
"heartbeat_interval": node.heartbeat_interval,
"active_connections": node.active_connections,
"total_requests": node.total_requests,
"avg_latency_ms": node.avg_latency_ms,
"created_at": node.created_at,
"updated_at": node.updated_at,
}
class ProxyNodeRegisterRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100, description="节点名")
ip: str = Field(..., description="公网 IPIPv4/IPv6")
port: int = Field(..., ge=1, le=65535, description="代理端口")
region: str | None = Field(None, max_length=100, description="区域标签")
heartbeat_interval: int = Field(30, ge=5, le=600, description="心跳间隔(秒)")
# 指标(可选)
active_connections: int | None = Field(None, ge=0, description="当前活跃连接数")
total_requests: int | None = Field(None, ge=0, description="累计请求数")
avg_latency_ms: float | None = Field(None, ge=0, description="平均延迟(毫秒)")
@field_validator("ip")
@classmethod
def validate_ip(cls, v: str) -> str:
v = v.strip()
try:
ipaddress.ip_address(v)
except ValueError as exc:
raise ValueError("ip 必须是合法的 IPv4/IPv6 地址") from exc
return v
class ProxyNodeHeartbeatRequest(BaseModel):
node_id: str = Field(..., min_length=1, max_length=36, description="节点 ID")
heartbeat_interval: int | None = Field(None, ge=5, le=600, description="心跳间隔(秒)")
active_connections: int | None = Field(None, ge=0, description="当前活跃连接数")
total_requests: int | None = Field(None, ge=0, description="累计请求数")
avg_latency_ms: float | None = Field(None, ge=0, description="平均延迟(毫秒)")
class ProxyNodeUnregisterRequest(BaseModel):
node_id: str = Field(..., min_length=1, max_length=36, description="节点 ID")
@router.post("/register")
async def register_proxy_node(request: Request, db: Session = Depends(get_db)) -> Any:
adapter = AdminRegisterProxyNodeAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/heartbeat")
async def heartbeat_proxy_node(request: Request, db: Session = Depends(get_db)) -> Any:
adapter = AdminHeartbeatProxyNodeAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/unregister")
async def unregister_proxy_node(request: Request, db: Session = Depends(get_db)) -> Any:
adapter = AdminUnregisterProxyNodeAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("")
async def list_proxy_nodes(
request: Request,
status: str | None = Query(None, description="按状态筛选online/unhealthy/offline"),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
db: Session = Depends(get_db),
) -> Any:
adapter = AdminListProxyNodesAdapter(status=status, skip=skip, limit=limit)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.delete("/{node_id}")
async def delete_proxy_node(node_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
adapter = AdminDeleteProxyNodeAdapter(node_id=node_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
def _format_validation_error(exc: ValidationError) -> str:
parts: list[str] = []
for err in exc.errors():
field = " -> ".join(str(x) for x in err.get("loc", []))
msg = str(err.get("msg", "invalid"))
parts.append(f"{field}: {msg}")
return "; ".join(parts) or "输入验证失败"
@dataclass
class AdminRegisterProxyNodeAdapter(AdminApiAdapter):
name: str = "admin_register_proxy_node"
async def handle(self, context: ApiRequestContext) -> Any:
payload = context.ensure_json_body()
try:
req = ProxyNodeRegisterRequest.model_validate(payload)
except ValidationError as exc:
raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc))
now = datetime.now(timezone.utc)
node = (
context.db.query(ProxyNode)
.filter(ProxyNode.ip == req.ip, ProxyNode.port == req.port)
.first()
)
if node:
node.name = req.name
node.region = req.region
node.status = ProxyNodeStatus.ONLINE
node.last_heartbeat_at = now
node.heartbeat_interval = req.heartbeat_interval
if req.active_connections is not None:
node.active_connections = req.active_connections
if req.total_requests is not None:
node.total_requests = req.total_requests
if req.avg_latency_ms is not None:
node.avg_latency_ms = req.avg_latency_ms
else:
node = ProxyNode(
id=str(uuid.uuid4()),
name=req.name,
ip=req.ip,
port=req.port,
region=req.region,
status=ProxyNodeStatus.ONLINE,
registered_by=context.user.id if context.user else None,
last_heartbeat_at=now,
heartbeat_interval=req.heartbeat_interval,
active_connections=req.active_connections or 0,
total_requests=req.total_requests or 0,
avg_latency_ms=req.avg_latency_ms,
created_at=now,
updated_at=now,
)
context.db.add(node)
context.db.commit()
context.db.refresh(node)
context.add_audit_metadata(
action="proxy_node_register",
proxy_node_id=node.id,
proxy_node_ip=node.ip,
proxy_node_port=node.port,
)
return {"node_id": node.id, "node": _node_to_dict(node)}
@dataclass
class AdminHeartbeatProxyNodeAdapter(AdminApiAdapter):
name: str = "admin_heartbeat_proxy_node"
async def handle(self, context: ApiRequestContext) -> Any:
payload = context.ensure_json_body()
try:
req = ProxyNodeHeartbeatRequest.model_validate(payload)
except ValidationError as exc:
raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc))
node = context.db.query(ProxyNode).filter(ProxyNode.id == req.node_id).first()
if not node:
raise NotFoundException(f"ProxyNode {req.node_id} 不存在", "proxy_node")
now = datetime.now(timezone.utc)
node.status = ProxyNodeStatus.ONLINE
node.last_heartbeat_at = now
if req.heartbeat_interval is not None:
node.heartbeat_interval = req.heartbeat_interval
if req.active_connections is not None:
node.active_connections = req.active_connections
if req.total_requests is not None:
node.total_requests = req.total_requests
if req.avg_latency_ms is not None:
node.avg_latency_ms = req.avg_latency_ms
context.db.commit()
context.db.refresh(node)
context.add_audit_metadata(
action="proxy_node_heartbeat",
proxy_node_id=node.id,
)
return {"message": "heartbeat ok", "node": _node_to_dict(node)}
@dataclass
class AdminUnregisterProxyNodeAdapter(AdminApiAdapter):
name: str = "admin_unregister_proxy_node"
async def handle(self, context: ApiRequestContext) -> Any:
payload = context.ensure_json_body()
try:
req = ProxyNodeUnregisterRequest.model_validate(payload)
except ValidationError as exc:
raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc))
node = context.db.query(ProxyNode).filter(ProxyNode.id == req.node_id).first()
if not node:
raise NotFoundException(f"ProxyNode {req.node_id} 不存在", "proxy_node")
node.status = ProxyNodeStatus.OFFLINE
node.updated_at = datetime.now(timezone.utc)
context.db.commit()
context.add_audit_metadata(
action="proxy_node_unregister",
proxy_node_id=node.id,
)
return {"message": "unregistered", "node_id": node.id}
@dataclass
class AdminListProxyNodesAdapter(AdminApiAdapter):
name: str = "admin_list_proxy_nodes"
status: str | None = None
skip: int = 0
limit: int = 100
async def handle(self, context: ApiRequestContext) -> Any:
query = context.db.query(ProxyNode)
if self.status:
normalized = self.status.strip().lower()
allowed = {"online", "unhealthy", "offline"}
if normalized not in allowed:
raise InvalidRequestException(f"status 必须是以下之一: {sorted(allowed)}", "status")
query = query.filter(ProxyNode.status == ProxyNodeStatus(normalized))
total = query.count()
nodes = (
query.order_by(ProxyNode.updated_at.desc()).offset(self.skip).limit(self.limit).all()
)
return {
"items": [_node_to_dict(n) for n in nodes],
"total": total,
"skip": self.skip,
"limit": self.limit,
}
@dataclass
class AdminDeleteProxyNodeAdapter(AdminApiAdapter):
name: str = "admin_delete_proxy_node"
node_id: str = ""
async def handle(self, context: ApiRequestContext) -> Any:
node = context.db.query(ProxyNode).filter(ProxyNode.id == self.node_id).first()
if not node:
raise NotFoundException(f"ProxyNode {self.node_id} 不存在", "proxy_node")
context.add_audit_metadata(
action="proxy_node_delete",
proxy_node_id=node.id,
proxy_node_ip=node.ip,
proxy_node_port=node.port,
)
context.db.delete(node)
context.db.commit()
return {"message": "deleted", "node_id": self.node_id}

View File

@@ -2182,7 +2182,15 @@ class CliMessageHandlerBase(BaseMessageHandler):
message = evt.get("message", {})
if isinstance(message, dict):
usage = message.get("usage")
# OpenAI 格式: 直接在 chunk 中
# OpenAI Responses API (openai:cli) 格式: response.completed 中 usage 嵌套在 response 对象内
elif event_type == "response.completed":
resp_obj = evt.get("response")
if isinstance(resp_obj, dict):
usage = resp_obj.get("usage")
# 兼容: 部分实现可能在顶层也有 usage
if not usage:
usage = evt.get("usage")
# OpenAI Chat 格式: 直接在 chunk 中
elif "usage" in evt:
usage = evt.get("usage")
# Gemini 格式: usageMetadata

View File

@@ -241,13 +241,145 @@ class OpenAIResponseParser(ResponseParser):
class OpenAICliResponseParser(OpenAIResponseParser):
"""OpenAI CLI 格式响应解析器"""
"""OpenAI CLI / Responses API 格式响应解析器
OpenAI Responses API 与 Chat Completions API 的关键差异:
- Usage 字段: input_tokens/output_tokens而非 prompt_tokens/completion_tokens
- 响应结构: output[].content[].text而非 choices[].message.content
- 流式事件: response.completed 事件中 usage 嵌套在 response 对象内
"""
def __init__(self) -> None:
super().__init__()
self.name = "openai:cli"
self.api_format = "openai:cli"
def parse_response(self, response: dict[str, Any], status_code: int) -> ParsedResponse:
result = ParsedResponse(
raw_response=response,
status_code=status_code,
)
# Responses API: 文本在 output[].content[].text 中
result.text_content = self._extract_responses_api_text(response)
result.response_id = response.get("id")
# Responses API usage: input_tokens / output_tokens
usage = self._extract_responses_api_usage(response)
result.input_tokens = usage.get("input_tokens", 0)
result.output_tokens = usage.get("output_tokens", 0)
result.cache_creation_tokens = usage.get("cache_creation_tokens", 0)
result.cache_read_tokens = usage.get("cache_read_tokens", 0)
# 检查错误(支持嵌套错误格式)
is_error, error_info = _check_nested_error(response)
if is_error and error_info:
result.is_error = True
result.error_type = error_info.get("type")
result.error_message = error_info.get("message")
result.embedded_status_code = _extract_embedded_status_code(error_info)
return result
def extract_usage_from_response(self, response: dict[str, Any]) -> dict[str, int]:
usage = self._extract_responses_api_usage(response)
return usage
def extract_text_content(self, response: dict[str, Any]) -> str:
return self._extract_responses_api_text(response)
@staticmethod
def _extract_responses_api_usage(response: dict[str, Any]) -> dict[str, int]:
"""从 Responses API 响应或流式事件中提取 usage
支持多种结构:
1. 顶层 usage非流式响应 / 部分转换后的响应)
2. response.usage流式 response.completed 事件)
3. 兼容 Chat Completions 字段名prompt_tokens/completion_tokens
"""
usage: dict[str, Any] = {}
# 优先从顶层 usage 提取
top_usage = response.get("usage")
if isinstance(top_usage, dict):
usage = top_usage
else:
# 流式事件: response.completed 中 usage 嵌套在 response 对象内
resp_obj = response.get("response")
if isinstance(resp_obj, dict):
nested_usage = resp_obj.get("usage")
if isinstance(nested_usage, dict):
usage = nested_usage
if not usage:
return {
"input_tokens": 0,
"output_tokens": 0,
"cache_creation_tokens": 0,
"cache_read_tokens": 0,
}
# Responses API 使用 input_tokens/output_tokens
# 兼容 Chat Completions 的 prompt_tokens/completion_tokens以防转换后的响应
input_tokens = usage.get("input_tokens") or usage.get("prompt_tokens") or 0
output_tokens = usage.get("output_tokens") or usage.get("completion_tokens") or 0
return {
"input_tokens": int(input_tokens),
"output_tokens": int(output_tokens),
"cache_creation_tokens": int(
usage.get("cache_creation_input_tokens") or usage.get("cache_creation_tokens") or 0
),
"cache_read_tokens": int(
usage.get("cache_read_input_tokens") or usage.get("cache_read_tokens") or 0
),
}
@staticmethod
def _extract_responses_api_text(response: dict[str, Any]) -> str:
"""从 Responses API 响应中提取文本内容
支持结构: output[].content[].text 或 output[].text
"""
text_parts: list[str] = []
output = response.get("output")
if isinstance(output, list):
for item in output:
if not isinstance(item, dict):
continue
# message 类型: output[].content[].text
if item.get("type") == "message":
content = item.get("content")
if isinstance(content, list):
for part in content:
if isinstance(part, dict):
ptype = str(part.get("type") or "")
if ptype in ("output_text", "text") and isinstance(
part.get("text"), str
):
text_parts.append(part["text"])
# 直接文本类型: output[].text
elif item.get("type") in ("output_text", "text") and isinstance(
item.get("text"), str
):
text_parts.append(item["text"])
# 兼容: 部分实现可能直接给 output_text
if not text_parts and isinstance(response.get("output_text"), str):
text_parts.append(response["output_text"])
# 兼容: 如果是 Chat Completions 格式(可能来自转换后的响应),回退到 choices 结构
if not text_parts:
choices = response.get("choices", [])
if isinstance(choices, list) and choices:
message = choices[0].get("message", {}) if isinstance(choices[0], dict) else {}
content = message.get("content") if isinstance(message, dict) else None
if isinstance(content, str):
text_parts.append(content)
return "".join(text_parts)
class ClaudeResponseParser(ResponseParser):
"""Claude 格式响应解析器"""