Spaces:
Running
Running
| """Streaming chat endpoint (Server-Sent Events).""" | |
| import json | |
| import logging | |
| import time | |
| from collections.abc import AsyncIterator | |
| from fastapi import APIRouter, Depends | |
| from fastapi.responses import StreamingResponse | |
| from sqlalchemy import select | |
| from starlette.background import BackgroundTask | |
| from app.agent.memory import maybe_summarize | |
| from app.agent.orchestrator import run_agent | |
| from app.api.deps import DB, CurrentUser, get_owned_session, rate_limit_chat | |
| from app.config import get_settings | |
| from app.database import AsyncSessionLocal | |
| from app.llm.pricing import estimate_cost | |
| from app.llm.registry import get_provider | |
| from app.models import ChatSession, Document, Message, utcnow | |
| from app.schemas import ChatRequest | |
| router = APIRouter(prefix="/chat", tags=["chat"], dependencies=[Depends(rate_limit_chat)]) | |
| logger = logging.getLogger("synapse.api.chat") | |
| def _sse(payload: dict) -> str: | |
| return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" | |
| async def _generate_title(first_message: str) -> str | None: | |
| settings = get_settings() | |
| try: | |
| title = await get_provider().complete( | |
| [ | |
| { | |
| "role": "user", | |
| "content": ( | |
| "Write a title of at most 6 words for a conversation that " | |
| f"starts with this message. Return only the title, no quotes.\n\n" | |
| f"Message: {first_message[:500]}" | |
| ), | |
| } | |
| ], | |
| model=settings.utility_model, | |
| temperature=0.2, | |
| ) | |
| except Exception as exc: | |
| logger.warning("title generation failed: %s", exc) | |
| return None | |
| title = title.strip().strip('"').strip() | |
| return title[:80] or None | |
| async def _summarize_in_background(session_id: str) -> None: | |
| async with AsyncSessionLocal() as db: | |
| try: | |
| await maybe_summarize(db, session_id) | |
| except Exception as exc: | |
| logger.warning("background summarization failed: %s", exc) | |
| async def chat( | |
| body: ChatRequest, | |
| user: CurrentUser, | |
| db: DB, | |
| session: ChatSession = Depends(get_owned_session), | |
| ) -> StreamingResponse: | |
| settings = get_settings() | |
| model = body.model or session.model or settings.chat_model | |
| if model not in settings.model_list: | |
| model = settings.chat_model | |
| doc_count = ( | |
| await db.execute( | |
| select(Document.id) | |
| .where(Document.user_id == user.id, Document.status == "ready") | |
| .limit(1) | |
| ) | |
| ).first() | |
| has_documents = doc_count is not None | |
| user_message = Message(session_id=session.id, role="user", content=body.content) | |
| db.add(user_message) | |
| session.updated_at = utcnow() | |
| await db.commit() | |
| needs_title = session.title == "New chat" | |
| async def event_stream() -> AsyncIterator[str]: | |
| started = time.perf_counter() | |
| final: dict = {} | |
| try: | |
| async for event in run_agent( | |
| db=db, | |
| user=user, | |
| session=session, | |
| user_content=body.content, | |
| model=model, | |
| temperature=body.temperature, | |
| use_rag=body.use_rag, | |
| has_documents=has_documents, | |
| ): | |
| if event["type"] == "final": | |
| final = event | |
| else: | |
| yield _sse(event) | |
| except Exception as exc: | |
| logger.exception("chat stream failed") | |
| yield _sse({"type": "error", "message": f"Something went wrong: {exc}"}) | |
| return | |
| latency_ms = round((time.perf_counter() - started) * 1000) | |
| cost = estimate_cost(model, final.get("input_tokens", 0), final.get("output_tokens", 0)) | |
| assistant_message = Message( | |
| session_id=session.id, | |
| role="assistant", | |
| content=final.get("content", ""), | |
| tool_calls_json=json.dumps(final.get("tool_calls", [])) if final.get("tool_calls") else None, | |
| citations_json=json.dumps(final.get("citations", [])) if final.get("citations") else None, | |
| model=model, | |
| input_tokens=final.get("input_tokens", 0), | |
| output_tokens=final.get("output_tokens", 0), | |
| cost_usd=cost, | |
| latency_ms=latency_ms, | |
| ) | |
| db.add(assistant_message) | |
| session.updated_at = utcnow() | |
| await db.commit() | |
| await db.refresh(assistant_message) | |
| if final.get("citations"): | |
| yield _sse({"type": "citations", "citations": final["citations"]}) | |
| yield _sse( | |
| { | |
| "type": "usage", | |
| "input_tokens": final.get("input_tokens", 0), | |
| "output_tokens": final.get("output_tokens", 0), | |
| "cost_usd": cost, | |
| "latency_ms": latency_ms, | |
| "model": model, | |
| } | |
| ) | |
| if needs_title: | |
| title = await _generate_title(body.content) | |
| if title: | |
| session.title = title | |
| await db.commit() | |
| yield _sse({"type": "title", "title": title}) | |
| yield _sse({"type": "done", "message_id": assistant_message.id}) | |
| # Summarization runs after the response is fully sent, so it never delays | |
| # the reply. Attaching it to the response (instead of firing a detached | |
| # task) means the framework awaits it and its database connection is | |
| # always released, rather than outliving the request. | |
| return StreamingResponse( | |
| event_stream(), | |
| media_type="text/event-stream", | |
| headers={ | |
| "Cache-Control": "no-cache", | |
| "Connection": "keep-alive", | |
| "X-Accel-Buffering": "no", | |
| }, | |
| background=BackgroundTask(_summarize_in_background, session.id), | |
| ) | |