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:
parent
8c770dbfa9
commit
f3f62fef24
6 changed files with 538 additions and 7 deletions
259
backend/app/api/v1/endpoints/conversations.py
Normal file
259
backend/app/api/v1/endpoints/conversations.py
Normal 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)
|
||||
)
|
||||
131
backend/app/api/v1/endpoints/messages.py
Normal file
131
backend/app/api/v1/endpoints/messages.py
Normal 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."
|
||||
)
|
||||
51
backend/app/api/v1/endpoints/tokens.py
Normal file
51
backend/app/api/v1/endpoints/tokens.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
70
backend/app/schemas/conversation.py
Normal file
70
backend/app/schemas/conversation.py
Normal 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
|
||||
23
backend/app/schemas/token.py
Normal file
23
backend/app/schemas/token.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue