Files
nextcraft/apps/ai-service/ai_service/api/chat.py
T
CIAgent 4c52d29f91 docs(P02): complete agent-framework phase
---ci---
phase: 2
milestone: v0.2
status: complete
---/ci---

REQ-2-004 complete. BaseAgent ABC, agent-scoped sessions (20-msg window,
500-cap LRU), registry, 4-layer structured output defense, 6-module
prompt library, D-021-aligned learner corpus, chat session persistence.
61/61 tests, ruff clean.
2026-09-11 15:52:00 +00:00

97 lines
3.6 KiB
Python

"""POST /v1/chat/stream — SSE chat with the D-016 envelope.
Envelope: meta event first (flushed before first token), then raw content
deltas, then done; error event before [DONE] on mid-stream failure.
Pre-first-byte failures become proper HTTP error statuses.
"""
import json
from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sse_starlette.sse import EventSourceResponse
from ..agents.session import SessionStore
from ..config import Settings
from ..llm.base import LLMProvider
from ..llm.types import Message
from .deps import get_provider, get_session_store, get_settings
router = APIRouter(prefix="/v1")
class ChatStreamRequest(BaseModel):
agent: str = Field(min_length=1)
session_id: str = Field(min_length=1)
messages: list[Message] = Field(min_length=1)
@router.post("/chat/stream")
async def chat_stream(
body: ChatStreamRequest,
provider: LLMProvider = Depends(get_provider),
settings: Settings = Depends(get_settings),
sessions: SessionStore = Depends(get_session_store),
) -> EventSourceResponse:
if not body.messages:
raise HTTPException(status_code=422, detail="messages must not be empty")
# Agent-scoped session (A-007/G-4): persisted turn history, windowed replay.
session = await sessions.get(body.session_id)
if session is None:
session = await sessions.create(body.session_id, agent=body.agent)
history = await sessions.history_window(body.session_id)
# Persist this turn's user message before streaming.
await sessions.append(body.session_id, body.messages[-1])
async def event_stream() -> AsyncIterator[dict]:
yield {"event": "message", "data": json.dumps({
"type": "meta",
"agent": body.agent,
"session_id": body.session_id,
"model": settings.model,
})}
first_byte = True
reply_parts: list[str] = []
try:
async for token in provider.stream_chat(
body.messages if not history else history + body.messages,
model=settings.model,
):
first_byte = False
reply_parts.append(token)
yield {"event": "message", "data": json.dumps({
"type": "delta", "content": token
})}
full_reply = "".join(reply_parts)
if full_reply:
await sessions.append(
body.session_id, Message(role="assistant", content=full_reply)
)
yield {"event": "message", "data": json.dumps({
"type": "done", "finish_reason": "stop"
})}
except Exception as exc: # CancelledError is BaseException — passes through
message = str(exc)
if first_byte:
# Pre-first-byte failure: we already flushed meta + 200 headers;
# surface as in-band error (status change is impossible post-flush).
yield {"event": "message", "data": json.dumps({
"type": "error", "code": "provider_unavailable", "message": message
})}
else:
yield {"event": "message", "data": json.dumps({
"type": "error", "code": "provider_error", "message": message
})}
finally:
yield {"event": "message", "data": "[DONE]"}
return EventSourceResponse(
event_stream(),
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)