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.keycloak_dependencies import get_current_keycloak_username from app.core.config import settings 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, username: str = Depends(get_current_keycloak_username), ): 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": 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", }, )