CJHauser commited on
Commit
e044e94
·
verified ·
1 Parent(s): 855d7f5

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +300 -0
app.py ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Optional
6
+ from datetime import datetime
7
+
8
+ from fastapi import FastAPI, HTTPException
9
+ from fastapi.responses import StreamingResponse, JSONResponse
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ from pydantic import BaseModel
12
+ import uvicorn
13
+
14
+ try:
15
+ from llama_cpp import Llama
16
+ except ImportError:
17
+ print("Installing llama-cpp-python...")
18
+ os.system("pip install llama-cpp-python -q")
19
+ from llama_cpp import Llama
20
+
21
+ # ============================================================================
22
+ # SETUP & CONFIG
23
+ # ============================================================================
24
+
25
+ logging.basicConfig(level=logging.INFO)
26
+ logger = logging.getLogger(__name__)
27
+
28
+ app = FastAPI(title="Qwen 3B Chat API", version="1.0.0")
29
+
30
+ # Enable CORS for all origins (or restrict as needed)
31
+ app.add_middleware(
32
+ CORSMiddleware,
33
+ allow_origins=["*"],
34
+ allow_credentials=True,
35
+ allow_methods=["*"],
36
+ allow_headers=["*"],
37
+ )
38
+
39
+ # Persistent storage path (HF Space mounts this)
40
+ STORAGE_DIR = Path("/data") # Default HF persistent storage mount point
41
+ STORAGE_DIR.mkdir(exist_ok=True)
42
+
43
+ MODEL_CACHE_DIR = STORAGE_DIR / "models"
44
+ MODEL_CACHE_DIR.mkdir(exist_ok=True)
45
+
46
+ # Model configuration
47
+ MODEL_REPO = "Qwen/Qwen2.5-3B-Instruct-GGUF"
48
+ MODEL_FILE = "qwen2.5-3b-instruct-q4_k_m.gguf"
49
+ MODEL_PATH = MODEL_CACHE_DIR / MODEL_FILE
50
+
51
+ # Global model instance
52
+ llm = None
53
+
54
+ # ============================================================================
55
+ # REQUEST/RESPONSE MODELS
56
+ # ============================================================================
57
+
58
+ class ChatMessage(BaseModel):
59
+ role: str # "user" or "assistant"
60
+ content: str
61
+
62
+ class ChatRequest(BaseModel):
63
+ messages: list[ChatMessage]
64
+ max_tokens: int = 512
65
+ temperature: float = 0.7
66
+ top_p: float = 0.9
67
+ stream: bool = False
68
+
69
+ class ChatResponse(BaseModel):
70
+ id: str
71
+ created: int
72
+ model: str
73
+ choices: list[dict]
74
+ usage: dict
75
+
76
+ # ============================================================================
77
+ # MODEL LOADING & INITIALIZATION
78
+ # ============================================================================
79
+
80
+ def download_model():
81
+ """Download model from HF Hub to persistent storage if not already cached."""
82
+ if MODEL_PATH.exists():
83
+ logger.info(f"Model found at {MODEL_PATH}")
84
+ return
85
+
86
+ logger.info(f"Downloading {MODEL_REPO}/{MODEL_FILE} to {MODEL_PATH}...")
87
+
88
+ try:
89
+ from huggingface_hub import hf_hub_download
90
+
91
+ hf_hub_download(
92
+ repo_id=MODEL_REPO,
93
+ filename=MODEL_FILE,
94
+ local_dir=MODEL_CACHE_DIR,
95
+ local_dir_use_symlinks=False
96
+ )
97
+ logger.info(f"Model downloaded successfully to {MODEL_PATH}")
98
+ except Exception as e:
99
+ logger.error(f"Failed to download model: {e}")
100
+ raise
101
+
102
+ def load_model():
103
+ """Load the GGUF model with llama-cpp-python."""
104
+ global llm
105
+
106
+ if llm is not None:
107
+ logger.info("Model already loaded")
108
+ return
109
+
110
+ if not MODEL_PATH.exists():
111
+ download_model()
112
+
113
+ logger.info(f"Loading model from {MODEL_PATH}...")
114
+
115
+ try:
116
+ llm = Llama(
117
+ model_path=str(MODEL_PATH),
118
+ n_gpu_layers=-1, # Use all GPU layers if GPU available, otherwise CPU
119
+ n_ctx=2048, # Context window
120
+ n_threads=4, # CPU threads (match Space CPU count)
121
+ verbose=False,
122
+ )
123
+ logger.info("Model loaded successfully")
124
+ except Exception as e:
125
+ logger.error(f"Failed to load model: {e}")
126
+ raise
127
+
128
+ @app.on_event("startup")
129
+ async def startup_event():
130
+ """Load model on startup."""
131
+ load_model()
132
+
133
+ # ============================================================================
134
+ # UTILITY FUNCTIONS
135
+ # ============================================================================
136
+
137
+ def format_messages_for_prompt(messages: list[ChatMessage]) -> str:
138
+ """Format chat messages into a prompt for Qwen."""
139
+ # Qwen2.5 uses a simple chat format
140
+ prompt = ""
141
+ for msg in messages:
142
+ if msg.role == "user":
143
+ prompt += f"User: {msg.content}\n"
144
+ elif msg.role == "assistant":
145
+ prompt += f"Assistant: {msg.content}\n"
146
+ prompt += "Assistant:"
147
+ return prompt
148
+
149
+ def parse_stream(output: str) -> str:
150
+ """Extract just the response content from model output."""
151
+ # Model may include "User: " prefix if continuing conversation
152
+ # Clean up for cleaner response
153
+ if "User:" in output:
154
+ output = output.split("User:")[0]
155
+ return output.strip()
156
+
157
+ # ============================================================================
158
+ # API ENDPOINTS
159
+ # ============================================================================
160
+
161
+ @app.get("/health")
162
+ async def health():
163
+ """Health check endpoint."""
164
+ return {"status": "healthy", "model_loaded": llm is not None}
165
+
166
+ @app.post("/v1/chat/completions")
167
+ async def chat_completions(request: ChatRequest):
168
+ """
169
+ OpenAI-compatible chat completion endpoint.
170
+ Supports both streaming and non-streaming responses.
171
+ """
172
+ if llm is None:
173
+ raise HTTPException(status_code=503, detail="Model not loaded")
174
+
175
+ try:
176
+ prompt = format_messages_for_prompt(request.messages)
177
+
178
+ if request.stream:
179
+ return StreamingResponse(
180
+ stream_chat(prompt, request),
181
+ media_type="text/event-stream"
182
+ )
183
+ else:
184
+ # Non-streaming response
185
+ output = llm(
186
+ prompt,
187
+ max_tokens=request.max_tokens,
188
+ temperature=request.temperature,
189
+ top_p=request.top_p,
190
+ stop=["User:", "\n\n"],
191
+ )
192
+
193
+ response_text = output["choices"][0]["text"].strip()
194
+
195
+ return ChatResponse(
196
+ id=f"chatcmpl-{datetime.now().timestamp()}",
197
+ created=int(datetime.now().timestamp()),
198
+ model="qwen2.5-3b-instruct",
199
+ choices=[
200
+ {
201
+ "index": 0,
202
+ "message": {
203
+ "role": "assistant",
204
+ "content": response_text
205
+ },
206
+ "finish_reason": "stop"
207
+ }
208
+ ],
209
+ usage={
210
+ "prompt_tokens": len(prompt.split()),
211
+ "completion_tokens": len(response_text.split()),
212
+ "total_tokens": len(prompt.split()) + len(response_text.split())
213
+ }
214
+ )
215
+
216
+ except Exception as e:
217
+ logger.error(f"Error in chat completions: {e}")
218
+ raise HTTPException(status_code=500, detail=str(e))
219
+
220
+ async def stream_chat(prompt: str, request: ChatRequest):
221
+ """Stream tokens from the model."""
222
+ try:
223
+ # Use llama-cpp-python's streaming capability
224
+ for chunk in llm(
225
+ prompt,
226
+ max_tokens=request.max_tokens,
227
+ temperature=request.temperature,
228
+ top_p=request.top_p,
229
+ stop=["User:", "\n\n"],
230
+ stream=True,
231
+ ):
232
+ token = chunk["choices"][0]["text"]
233
+ if token:
234
+ # OpenAI-compatible streaming format
235
+ data = {
236
+ "id": f"chatcmpl-{datetime.now().timestamp()}",
237
+ "created": int(datetime.now().timestamp()),
238
+ "model": "qwen2.5-3b-instruct",
239
+ "choices": [
240
+ {
241
+ "index": 0,
242
+ "delta": {"role": "assistant", "content": token},
243
+ "finish_reason": None
244
+ }
245
+ ]
246
+ }
247
+ yield f"data: {json.dumps(data)}\n\n"
248
+
249
+ # Send final chunk
250
+ yield f"data: [DONE]\n\n"
251
+
252
+ except Exception as e:
253
+ logger.error(f"Error in stream_chat: {e}")
254
+ yield f"data: {json.dumps({'error': str(e)})}\n\n"
255
+
256
+ @app.post("/chat")
257
+ async def chat_simple(request: ChatRequest):
258
+ """Simple chat endpoint (returns full response at once)."""
259
+ response = await chat_completions(request)
260
+
261
+ # If streaming response, convert to regular response
262
+ if isinstance(response, StreamingResponse):
263
+ raise HTTPException(status_code=400, detail="Disable streaming for this endpoint")
264
+
265
+ return response
266
+
267
+ @app.get("/")
268
+ async def root():
269
+ """Root endpoint with API documentation."""
270
+ return {
271
+ "name": "Qwen 3B Chat API",
272
+ "version": "1.0.0",
273
+ "model": "Qwen2.5-3B-Instruct",
274
+ "endpoints": {
275
+ "/health": "GET - Health check",
276
+ "/v1/chat/completions": "POST - OpenAI-compatible chat completions",
277
+ "/chat": "POST - Simple chat endpoint",
278
+ "/docs": "Interactive API documentation (Swagger UI)",
279
+ },
280
+ "example_request": {
281
+ "messages": [
282
+ {"role": "user", "content": "Hello, how are you?"}
283
+ ],
284
+ "max_tokens": 512,
285
+ "temperature": 0.7,
286
+ "stream": False
287
+ }
288
+ }
289
+
290
+ # ============================================================================
291
+ # RUN
292
+ # ============================================================================
293
+
294
+ if __name__ == "__main__":
295
+ uvicorn.run(
296
+ app,
297
+ host="0.0.0.0",
298
+ port=7860, # HF Spaces default port
299
+ log_level="info"
300
+ )