fomext commited on
Commit
137b458
·
verified ·
1 Parent(s): b9f84cd

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +65 -123
app.py CHANGED
@@ -1,16 +1,15 @@
1
  import asyncio
2
  import json
3
- import os
4
  import time
5
  import uuid
6
  from threading import Thread
7
  from typing import AsyncIterator, Optional
8
 
 
9
  import spaces
10
  import torch
11
  import uvicorn
12
- import gradio as gr
13
- from fastapi import FastAPI, HTTPException, Request
14
  from fastapi.responses import JSONResponse, StreamingResponse
15
  from pydantic import BaseModel, Field
16
  from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
@@ -23,7 +22,8 @@ MODEL_ID = "Qwen/Qwen3-30B-A3B"
23
  MODEL_ALIAS = "qwen3-30b-a3b"
24
 
25
  # ---------------------------------------------------------------------------
26
- # Model loading (done once at startup; ZeroGPU assigns the GPU per request)
 
27
  # ---------------------------------------------------------------------------
28
 
29
  print(f"Loading tokenizer for {MODEL_ID} …")
@@ -61,26 +61,22 @@ class ChatCompletionRequest(BaseModel):
61
  temperature: Optional[float] = Field(default=0.7)
62
  top_p: Optional[float] = Field(default=0.9)
63
  stream: Optional[bool] = Field(default=False)
64
- # Qwen3 thinking mode – set to False for faster / cheaper responses
65
  enable_thinking: Optional[bool] = Field(default=False)
66
 
67
 
68
  # ---------------------------------------------------------------------------
69
- # Core generation helpers
70
  # ---------------------------------------------------------------------------
71
 
72
 
73
  def build_prompt(messages: list[ChatMessage], enable_thinking: bool) -> str:
74
- """Apply the Qwen3 chat template."""
75
  hf_messages = [{"role": m.role, "content": m.content} for m in messages]
76
- text = tokenizer.apply_chat_template(
77
  hf_messages,
78
  tokenize=False,
79
  add_generation_prompt=True,
80
- # Qwen3 supports an explicit thinking toggle via the template
81
  enable_thinking=enable_thinking,
82
  )
83
- return text
84
 
85
 
86
  def make_generation_kwargs(request: ChatCompletionRequest) -> dict:
@@ -94,89 +90,84 @@ def make_generation_kwargs(request: ChatCompletionRequest) -> dict:
94
 
95
 
96
  # ---------------------------------------------------------------------------
97
- # Non-streaming generation (wrapped with @spaces.GPU for ZeroGPU)
 
 
 
 
98
  # ---------------------------------------------------------------------------
99
 
100
 
101
  @spaces.GPU
102
- def generate_response(prompt: str, gen_kwargs: dict) -> str:
 
 
 
 
 
 
103
  inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
104
  with torch.no_grad():
105
- output_ids = model.generate(**inputs, **gen_kwargs)
106
- # Decode only the newly generated tokens
 
 
 
 
 
 
107
  new_ids = output_ids[0][inputs["input_ids"].shape[1]:]
108
  return tokenizer.decode(new_ids, skip_special_tokens=True)
109
 
110
 
111
- # ---------------------------------------------------------------------------
112
- # Streaming generation (also wrapped with @spaces.GPU)
113
- # ---------------------------------------------------------------------------
 
 
 
 
 
114
 
115
 
116
  @spaces.GPU
117
- def generate_streaming(prompt: str, gen_kwargs: dict, streamer: TextIteratorStreamer):
 
118
  inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
119
  with torch.no_grad():
120
  model.generate(**inputs, streamer=streamer, **gen_kwargs)
121
 
122
 
123
  # ---------------------------------------------------------------------------
124
- # OpenAI-compatible response builders
125
  # ---------------------------------------------------------------------------
126
 
127
 
128
- def chat_completion_object(
129
- content: str,
130
- model: str,
131
- finish_reason: str = "stop",
132
- completion_id: Optional[str] = None,
133
- ) -> dict:
134
  cid = completion_id or f"chatcmpl-{uuid.uuid4().hex}"
135
  return {
136
  "id": cid,
137
  "object": "chat.completion",
138
  "created": int(time.time()),
139
- "model": model,
140
- "choices": [
141
- {
142
- "index": 0,
143
- "message": {"role": "assistant", "content": content},
144
- "finish_reason": finish_reason,
145
- }
146
- ],
147
- "usage": {
148
- # token counts are approximate (not tracked here)
149
- "prompt_tokens": -1,
150
- "completion_tokens": -1,
151
- "total_tokens": -1,
152
- },
153
  }
154
 
155
 
156
- def stream_chunk(
157
- delta_content: str,
158
- model: str,
159
- completion_id: str,
160
- finish_reason: Optional[str] = None,
161
- ) -> str:
162
  chunk = {
163
  "id": completion_id,
164
  "object": "chat.completion.chunk",
165
  "created": int(time.time()),
166
- "model": model,
167
- "choices": [
168
- {
169
- "index": 0,
170
- "delta": {"content": delta_content} if delta_content else {},
171
- "finish_reason": finish_reason,
172
- }
173
- ],
174
  }
175
  return f"data: {json.dumps(chunk)}\n\n"
176
 
177
 
178
  # ---------------------------------------------------------------------------
179
- # Routes
180
  # ---------------------------------------------------------------------------
181
 
182
 
@@ -184,14 +175,7 @@ def stream_chunk(
184
  async def list_models():
185
  return {
186
  "object": "list",
187
- "data": [
188
- {
189
- "id": MODEL_ALIAS,
190
- "object": "model",
191
- "created": int(time.time()),
192
- "owned_by": "qwen",
193
- }
194
- ],
195
  }
196
 
197
 
@@ -203,112 +187,70 @@ async def chat_completions(request: ChatCompletionRequest):
203
  except Exception as exc:
204
  raise HTTPException(status_code=422, detail=str(exc))
205
 
206
- # -----------------------------------------------------------------------
207
- # Streaming path
208
- # -----------------------------------------------------------------------
209
  if request.stream:
210
  completion_id = f"chatcmpl-{uuid.uuid4().hex}"
211
 
212
  async def token_generator() -> AsyncIterator[str]:
213
- # Send first chunk with role
214
  role_chunk = {
215
- "id": completion_id,
216
- "object": "chat.completion.chunk",
217
- "created": int(time.time()),
218
- "model": request.model,
219
- "choices": [
220
- {"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}
221
- ],
222
  }
223
  yield f"data: {json.dumps(role_chunk)}\n\n"
224
 
225
- streamer = TextIteratorStreamer(
226
- tokenizer, skip_prompt=True, skip_special_tokens=True
227
- )
228
-
229
- # Run the GPU-bound generation in a background thread so we can
230
- # yield tokens into the async generator without blocking the event loop.
231
- thread = Thread(
232
- target=generate_streaming,
233
- args=(prompt, gen_kwargs, streamer),
234
- daemon=True,
235
- )
236
  thread.start()
237
 
238
- loop = asyncio.get_event_loop()
239
  try:
240
  for token_text in streamer:
241
  if token_text:
242
  yield stream_chunk(token_text, request.model, completion_id)
243
- # Yield control back to the event loop between tokens
244
  await asyncio.sleep(0)
245
  finally:
246
  thread.join()
247
 
248
- # Final chunk signalling end of stream
249
  yield stream_chunk("", request.model, completion_id, finish_reason="stop")
250
  yield "data: [DONE]\n\n"
251
 
252
- return StreamingResponse(
253
- token_generator(),
254
- media_type="text/event-stream",
255
- headers={
256
- "Cache-Control": "no-cache",
257
- "X-Accel-Buffering": "no",
258
- },
259
- )
260
 
261
- # -----------------------------------------------------------------------
262
- # Non-streaming path
263
- # -----------------------------------------------------------------------
264
  try:
265
- content = generate_response(prompt, gen_kwargs)
266
  except Exception as exc:
267
  raise HTTPException(status_code=500, detail=f"Generation failed: {exc}")
268
 
269
  return JSONResponse(chat_completion_object(content, request.model))
270
 
271
 
272
- # ---------------------------------------------------------------------------
273
- # Health-check
274
- # ---------------------------------------------------------------------------
275
-
276
-
277
  @app.get("/health")
278
  async def health():
279
  return {"status": "ok", "model": MODEL_ID}
280
 
281
 
282
  # ---------------------------------------------------------------------------
283
- # Mount FastAPI inside a Gradio app
284
- # (Gradio is required to keep the ZeroGPU Space alive; FastAPI rides on top)
285
  # ---------------------------------------------------------------------------
286
 
287
- with gr.Blocks(title="Qwen3-30B-A3B API") as demo:
288
- gr.Markdown(
289
- f"""
290
- # Qwen3-30B-A3B — OpenAI-compatible API
291
 
292
- This Space exposes an OpenAI-compatible REST API. Point **Paperclip** (or any
293
- OpenAI-compatible client) at this URL and use model ID `{MODEL_ALIAS}`.
294
 
295
- **Endpoints**
296
  | Method | Path | Description |
297
  |--------|------|-------------|
298
- | GET | `/v1/models` | List available models |
299
  | POST | `/v1/chat/completions` | Chat (streaming & non-streaming) |
300
  | GET | `/health` | Health check |
301
 
302
- **Paperclip setup**
303
- 1. Open Paperclip → Settings → Add Provider → OpenAI-compatible
304
- 2. Base URL: `https://<your-space>.hf.space`
305
- 3. Model: `{MODEL_ALIAS}`
306
- 4. API key: *(leave blank or enter anything)*
307
- """
308
- )
309
 
310
- # `gr.mount_gradio_app` lets Gradio and FastAPI share the same process.
311
- # The FastAPI routes are accessible at the root; Gradio lives at /gradio.
312
  app = gr.mount_gradio_app(app, demo, path="/gradio")
313
 
314
  # ---------------------------------------------------------------------------
 
1
  import asyncio
2
  import json
 
3
  import time
4
  import uuid
5
  from threading import Thread
6
  from typing import AsyncIterator, Optional
7
 
8
+ import gradio as gr
9
  import spaces
10
  import torch
11
  import uvicorn
12
+ from fastapi import FastAPI, HTTPException
 
13
  from fastapi.responses import JSONResponse, StreamingResponse
14
  from pydantic import BaseModel, Field
15
  from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
 
22
  MODEL_ALIAS = "qwen3-30b-a3b"
23
 
24
  # ---------------------------------------------------------------------------
25
+ # Model loading tokenizer on CPU at startup; model loaded with device_map
26
+ # so ZeroGPU can manage GPU placement per request.
27
  # ---------------------------------------------------------------------------
28
 
29
  print(f"Loading tokenizer for {MODEL_ID} …")
 
61
  temperature: Optional[float] = Field(default=0.7)
62
  top_p: Optional[float] = Field(default=0.9)
63
  stream: Optional[bool] = Field(default=False)
 
64
  enable_thinking: Optional[bool] = Field(default=False)
65
 
66
 
67
  # ---------------------------------------------------------------------------
68
+ # Helpers
69
  # ---------------------------------------------------------------------------
70
 
71
 
72
  def build_prompt(messages: list[ChatMessage], enable_thinking: bool) -> str:
 
73
  hf_messages = [{"role": m.role, "content": m.content} for m in messages]
74
+ return tokenizer.apply_chat_template(
75
  hf_messages,
76
  tokenize=False,
77
  add_generation_prompt=True,
 
78
  enable_thinking=enable_thinking,
79
  )
 
80
 
81
 
82
  def make_generation_kwargs(request: ChatCompletionRequest) -> dict:
 
90
 
91
 
92
  # ---------------------------------------------------------------------------
93
+ # GPU generation functions
94
+ # NOTE: ZeroGPU requires at least one @spaces.GPU function to be wired into
95
+ # the Gradio UI (not just defined). We satisfy this by using `gradio_chat`
96
+ # as both the Gradio interface handler AND calling the same underlying logic
97
+ # that the FastAPI routes use.
98
  # ---------------------------------------------------------------------------
99
 
100
 
101
  @spaces.GPU
102
+ def gradio_chat(message: str, history: list) -> str:
103
+ """Gradio-facing chat handler — also acts as the ZeroGPU anchor function."""
104
+ hf_messages = [{"role": "user" if i % 2 == 0 else "assistant", "content": m}
105
+ for i, m in enumerate([msg for pair in history for msg in pair] + [message])]
106
+ prompt = tokenizer.apply_chat_template(
107
+ hf_messages, tokenize=False, add_generation_prompt=True, enable_thinking=False
108
+ )
109
  inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
110
  with torch.no_grad():
111
+ output_ids = model.generate(
112
+ **inputs,
113
+ max_new_tokens=512,
114
+ do_sample=True,
115
+ temperature=0.7,
116
+ top_p=0.9,
117
+ pad_token_id=tokenizer.eos_token_id,
118
+ )
119
  new_ids = output_ids[0][inputs["input_ids"].shape[1]:]
120
  return tokenizer.decode(new_ids, skip_special_tokens=True)
121
 
122
 
123
+ @spaces.GPU
124
+ def _generate_response(prompt: str, gen_kwargs: dict) -> str:
125
+ """Non-streaming generation for FastAPI."""
126
+ inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
127
+ with torch.no_grad():
128
+ output_ids = model.generate(**inputs, **gen_kwargs)
129
+ new_ids = output_ids[0][inputs["input_ids"].shape[1]:]
130
+ return tokenizer.decode(new_ids, skip_special_tokens=True)
131
 
132
 
133
  @spaces.GPU
134
+ def _generate_streaming(prompt: str, gen_kwargs: dict, streamer: TextIteratorStreamer):
135
+ """Streaming generation for FastAPI."""
136
  inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
137
  with torch.no_grad():
138
  model.generate(**inputs, streamer=streamer, **gen_kwargs)
139
 
140
 
141
  # ---------------------------------------------------------------------------
142
+ # OpenAI response builders
143
  # ---------------------------------------------------------------------------
144
 
145
 
146
+ def chat_completion_object(content: str, model_name: str, completion_id: Optional[str] = None) -> dict:
 
 
 
 
 
147
  cid = completion_id or f"chatcmpl-{uuid.uuid4().hex}"
148
  return {
149
  "id": cid,
150
  "object": "chat.completion",
151
  "created": int(time.time()),
152
+ "model": model_name,
153
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": content}, "finish_reason": "stop"}],
154
+ "usage": {"prompt_tokens": -1, "completion_tokens": -1, "total_tokens": -1},
 
 
 
 
 
 
 
 
 
 
 
155
  }
156
 
157
 
158
+ def stream_chunk(delta_content: str, model_name: str, completion_id: str, finish_reason: Optional[str] = None) -> str:
 
 
 
 
 
159
  chunk = {
160
  "id": completion_id,
161
  "object": "chat.completion.chunk",
162
  "created": int(time.time()),
163
+ "model": model_name,
164
+ "choices": [{"index": 0, "delta": {"content": delta_content} if delta_content else {}, "finish_reason": finish_reason}],
 
 
 
 
 
 
165
  }
166
  return f"data: {json.dumps(chunk)}\n\n"
167
 
168
 
169
  # ---------------------------------------------------------------------------
170
+ # FastAPI routes
171
  # ---------------------------------------------------------------------------
172
 
173
 
 
175
  async def list_models():
176
  return {
177
  "object": "list",
178
+ "data": [{"id": MODEL_ALIAS, "object": "model", "created": int(time.time()), "owned_by": "qwen"}],
 
 
 
 
 
 
 
179
  }
180
 
181
 
 
187
  except Exception as exc:
188
  raise HTTPException(status_code=422, detail=str(exc))
189
 
 
 
 
190
  if request.stream:
191
  completion_id = f"chatcmpl-{uuid.uuid4().hex}"
192
 
193
  async def token_generator() -> AsyncIterator[str]:
 
194
  role_chunk = {
195
+ "id": completion_id, "object": "chat.completion.chunk",
196
+ "created": int(time.time()), "model": request.model,
197
+ "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}],
 
 
 
 
198
  }
199
  yield f"data: {json.dumps(role_chunk)}\n\n"
200
 
201
+ streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
202
+ thread = Thread(target=_generate_streaming, args=(prompt, gen_kwargs, streamer), daemon=True)
 
 
 
 
 
 
 
 
 
203
  thread.start()
204
 
 
205
  try:
206
  for token_text in streamer:
207
  if token_text:
208
  yield stream_chunk(token_text, request.model, completion_id)
 
209
  await asyncio.sleep(0)
210
  finally:
211
  thread.join()
212
 
 
213
  yield stream_chunk("", request.model, completion_id, finish_reason="stop")
214
  yield "data: [DONE]\n\n"
215
 
216
+ return StreamingResponse(token_generator(), media_type="text/event-stream",
217
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
 
 
 
 
 
 
218
 
 
 
 
219
  try:
220
+ content = _generate_response(prompt, gen_kwargs)
221
  except Exception as exc:
222
  raise HTTPException(status_code=500, detail=f"Generation failed: {exc}")
223
 
224
  return JSONResponse(chat_completion_object(content, request.model))
225
 
226
 
 
 
 
 
 
227
  @app.get("/health")
228
  async def health():
229
  return {"status": "ok", "model": MODEL_ID}
230
 
231
 
232
  # ---------------------------------------------------------------------------
233
+ # Gradio UI must have a real wired @spaces.GPU function for ZeroGPU
 
234
  # ---------------------------------------------------------------------------
235
 
236
+ with gr.Blocks(title=f"{MODEL_ALIAS} API") as demo:
237
+ gr.Markdown(f"""
238
+ # {MODEL_ALIAS} — OpenAI-compatible API
 
239
 
240
+ Point **Paperclip** at `https://<your-space>.hf.space` with model `{MODEL_ALIAS}`.
 
241
 
 
242
  | Method | Path | Description |
243
  |--------|------|-------------|
244
+ | GET | `/v1/models` | List models |
245
  | POST | `/v1/chat/completions` | Chat (streaming & non-streaming) |
246
  | GET | `/health` | Health check |
247
 
248
+ You can also chat directly below.
249
+ """)
250
+ # This ChatInterface wires `gradio_chat` into Gradio's event system,
251
+ # which is what ZeroGPU's startup scanner requires.
252
+ gr.ChatInterface(fn=gradio_chat)
 
 
253
 
 
 
254
  app = gr.mount_gradio_app(app, demo, path="/gradio")
255
 
256
  # ---------------------------------------------------------------------------