Simonc-44 commited on
Commit
21ebd5e
·
verified ·
1 Parent(s): e4231cb

Upload app.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. app.py +274 -0
app.py ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SimonLM Cloud — Hugging Face Space (ZeroGPU).
3
+
4
+ - Wheel llama-cpp-python CUDA (GPU gratuit via ZeroGPU)
5
+ - Interface Gradio (chat) + API OpenAI-compatible (/v1/chat/completions,
6
+ /v1/models) pour que SimonLM local puisse router ses questions SIMPLE
7
+ vers ce GPU au lieu de charger le 0.6B sur la machine.
8
+ - Le modèle est chargé une seule fois (cache global) et l'inférence passe par
9
+ @spaces.GPU pour utiliser le GPU réel.
10
+
11
+ RÈGLE ZeroGPU : la fonction bindée aux événements Gradio (respond) DOIT être
12
+ décorée @spaces.GPU — le scan de démarrage ne détecte que les handlers
13
+ Gradio décorés. L'API utilise sa propre fonction décorée (même worker GPU).
14
+
15
+ Réglages via variables d'environnement : MODEL_REPO, MODEL_FILE, N_CTX,
16
+ N_THREADS, MAX_TOKENS, N_GPU_LAYERS.
17
+ """
18
+ import ctypes
19
+ import glob
20
+ import os
21
+ import site
22
+ import time
23
+ import uuid
24
+
25
+ # --- Pré-chargement des libs CUDA (libcudart, libcublas) requises par llama-cpp-python ---
26
+ # L'image ZeroGPU embarque PyTorch + CUDA ; on localise et on charge les .so
27
+ # avant l'import de llama_cpp pour que ctypes les trouve.
28
+ def _preload_cuda_libs():
29
+ dirs = []
30
+ # torch/lib (présent dans l'image ZeroGPU)
31
+ try:
32
+ import torch
33
+ dirs.append(os.path.join(os.path.dirname(torch.__file__), "lib"))
34
+ except Exception:
35
+ pass
36
+ # packages nvidia installés par pip (nvidia-cuda-runtime-cu12, nvidia-cublas-cu12)
37
+ for sp in site.getsitepackages():
38
+ dirs.extend(glob.glob(os.path.join(sp, "nvidia", "*", "lib")))
39
+ loaded = []
40
+ for d in dirs:
41
+ for so in sorted(glob.glob(os.path.join(d, "libcudart.so*")) +
42
+ glob.glob(os.path.join(d, "libcublas*.so*")) +
43
+ glob.glob(os.path.join(d, "libcuda.so*"))):
44
+ try:
45
+ ctypes.CDLL(so)
46
+ loaded.append(os.path.basename(so))
47
+ except Exception:
48
+ pass
49
+ return loaded
50
+
51
+ _preload_cuda_libs()
52
+
53
+ from typing import List
54
+
55
+ from fastapi.responses import JSONResponse, StreamingResponse
56
+ from pydantic import BaseModel
57
+ import gradio as gr
58
+ from gradio.routes import App
59
+ import spaces
60
+
61
+ MODEL_REPO = os.getenv("MODEL_REPO", "unsloth/Qwen3-0.6B-GGUF")
62
+ MODEL_FILE = os.getenv("MODEL_FILE", "Qwen3-0.6B-Q4_K_M.gguf")
63
+ N_CTX = int(os.getenv("N_CTX", "8192"))
64
+ N_THREADS = int(os.getenv("N_THREADS", "4"))
65
+ MAX_TOKENS = int(os.getenv("MAX_TOKENS", "512"))
66
+ N_GPU_LAYERS = int(os.getenv("N_GPU_LAYERS", "-1")) # -1 = tout sur GPU
67
+ API_MODEL_ID = os.getenv("API_MODEL_ID", "simonlm-0.6b")
68
+
69
+ _llm = None
70
+
71
+
72
+ def _get_llm():
73
+ """Charge le modèle une seule fois (cache global, dans le worker GPU)."""
74
+ global _llm
75
+ if _llm is None:
76
+ from llama_cpp import Llama
77
+
78
+ print(f"[SimonLM] Chargement de {MODEL_REPO}/{MODEL_FILE} "
79
+ f"(n_ctx={N_CTX}, gpu_layers={N_GPU_LAYERS})…", flush=True)
80
+ _llm = Llama.from_pretrained(
81
+ repo_id=MODEL_REPO,
82
+ filename=MODEL_FILE,
83
+ n_ctx=N_CTX,
84
+ n_threads=N_THREADS,
85
+ n_gpu_layers=N_GPU_LAYERS,
86
+ verbose=False,
87
+ )
88
+ print("[SimonLM] Modèle prêt (GPU)", flush=True)
89
+ return _llm
90
+
91
+
92
+ def _infer(messages: list, max_tokens: int, temperature: float, top_p: float) -> str:
93
+ """Inférence GPU brute (à appeler DANS une fonction @spaces.GPU)."""
94
+ llm = _get_llm()
95
+ out = llm.create_chat_completion(
96
+ messages=messages,
97
+ max_tokens=max_tokens,
98
+ temperature=temperature,
99
+ top_p=top_p,
100
+ )
101
+ return out["choices"][0]["message"].get("content", "")
102
+
103
+
104
+ def _split_think(text: str):
105
+ """Sépare le bloc <think> Qwen3 : (reasoning, content)."""
106
+ if "<think>" in text and "</think>" in text:
107
+ reasoning, content = text.split("</think>", 1)
108
+ reasoning = reasoning.replace("<think>", "").strip()
109
+ return reasoning, content.lstrip("\n")
110
+ return "", text
111
+
112
+
113
+ # ── Interface Gradio (chat humain) ────────────────────────────────────────────
114
+ # ⚠️ DÉCORÉ @spaces.GPU : le scan ZeroGPU ne détecte QUE les handlers Gradio
115
+ # décorés. respond est bindé à ChatInterface → c'est lui qui doit l'être.
116
+
117
+ _history: list = []
118
+
119
+
120
+ @spaces.GPU(duration=120)
121
+ def respond(message: str, chat_history):
122
+ global _history
123
+ _history.append({"role": "user", "content": message})
124
+ if len(_history) > 12:
125
+ del _history[:2]
126
+ try:
127
+ raw = _infer(list(_history), max_tokens=MAX_TOKENS, temperature=0.7, top_p=0.9)
128
+ _, content = _split_think(raw)
129
+ reply = content
130
+ except Exception as e: # noqa: BLE001 — jamais planter l'interface
131
+ reply = f"⚠️ Erreur : {e}"
132
+ _history.append({"role": "assistant", "content": reply})
133
+ return reply
134
+
135
+
136
+ demo = gr.ChatInterface(
137
+ fn=respond,
138
+ title="🤖 SimonLM Cloud",
139
+ description=f"Assistant IA — modèle {MODEL_FILE} sur ZeroGPU (GPU gratuit HF Spaces).",
140
+ )
141
+
142
+
143
+ # ── API OpenAI-compatible ─────────────────────────────────────────────────────
144
+ # App = sous-classe FastAPI de Gradio → on y ajoute nos routes /v1/* puis on la
145
+ # passe à demo.launch(_app=...) : Gradio s'y monte et le scan ZeroGPU s'exécute.
146
+ app = App(title="SimonLM Cloud API")
147
+
148
+
149
+ class ChatMessage(BaseModel):
150
+ role: str
151
+ content: str
152
+
153
+
154
+ class ChatRequest(BaseModel):
155
+ model: str = API_MODEL_ID
156
+ messages: List[ChatMessage]
157
+ temperature: float = 0.7
158
+ top_p: float = 0.9
159
+ max_tokens: int = MAX_TOKENS
160
+ stream: bool = False
161
+
162
+
163
+ @spaces.GPU(duration=120)
164
+ def _api_infer(messages: list, max_tokens: int, temperature: float, top_p: float) -> str:
165
+ return _infer(messages, max_tokens, temperature, top_p)
166
+
167
+
168
+ @app.get("/v1/models")
169
+ def list_models():
170
+ return JSONResponse({
171
+ "object": "list",
172
+ "data": [{
173
+ "id": API_MODEL_ID,
174
+ "object": "model",
175
+ "owned_by": "simonlm",
176
+ }],
177
+ })
178
+
179
+
180
+ def _sse_chunks(content: str, model: str):
181
+ """Découpe la réponse en fragments SSE (ressenti streaming)."""
182
+ import json as _json
183
+ frags = []
184
+ for part in content.split(" "):
185
+ frags.append(part + " " if part else " ")
186
+ buf = ""
187
+ for f in frags:
188
+ buf += f
189
+ if len(buf) >= 15:
190
+ chunk = {
191
+ "id": f"chatcmpl-{uuid.uuid4().hex[:12]}",
192
+ "object": "chat.completion.chunk",
193
+ "created": int(time.time()),
194
+ "model": model,
195
+ "choices": [{
196
+ "index": 0,
197
+ "delta": {"content": buf},
198
+ "finish_reason": None,
199
+ }],
200
+ }
201
+ yield f"data: {_json.dumps(chunk)}\n\n"
202
+ buf = ""
203
+ if buf:
204
+ chunk = {
205
+ "id": f"chatcmpl-{uuid.uuid4().hex[:12]}",
206
+ "object": "chat.completion.chunk",
207
+ "created": int(time.time()),
208
+ "model": model,
209
+ "choices": [{
210
+ "index": 0,
211
+ "delta": {"content": buf},
212
+ "finish_reason": None,
213
+ }],
214
+ }
215
+ yield f"data: {_json.dumps(chunk)}\n\n"
216
+ done = {
217
+ "id": f"chatcmpl-{uuid.uuid4().hex[:12]}",
218
+ "object": "chat.completion.chunk",
219
+ "created": int(time.time()),
220
+ "model": model,
221
+ "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
222
+ }
223
+ yield f"data: {_json.dumps(done)}\n\n"
224
+ yield "data: [DONE]\n\n"
225
+
226
+
227
+ @app.post("/v1/chat/completions")
228
+ def chat_completions(req: ChatRequest):
229
+ raw_messages = [{"role": m.role, "content": m.content} for m in req.messages]
230
+ try:
231
+ raw = _api_infer(
232
+ raw_messages,
233
+ max_tokens=min(req.max_tokens, MAX_TOKENS),
234
+ temperature=req.temperature,
235
+ top_p=req.top_p,
236
+ )
237
+ except Exception as e: # noqa: BLE001 — erreur propre côté client
238
+ return JSONResponse({
239
+ "error": {"message": f"Inférence GPU en échec : {e}", "type": "server_error"},
240
+ }, status_code=500)
241
+
242
+ reasoning, content = _split_think(raw)
243
+ msg = {"role": "assistant", "content": content}
244
+ if reasoning:
245
+ msg["reasoning_content"] = reasoning
246
+
247
+ if req.stream:
248
+ return StreamingResponse(
249
+ _sse_chunks(content, req.model),
250
+ media_type="text/event-stream",
251
+ )
252
+
253
+ return JSONResponse({
254
+ "id": f"chatcmpl-{uuid.uuid4().hex[:12]}",
255
+ "object": "chat.completion",
256
+ "created": int(time.time()),
257
+ "model": req.model,
258
+ "choices": [{
259
+ "index": 0,
260
+ "message": msg,
261
+ "finish_reason": "stop",
262
+ }],
263
+ "usage": {
264
+ "prompt_tokens": 0,
265
+ "completion_tokens": 0,
266
+ "total_tokens": 0,
267
+ },
268
+ })
269
+
270
+
271
+ # Lancement via demo.launch(_app=...) : le scan ZeroGPU s'exécute au launch()
272
+ # (gr.Blocks.launch est patché par le runtime) ET nos routes /v1/* sont montées
273
+ # sur la même app FastAPI que Gradio.
274
+ demo.launch(_app=app)