krydon commited on
Commit
f57b53f
Β·
verified Β·
1 Parent(s): 956e6da

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +119 -73
app.py CHANGED
@@ -10,10 +10,10 @@ from flask import Flask, request, jsonify, Response
10
  from flask_cors import CORS
11
 
12
  # --- Model Configuration ---
13
- HF_REPO = "paulsp94/Qwen3.5-2B-LiteRT-LM"
14
- HF_FILE = "model.litertlm"
15
 
16
- _SERVER_DIR = os.path.dirname(os.path.abspath(__file__))
17
  _DEFAULT_PATH = os.path.join(_SERVER_DIR, "models", "qwen", HF_FILE)
18
 
19
  # litert_lm links against libvulkan.so.1 even on CPU-only runs.
@@ -21,19 +21,19 @@ _vk_stub = os.path.join(_SERVER_DIR, "libvulkan.so.1")
21
  if os.path.exists(_vk_stub):
22
  try:
23
  ctypes.CDLL(_vk_stub, mode=ctypes.RTLD_GLOBAL)
24
- except OSError:
25
- pass
26
 
27
  # Suppress verbose C++ logs from litert_lm
28
  os.environ.setdefault("GLOG_minloglevel", "3")
29
 
30
  MODEL_PATH = os.environ.get("GEMMA_MODEL_PATH", _DEFAULT_PATH).strip()
31
- MODEL_ID = "qwen3.5-2b"
32
 
33
  model_status = "loading"
34
- engine = None
35
- _engine_ctx = None
36
- engine_lock = threading.Lock()
37
 
38
  app = Flask(__name__)
39
  CORS(app)
@@ -42,29 +42,45 @@ CORS(app)
42
  # ─── Model loading ─────────────────────────────────────────────────────────────
43
 
44
  def load_model():
45
- global engine, model_status, _engine_ctx
46
  if not MODEL_PATH:
47
  print("[INFO] GEMMA_MODEL_PATH not set β€” no model loaded", flush=True)
48
  model_status = "no_model_path"
49
  return
 
50
  try:
51
- import litert_lm as _lm
52
- _lm.set_min_log_severity(_lm.LogSeverity.SILENT)
53
  except ImportError:
54
- print("[INFO] litert_lm not installed β€” no model loaded", flush=True)
55
  model_status = "no_litert_lm"
56
  return
 
 
 
 
 
 
 
57
  if not os.path.exists(MODEL_PATH):
58
  print(f"[WARN] Model file not found: {MODEL_PATH}", flush=True)
59
  model_status = "model_file_missing"
60
  return
 
61
  try:
62
- _engine_ctx = _lm.Engine(
63
- MODEL_PATH,
64
- backend=_lm.interfaces.CPU(),
65
- vision_backend=_lm.interfaces.CPU(),
66
- )
67
- engine = _engine_ctx.__enter__()
 
 
 
 
 
 
 
68
  model_status = "ready"
69
  print(f"[INFO] Model ready β†’ {MODEL_PATH}", flush=True)
70
  except Exception as e:
@@ -74,8 +90,8 @@ def load_model():
74
 
75
  # ─── OpenAI Request Parsing ────────────────────────────────────────────────────
76
 
77
- def parse_openai_messages(messages: list) -> tuple[str, bytes | None]:
78
- """Parses OpenAI formatted messages into a flat text prompt and an optional image."""
79
  prompt_text = ""
80
  image_bytes = None
81
 
@@ -88,16 +104,17 @@ def parse_openai_messages(messages: list) -> tuple[str, bytes | None]:
88
  elif isinstance(content, list):
89
  prompt_text += f"{role}:\n"
90
  for part in content:
91
- if part.get("type") == "text":
 
92
  prompt_text += part.get("text", "") + "\n"
93
- elif part.get("type") == "image_url":
94
  url = part.get("image_url", {}).get("url", "")
95
  if url.startswith("data:image"):
96
  try:
97
  b64_data = url.split(",", 1)[1]
98
  image_bytes = base64.b64decode(b64_data)
99
  except Exception as e:
100
- print(f"[WARN] Failed to decode base64 image: {e}")
101
 
102
  prompt_text += "assistant: "
103
  return prompt_text.strip(), image_bytes
@@ -105,41 +122,65 @@ def parse_openai_messages(messages: list) -> tuple[str, bytes | None]:
105
 
106
  # ─── Inference Engine ──────────────────────────────────────────────────────────
107
 
108
- def _run_real_model_generator(ask: str, image_bytes: bytes | None):
109
  """Yields text chunks as they are generated by the model."""
110
- import litert_lm
111
- # engine_lock ensures only 1 request processes at a time to prevent RAM crashes
112
  with engine_lock:
113
- with engine.create_conversation() as conv:
114
- if image_bytes:
115
- # NOTE: Qwen 3.5 2B is text-only. If a caller sends an image
116
- # against this model, we ignore the image bytes rather than
117
- # crash the engine, since the checkpoint has no vision tower.
118
- msg = ask
119
- else:
120
- msg = ask
121
 
122
- for chunk in conv.send_message_async(msg):
123
- for part in chunk.get("content", []):
124
- if part.get("type") == "text":
125
- text = part.get("text", "")
126
- if text:
127
- yield text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
 
129
 
130
  def _run_mock_generator(ask: str, has_image: bool):
131
  """Fallback generator when the model is missing/loading."""
132
- msg = f"[MOCK] Received prompt. Vision included: {has_image}. Connect litert_lm for real output."
 
 
133
  for word in msg.split():
134
  yield word + " "
135
- time.sleep(0.05)
136
 
137
 
138
  # ─── Routes ────────────────────────────────────────────────────────────────────
139
 
 
 
 
 
 
140
  @app.route("/v1/models", methods=["GET"])
141
  def list_models():
142
- """OpenAI models endpoint."""
143
  return jsonify({
144
  "object": "list",
145
  "data": [{
@@ -150,23 +191,21 @@ def list_models():
150
  }]
151
  })
152
 
 
153
  @app.route("/v1/chat/completions", methods=["POST"])
154
  def chat_completions():
155
- """OpenAI compatible chat completions endpoint."""
156
  data = request.get_json(silent=True) or {}
157
  messages = data.get("messages", [])
158
- stream = data.get("stream", False)
159
-
160
  if not messages:
161
- return jsonify({"error": {"message": "Missing 'messages' array", "type": "invalid_request_error"}}), 400
 
 
162
 
163
  ask, image_bytes = parse_openai_messages(messages)
164
-
165
- # Determine which generator to use
166
- if engine is None or model_status != "ready":
167
- generator = _run_mock_generator(ask, bool(image_bytes))
168
- else:
169
- generator = _run_real_model_generator(ask, image_bytes)
170
 
171
  req_model = data.get("model", MODEL_ID)
172
  cmpl_id = f"chatcmpl-{uuid.uuid4().hex}"
@@ -174,38 +213,46 @@ def chat_completions():
174
 
175
  if stream:
176
  def stream_response():
177
- # 1. Initial chunk indicating role
178
  init_chunk = {
179
- "id": cmpl_id, "object": "chat.completion.chunk", "created": created_time, "model": req_model,
 
180
  "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}]
181
  }
182
  yield f"data: {json.dumps(init_chunk)}\n\n"
183
 
184
- # 2. Stream tokens
185
  try:
186
- for text_chunk in generator:
 
 
187
  chunk = {
188
- "id": cmpl_id, "object": "chat.completion.chunk", "created": created_time, "model": req_model,
 
189
  "choices": [{"index": 0, "delta": {"content": text_chunk}, "finish_reason": None}]
190
  }
191
  yield f"data: {json.dumps(chunk)}\n\n"
192
  except Exception as e:
193
- err_chunk = {"error": str(e)}
 
 
 
 
194
  yield f"data: {json.dumps(err_chunk)}\n\n"
