|
1 |
| -import os |
2 |
| -import logging |
3 |
| -from typing import List, Dict |
4 |
| -from dotenv import load_dotenv |
5 |
| -from fastapi import APIRouter, Request, UploadFile, File, HTTPException, Depends, Form, Path |
6 |
| -from fastapi.responses import StreamingResponse |
7 |
| -from openai import AsyncOpenAI |
8 |
| -from openai.types.file_purpose import FilePurpose |
9 |
| -from utils.files import get_or_create_vector_store |
10 |
| -from utils.streaming import stream_file_content |
11 |
| - |
12 |
| -logger = logging.getLogger("uvicorn.error") |
13 |
| - |
14 |
| -# Get assistant ID from environment variables |
15 |
| -load_dotenv(override=True) |
16 |
| -assistant_id_env = os.getenv("ASSISTANT_ID") |
17 |
| -if not assistant_id_env: |
18 |
| - raise ValueError("ASSISTANT_ID environment variable not set") |
19 |
| -assistant_id: str = assistant_id_env |
20 |
| - |
21 |
| -router = APIRouter( |
22 |
| - prefix="/assistants/{assistant_id}/files", |
23 |
| - tags=["assistants_files"] |
24 |
| -) |
25 |
| - |
26 |
| - |
27 |
| -#TODO: Correctly return HTML, not JSON, from the routes below |
28 |
| - |
29 |
| -@router.get("/") |
30 |
| -async def list_files(client: AsyncOpenAI = Depends(lambda: AsyncOpenAI())) -> List[Dict[str, str]]: |
31 |
| - # List files in the vector store |
32 |
| - vector_store_id = await get_or_create_vector_store(assistant_id, client) |
33 |
| - file_list = await client.vector_stores.files.list(vector_store_id) |
34 |
| - files_array: List[Dict[str, str]] = [] |
35 |
| - |
36 |
| - if file_list.data: |
37 |
| - for file in file_list.data: |
38 |
| - file_details = await client.files.retrieve(file.id) |
39 |
| - vector_file_details = await client.vector_stores.files.retrieve( |
40 |
| - vector_store_id=vector_store_id, |
41 |
| - file_id=file.id |
42 |
| - ) |
43 |
| - files_array.append({ |
44 |
| - "file_id": file.id, |
45 |
| - "filename": file_details.filename or "unknown_filename", |
46 |
| - "status": vector_file_details.status or "unknown_status", |
47 |
| - }) |
48 |
| - return files_array |
49 |
| - |
50 |
| - |
51 |
| -# Take a purpose parameter, defaulting to "assistants" |
52 |
| -@router.post("/") |
53 |
| -async def upload_file(file: UploadFile = File(...), purpose: FilePurpose = Form(default="assistants")) -> Dict[str, str]: |
54 |
| - try: |
55 |
| - client = AsyncOpenAI() |
56 |
| - vector_store_id = await get_or_create_vector_store(assistant_id) |
57 |
| - openai_file = await client.files.create( |
58 |
| - file=file.file, |
59 |
| - purpose=purpose |
60 |
| - ) |
61 |
| - await client.vector_stores.files.create( |
62 |
| - vector_store_id=vector_store_id, |
63 |
| - file_id=openai_file.id |
64 |
| - ) |
65 |
| - return {"message": "File uploaded successfully"} |
66 |
| - except Exception as e: |
67 |
| - raise HTTPException(status_code=500, detail=str(e)) |
68 |
| - |
69 |
| - |
70 |
| -@router.delete("/delete") |
71 |
| -async def delete_file( |
72 |
| - request: Request, |
73 |
| - fileId: str = Form(...), |
74 |
| - client: AsyncOpenAI = Depends(lambda: AsyncOpenAI()) |
75 |
| -) -> Dict[str, str]: |
76 |
| - vector_store_id = await get_or_create_vector_store(assistant_id, client) |
77 |
| - await client.vector_stores.files.delete(vector_store_id=vector_store_id, file_id=fileId) |
78 |
| - return {"message": "File deleted successfully"} |
79 |
| - |
80 |
| - |
81 |
| -# --- Streaming file content --- |
82 |
| - |
83 |
| - |
84 |
| - |
85 |
| - |
86 |
| -@router.get("/{file_id}") |
87 |
| -async def download_assistant_file( |
88 |
| - file_id: str = Path(..., description="The ID of the file to retrieve"), |
89 |
| - client: AsyncOpenAI = Depends(lambda: AsyncOpenAI()) |
90 |
| -) -> StreamingResponse: |
91 |
| - try: |
92 |
| - file = await client.files.retrieve(file_id) |
93 |
| - file_content = await client.files.content(file_id) |
94 |
| - |
95 |
| - if not hasattr(file_content, 'content'): |
96 |
| - raise HTTPException(status_code=500, detail="File content not available") |
97 |
| - |
98 |
| - return StreamingResponse( |
99 |
| - stream_file_content(file_content.content), |
100 |
| - headers={"Content-Disposition": f'attachment; filename=\"{file.filename or "download"}\"'} |
101 |
| - ) |
102 |
| - except Exception as e: |
103 |
| - raise HTTPException(status_code=500, detail=str(e)) |
104 |
| - |
105 |
| - |
106 |
| -@router.get("/{file_id}/content") |
107 |
| -async def get_assistant_image_content( |
108 |
| - file_id: str, |
109 |
| - client: AsyncOpenAI = Depends(lambda: AsyncOpenAI()) |
110 |
| -) -> StreamingResponse: |
111 |
| - """ |
112 |
| - Streams file content from OpenAI API. |
113 |
| - This route is used to serve images and other files generated by the code interpreter. |
114 |
| - """ |
115 |
| - try: |
116 |
| - # Get the file content from OpenAI |
117 |
| - file_content = await client.files.content(file_id) |
118 |
| - file_bytes = file_content.read() # Remove await since read() returns bytes directly |
119 |
| - |
120 |
| - # Return the file content as a streaming response |
121 |
| - # Note: In a production environment, you might want to add caching |
122 |
| - return StreamingResponse( |
123 |
| - content=iter([file_bytes]), |
124 |
| - media_type="image/png" # You might want to make this dynamic based on file type |
125 |
| - ) |
126 |
| - except Exception as e: |
127 |
| - logger.error(f"Error getting file content: {e}") |
128 |
| - raise HTTPException(status_code=500, detail=str(e)) |
| 1 | +import os |
| 2 | +import logging |
| 3 | +from typing import Literal |
| 4 | +from dotenv import load_dotenv |
| 5 | +from fastapi import ( |
| 6 | + APIRouter, Request, UploadFile, File, HTTPException, Depends, Path, Form |
| 7 | +) |
| 8 | +from fastapi.responses import HTMLResponse, StreamingResponse |
| 9 | +from fastapi.templating import Jinja2Templates |
| 10 | +from openai import AsyncOpenAI |
| 11 | +from utils.files import get_or_create_vector_store, get_files_for_vector_store |
| 12 | +from utils.streaming import stream_file_content |
| 13 | + |
| 14 | +logger = logging.getLogger("uvicorn.error") |
| 15 | + |
| 16 | +# Setup Jinja2 templates |
| 17 | +templates = Jinja2Templates(directory="templates") |
| 18 | + |
| 19 | +# Get assistant ID from environment variables |
| 20 | +load_dotenv(override=True) |
| 21 | +assistant_id_env = os.getenv("ASSISTANT_ID") |
| 22 | +if not assistant_id_env: |
| 23 | + raise ValueError("ASSISTANT_ID environment variable not set") |
| 24 | +default_assistant_id: str = assistant_id_env |
| 25 | + |
| 26 | +router = APIRouter( |
| 27 | + prefix="/assistants/{assistant_id}/files", |
| 28 | + tags=["assistants_files"] |
| 29 | +) |
| 30 | + |
| 31 | + |
| 32 | +@router.get("/list", response_class=HTMLResponse) |
| 33 | +async def list_files( |
| 34 | + request: Request, |
| 35 | + assistant_id: str = Path(..., description="The ID of the Assistant"), |
| 36 | + client: AsyncOpenAI = Depends(lambda: AsyncOpenAI()) |
| 37 | +) -> HTMLResponse: |
| 38 | + """Lists files and returns an HTML partial.""" |
| 39 | + try: |
| 40 | + vector_store_id = await get_or_create_vector_store(assistant_id, client) |
| 41 | + files = await get_files_for_vector_store(vector_store_id, client) |
| 42 | + return templates.TemplateResponse( |
| 43 | + "components/file-list.html", |
| 44 | + {"request": request, "files": files} |
| 45 | + ) |
| 46 | + except Exception as e: |
| 47 | + logger.error(f"Error generating file list HTML for assistant {assistant_id}: {e}") |
| 48 | + # Return an error message within the HTML structure |
| 49 | + return HTMLResponse(content=f'<div id="file-list-container"><p class="error-message">Error loading files: {e}</p></div>') |
| 50 | + |
| 51 | + |
| 52 | +# Modified upload_file |
| 53 | +@router.post("/", response_class=HTMLResponse) |
| 54 | +async def upload_file( |
| 55 | + request: Request, |
| 56 | + assistant_id: str = Path(..., description="The ID of the Assistant"), |
| 57 | + file: UploadFile = File(...), |
| 58 | + purpose: Literal["assistants", "vision"] = Form(default="assistants"), |
| 59 | + client: AsyncOpenAI = Depends(lambda: AsyncOpenAI()) |
| 60 | +) -> HTMLResponse: |
| 61 | + """Uploads a file, adds it to the vector store, and returns the updated file list HTML.""" |
| 62 | + try: |
| 63 | + vector_store_id = await get_or_create_vector_store(assistant_id, client) |
| 64 | + except Exception as e: |
| 65 | + logger.error(f"Error getting or creating vector store for assistant {assistant_id}: {e}") |
| 66 | + return templates.TemplateResponse( |
| 67 | + "components/file-list.html", |
| 68 | + {"request": request, "error_message": f"Error getting or creating vector store for assistant"} |
| 69 | + ) |
| 70 | + |
| 71 | + error_message = None |
| 72 | + try: |
| 73 | + # Upload the file to OpenAI |
| 74 | + openai_file = await client.files.create( |
| 75 | + file=(file.filename, file.file), |
| 76 | + purpose=purpose |
| 77 | + ) |
| 78 | + |
| 79 | + # Add the uploaded file to the vector store |
| 80 | + await client.vector_stores.files.create( |
| 81 | + vector_store_id=vector_store_id, |
| 82 | + file_id=openai_file.id |
| 83 | + ) |
| 84 | + except Exception as e: |
| 85 | + logger.error(f"Error uploading file for assistant {assistant_id}: {e}") |
| 86 | + error_message = f"Error uploading file for assistant" |
| 87 | + |
| 88 | + # Fetch the updated list of files and render the partial |
| 89 | + files = [] |
| 90 | + try: |
| 91 | + if vector_store_id: |
| 92 | + files = await get_files_for_vector_store(vector_store_id, client) |
| 93 | + except Exception as e: |
| 94 | + logger.error(f"Error fetching files for assistant {assistant_id}: {e}") |
| 95 | + error_message = f"Error fetching files for assistant" |
| 96 | + |
| 97 | + # Return the response, conditionally including error message |
| 98 | + return templates.TemplateResponse( |
| 99 | + "components/file-list.html", |
| 100 | + { |
| 101 | + "request": request, |
| 102 | + "files": files, |
| 103 | + **({"error_message": error_message} if error_message else {}) |
| 104 | + } |
| 105 | + ) |
| 106 | + |
| 107 | + |
| 108 | +# Modified delete_file |
| 109 | +@router.delete("/{file_id}", response_class=HTMLResponse) # Changed path to include file_id |
| 110 | +async def delete_file( |
| 111 | + request: Request, # Add request for template rendering |
| 112 | + assistant_id: str = Path(..., description="The ID of the Assistant"), |
| 113 | + file_id: str = Path(..., description="The ID of the file to delete"), |
| 114 | + client: AsyncOpenAI = Depends(lambda: AsyncOpenAI()) |
| 115 | +) -> HTMLResponse: |
| 116 | + """Deletes a file from the vector store and OpenAI account, then returns the updated file list HTML.""" |
| 117 | + error_message = None |
| 118 | + files = [] |
| 119 | + vector_store_id = None |
| 120 | + |
| 121 | + try: |
| 122 | + vector_store_id = await get_or_create_vector_store(assistant_id, client) |
| 123 | + |
| 124 | + # 1. Delete the file association from the vector store |
| 125 | + try: |
| 126 | + deleted_vs_file = await client.vector_stores.files.delete( |
| 127 | + vector_store_id=vector_store_id, |
| 128 | + file_id=file_id |
| 129 | + ) |
| 130 | + |
| 131 | + # 2. If vector store deletion was successful, attempt to delete the base file object |
| 132 | + if deleted_vs_file.deleted: |
| 133 | + try: |
| 134 | + await client.files.delete(file_id=file_id) |
| 135 | + except Exception as file_delete_error: |
| 136 | + # Log the warning but don't set error_message, as VS deletion succeeded |
| 137 | + logger.warning(f"File {file_id} removed from vector store {vector_store_id}, but failed to delete base file object: {file_delete_error}") |
| 138 | + else: |
| 139 | + # Log the warning and potentially set an error if VS deletion failed |
| 140 | + logger.warning(f"Failed to remove file {file_id} association from vector store {vector_store_id}") |
| 141 | + # Decide if this constitutes a full error for the user |
| 142 | + error_message = f"Failed to remove file from vector store." |
| 143 | + |
| 144 | + except Exception as delete_error: |
| 145 | + logger.error(f"Error during file deletion process for file {file_id}, assistant {assistant_id}: {delete_error}") |
| 146 | + error_message = f"Error deleting file: {delete_error}" |
| 147 | + |
| 148 | + except Exception as vs_error: |
| 149 | + logger.error(f"Error getting or creating vector store for assistant {assistant_id}: {vs_error}") |
| 150 | + error_message = f"Error accessing vector store: {vs_error}" |
| 151 | + |
| 152 | + # Always try to fetch the current list of files, even if deletion had issues |
| 153 | + try: |
| 154 | + if vector_store_id: # Only fetch if we got the ID |
| 155 | + files = await get_files_for_vector_store(vector_store_id, client) |
| 156 | + elif not error_message: # If we couldn't get VS ID and have no other error, set one |
| 157 | + error_message = "Could not retrieve vector store information." |
| 158 | + |
| 159 | + except Exception as fetch_error: |
| 160 | + logger.error(f"Error fetching file list after delete attempt for assistant {assistant_id}: {fetch_error}") |
| 161 | + # If an error message wasn't already set, set one now. Otherwise, keep the original error. |
| 162 | + if not error_message: |
| 163 | + error_message = f"Error fetching file list: {fetch_error}" |
| 164 | + |
| 165 | + # Return the response, conditionally including error message |
| 166 | + return templates.TemplateResponse( |
| 167 | + "components/file-list.html", |
| 168 | + { |
| 169 | + "request": request, |
| 170 | + "files": files, |
| 171 | + **({"error_message": error_message} if error_message else {}) |
| 172 | + } |
| 173 | + ) |
| 174 | + |
| 175 | + |
| 176 | +# --- Streaming file content routes --- |
| 177 | + |
| 178 | +@router.get("/{file_id}") |
| 179 | +async def download_assistant_file( |
| 180 | + file_id: str = Path(..., description="The ID of the file to retrieve"), |
| 181 | + client: AsyncOpenAI = Depends(lambda: AsyncOpenAI()) |
| 182 | +) -> StreamingResponse: |
| 183 | + try: |
| 184 | + file = await client.files.retrieve(file_id) |
| 185 | + file_content = await client.files.content(file_id) |
| 186 | + |
| 187 | + if not hasattr(file_content, 'content'): |
| 188 | + raise HTTPException(status_code=500, detail="File content not available") |
| 189 | + |
| 190 | + return StreamingResponse( |
| 191 | + stream_file_content(file_content.content), |
| 192 | + headers={"Content-Disposition": f'attachment; filename=\"{file.filename or "download"}\"'} |
| 193 | + ) |
| 194 | + except Exception as e: |
| 195 | + raise HTTPException(status_code=500, detail=str(e)) |
| 196 | + |
| 197 | + |
| 198 | +@router.get("/{file_id}/content") |
| 199 | +async def get_assistant_image_content( |
| 200 | + file_id: str, |
| 201 | + client: AsyncOpenAI = Depends(lambda: AsyncOpenAI()) |
| 202 | +) -> StreamingResponse: |
| 203 | + """ |
| 204 | + Streams file content from OpenAI API. |
| 205 | + This route is used to serve images and other files generated by the code interpreter. |
| 206 | + """ |
| 207 | + try: |
| 208 | + # Get the file content from OpenAI |
| 209 | + file_content = await client.files.content(file_id) |
| 210 | + file_bytes = file_content.read() # Remove await since read() returns bytes directly |
| 211 | + |
| 212 | + # Return the file content as a streaming response |
| 213 | + # Note: In a production environment, you might want to add caching |
| 214 | + return StreamingResponse( |
| 215 | + content=iter([file_bytes]), |
| 216 | + media_type="image/png" # You might want to make this dynamic based on file type |
| 217 | + ) |
| 218 | + except Exception as e: |
| 219 | + logger.error(f"Error getting file content: {e}") |
| 220 | + raise HTTPException(status_code=500, detail=str(e)) |
0 commit comments