From f3f62fef248ebf17279190f1e2391f0efb2e3e01 Mon Sep 17 00:00:00 2001 From: SamoilenkoVadym Date: Tue, 27 Jan 2026 14:34:39 +0000 Subject: [PATCH] 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 --- backend/app/api/v1/endpoints/conversations.py | 259 ++++++++++++++++++ backend/app/api/v1/endpoints/messages.py | 131 +++++++++ backend/app/api/v1/endpoints/tokens.py | 51 ++++ backend/app/api/v1/router.py | 11 +- backend/app/schemas/conversation.py | 70 +++++ backend/app/schemas/token.py | 23 ++ 6 files changed, 538 insertions(+), 7 deletions(-) create mode 100644 backend/app/api/v1/endpoints/conversations.py create mode 100644 backend/app/api/v1/endpoints/messages.py create mode 100644 backend/app/api/v1/endpoints/tokens.py create mode 100644 backend/app/schemas/conversation.py create mode 100644 backend/app/schemas/token.py diff --git a/backend/app/api/v1/endpoints/conversations.py b/backend/app/api/v1/endpoints/conversations.py new file mode 100644 index 0000000..12af1ff --- /dev/null +++ b/backend/app/api/v1/endpoints/conversations.py @@ -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) + ) diff --git a/backend/app/api/v1/endpoints/messages.py b/backend/app/api/v1/endpoints/messages.py new file mode 100644 index 0000000..e0f5faf --- /dev/null +++ b/backend/app/api/v1/endpoints/messages.py @@ -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." + ) diff --git a/backend/app/api/v1/endpoints/tokens.py b/backend/app/api/v1/endpoints/tokens.py new file mode 100644 index 0000000..febb295 --- /dev/null +++ b/backend/app/api/v1/endpoints/tokens.py @@ -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) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 0a575d2..a33395c 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -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) diff --git a/backend/app/schemas/conversation.py b/backend/app/schemas/conversation.py new file mode 100644 index 0000000..73738b2 --- /dev/null +++ b/backend/app/schemas/conversation.py @@ -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 diff --git a/backend/app/schemas/token.py b/backend/app/schemas/token.py new file mode 100644 index 0000000..4e7fede --- /dev/null +++ b/backend/app/schemas/token.py @@ -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