195
 
196
- # 3. Final chunk indicating stop
197
  final_chunk = {
198
- "id": cmpl_id, "object": "chat.completion.chunk", "created": created_time, "model": req_model,
 
199
  "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]
200
  }
201
  yield f"data: {json.dumps(final_chunk)}\n\n"
202
  yield "data: [DONE]\n\n"
203
 
204
  return Response(stream_response(), mimetype="text/event-stream")
205
-
206
  else:
207
  try:
208
- full_text = "".join(list(generator))
 
 
209
  response = {
210
  "id": cmpl_id,
211
  "object": "chat.completion",
@@ -213,27 +260,26 @@ def chat_completions():
213
  "model": req_model,
214
  "choices": [{
215
  "index": 0,
216
- "message": {
217
- "role": "assistant",
218
- "content": full_text
219
- },
220
  "finish_reason": "stop"
221
  }],
222
  "usage": {
223
- "prompt_tokens": 0, # litert_lm token counting not implemented
224
- "completion_tokens": 0,
225
  "total_tokens": 0
226
  }
227
  }
228
  return jsonify(response)
229
  except Exception as e:
230
- return jsonify({"error": {"message": f"Model error: {e}", "type": "server_error"}}), 500
 
 
231
 
232
 
233
  # ─── Entry ─────────────────────────────────────────────────────────────────────
234
 
235
  if __name__ == "__main__":
236
- port = int(os.environ.get("PORT", 5173))
237
  threading.Thread(target=load_model, daemon=True).start()
238
  print(f"[INFO] Qwen 3.5 2B OpenAI-Compatible API listening on :{port}", flush=True)
239
- app.run(host="0.0.0.0", port=port, debug=False)
 
10
  from flask_cors import CORS
11
 
12
  # --- Model Configuration ---
13
+ HF_REPO = "paulsp94/Qwen3.5-2B-LiteRT-LM"
14
+ HF_FILE = "model.litertlm"
15
 
16
+ _SERVER_DIR = os.path.dirname(os.path.abspath(__file__))
17
  _DEFAULT_PATH = os.path.join(_SERVER_DIR, "models", "qwen", HF_FILE)
18
 
19
  # litert_lm links against libvulkan.so.1 even on CPU-only runs.
 
21
  if os.path.exists(_vk_stub):
22
  try:
23
  ctypes.CDLL(_vk_stub, mode=ctypes.RTLD_GLOBAL)
24
+ except OSError as e:
25
+ print(f"[WARN] Could not preload vulkan stub: {e}", flush=True)
26
 
27
  # Suppress verbose C++ logs from litert_lm
28
  os.environ.setdefault("GLOG_minloglevel", "3")
29
 
30
  MODEL_PATH = os.environ.get("GEMMA_MODEL_PATH", _DEFAULT_PATH).strip()
31
+ MODEL_ID = "qwen3.5-2b"
32
 
33
  model_status = "loading"
34
+ engine = None
35
+ _lm = None
36
+ engine_lock = threading.Lock()
37
 
38
  app = Flask(__name__)
39
  CORS(app)
 
42
  # ─── Model loading ─────────────────────────────────────────────────────────────
43
 
44
  def load_model():
45
+ global engine, model_status, _lm
46
  if not MODEL_PATH:
47
  print("[INFO] GEMMA_MODEL_PATH not set β€” no model loaded", flush=True)
48
  model_status = "no_model_path"
49
  return
50
+
51
  try:
52
+ import litert_lm as lm
53
+ _lm = lm
54
  except ImportError:
55
+ print("[INFO] litert_lm not installed β€” running in mock mode", flush=True)
56
  model_status = "no_litert_lm"
57
  return
58
+
59
+ # Try to silence logs if the API exists
60
+ try:
61
+ _lm.set_min_log_severity(_lm.LogSeverity.SILENT)
62
+ except Exception:
63
+ pass
64
+
65
  if not os.path.exists(MODEL_PATH):
