新增设备身份管理、认证限流、并发通道、Webhook路由、RBAC权限控制、SSE/轮询降级等全套网关通道功能,包含: 1. 设备身份生成与签名验证 2. 设备令牌认证与速率限制 3. 内存+数据库双重设备注册表 4. 并发通道限流管理 5. Webhook安全处理与路由 6. RBAC权限校验系统 7. OpenAI API兼容适配层 8. Tailscale认证支持 9. HTTP轮询降级机制
278 lines
8.7 KiB
Python
278 lines
8.7 KiB
Python
"""Gateway HTTP SSE 端点 — 为不支持 WebSocket 的客户端提供备选流式通道。
|
|
|
|
复用 stream_agent_chat 的流式输出能力,通过 HTTP SSE (text/event-stream) 推送给浏览器客户端。
|
|
|
|
SSE 事件类型对齐 CowAgent 标准:
|
|
reasoning / delta / tool_start / tool_end / message_end /
|
|
phase / image / file / video / done / error
|
|
|
|
路由:
|
|
POST /api/sse/chat — 发送聊天消息,返回 request_id
|
|
GET /api/sse/stream — 订阅 SSE 事件流
|
|
GET /api/poll — Polling 降级轮询
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import time
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, Header, Query, Request
|
|
from fastapi.responses import StreamingResponse
|
|
|
|
from yuxi.channel.protocols import SseEventType
|
|
from yuxi.storage.postgres.manager import pg_manager
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SSE_KEEP_ALIVE_SEC = 15.0
|
|
SSE_QUEUE_TTL_SEC = 600
|
|
MAX_QUEUE_SIZE = 256
|
|
|
|
|
|
def _sse_frame(event_type: str, data: dict | str) -> str:
|
|
content = json.dumps(data, ensure_ascii=False) if isinstance(data, dict) else data
|
|
return f"event: {event_type}\ndata: {content}\n\n"
|
|
|
|
|
|
def _sse_comment(comment: str) -> str:
|
|
return f": {comment}\n\n"
|
|
|
|
|
|
class GatewaySseEndpoint:
|
|
def __init__(self):
|
|
self._queues: dict[str, asyncio.Queue[dict]] = {}
|
|
self._tasks: dict[str, asyncio.Task] = {}
|
|
self._last_active: dict[str, float] = {}
|
|
|
|
def create(self, rid: str, q: asyncio.Queue[dict], task: asyncio.Task) -> None:
|
|
self._queues[rid] = q
|
|
self._tasks[rid] = task
|
|
self._last_active[rid] = time.monotonic()
|
|
|
|
def get(self, rid: str) -> asyncio.Queue[dict] | None:
|
|
return self._queues.get(rid)
|
|
|
|
def touch(self, rid: str) -> None:
|
|
self._last_active[rid] = time.monotonic()
|
|
|
|
def remove(self, rid: str) -> None:
|
|
self._queues.pop(rid, None)
|
|
task = self._tasks.pop(rid, None)
|
|
if task and not task.done():
|
|
task.cancel()
|
|
self._last_active.pop(rid, None)
|
|
|
|
def cleanup_stale(self) -> int:
|
|
now = time.monotonic()
|
|
stale = [rid for rid, ts in self._last_active.items() if now - ts > SSE_QUEUE_TTL_SEC]
|
|
for rid in stale:
|
|
self.remove(rid)
|
|
return len(stale)
|
|
|
|
|
|
gateway_sse_endpoint = GatewaySseEndpoint()
|
|
|
|
|
|
async def _run_chat_feed(
|
|
query: str,
|
|
agent_config_id: int,
|
|
thread_id: str | None,
|
|
image_content: str | None,
|
|
current_user,
|
|
db,
|
|
q: asyncio.Queue[dict],
|
|
rid: str,
|
|
) -> None:
|
|
from yuxi.services.chat_service import stream_agent_chat
|
|
|
|
meta = {
|
|
"source": "gateway_sse",
|
|
"channel_type": "sse",
|
|
"account_id": "default",
|
|
"request_id": rid,
|
|
}
|
|
|
|
try:
|
|
async for chunk in stream_agent_chat(
|
|
query=query,
|
|
agent_config_id=agent_config_id,
|
|
thread_id=thread_id,
|
|
meta=meta,
|
|
image_content=image_content,
|
|
current_user=current_user,
|
|
db=db,
|
|
):
|
|
try:
|
|
data = json.loads(chunk.decode("utf-8"))
|
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
continue
|
|
|
|
status = data.get("status", "")
|
|
content = data.get("response", "")
|
|
|
|
if status == "error":
|
|
await q.put({"type": SseEventType.ERROR, "data": {"message": data.get("error_message", content)}})
|
|
await q.put({"type": SseEventType.DONE, "data": {}})
|
|
return
|
|
|
|
if status == "init":
|
|
continue
|
|
|
|
if status in ("streaming", "reasoning"):
|
|
event_type = SseEventType.REASONING if data.get("reasoning") else SseEventType.DELTA
|
|
if content:
|
|
await q.put({"type": event_type, "data": {"content": content}})
|
|
|
|
if status == "thinking":
|
|
await q.put({"type": SseEventType.PHASE, "data": {"content": content or "thinking"}})
|
|
|
|
if status == "finished":
|
|
final_content = data.get("final_response", content)
|
|
await q.put(
|
|
{
|
|
"type": SseEventType.MESSAGE_END,
|
|
"data": {"content": final_content, "thread_id": data.get("thread_id", thread_id)},
|
|
}
|
|
)
|
|
|
|
await q.put({"type": SseEventType.DONE, "data": {}})
|
|
except asyncio.CancelledError:
|
|
pass
|
|
except Exception:
|
|
logger.exception("SSE chat stream failed for %s", rid)
|
|
await q.put({"type": SseEventType.ERROR, "data": {"message": "SSE stream error"}})
|
|
await q.put({"type": SseEventType.DONE, "data": {}})
|
|
|
|
|
|
async def _resolve_sse_user(db, authorization: str | None):
|
|
from sqlalchemy import select
|
|
|
|
from server.utils.auth_utils import AuthUtils
|
|
from yuxi.storage.postgres.models_business import User
|
|
|
|
if authorization and authorization.startswith("Bearer "):
|
|
token = authorization[7:]
|
|
try:
|
|
payload = AuthUtils.verify_access_token(token)
|
|
user_id = payload.get("sub")
|
|
if user_id:
|
|
result = await db.execute(select(User).where(User.id == int(user_id)))
|
|
user = result.scalar_one_or_none()
|
|
if user:
|
|
return user
|
|
except Exception:
|
|
pass
|
|
|
|
result = await db.execute(
|
|
select(User).where(User.is_deleted == 0).order_by(User.id).limit(1)
|
|
)
|
|
user = result.scalar_one_or_none()
|
|
if user:
|
|
logger.warning("SSE endpoint using fallback user id=%s (no valid auth provided)", user.id)
|
|
return user
|
|
|
|
|
|
router = APIRouter(prefix="/api/sse", tags=["sse"])
|
|
|
|
|
|
@router.post("/chat")
|
|
async def sse_chat(
|
|
query: str = Query(...),
|
|
agent_config_id: int = Query(...),
|
|
thread_id: str | None = Query(None),
|
|
image_content: str | None = Query(None),
|
|
authorization: str | None = Header(None),
|
|
):
|
|
rid = str(uuid.uuid4())
|
|
|
|
async with pg_manager.get_async_session_context() as db:
|
|
current_user = await _resolve_sse_user(db, authorization)
|
|
|
|
q: asyncio.Queue[dict] = asyncio.Queue(maxsize=MAX_QUEUE_SIZE)
|
|
|
|
task = asyncio.create_task(
|
|
_run_chat_feed(
|
|
query=query,
|
|
agent_config_id=agent_config_id,
|
|
thread_id=thread_id,
|
|
image_content=image_content,
|
|
current_user=current_user,
|
|
db=db,
|
|
q=q,
|
|
rid=rid,
|
|
),
|
|
name=f"sse_chat:{rid}",
|
|
)
|
|
gateway_sse_endpoint.create(rid, q, task)
|
|
|
|
return {"request_id": rid, "stream_url": f"/api/sse/stream?request_id={rid}"}
|
|
|
|
|
|
@router.get("/stream")
|
|
async def sse_stream(request: Request, request_id: str = Query(...)):
|
|
q = gateway_sse_endpoint.get(request_id)
|
|
if q is None:
|
|
return StreamingResponse(
|
|
iter([_sse_frame(SseEventType.ERROR, {"message": "request_id invalid or expired"})]),
|
|
media_type="text/event-stream",
|
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
status_code=404,
|
|
)
|
|
|
|
gateway_sse_endpoint.touch(request_id)
|
|
|
|
async def generate():
|
|
try:
|
|
while True:
|
|
if await request.is_disconnected():
|
|
logger.info("SSE client disconnected: %s", request_id)
|
|
break
|
|
try:
|
|
event = await asyncio.wait_for(q.get(), timeout=SSE_KEEP_ALIVE_SEC)
|
|
yield _sse_frame(str(event["type"]), event["data"])
|
|
if event["type"] == SseEventType.DONE:
|
|
break
|
|
except TimeoutError:
|
|
yield _sse_comment("keepalive")
|
|
finally:
|
|
gateway_sse_endpoint.remove(request_id)
|
|
|
|
return StreamingResponse(
|
|
generate(),
|
|
media_type="text/event-stream",
|
|
headers={
|
|
"Cache-Control": "no-cache",
|
|
"Connection": "keep-alive",
|
|
"X-Accel-Buffering": "no",
|
|
},
|
|
)
|
|
|
|
|
|
# ── OPT-7: 实时日志 SSE ──────────────────────────────────
|
|
|
|
|
|
@router.get("/logs")
|
|
async def sse_logs(
|
|
request: Request,
|
|
levels: str | None = Query(None, description="comma-separated: DEBUG,INFO,WARNING,ERROR"),
|
|
):
|
|
logger.info("SSE log stream requested (not yet implemented), levels=%s", levels)
|
|
|
|
async def generate():
|
|
yield _sse_frame(SseEventType.ERROR, {"message": "SSE log streaming is not yet implemented"})
|
|
yield _sse_frame(SseEventType.DONE, {})
|
|
|
|
return StreamingResponse(
|
|
generate(),
|
|
media_type="text/event-stream",
|
|
headers={
|
|
"Cache-Control": "no-cache",
|
|
"Connection": "keep-alive",
|
|
"X-Accel-Buffering": "no",
|
|
},
|
|
)
|