mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 09:50:21 +08:00
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:
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
@@ -20,6 +21,7 @@ from urllib.parse import quote, urlparse
|
||||
import httpx
|
||||
|
||||
from src.config import config
|
||||
from src.core.exceptions import ProxyNodeUnavailableError
|
||||
from src.core.logger import logger
|
||||
from src.utils.ssl_utils import get_ssl_context
|
||||
|
||||
@@ -27,6 +29,70 @@ from src.utils.ssl_utils import get_ssl_context
|
||||
_proxy_clients_lock = asyncio.Lock()
|
||||
_default_client_lock = asyncio.Lock()
|
||||
|
||||
# ProxyNode 信息缓存(降低高频 DB 查询开销)
|
||||
_proxy_node_cache: dict[str, tuple[dict[str, Any] | None, float]] = {}
|
||||
_PROXY_NODE_CACHE_TTL_SECONDS = 60.0
|
||||
_PROXY_NODE_CACHE_MAX_SIZE = 256
|
||||
|
||||
|
||||
def _get_proxy_node_info(node_id: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
读取 ProxyNode 信息(带内存 TTL 缓存)
|
||||
|
||||
Returns:
|
||||
{"ip": str, "port": int} 或 None(不存在/非在线)
|
||||
"""
|
||||
now = time.time()
|
||||
cached = _proxy_node_cache.get(node_id)
|
||||
if cached:
|
||||
value, expires_at = cached
|
||||
if now < expires_at:
|
||||
return value
|
||||
|
||||
# 防止无效 node_id 导致缓存无限膨胀
|
||||
if len(_proxy_node_cache) >= _PROXY_NODE_CACHE_MAX_SIZE:
|
||||
_proxy_node_cache.clear()
|
||||
|
||||
from src.database import create_session
|
||||
from src.models.database import ProxyNode, ProxyNodeStatus
|
||||
|
||||
db = create_session()
|
||||
try:
|
||||
node = db.query(ProxyNode).filter(ProxyNode.id == node_id).first()
|
||||
if not node or node.status != ProxyNodeStatus.ONLINE:
|
||||
_proxy_node_cache[node_id] = (None, now + _PROXY_NODE_CACHE_TTL_SECONDS)
|
||||
return None
|
||||
|
||||
value = {"ip": node.ip, "port": node.port}
|
||||
_proxy_node_cache[node_id] = (value, now + _PROXY_NODE_CACHE_TTL_SECONDS)
|
||||
return value
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _build_hmac_proxy_url(ip: str, port: int, node_id: str) -> str:
|
||||
"""
|
||||
构建带 HMAC BasicAuth 的 httpx proxy URL
|
||||
|
||||
格式: http://hmac:{timestamp}.{signature}@{ip}:{port}
|
||||
signature = HMAC-SHA256(PROXY_HMAC_KEY, "{timestamp}\\n{node_id}") 的 hex
|
||||
"""
|
||||
if not config.proxy_hmac_key:
|
||||
raise ProxyNodeUnavailableError(
|
||||
"PROXY_HMAC_KEY 未配置,无法使用 ProxyNode 代理", node_id=node_id
|
||||
)
|
||||
|
||||
timestamp = str(int(time.time()))
|
||||
payload = f"{timestamp}\n{node_id}".encode("utf-8")
|
||||
signature = hmac.new(
|
||||
config.proxy_hmac_key.encode("utf-8"),
|
||||
payload,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
host = f"[{ip}]" if ":" in ip else ip
|
||||
return f"http://hmac:{timestamp}.{signature}@{host}:{int(port)}"
|
||||
|
||||
|
||||
def _compute_proxy_cache_key(proxy_config: dict[str, Any] | None) -> str:
|
||||
"""
|
||||
@@ -41,6 +107,16 @@ def _compute_proxy_cache_key(proxy_config: dict[str, Any] | None) -> str:
|
||||
if not proxy_config:
|
||||
return "__no_proxy__"
|
||||
|
||||
# enabled=False 时视为无代理(兼容旧数据)
|
||||
if not proxy_config.get("enabled", True):
|
||||
return "__no_proxy__"
|
||||
|
||||
# ProxyNode 模式:基于 node_id + 时间桶缓存,避免签名随时间变化导致 cache key 爆炸
|
||||
node_id = proxy_config.get("node_id")
|
||||
if isinstance(node_id, str) and node_id.strip():
|
||||
time_bucket = int(time.time() / 120) # 120 秒一个桶
|
||||
return f"proxy_node:{node_id.strip()}:{time_bucket}"
|
||||
|
||||
# 构建代理 URL 作为缓存键的基础
|
||||
proxy_url = build_proxy_url(proxy_config)
|
||||
if not proxy_url:
|
||||
@@ -55,7 +131,9 @@ def build_proxy_url(proxy_config: dict[str, Any]) -> str | None:
|
||||
根据代理配置构建完整的代理 URL
|
||||
|
||||
Args:
|
||||
proxy_config: 代理配置字典,包含 url, username, password, enabled
|
||||
proxy_config: 代理配置字典,支持两种模式:
|
||||
- 手动 URL 模式: {url, username, password, enabled}
|
||||
- ProxyNode 模式: {node_id, enabled}
|
||||
|
||||
Returns:
|
||||
完整的代理 URL,如 socks5://user:pass@host:port
|
||||
@@ -68,6 +146,15 @@ def build_proxy_url(proxy_config: dict[str, Any]) -> str | None:
|
||||
if not proxy_config.get("enabled", True):
|
||||
return None
|
||||
|
||||
# ProxyNode 模式(aether-proxy)
|
||||
node_id = proxy_config.get("node_id")
|
||||
if isinstance(node_id, str) and node_id.strip():
|
||||
node_id = node_id.strip()
|
||||
node_info = _get_proxy_node_info(node_id)
|
||||
if not node_info:
|
||||
raise ProxyNodeUnavailableError(f"代理节点 {node_id} 不可用", node_id=node_id)
|
||||
return _build_hmac_proxy_url(node_info["ip"], node_info["port"], node_id)
|
||||
|
||||
proxy_url: str | None = proxy_config.get("url")
|
||||
if not proxy_url:
|
||||
return None
|
||||
@@ -307,9 +394,13 @@ class HTTPClientPool:
|
||||
client = httpx.AsyncClient(**client_config) # type: ignore[arg-type]
|
||||
cls._proxy_clients[cache_key] = (client, time.time())
|
||||
|
||||
proxy_label = "none"
|
||||
if proxy_config:
|
||||
proxy_label = str(
|
||||
proxy_config.get("node_id") or proxy_config.get("url") or "unknown"
|
||||
)
|
||||
logger.debug(
|
||||
f"创建代理客户端(缓存): {proxy_config.get('url', 'unknown') if proxy_config else 'none'}, "
|
||||
f"缓存数量: {len(cls._proxy_clients)}"
|
||||
f"创建代理客户端(缓存): {proxy_label}, " f"缓存数量: {len(cls._proxy_clients)}"
|
||||
)
|
||||
|
||||
return client
|
||||
|
||||
Reference in New Issue
Block a user