mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
feat: 手动代理节点支持、系统默认代理与跨格式流式 usage 提取
代理节点: - 支持手动添加代理节点(HTTP/HTTPS/SOCKS5),含地址、认证信息和区域标签 - 新增手动节点的 CRUD API 和前端管理界面 - 提供商代理配置从 URL 字符串迁移至代理节点选择器(proxy_node_id) - 新增系统默认代理节点设置,未单独配置代理的提供商自动回退使用 - 删除节点时自动清除系统默认代理引用并失效缓存 - 健康检查跳过手动节点(无心跳,始终在线) 流式处理: - CLI handler 跨格式转换时委托基类解析 Provider 原始事件的 usage - StreamProcessor 新增 _extract_usage_from_converted_event 从转换后事件补充提取 usage - 支持 Claude/OpenAI/OpenAI Responses/Gemini 多种 usage 格式
This commit is contained in:
@@ -20,20 +20,30 @@ 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
|
||||
from src.models.database import ProxyNode, ProxyNodeStatus, SystemConfig
|
||||
|
||||
router = APIRouter(prefix="/api/admin/proxy-nodes", tags=["Admin - Proxy Nodes"])
|
||||
pipeline = ApiRequestPipeline()
|
||||
|
||||
|
||||
def _mask_password(password: str | None) -> str | None:
|
||||
"""脱敏密码,仅显示前2位和后2位"""
|
||||
if not password:
|
||||
return None
|
||||
if len(password) <= 4:
|
||||
return "****"
|
||||
return password[:2] + "****" + password[-2:]
|
||||
|
||||
|
||||
def _node_to_dict(node: ProxyNode) -> dict[str, Any]:
|
||||
return {
|
||||
d = {
|
||||
"id": node.id,
|
||||
"name": node.name,
|
||||
"ip": node.ip,
|
||||
"port": node.port,
|
||||
"region": node.region,
|
||||
"status": node.status.value if node.status else None,
|
||||
"is_manual": bool(node.is_manual),
|
||||
"registered_by": node.registered_by,
|
||||
"last_heartbeat_at": node.last_heartbeat_at,
|
||||
"heartbeat_interval": node.heartbeat_interval,
|
||||
@@ -43,6 +53,12 @@ def _node_to_dict(node: ProxyNode) -> dict[str, Any]:
|
||||
"created_at": node.created_at,
|
||||
"updated_at": node.updated_at,
|
||||
}
|
||||
# 手动节点附带代理配置(密码脱敏)
|
||||
if node.is_manual:
|
||||
d["proxy_url"] = node.proxy_url
|
||||
d["proxy_username"] = node.proxy_username
|
||||
d["proxy_password"] = _mask_password(node.proxy_password)
|
||||
return d
|
||||
|
||||
|
||||
class ProxyNodeRegisterRequest(BaseModel):
|
||||
@@ -81,6 +97,58 @@ class ProxyNodeUnregisterRequest(BaseModel):
|
||||
node_id: str = Field(..., min_length=1, max_length=36, description="节点 ID")
|
||||
|
||||
|
||||
class ManualProxyNodeCreateRequest(BaseModel):
|
||||
"""手动创建代理节点"""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=100, description="节点名")
|
||||
proxy_url: str = Field(
|
||||
..., min_length=1, max_length=500, description="代理 URL (http/https/socks5)"
|
||||
)
|
||||
username: str | None = Field(None, max_length=255, description="代理用户名")
|
||||
password: str | None = Field(None, max_length=500, description="代理密码")
|
||||
region: str | None = Field(None, max_length=100, description="区域标签")
|
||||
|
||||
@field_validator("proxy_url")
|
||||
@classmethod
|
||||
def validate_proxy_url(cls, v: str) -> str:
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
v = v.strip()
|
||||
if not re.match(r"^(http|https|socks5)://", v, re.IGNORECASE):
|
||||
raise ValueError("代理 URL 必须以 http://, https:// 或 socks5:// 开头")
|
||||
parsed = urlparse(v)
|
||||
if not parsed.hostname:
|
||||
raise ValueError("代理 URL 必须包含有效的 host")
|
||||
return v
|
||||
|
||||
|
||||
class ManualProxyNodeUpdateRequest(BaseModel):
|
||||
"""更新手动代理节点"""
|
||||
|
||||
name: str | None = Field(None, min_length=1, max_length=100, description="节点名")
|
||||
proxy_url: str | None = Field(None, min_length=1, max_length=500, description="代理 URL")
|
||||
username: str | None = Field(None, max_length=255, description="代理用户名")
|
||||
password: str | None = Field(None, max_length=500, description="代理密码")
|
||||
region: str | None = Field(None, max_length=100, description="区域标签")
|
||||
|
||||
@field_validator("proxy_url")
|
||||
@classmethod
|
||||
def validate_proxy_url(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
v = v.strip()
|
||||
if not re.match(r"^(http|https|socks5)://", v, re.IGNORECASE):
|
||||
raise ValueError("代理 URL 必须以 http://, https:// 或 socks5:// 开头")
|
||||
parsed = urlparse(v)
|
||||
if not parsed.hostname:
|
||||
raise ValueError("代理 URL 必须包含有效的 host")
|
||||
return v
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register_proxy_node(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
adapter = AdminRegisterProxyNodeAdapter()
|
||||
@@ -111,6 +179,20 @@ async def list_proxy_nodes(
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/manual")
|
||||
async def create_manual_proxy_node(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
adapter = AdminCreateManualProxyNodeAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.patch("/{node_id}")
|
||||
async def update_manual_proxy_node(
|
||||
node_id: str, request: Request, db: Session = Depends(get_db)
|
||||
) -> Any:
|
||||
adapter = AdminUpdateManualProxyNodeAdapter(node_id=node_id)
|
||||
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)
|
||||
@@ -298,7 +380,151 @@ class AdminDeleteProxyNodeAdapter(AdminApiAdapter):
|
||||
proxy_node_port=node.port,
|
||||
)
|
||||
|
||||
# 若该节点是系统默认代理,自动清除引用
|
||||
was_system_proxy = False
|
||||
sys_cfg = (
|
||||
context.db.query(SystemConfig)
|
||||
.filter(SystemConfig.key == "system_proxy_node_id")
|
||||
.first()
|
||||
)
|
||||
if sys_cfg and sys_cfg.value == self.node_id:
|
||||
sys_cfg.value = None
|
||||
was_system_proxy = True
|
||||
|
||||
context.db.delete(node)
|
||||
context.db.commit()
|
||||
|
||||
return {"message": "deleted", "node_id": self.node_id}
|
||||
if was_system_proxy:
|
||||
from src.clients.http_client import invalidate_system_proxy_cache
|
||||
|
||||
invalidate_system_proxy_cache()
|
||||
|
||||
msg = "deleted"
|
||||
if was_system_proxy:
|
||||
msg = "deleted, system default proxy cleared"
|
||||
return {"message": msg, "node_id": self.node_id, "cleared_system_proxy": was_system_proxy}
|
||||
|
||||
|
||||
def _parse_host_port(proxy_url: str) -> tuple[str, int]:
|
||||
"""从代理 URL 中解析 host 和 port(含协议前缀,避免唯一约束冲突)"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(proxy_url)
|
||||
host = parsed.hostname or "manual"
|
||||
default_ports = {"https": 443, "socks5": 1080}
|
||||
port = parsed.port or default_ports.get((parsed.scheme or "").lower(), 80)
|
||||
# 添加协议前缀区分同 host:port 不同协议的场景
|
||||
scheme = (parsed.scheme or "http").lower()
|
||||
if scheme != "http":
|
||||
host = f"{scheme}://{host}"
|
||||
return host, port
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminCreateManualProxyNodeAdapter(AdminApiAdapter):
|
||||
name: str = "admin_create_manual_proxy_node"
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any:
|
||||
payload = context.ensure_json_body()
|
||||
try:
|
||||
req = ManualProxyNodeCreateRequest.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc))
|
||||
|
||||
host, port = _parse_host_port(req.proxy_url)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
node = ProxyNode(
|
||||
id=str(uuid.uuid4()),
|
||||
name=req.name,
|
||||
ip=host,
|
||||
port=port,
|
||||
region=req.region,
|
||||
is_manual=True,
|
||||
proxy_url=req.proxy_url,
|
||||
proxy_username=req.username,
|
||||
proxy_password=req.password,
|
||||
status=ProxyNodeStatus.ONLINE,
|
||||
registered_by=context.user.id if context.user else None,
|
||||
last_heartbeat_at=None,
|
||||
heartbeat_interval=0,
|
||||
active_connections=0,
|
||||
total_requests=0,
|
||||
avg_latency_ms=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
# 检查是否已存在同地址的节点
|
||||
existing = (
|
||||
context.db.query(ProxyNode).filter(ProxyNode.ip == host, ProxyNode.port == port).first()
|
||||
)
|
||||
if existing:
|
||||
raise InvalidRequestException(
|
||||
f"已存在相同地址的代理节点: {existing.name} ({existing.ip}:{existing.port})"
|
||||
)
|
||||
|
||||
context.db.add(node)
|
||||
context.db.commit()
|
||||
context.db.refresh(node)
|
||||
|
||||
context.add_audit_metadata(
|
||||
action="proxy_node_manual_create",
|
||||
proxy_node_id=node.id,
|
||||
)
|
||||
|
||||
return {"node_id": node.id, "node": _node_to_dict(node)}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminUpdateManualProxyNodeAdapter(AdminApiAdapter):
|
||||
name: str = "admin_update_manual_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")
|
||||
if not node.is_manual:
|
||||
raise InvalidRequestException("只能编辑手动添加的代理节点")
|
||||
|
||||
payload = context.ensure_json_body()
|
||||
try:
|
||||
req = ManualProxyNodeUpdateRequest.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc))
|
||||
|
||||
if req.name is not None:
|
||||
node.name = req.name
|
||||
if req.proxy_url is not None:
|
||||
node.proxy_url = req.proxy_url
|
||||
host, port = _parse_host_port(req.proxy_url)
|
||||
# 检查新地址是否与其他节点冲突
|
||||
existing = (
|
||||
context.db.query(ProxyNode)
|
||||
.filter(ProxyNode.ip == host, ProxyNode.port == port, ProxyNode.id != node.id)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
raise InvalidRequestException(
|
||||
f"已存在相同地址的代理节点: {existing.name} ({existing.ip}:{existing.port})"
|
||||
)
|
||||
node.ip = host
|
||||
node.port = port
|
||||
if req.username is not None:
|
||||
node.proxy_username = req.username
|
||||
# password: None=不发送(保留原值), ""=清空, 非空=更新
|
||||
if req.password is not None:
|
||||
node.proxy_password = req.password or None
|
||||
if req.region is not None:
|
||||
node.region = req.region
|
||||
|
||||
node.updated_at = datetime.now(timezone.utc)
|
||||
context.db.commit()
|
||||
context.db.refresh(node)
|
||||
|
||||
context.add_audit_metadata(
|
||||
action="proxy_node_manual_update",
|
||||
proxy_node_id=node.id,
|
||||
)
|
||||
|
||||
return {"node_id": node.id, "node": _node_to_dict(node)}
|
||||
|
||||
@@ -276,6 +276,73 @@ class StreamProcessor:
|
||||
if finish_reason is not None:
|
||||
ctx.has_completion = True
|
||||
|
||||
def _extract_usage_from_converted_event(
|
||||
self,
|
||||
ctx: StreamContext,
|
||||
evt: dict[str, Any],
|
||||
event_type: str,
|
||||
) -> None:
|
||||
"""
|
||||
从转换后的事件中提取 usage 信息(补充 Provider 事件解析)。
|
||||
|
||||
支持多种格式:
|
||||
- Claude: message_delta.usage, message_start.message.usage
|
||||
- OpenAI: chunk.usage / response.completed.response.usage
|
||||
- Gemini: usageMetadata
|
||||
"""
|
||||
usage: dict[str, Any] | None = None
|
||||
|
||||
# Claude 格式: message_delta 或 message_start
|
||||
if event_type == "message_delta":
|
||||
usage = evt.get("usage")
|
||||
elif event_type == "message_start":
|
||||
message = evt.get("message", {})
|
||||
if isinstance(message, dict):
|
||||
usage = message.get("usage")
|
||||
# OpenAI Responses API 格式: 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
|
||||
elif "usageMetadata" in evt:
|
||||
meta = evt.get("usageMetadata", {})
|
||||
if isinstance(meta, dict):
|
||||
usage = {
|
||||
"input_tokens": meta.get("promptTokenCount", 0),
|
||||
"output_tokens": meta.get("candidatesTokenCount", 0),
|
||||
"cache_read_tokens": meta.get("cachedContentTokenCount", 0),
|
||||
"cache_creation_tokens": 0,
|
||||
}
|
||||
|
||||
if usage and isinstance(usage, dict):
|
||||
new_input = usage.get("input_tokens", 0) or 0
|
||||
new_output = usage.get("output_tokens", 0) or 0
|
||||
new_cached = usage.get("cache_read_tokens") or usage.get("cache_read_input_tokens") or 0
|
||||
new_cache_creation = (
|
||||
usage.get("cache_creation_tokens") or usage.get("cache_creation_input_tokens") or 0
|
||||
)
|
||||
|
||||
if new_input > ctx.input_tokens:
|
||||
ctx.input_tokens = new_input
|
||||
logger.debug("[{}] 从转换后事件更新 input_tokens: {}", self.request_id, new_input)
|
||||
if new_output > ctx.output_tokens:
|
||||
ctx.output_tokens = new_output
|
||||
logger.debug("[{}] 从转换后事件更新 output_tokens: {}", self.request_id, new_output)
|
||||
if new_cached > ctx.cached_tokens:
|
||||
ctx.cached_tokens = new_cached
|
||||
if new_cache_creation > ctx.cache_creation_tokens:
|
||||
ctx.cache_creation_tokens = new_cache_creation
|
||||
|
||||
if any([new_input, new_output, new_cached, new_cache_creation]):
|
||||
ctx.final_usage = usage
|
||||
|
||||
async def prefetch_and_check_error(
|
||||
self,
|
||||
byte_iterator: Any,
|
||||
@@ -748,6 +815,18 @@ class StreamProcessor:
|
||||
ctx.data_count += 1
|
||||
if ctx.record_parsed_chunks:
|
||||
ctx.parsed_chunks.append(evt)
|
||||
event_type = evt.get("type", "")
|
||||
if event_type in ("message_stop", "response.completed"):
|
||||
ctx.has_completion = True
|
||||
elif "choices" in evt:
|
||||
choices = evt.get("choices", [])
|
||||
for choice in choices:
|
||||
if isinstance(choice, dict) and choice.get("finish_reason"):
|
||||
ctx.has_completion = True
|
||||
break
|
||||
|
||||
# 从转换后的事件中补充 usage 信息
|
||||
self._extract_usage_from_converted_event(ctx, evt, event_type)
|
||||
|
||||
# 统一使用 SSE 格式输出(Gemini streamGenerateContent 也使用 SSE)
|
||||
# 参考: https://ai.google.dev/api/generate-content
|
||||
|
||||
@@ -87,7 +87,18 @@ class ClaudeCliMessageHandler(CliMessageHandlerBase):
|
||||
- content_block_delta: 文本增量
|
||||
- message_delta: 消息增量,包含最终 usage
|
||||
- message_stop: 消息结束
|
||||
|
||||
跨格式转换时(如 provider=openai:cli),原始事件数据是 Provider 格式而非 Claude 格式。
|
||||
此时委托基类方法通过 Provider 格式解析器提取 usage。
|
||||
"""
|
||||
# 跨格式转换时:原始事件是 Provider 格式,
|
||||
# 基类 _process_event_data 会自动选择正确的 Provider 解析器提取 usage/text
|
||||
if ctx.provider_api_format and ctx.provider_api_format != ctx.client_api_format:
|
||||
super()._process_event_data(ctx, event_type, data)
|
||||
return
|
||||
|
||||
# 以下是同格式(claude:cli / claude:chat)的处理逻辑
|
||||
|
||||
# 处理 message_start 事件
|
||||
if event_type == "message_start":
|
||||
message = data.get("message", {})
|
||||
|
||||
@@ -208,7 +208,18 @@ class GeminiCliMessageHandler(CliMessageHandlerBase):
|
||||
|
||||
注意: Gemini 流解析器会将每个 JSON 对象作为一个"事件"传递
|
||||
event_type 在这里可能为空或是自定义的标记
|
||||
|
||||
跨格式转换时(如 provider=claude:chat),原始事件数据是 Provider 格式而非 Gemini 格式。
|
||||
此时委托基类方法通过 Provider 格式解析器提取 usage。
|
||||
"""
|
||||
# 跨格式转换时:原始事件是 Provider 格式,
|
||||
# 基类 _process_event_data 会自动选择正确的 Provider 解析器提取 usage/text
|
||||
if ctx.provider_api_format and ctx.provider_api_format != ctx.client_api_format:
|
||||
super()._process_event_data(ctx, _event_type, data)
|
||||
return
|
||||
|
||||
# 以下是同格式(gemini:cli / gemini:chat)的处理逻辑
|
||||
|
||||
# 提取候选响应
|
||||
candidates = data.get("candidates", [])
|
||||
if candidates:
|
||||
|
||||
@@ -84,7 +84,18 @@ class OpenAICliMessageHandler(CliMessageHandlerBase):
|
||||
事件类型:
|
||||
- response.output_text.delta: 文本增量
|
||||
- response.completed: 响应完成(包含 usage)
|
||||
|
||||
跨格式转换时(如 provider=claude:chat),原始事件数据是 Provider 格式而非 OpenAI CLI 格式。
|
||||
此时先调用基类方法通过 Provider 格式解析器提取 usage,再执行 OpenAI CLI 特定的处理逻辑。
|
||||
"""
|
||||
# 跨格式转换时:原始事件是 Provider 格式(如 Claude),
|
||||
# 基类 _process_event_data 会自动选择正确的 Provider 解析器提取 usage/text
|
||||
if ctx.provider_api_format and ctx.provider_api_format != ctx.client_api_format:
|
||||
super()._process_event_data(ctx, event_type, data)
|
||||
return
|
||||
|
||||
# 以下是同格式(openai:cli)的处理逻辑
|
||||
|
||||
# 提取 response_id
|
||||
if not ctx.response_id:
|
||||
response_obj = data.get("response")
|
||||
|
||||
@@ -40,7 +40,9 @@ def _get_proxy_node_info(node_id: str) -> dict[str, Any] | None:
|
||||
读取 ProxyNode 信息(带内存 TTL 缓存)
|
||||
|
||||
Returns:
|
||||
{"ip": str, "port": int} 或 None(不存在/非在线)
|
||||
aether-proxy 节点: {"ip": str, "port": int}
|
||||
手动节点: {"is_manual": True, "proxy_url": str, "username": str|None, "password": str|None}
|
||||
不存在/非在线: None
|
||||
"""
|
||||
now = time.time()
|
||||
cached = _proxy_node_cache.get(node_id)
|
||||
@@ -63,7 +65,16 @@ def _get_proxy_node_info(node_id: str) -> dict[str, Any] | None:
|
||||
_proxy_node_cache[node_id] = (None, now + _PROXY_NODE_CACHE_TTL_SECONDS)
|
||||
return None
|
||||
|
||||
value = {"ip": node.ip, "port": node.port}
|
||||
if node.is_manual:
|
||||
value: dict[str, Any] = {
|
||||
"is_manual": True,
|
||||
"proxy_url": node.proxy_url,
|
||||
"username": node.proxy_username,
|
||||
"password": node.proxy_password,
|
||||
}
|
||||
else:
|
||||
value = {"ip": node.ip, "port": node.port}
|
||||
|
||||
_proxy_node_cache[node_id] = (value, now + _PROXY_NODE_CACHE_TTL_SECONDS)
|
||||
return value
|
||||
finally:
|
||||
@@ -94,6 +105,90 @@ def _build_hmac_proxy_url(ip: str, port: int, node_id: str) -> str:
|
||||
return f"http://hmac:{timestamp}.{signature}@{host}:{int(port)}"
|
||||
|
||||
|
||||
# 系统默认代理缓存
|
||||
_system_proxy_cache: tuple[dict[str, Any] | None, float] | None = None
|
||||
_SYSTEM_PROXY_CACHE_TTL = 60.0
|
||||
|
||||
|
||||
def invalidate_system_proxy_cache() -> None:
|
||||
"""手动失效系统代理缓存(在删除节点等操作后调用)"""
|
||||
global _system_proxy_cache
|
||||
_system_proxy_cache = None
|
||||
|
||||
|
||||
def get_system_proxy_config() -> dict[str, Any] | None:
|
||||
"""
|
||||
获取系统默认代理配置(带 TTL 缓存)
|
||||
|
||||
从 system_configs 表中读取 system_proxy_node_id。
|
||||
返回 {"node_id": "...", "enabled": True} 或 None。
|
||||
"""
|
||||
global _system_proxy_cache
|
||||
now = time.time()
|
||||
if _system_proxy_cache:
|
||||
value, expires_at = _system_proxy_cache
|
||||
if now < expires_at:
|
||||
return value
|
||||
|
||||
from src.database import create_session
|
||||
from src.services.system.config import SystemConfigService
|
||||
|
||||
db = create_session()
|
||||
try:
|
||||
node_id = SystemConfigService.get_config(db, "system_proxy_node_id")
|
||||
if node_id and isinstance(node_id, str) and node_id.strip():
|
||||
result: dict[str, Any] | None = {"node_id": node_id.strip(), "enabled": True}
|
||||
else:
|
||||
result = None
|
||||
_system_proxy_cache = (result, now + _SYSTEM_PROXY_CACHE_TTL)
|
||||
return result
|
||||
except Exception:
|
||||
_system_proxy_cache = (None, now + _SYSTEM_PROXY_CACHE_TTL)
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def resolve_ops_proxy(connector_config: dict[str, Any] | None) -> str | None:
|
||||
"""
|
||||
从 ops connector.config 中解析代理 URL(含系统默认回退)
|
||||
|
||||
优先级:
|
||||
1. connector_config.proxy_node_id(新格式)
|
||||
2. connector_config.proxy(旧格式 URL 字符串)
|
||||
3. 系统默认代理节点
|
||||
|
||||
Args:
|
||||
connector_config: connector 的 config 字典
|
||||
|
||||
Returns:
|
||||
代理 URL 字符串,或 None
|
||||
"""
|
||||
if connector_config:
|
||||
# 新格式:proxy_node_id → 通过 build_proxy_url 解析
|
||||
node_id = connector_config.get("proxy_node_id")
|
||||
if isinstance(node_id, str) and node_id.strip():
|
||||
try:
|
||||
return build_proxy_url({"node_id": node_id.strip(), "enabled": True})
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# 旧格式:直接返回 proxy URL 字符串
|
||||
proxy = connector_config.get("proxy")
|
||||
if isinstance(proxy, str) and proxy.strip():
|
||||
return proxy
|
||||
|
||||
# 回退:系统默认代理
|
||||
system_proxy = get_system_proxy_config()
|
||||
if system_proxy:
|
||||
try:
|
||||
return build_proxy_url(system_proxy)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _compute_proxy_cache_key(proxy_config: dict[str, Any] | None) -> str:
|
||||
"""
|
||||
计算代理配置的缓存键
|
||||
@@ -146,13 +241,43 @@ def build_proxy_url(proxy_config: dict[str, Any]) -> str | None:
|
||||
if not proxy_config.get("enabled", True):
|
||||
return None
|
||||
|
||||
# ProxyNode 模式(aether-proxy)
|
||||
# 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)
|
||||
|
||||
# 手动节点:直接使用存储的代理 URL(含认证信息)
|
||||
if node_info.get("is_manual"):
|
||||
manual_url = node_info.get("proxy_url")
|
||||
if not manual_url:
|
||||
raise ProxyNodeUnavailableError(
|
||||
f"手动代理节点 {node_id} 缺少 proxy_url", node_id=node_id
|
||||
)
|
||||
username = node_info.get("username")
|
||||
password = node_info.get("password")
|
||||
if username:
|
||||
parsed = urlparse(manual_url)
|
||||
encoded_username = quote(username, safe="")
|
||||
encoded_password = quote(password, safe="") if password else ""
|
||||
# 使用 hostname+port 而非 netloc,避免 URL 内嵌凭据导致双重认证
|
||||
host_part = parsed.hostname or "localhost"
|
||||
if parsed.port:
|
||||
host_part = f"{host_part}:{parsed.port}"
|
||||
if encoded_password:
|
||||
auth_url = (
|
||||
f"{parsed.scheme}://{encoded_username}:{encoded_password}@{host_part}"
|
||||
)
|
||||
else:
|
||||
auth_url = f"{parsed.scheme}://{encoded_username}@{host_part}"
|
||||
if parsed.path:
|
||||
auth_url += parsed.path
|
||||
return auth_url
|
||||
return manual_url
|
||||
|
||||
# aether-proxy 节点:使用 HMAC 认证
|
||||
return _build_hmac_proxy_url(node_info["ip"], node_info["port"], node_id)
|
||||
|
||||
proxy_url: str | None = proxy_config.get("url")
|
||||
@@ -337,14 +462,19 @@ class HTTPClientPool:
|
||||
获取代理客户端(带缓存复用)
|
||||
|
||||
相同代理配置会复用同一个客户端,大幅减少连接建立开销。
|
||||
当 proxy_config 为 None 时,自动回退到系统默认代理节点。
|
||||
注意:返回的客户端使用默认超时配置,如需自定义超时请在请求时传递 timeout 参数。
|
||||
|
||||
Args:
|
||||
proxy_config: 代理配置字典,包含 url, username, password
|
||||
proxy_config: 代理配置字典,为 None 时使用系统默认代理
|
||||
|
||||
Returns:
|
||||
可复用的 httpx.AsyncClient 实例
|
||||
"""
|
||||
# 无特定代理时,回退到系统默认代理
|
||||
if not proxy_config:
|
||||
proxy_config = get_system_proxy_config()
|
||||
|
||||
cache_key = _compute_proxy_cache_key(proxy_config)
|
||||
|
||||
# 无代理时返回默认客户端
|
||||
|
||||
@@ -801,16 +801,22 @@ class ProxyNodeStatus(PyEnum):
|
||||
|
||||
|
||||
class ProxyNode(Base):
|
||||
"""代理节点表(用于 aether-proxy 注册/心跳)"""
|
||||
"""代理节点表(aether-proxy 自动注册 + 手动添加)"""
|
||||
|
||||
__tablename__ = "proxy_nodes"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
|
||||
name = Column(String(100), nullable=False) # 节点名
|
||||
ip = Column(String(45), nullable=False) # 公网 IP(IPv6 最长 39 + 冗余)
|
||||
ip = Column(String(512), nullable=False) # 公网 IP 或手动节点的主机名(含协议前缀)
|
||||
port = Column(Integer, nullable=False) # 代理端口
|
||||
region = Column(String(100), nullable=True) # 区域标签
|
||||
|
||||
# 手动节点专用字段
|
||||
is_manual = Column(Boolean, default=False, nullable=False, comment="是否为手动添加的代理节点")
|
||||
proxy_url = Column(String(500), nullable=True, comment="手动节点的完整代理 URL")
|
||||
proxy_username = Column(String(255), nullable=True, comment="手动节点的代理用户名")
|
||||
proxy_password = Column(String(500), nullable=True, comment="手动节点的代理密码")
|
||||
|
||||
status = Column(
|
||||
Enum(
|
||||
ProxyNodeStatus,
|
||||
|
||||
@@ -408,8 +408,10 @@ class AnyrouterArchitecture(ProviderArchitecture):
|
||||
Returns:
|
||||
包含 acw_cookie 的配置
|
||||
"""
|
||||
# 从 config 获取代理配置
|
||||
proxy = config.get("proxy")
|
||||
# 从 config 获取代理配置(支持 proxy_node_id 和旧的 proxy URL)
|
||||
from src.clients.http_client import resolve_ops_proxy
|
||||
|
||||
proxy = resolve_ops_proxy(config)
|
||||
acw_cookie = await _get_acw_cookie(base_url, proxy=proxy)
|
||||
if acw_cookie:
|
||||
return {"acw_cookie": acw_cookie}
|
||||
|
||||
@@ -50,8 +50,10 @@ class ProviderConnector(ABC):
|
||||
self._expires_at: datetime | None = None
|
||||
self._last_error: str | None = None
|
||||
|
||||
# 代理配置
|
||||
self._proxy: str | None = self.config.get("proxy")
|
||||
# 代理配置(支持 proxy_node_id 和旧的 proxy URL)
|
||||
from src.clients.http_client import resolve_ops_proxy
|
||||
|
||||
self._proxy: str | None = resolve_ops_proxy(self.config)
|
||||
|
||||
# HTTP 客户端配置
|
||||
self._timeout = self.config.get("timeout", 30)
|
||||
|
||||
@@ -180,7 +180,9 @@ class NekoCodeArchitecture(ProviderArchitecture):
|
||||
"timeout": 10,
|
||||
"verify": get_ssl_context(),
|
||||
}
|
||||
proxy = config.get("proxy")
|
||||
from src.clients.http_client import resolve_ops_proxy
|
||||
|
||||
proxy = resolve_ops_proxy(config)
|
||||
if proxy:
|
||||
client_kwargs["proxy"] = proxy
|
||||
|
||||
|
||||
@@ -193,8 +193,10 @@ class YesCodeArchitecture(ProviderArchitecture):
|
||||
|
||||
cookie_header = _build_cookie_header(cookie_input)
|
||||
|
||||
# 获取代理配置
|
||||
proxy = config.get("proxy")
|
||||
# 获取代理配置(支持 proxy_node_id 和旧的 proxy URL)
|
||||
from src.clients.http_client import resolve_ops_proxy
|
||||
|
||||
proxy = resolve_ops_proxy(config)
|
||||
|
||||
try:
|
||||
# 构建 client 参数
|
||||
|
||||
@@ -916,8 +916,10 @@ class ProviderOpsService:
|
||||
f"endpoint={verify_endpoint}, headers={list(headers.keys())}"
|
||||
)
|
||||
|
||||
# 获取代理配置
|
||||
proxy = config.get("proxy")
|
||||
# 获取代理配置(支持 proxy_node_id 和旧的 proxy URL)
|
||||
from src.clients.http_client import resolve_ops_proxy
|
||||
|
||||
proxy = resolve_ops_proxy(config)
|
||||
|
||||
try:
|
||||
# 构建 httpx client 参数
|
||||
|
||||
@@ -55,7 +55,15 @@ class ProxyNodeHealthScheduler:
|
||||
db = create_session()
|
||||
try:
|
||||
now = datetime.now(timezone.utc)
|
||||
nodes = db.query(ProxyNode).filter(ProxyNode.status != ProxyNodeStatus.OFFLINE).all()
|
||||
# 仅检查非手动节点(手动节点无心跳,始终保持 ONLINE)
|
||||
nodes = (
|
||||
db.query(ProxyNode)
|
||||
.filter(
|
||||
ProxyNode.status != ProxyNodeStatus.OFFLINE,
|
||||
ProxyNode.is_manual == False, # noqa: E712
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if not nodes:
|
||||
return
|
||||
|
||||
|
||||
@@ -168,6 +168,11 @@ class SystemConfigService:
|
||||
"value": 30,
|
||||
"description": "审计日志保留天数,超过此天数的审计日志将被自动清理",
|
||||
},
|
||||
# 系统代理
|
||||
"system_proxy_node_id": {
|
||||
"value": None,
|
||||
"description": "系统默认代理节点 ID,为空时直连。仅影响提供商出站请求(大模型API/余额查询/OAuth),不影响系统内部接口",
|
||||
},
|
||||
# SMTP 邮件配置
|
||||
"smtp_host": {
|
||||
"value": None,
|
||||
|
||||
Reference in New Issue
Block a user