66
  print(f"[WARN] Model file not found: {MODEL_PATH}", flush=True)
67
  model_status = "model_file_missing"
68
  return
69
+
70
  try:
71
+ # The litert_lm Engine API. Build args defensively depending
72
+ # on what the installed version exposes.
73
+ try:
74
+ cpu_backend = _lm.interfaces.CPU()
75
+ engine = _lm.Engine(
76
+ MODEL_PATH,
77
+ backend=cpu_backend,
78
+ vision_backend=cpu_backend,
79
+ )
80
+ except (AttributeError, TypeError):
81
+ # Fallback: simpler constructor signature
82
+ engine = _lm.Engine(MODEL_PATH)
83
+
84
  model_status = "ready"
85
  print(f"[INFO] Model ready β†’ {MODEL_PATH}", flush=True)
86
  except Exception as e:
 
90
 
91
  # ─── OpenAI Request Parsing ────────────────────────────────────────────────────
92
 
93
+ def parse_openai_messages(messages: list):
94
+ """Parses OpenAI formatted messages into a flat text prompt and optional image."""
95
  prompt_text = ""
96
  image_bytes = None
97
 
 
104
  elif isinstance(content, list):
105
  prompt_text += f"{role}:\n"
106
  for part in content:
107
+ ptype = part.get("type")
108
+ if ptype == "text":
109
  prompt_text += part.get("text", "") + "\n"
110
+ elif ptype == "image_url":
111
  url = part.get("image_url", {}).get("url", "")
112
  if url.startswith("data:image"):
113
  try:
114
  b64_data = url.split(",", 1)[1]
115
  image_bytes = base64.b64decode(b64_data)
116
  except Exception as e:
117
+ print(f"[WARN] Failed to decode base64 image: {e}", flush=True)
118
 
119
  prompt_text += "assistant: "
120
  return prompt_text.strip(), image_bytes
 
122
 
123
  # ─── Inference Engine ──────────────────────────────────────────────────────────
124
 
125
+ def _run_real_model_generator(ask: str, image_bytes):
126
  """Yields text chunks as they are generated by the model."""
127
+ # Qwen 3.5 2B is text-only; image_bytes are ignored intentionally.
 
128
  with engine_lock:
129
+ conv = None
130
+ try:
131
+ conv = engine.create_conversation()
 
 
 
 
 
132
 
133
+ # Support both context-manager and plain object styles
134
+ if hasattr(conv, "__enter__"):
135
+ conv_obj = conv.__enter__()
136
+ else:
137
+ conv_obj = conv
138
+
139
+ stream = conv_obj.send_message_async(ask)
140
+
141
+ for chunk in stream:
142
+ # Chunk may be a plain string or a structured dict
143
+ if isinstance(chunk, str):
144
+ if chunk:
145
+ yield chunk
146
+ elif isinstance(chunk, dict):
147
+ for part in chunk.get("content", []):
148
+ if part.get("type") == "text":
149
+ text = part.get("text", "")
150
+ if text:
151
+ yield text
152
+ else:
153
+ # Try common attribute names
154
+ text = getattr(chunk, "text", None)
155
+ if text:
156
+ yield text
157
+ finally:
158
+ if conv is not None and hasattr(conv, "__exit__"):
159
+ try:
160
+ conv.__exit__(None, None, None)
161
+ except Exception:
162
+ pass
163
 
164
 
165
  def _run_mock_generator(ask: str, has_image: bool):
166
  """Fallback generator when the model is missing/loading."""
167
+ msg = (f"[MOCK] Model status: {model_status}. "
168
+ f"Vision included: {has_image}. "
169
+ f"Connect litert_lm + model file for real output.")
170
  for word in msg.split():
171
  yield word + " "
172
+ time.sleep(0.02)
173
 
174
 
175
  # ─── Routes ────────────────────────────────────────────────────────────────────
176
 
177
+ @app.route("/health", methods=["GET"])
178
+ def health():
179
+ return jsonify({"status": model_status}), 200
180
+
181
+
182
  @app.route("/v1/models", methods=["GET"])
