feat(proxy): 节点状态简化、连接事件记录、可靠性指标与批量删除

- 移除 UNHEALTHY 中间状态,节点状态简化为 ONLINE/OFFLINE
- 新增 proxy_node_events 表记录 tunnel 连接/断开/错误事件
- 新增 failed_requests/dns_failures/stream_errors 可靠性指标(增量累加)
- tunnel 重连改为固定 1s 延迟,移除指数退避逻辑
- resolver/service 改为以 TunnelManager 内存状态判断节点可用性,避免 DB 竞态
- 修正 Claude cache_control 字段格式,使用 ttl 字段控制缓存时长
- 移除前端手动勾选 capability 的 UI,改为从价格配置自动推断
- 新增全局模型批量删除 API,替换前端并行单个删除
This commit is contained in:
fawney19
2026-02-28 13:52:32 +08:00
parent ecb16d345a
commit 54530faf03
25 changed files with 586 additions and 225 deletions

View File

@@ -9,7 +9,7 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from fastapi import APIRouter, Depends, Query, Request, Response
from fastapi import APIRouter, Body, Depends, Query, Request, Response
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
@@ -181,6 +181,21 @@ async def delete_global_model(
return Response(status_code=204)
@router.post("/batch-delete")
async def batch_delete_global_models(
request: Request,
ids: list[str] = Body(..., embed=True, max_length=100),
db: Session = Depends(get_db),
) -> dict:
"""
批量删除 GlobalModel
顺序删除多个 GlobalModel每个独立提交避免并行删除导致的锁竞争。
"""
adapter = AdminBatchDeleteGlobalModelsAdapter(ids=ids)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post(
"/{global_model_id}/assign-to-providers", response_model=BatchAssignToProvidersResponse
)
@@ -517,6 +532,50 @@ class AdminDeleteGlobalModelAdapter(AdminApiAdapter):
return None
@dataclass
class AdminBatchDeleteGlobalModelsAdapter(AdminApiAdapter):
"""批量删除多个 GlobalModel顺序执行每个删除独立提交"""
ids: list[str]
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
from src.core.exceptions import NotFoundException
from src.models.database import GlobalModel
success_count = 0
failed: list[dict] = []
deleted_names: list[tuple[str, str]] = [] # (name, id)
for gm_id in self.ids:
try:
gm = context.db.query(GlobalModel).filter(GlobalModel.id == gm_id).first()
if gm:
name = gm.name
mid = gm.id
GlobalModelService.delete_global_model(context.db, gm_id)
deleted_names.append((name, mid))
success_count += 1
else:
failed.append({"id": gm_id, "error": "not found"})
except NotFoundException:
failed.append({"id": gm_id, "error": "not found"})
except Exception as e:
context.db.rollback()
failed.append({"id": gm_id, "error": str(e)})
# 批量失效缓存
if deleted_names:
from src.services.cache.invalidation import get_cache_invalidation_service
cache_service = get_cache_invalidation_service()
for name, mid in deleted_names:
await cache_service.on_global_model_changed(name, mid)
logger.info("批量删除 GlobalModel: success={}, failed={}", success_count, len(failed))
return {"success_count": success_count, "failed": failed}
@dataclass
class AdminBatchAssignToProvidersAdapter(AdminApiAdapter):
"""批量为 Provider 添加 GlobalModel 实现"""

View File

@@ -221,6 +221,17 @@ async def update_proxy_node_config(
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/{node_id}/events")
async def list_proxy_node_events(
node_id: str,
request: Request,
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
) -> Any:
adapter = AdminListProxyNodeEventsAdapter(node_id=node_id, limit=limit)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# ---------------------------------------------------------------------------
# 辅助函数
# ---------------------------------------------------------------------------
@@ -517,3 +528,34 @@ class AdminTestProxyUrlAdapter(AdminApiAdapter):
username=req.username,
password=req.password,
)
@dataclass
class AdminListProxyNodeEventsAdapter(AdminApiAdapter):
"""查询代理节点连接事件(连接/断开/错误历史)"""
name: str = "admin_list_proxy_node_events"
node_id: str = ""
limit: int = 50
async def handle(self, context: ApiRequestContext) -> Any:
from src.models.database import ProxyNodeEvent
events = (
context.db.query(ProxyNodeEvent)
.filter(ProxyNodeEvent.node_id == self.node_id)
.order_by(ProxyNodeEvent.created_at.desc())
.limit(self.limit)
.all()
)
return {
"items": [
{
"id": e.id,
"event_type": e.event_type,
"detail": e.detail,
"created_at": e.created_at,
}
for e in events
],
}

View File

@@ -119,6 +119,7 @@ async def proxy_tunnel_ws(ws: WebSocket) -> None:
# 启动服务端 ping 任务,防止中间代理因空闲超时关闭连接
ping_task = asyncio.create_task(_ping_loop(conn))
disconnect_reason: str | None = None
try:
oversized_count = 0
while True:
@@ -126,6 +127,7 @@ async def proxy_tunnel_ws(ws: WebSocket) -> None:
data = await asyncio.wait_for(ws.receive_bytes(), timeout=_IDLE_TIMEOUT)
except asyncio.TimeoutError:
logger.warning("tunnel idle timeout for node_id={}", node_id)
disconnect_reason = "idle timeout"
await ws.close(code=4004, reason="idle timeout")
break
if len(data) > _MAX_FRAME_SIZE:
@@ -133,6 +135,7 @@ async def proxy_tunnel_ws(ws: WebSocket) -> None:
logger.warning("tunnel frame too large from {}: {} bytes", node_id, len(data))
if oversized_count >= 5:
logger.warning("too many oversized frames from {}, closing", node_id)
disconnect_reason = "too many oversized frames"
await ws.close(code=4003, reason="too many oversized frames")
break
continue
@@ -146,14 +149,16 @@ async def proxy_tunnel_ws(ws: WebSocket) -> None:
await manager.handle_incoming_frame(conn, frame)
except WebSocketDisconnect:
disconnect_reason = "WebSocket disconnected"
logger.info("tunnel WebSocket disconnected: node_id={}", node_id)
except Exception as e:
disconnect_reason = f"error: {e}"
logger.error("tunnel WebSocket error for node_id={}: {}", node_id, e)
finally:
ping_task.cancel()
manager.unregister(conn)
if not manager.has_tunnel(node_id):
await _update_tunnel_status(node_id, connected=False)
await _update_tunnel_status(node_id, connected=False, detail=disconnect_reason)
else:
logger.info("tunnel connection closed but pool still active: node_id={}", node_id)
@@ -174,14 +179,16 @@ async def _ping_loop(conn: TunnelConnection) -> None:
pass
async def _update_tunnel_status(node_id: str, *, connected: bool) -> None:
"""更新 ProxyNode 的 tunnel 连接状态(在线程池中执行,避免阻塞 event loop"""
async def _update_tunnel_status(
node_id: str, *, connected: bool, detail: str | None = None
) -> None:
"""更新 ProxyNode 的 tunnel 连接状态并记录事件(在线程池中执行)"""
def _sync_update() -> None:
from datetime import datetime, timezone
from src.database import create_session
from src.models.database import ProxyNode, ProxyNodeStatus
from src.models.database import ProxyNode, ProxyNodeEvent, ProxyNodeStatus
db = create_session()
try:
@@ -193,9 +200,16 @@ async def _update_tunnel_status(node_id: str, *, connected: bool) -> None:
node.tunnel_connected_at = now
node.status = ProxyNodeStatus.ONLINE
else:
# 记录断开时刻,供 health_scheduler 计算 UNHEALTHY 缓冲期
node.tunnel_connected_at = now
node.status = ProxyNodeStatus.UNHEALTHY
node.status = ProxyNodeStatus.OFFLINE
# 记录连接事件
event = ProxyNodeEvent(
node_id=node_id,
event_type="connected" if connected else "disconnected",
detail=detail,
)
db.add(event)
db.commit()
finally:
db.close()

View File

@@ -131,7 +131,7 @@ class ClaudeChatAdapter(ChatAdapterBase):
request_body: dict[str, Any] | None = None,
) -> dict[str, bool]:
"""检测 Claude 请求中隐含的能力需求"""
return ClaudeCapabilityDetector.detect_from_headers(headers)
return ClaudeCapabilityDetector.detect_from_headers(headers, request_body)
# =========================================================================
# Claude 特定的计费逻辑