Implement chat API endpoints (conversations, messages, tokens)

Conversation Endpoints (/api/v1/conversations):
- POST / - Create new conversation
- GET / - List user's conversations with pagination
- GET /{id} - Get conversation details
- PUT /{id} - Update conversation title
- POST /{id}/archive - Archive conversation
- DELETE /{id} - Delete conversation with cascade

Message Endpoints (/api/v1/conversations/{id}/messages):
- GET / - Get messages for conversation with pagination
- POST / - Send message and get AI response

Token Usage Endpoints (/api/v1/tokens):
- GET /usage - Get token usage summary with daily breakdown

Schemas:
- ConversationCreate/Update/Response
- ConversationListResponse for listing
- MessageCreate/Response
- SendMessageResponse with usage stats
- TokenUsageSummary with analytics

Features:
- Full permission checks (user ownership verification)
- Pagination support for all list endpoints
- Detailed error handling with appropriate HTTP codes
- Usage statistics tracking per message
- Cost calculation and reporting
- File search results in message metadata

Security:
- All endpoints require authentication
- User can only access their own conversations
- Proper 403/404 error handling
- Request validation with Pydantic

Router Updates:
- Connected all new endpoints to /api/v1
- Organized by resource (auth, conversations, messages, tokens)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
SamoilenkoVadym 2026-01-27 14:34:39 +00:00
parent 8c770dbfa9
commit f3f62fef24
6 changed files with 538 additions and 7 deletions

View file

