"""``POST /agent/chat`` + 会话持久化接口(FR-AGT-09 / T-P4-01)。 One request exercises: JWT auth → quota reserve → LLM gateway (routing, prompt version, cache-prefix check, daily budget) → ``ob_ai_call`` cost row → quota commit/refund → ``ag_message`` 双行落库 → unified envelope. Streaming (SSE, FR-AGT-01) is intentionally out of scope for the scaffold. 会话接口:创建 / 列表 / 历史。chat 不带 conversation_id 时隐式建会话并在 响应里带回;带则做归属校验(他人会话与不存在同口径 404,防 ID 遍历)。 """ from __future__ import annotations import uuid from fastapi import APIRouter from apps.api.deps import AgentServiceDep, CurrentUserId from apps.api.schemas import ( ChatMessageItem, ChatRequest, ChatResponse, ChatUsage, ConversationResponse, ) from packages.llm.errors import ( AllProvidersFailedError, CachePrefixViolationError, DailyBudgetExceededError, SceneDegradedError, StructuredOutputError, ) from packages.shared.errors import AppError, ErrorCode from packages.shared.models import AgConversation from packages.shared.response import ok router = APIRouter(prefix="/agent", tags=["agent"]) def _conversation_payload(conversation: AgConversation) -> ConversationResponse: return ConversationResponse( conversation_id=str(conversation.conversation_id), title=conversation.title, message_count=conversation.message_count, last_message_at=conversation.last_message_at, created_at=conversation.created_at, ) @router.post("/conversations", summary="创建空会话(FR-AGT-09)") async def create_conversation( user_id: CurrentUserId, agent: AgentServiceDep, ) -> dict[str, object]: conversation = await agent.create_conversation(user_id=user_id) return ok(_conversation_payload(conversation).model_dump(mode="json")) @router.get("/conversations", summary="会话列表(最近活跃倒序)") async def list_conversations( user_id: CurrentUserId, agent: AgentServiceDep, ) -> dict[str, object]: conversations = await agent.list_conversations(user_id=user_id) return ok([_conversation_payload(c).model_dump(mode="json") for c in conversations]) @router.get("/conversations/{conversation_id}/messages", summary="会话历史(时间正序)") async def list_messages( conversation_id: int, user_id: CurrentUserId, agent: AgentServiceDep, ) -> dict[str, object]: messages = await agent.list_messages(user_id=user_id, conversation_id=conversation_id) return ok( [ ChatMessageItem( message_id=str(m.message_id), role=m.role, content=m.content, model=m.model, call_id=str(m.call_id) if m.call_id is not None else None, created_at=m.created_at, ).model_dump(mode="json") for m in messages ] ) @router.post("/chat", summary="Single-turn styling chat (non-streaming)") async def chat( payload: ChatRequest, user_id: CurrentUserId, agent: AgentServiceDep, ) -> dict[str, object]: request_key = payload.request_key or uuid.uuid4().hex try: result = await agent.chat( user_id=user_id, message=payload.message, request_key=request_key, conversation_id=payload.conversation_id, ) except DailyBudgetExceededError as exc: raise AppError(ErrorCode.LLM_DAILY_BUDGET_EXCEEDED) from exc except SceneDegradedError as exc: # 分级降级(§13.3 tier2/3):该场景今日被关停,但平台未爆预算 raise AppError(ErrorCode.LLM_SCENE_DEGRADED) from exc except StructuredOutputError as exc: raise AppError(ErrorCode.LLM_STRUCTURED_OUTPUT_FAILED) from exc except AllProvidersFailedError as exc: raise AppError(ErrorCode.LLM_ALL_PROVIDERS_FAILED) from exc except CachePrefixViolationError as exc: raise AppError(ErrorCode.LLM_PROMPT_CACHE_VIOLATION) from exc return ok( ChatResponse( conversation_id=str(result.conversation_id), reply=result.reply, provider=result.provider, model=result.model, prompt_id=result.prompt_id, prompt_version=result.prompt_version, call_id=str(result.call_id), usage=ChatUsage( tokens_in=result.tokens_in, tokens_out=result.tokens_out, tokens_cached=result.tokens_cached, latency_ms=result.latency_ms, cost_cents=str(result.cost_cents), ), quota_remaining=result.quota_remaining, ).model_dump() )