183
  def list_models():
 
184
  return jsonify({
185
  "object": "list",
186
  "data": [{
 
191
  }]
192
  })
193
 
194
+
195
  @app.route("/v1/chat/completions", methods=["POST"])
196
  def chat_completions():
 
197
  data = request.get_json(silent=True) or {}
198
  messages = data.get("messages", [])
199
+ stream = bool(data.get("stream", False))
200
+
201
  if not messages:
202
+ return jsonify({
203
+ "error": {"message": "Missing 'messages' array", "type": "invalid_request_error"}
204
+ }), 400
205
 
206
  ask, image_bytes = parse_openai_messages(messages)
207
+
208
+ use_mock = engine is None or model_status != "ready"
 
 
 
 
209
 
210
  req_model = data.get("model", MODEL_ID)
211
  cmpl_id = f"chatcmpl-{uuid.uuid4().hex}"
 
213
 
214
  if stream:
215
  def stream_response():
 
216
  init_chunk = {
217
+ "id": cmpl_id, "object": "chat.completion.chunk",
218
+ "created": created_time, "model": req_model,
219
  "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}]
220
  }
221
  yield f"data: {json.dumps(init_chunk)}\n\n"
222
 
 
223
  try:
224
+ gen = (_run_mock_generator(ask, bool(image_bytes)) if use_mock
225
+ else _run_real_model_generator(ask, image_bytes))
226
+ for text_chunk in gen:
227
  chunk = {
228
+ "id": cmpl_id, "object": "chat.completion.chunk",
229
+ "created": created_time, "model": req_model,
230
  "choices": [{"index": 0, "delta": {"content": text_chunk}, "finish_reason": None}]
231
  }
232
  yield f"data: {json.dumps(chunk)}\n\n"
233
  except Exception as e:
234
+ err_chunk = {
235
+ "id": cmpl_id, "object": "chat.completion.chunk",
236
+ "created": created_time, "model": req_model,
237
+ "choices": [{"index": 0, "delta": {"content": f"[ERROR] {e}"}, "finish_reason": "stop"}]
238
+ }
239
  yield f"data: {json.dumps(err_chunk)}\n\n"
240
 
 
241
  final_chunk = {
242
+ "id": cmpl_id, "object": "chat.completion.chunk",
243
+ "created": created_time, "model": req_model,
244
  "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]
245
  }
246
  yield f"data: {json.dumps(final_chunk)}\n\n"
247
  yield "data: [DONE]\n\n"
248
 
249
  return Response(stream_response(), mimetype="text/event-stream")
250
+
251
  else:
252
  try:
253
+ gen = (_run_mock_generator(ask, bool(image_bytes)) if use_mock
254
+ else _run_real_model_generator(ask, image_bytes))
255
+ full_text = "".join(gen)
256
  response = {
257
  "id": cmpl_id,
258
  "object": "chat.completion",
 
260
  "model": req_model,
261
  "choices": [{
262
  "index": 0,
263
+ "message": {"role": "assistant", "content": full_text},
 
 
 
264
  "finish_reason": "stop"
265
  }],
266
  "usage": {
267
+ "prompt_tokens": 0,
268
+ "completion_tokens": 0,
269
  "total_tokens": 0
270
  }
271
  }
272
  return jsonify(response)
273
  except Exception as e:
274
+ return jsonify({
275
+ "error": {"message": f"Model error: {e}", "type": "server_error"}
276
+ }), 500
277
 
278
 
279
  # ─── Entry ─────────────────────────────────────────────────────────────────────
280
 
281
  if __name__ == "__main__":
282
+ port = int(os.environ.get("PORT", 7860))
283
  threading.Thread(target=load_model, daemon=True).start()
284
  print(f"[INFO] Qwen 3.5 2B OpenAI-Compatible API listening on :{port}", flush=True)
285
+ app.run(host="0.0.0.0", port=port, debug=False, threaded=True)