@ -0,0 +1,259 @@
"""
Conversation Endpoints
API endpoints for conversation management
"""
import logging
from typing import List
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services.chat_service import ChatService
from app.schemas.conversation import (
ConversationCreate,
ConversationUpdate,
ConversationResponse,
ConversationListResponse
)
from app.core.middleware import get_current_active_user
from app.models.user import User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/conversations", tags=["Conversations"])
@router.post("", response_model=ConversationResponse, status_code=status.HTTP_201_CREATED)
async def create_conversation(
request: ConversationCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""
Create a new conversation
Args:
request: Conversation creation request
current_user: Current authenticated user
db: Database session
Returns:
Created conversation
"""
chat_service = ChatService(db)
conversation = await chat_service.create_conversation(
user_id=current_user.id,
title=request.title
)
logger.info(f"User {current_user.id} created conversation {conversation['id']}")
return ConversationResponse(**conversation)
@router.get("", response_model=List[ConversationListResponse])
async def list_conversations(
include_archived: bool = Query(False, description="Include archived conversations"),
skip: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(50, ge=1, le=100, description="Maximum number of records"),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""
List user's conversations
Args:
include_archived: Include archived conversations
skip: Pagination offset
limit: Pagination limit
current_user: Current authenticated user
db: Database session
Returns:
List of conversations
"""
chat_service = ChatService(db)
conversations = await chat_service.list_conversations(
user_id=current_user.id,
include_archived=include_archived,
skip=skip,
limit=limit
)
return [ConversationListResponse(**conv) for conv in conversations]
@router.get("/{conversation_id}", response_model=ConversationResponse)
async def get_conversation(
conversation_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""
Get conversation by ID
Args:
conversation_id: Conversation UUID
current_user: Current authenticated user
db: Database session
Returns:
Conversation details
Raises:
HTTPException: If conversation not found or access denied
"""
chat_service = ChatService(db)
conversation = await chat_service.get_conversation(conversation_id)
if not conversation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Conversation not found"
)
# Verify ownership
if UUID(conversation["user_id"]) != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
return ConversationResponse(**conversation)
@router.put("/{conversation_id}", response_model=ConversationResponse)
async def update_conversation(
conversation_id: UUID,
request: ConversationUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""
Update conversation title
Args:
conversation_id: Conversation UUID
request: Update request with new title
current_user: Current authenticated user
db: Database session
Returns:
Updated conversation
Raises:
HTTPException: If conversation not found or access denied
"""
chat_service = ChatService(db)
# Verify ownership
conversation = await chat_service.get_conversation(conversation_id)
if not conversation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Conversation not found"
)
if UUID(conversation["user_id"]) != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
# Update title
await chat_service.update_conversation_title(conversation_id, request.title)
# Get updated conversation
updated = await chat_service.get_conversation(conversation_id)
logger.info(f"User {current_user.id} updated conversation {conversation_id}")
return ConversationResponse(**updated)
@router.post("/{conversation_id}/archive")
async def archive_conversation(
conversation_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""
Archive a conversation
Args:
conversation_id: Conversation UUID
current_user: Current authenticated user
db: Database session
Returns:
Success message
Raises:
HTTPException: If conversation not found or access denied
"""
chat_service = ChatService(db)
# Verify ownership
conversation = await chat_service.get_conversation(conversation_id)
if not conversation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Conversation not found"
)
if UUID(conversation["user_id"]) != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
await chat_service.archive_conversation(conversation_id)
logger.info(f"User {current_user.id} archived conversation {conversation_id}")
return {"message": "Conversation archived successfully"}
@router.delete("/{conversation_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_conversation(
conversation_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""
Delete a conversation
Args:
conversation_id: Conversation UUID
current_user: Current authenticated user
db: Database session
Raises:
HTTPException: If conversation not found or access denied
"""
chat_service = ChatService(db)
try:
await chat_service.delete_conversation(
user_id=current_user.id,
conversation_id=conversation_id
)
logger.info(f"User {current_user.id} deleted conversation {conversation_id}")
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
except PermissionError as e:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(e)
)

View file

@ -0,0 +1,131 @@
"""
Message Endpoints
API endpoints for sending and retrieving messages
"""
import logging
from typing import List
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services.chat_service import ChatService
from app.schemas.conversation import (
MessageCreate,
MessageResponse,
SendMessageResponse
)
from app.core.middleware import get_current_active_user
from app.models.user import User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/conversations", tags=["Messages"])
@router.get("/{conversation_id}/messages", response_model=List[MessageResponse])
async def get_messages(
conversation_id: UUID,
skip: int = Query(0, ge=0, description="Number of messages to skip"),
limit: int = Query(100, ge=1, le=200, description="Maximum number of messages"),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""
Get messages for a conversation
Args:
conversation_id: Conversation UUID
skip: Pagination offset
limit: Pagination limit
current_user: Current authenticated user
db: Database session
Returns:
List of messages
Raises:
HTTPException: If conversation not found or access denied
"""
chat_service = ChatService(db)
# Verify conversation exists and belongs to user
conversation = await chat_service.get_conversation(conversation_id)
if not conversation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Conversation not found"
)
if UUID(conversation["user_id"]) != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
messages = await chat_service.get_messages(
conversation_id=conversation_id,
skip=skip,
limit=limit
)
return [MessageResponse(**msg) for msg in messages]
@router.post("/{conversation_id}/messages", response_model=SendMessageResponse)
async def send_message(
conversation_id: UUID,
request: MessageCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""
Send a message and get AI response
Args:
conversation_id: Conversation UUID
request: Message content
current_user: Current authenticated user
db: Database session
Returns:
SendMessageResponse with user message, assistant response, and usage stats
Raises:
HTTPException: If conversation not found, access denied, or OpenAI error
"""
chat_service = ChatService(db)
try:
result = await chat_service.send_message(
user_id=current_user.id,
conversation_id=conversation_id,
message_content=request.content
)
logger.info(
f"Message exchange completed in conversation {conversation_id}. "
f"Tokens: {result['usage']['total_tokens']}, Cost: ${result['cost_usd']:.6f}"
)
return SendMessageResponse(**result)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
except PermissionError as e:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(e)
)
except Exception as e:
logger.error(f"Error sending message: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to process message. Please try again."
)

View file

@ -0,0 +1,51 @@
"""
Token Usage Endpoints
API endpoints for token usage analytics
"""
import logging
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services.chat_service import ChatService
from app.schemas.token import TokenUsageSummary
from app.core.middleware import get_current_active_user
from app.models.user import User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/tokens", tags=["Token Usage"])
@router.get("/usage", response_model=TokenUsageSummary)
async def get_token_usage(
days: int = Query(30, ge=1, le=365, description="Number of days to retrieve"),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""
Get token usage summary for current user
Args:
days: Number of days to include in summary
current_user: Current authenticated user
db: Database session
Returns:
TokenUsageSummary with total tokens, cost, and daily breakdown
"""
chat_service = ChatService(db)
summary = await chat_service.get_token_usage_summary(
user_id=current_user.id,
days=days
)
logger.info(
f"Retrieved token usage for user {current_user.id}: "
f"{summary['total_tokens']} tokens, ${summary['total_cost_usd']:.2f}"
)
return TokenUsageSummary(**summary)

View file

@ -6,16 +6,13 @@ Aggregates all API v1 endpoints
from fastapi import APIRouter
from app.api.v1.endpoints import auth
from app.api.v1.endpoints import auth, conversations, messages, tokens
# Create main API router
api_router = APIRouter()
# Include endpoint routers
api_router.include_router(auth.router)
# Additional routers will be added here:
# api_router.include_router(users.router)
# api_router.include_router(conversations.router)
# api_router.include_router(messages.router)
# api_router.include_router(tokens.router)
api_router.include_router(conversations.router)
api_router.include_router(messages.router)
api_router.include_router(tokens.router)

View file

@ -0,0 +1,70 @@
"""
Conversation Schemas
Pydantic models for conversation requests and responses
"""
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
class ConversationCreate(BaseModel):
"""Create conversation request"""
title: Optional[str] = None
class ConversationUpdate(BaseModel):
"""Update conversation request"""
title: str
class ConversationResponse(BaseModel):
"""Conversation response"""
id: str
user_id: str
title: str
created_at: datetime
last_message_at: Optional[datetime]
is_archived: bool
last_response_id: Optional[str] = None
class Config:
from_attributes = True
class ConversationListResponse(BaseModel):
"""List of conversations"""
id: str
title: str
created_at: datetime
last_message_at: Optional[datetime]
is_archived: bool
class Config:
from_attributes = True
class MessageCreate(BaseModel):
"""Create message request"""
content: str
class MessageResponse(BaseModel):
"""Message response"""
id: str
role: str
content: str
created_at: datetime
metadata: Optional[dict] = None
class Config:
from_attributes = True
class SendMessageResponse(BaseModel):
"""Send message response with user and assistant messages"""
user_message: dict
assistant_message: dict
usage: dict
cost_usd: float

View file

@ -0,0 +1,23 @@
"""
Token Usage Schemas
Pydantic models for token usage analytics
"""
from pydantic import BaseModel
from typing import List, Optional
class TokenUsageSummary(BaseModel):
"""Token usage summary for user"""
total_tokens: int
total_cost_usd: float
daily_breakdown: List[dict]
period_days: Optional[int] = None
class DailyUsage(BaseModel):
"""Daily token usage"""
date: str
tokens: int
cost: float