from __future__ import annotations import json from typing import AsyncGenerator, Optional import httpx from fastapi import APIRouter, Depends, Request, status from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field from app.auth.dependencies import get_current_active_user from app.core.config import settings from app.domain.schemas.user import UserInDB router = APIRouter() class CopilotChatStreamRequest(BaseModel): message: str = Field(..., min_length=1, max_length=10000) conversation_id: Optional[str] = Field(default=None, max_length=128) def _sse_event(event: str, data: dict) -> str: return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" @router.post( "/chat/stream", summary="Copilot 聊天流式响应", description="向 Python Copilot sidecar 转发请求并通过 SSE 返回增量内容", ) async def copilot_chat_stream( payload: CopilotChatStreamRequest, request: Request, current_user: UserInDB = Depends(get_current_active_user), ): timeout = httpx.Timeout( connect=10.0, read=float(settings.COPILOT_STREAM_TIMEOUT_SECONDS), write=10.0, pool=10.0, ) sidecar_url = settings.COPILOT_SIDECAR_URL.rstrip("/") upstream_url = f"{sidecar_url}/chat/stream" async def event_generator() -> AsyncGenerator[str, None]: headers: dict[str, str] = {} auth_header = request.headers.get("authorization") project_id = request.headers.get("x-project-id") if auth_header: headers["authorization"] = auth_header if project_id: headers["x-project-id"] = project_id body = { "message": payload.message, "conversationId": payload.conversation_id, "userId": current_user.username, } try: async with httpx.AsyncClient(timeout=timeout) as client: async with client.stream( "POST", upstream_url, json=body, headers=headers, ) as response: if response.status_code >= 400: detail_text = await response.aread() detail = detail_text.decode("utf-8", errors="replace") yield _sse_event( "error", { "message": "Copilot sidecar request failed", "status": response.status_code, "detail": detail, }, ) return async for line in response.aiter_lines(): if await request.is_disconnected(): return yield f"{line}\n" except httpx.ReadTimeout: yield _sse_event( "error", { "message": "Copilot stream timeout", "status": status.HTTP_504_GATEWAY_TIMEOUT, }, ) except httpx.ConnectError as exc: yield _sse_event( "error", { "message": "Copilot sidecar unavailable", "status": status.HTTP_503_SERVICE_UNAVAILABLE, "detail": str(exc), }, ) except Exception as exc: yield _sse_event( "error", { "message": "Unexpected stream proxy error", "status": status.HTTP_500_INTERNAL_SERVER_ERROR, "detail": str(exc), }, ) return StreamingResponse( event_generator(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", }, )