GgaammGo commited on
Commit
8dcf0c5
Β·
verified Β·
1 Parent(s): 5eef523

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +280 -0
app.py CHANGED
@@ -6,6 +6,15 @@ import requests
6
  import gradio as gr
7
  from openai import OpenAI, APIError
8
 
 
 
 
 
 
 
 
 
 
9
  MODEL_ID = "openrouter/free"
10
  OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", None)
11
 
@@ -182,6 +191,277 @@ def _call_openrouter_stream(request_model: str, messages: list, extra_body: dict
182
  )
183
 
184
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  def predict_chat(message, history: list):
186
  # multimodal=True일 λ•Œ messageλŠ” {"text": "...", "files": ["경둜", ...]}
187
  # ν˜•νƒœμ˜ λ”•μ…”λ„ˆλ¦¬λ‘œ λ“€μ–΄μ˜΅λ‹ˆλ‹€. (Gradio 곡식 λ¬Έμ„œ κΈ°μ€€)
 
6
  import gradio as gr
7
  from openai import OpenAI, APIError
8
 
9
+ MODEL_ID = "openrouter/free"
10
+ OPENROUTER_API_KEY = os.environ.get("OPEimport base64
11
+ import mimetypes
12
+ import os
13
+ import time
14
+ import requests
15
+ import gradio as gr
16
+ from openai import OpenAI, APIError
17
+
18
  MODEL_ID = "openrouter/free"
19
  OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", None)
20
 
 
191
  )
192
 
193
 
194
+ def predict_chat(message, history: list):
195
+ # multimodal=True일 λ•Œ messageλŠ” {"text": "...", "files": ["경둜", ...]}
196
+ # ν˜•νƒœμ˜ λ”•μ…”λ„ˆλ¦¬λ‘œ λ“€μ–΄μ˜΅λ‹ˆλ‹€. (Gradio 곡식 λ¬Έμ„œ κΈ°μ€€)
197
+ if isinstance(message, dict):
198
+ text = message.get("text") or ""
199
+ files = message.get("files") or []
200
+ else:
201
+ text = message
202
+ files = []
203
+
204
+ messages = _normalize_history(history)
205
+ messages.append({"role": "user", "content": _build_content(text, files)})
206
+
207
+ has_image = bool(files)
208
+
209
+ def _resolve_request_target(force_refresh: bool = False):
210
+ if not has_image:
211
+ return MODEL_ID, {}
212
+ vision_models = get_vision_capable_free_models(force_refresh=force_refresh)
213
+ # OpenRouter의 fallback κΈ°λŠ₯은 'models' 배열에 μ΅œλŒ€ 3κ°œκΉŒμ§€λ§Œ ν—ˆμš©ν•©λ‹ˆλ‹€.
214
+ # (OpenRouter μ„œλ²„ 응닡 κΈ°μ€€: "'models' array must have 3 items or fewer.")
215
+ vision_models = vision_models[:3]
216
+ return vision_models[0], {"models": vision_models}
217
+
218
+ request_model, extra_body = _resolve_request_target()
219
+
220
+ try:
221
+ try:
222
+ stream = _call_openrouter_stream(request_model, messages, extra_body)
223
+ except APIError as e:
224
+ # 이미지 μš”μ²­μΈλ° "지원 λͺ¨λΈμ„ λͺ» 찾음(404)" 였λ₯˜κ°€ λ‚˜λ©΄,
225
+ # μΊμ‹œκ°€ λ‚‘μ•˜μ„ κ°€λŠ₯성이 μžˆμœΌλ―€λ‘œ κ°•μ œλ‘œ ν•œ 번 더 μƒˆλ‘œ μ‘°νšŒν•΄μ„œ
226
+ # λ”± ν•œ 번만 μž¬μ‹œλ„ν•©λ‹ˆλ‹€.
227
+ if has_image and getattr(e, "status_code", None) == 404:
228
+ request_model, extra_body = _resolve_request_target(force_refresh=True)
229
+ stream = _call_openrouter_stream(request_model, messages, extra_body)
230
+ else:
231
+ raise
232
+
233
+ partial_output = ""
234
+ resolved_model = request_model
235
+
236
+ for chunk in stream:
237
+ # 각 쑰각(chunk)에도 μ‹€μ œ 응닡 쀑인 λͺ¨λΈλͺ…이 담겨 μžˆμ–΄,
238
+ # 슀트리밍 도쀑 계속 μ΅œμ‹ κ°’μœΌλ‘œ κ°±μ‹ ν•©λ‹ˆλ‹€.
239
+ if getattr(chunk, "model", None):
240
+ resolved_model = chunk.model
241
+
242
+ if not chunk.choices:
243
+ continue
244
+ delta = chunk.choices[0].delta
245
+ token = getattr(delta, "content", None)
246
+ if token:
247
+ partial_output += token
248
+ # yieldν•  λ•Œλ§ˆλ‹€ Gradio 화면이 μ‹€μ‹œκ°„μœΌλ‘œ κ°±μ‹ λ©λ‹ˆλ‹€.
249
+ yield partial_output
250
+
251
+ # 슀트리밍이 λλ‚œ λ’€, μ‹€μ œ μ‚¬μš© λͺ¨λΈλͺ…을 λ§ˆμ§€λ§‰μ— 덧뢙여 μ΅œμ’… κ°±μ‹ ν•©λ‹ˆλ‹€.
252
+ yield f"{partial_output}\n\n---\n_(μ‹€μ œ μ‚¬μš© λͺ¨λΈ: `{resolved_model}`)_"
253
+
254
+ except APIError as e:
255
+ # 무료 λͺ¨λΈμ˜ λΆ„λ‹Ή/일일 μš”μ²­ ν•œλ„ 초과, 이미지 미지원 λͺ¨λΈ 선택,
256
+ # λͺ¨λΈ μΌμ‹œ 쀑단 λ“± OpenRouter μͺ½ μ‚¬μœ λ‘œ 였λ₯˜κ°€ λ‚˜λŠ” κ²½μš°μž…λ‹ˆλ‹€.
257
+ yield f"❌ OpenRouter API 였λ₯˜: {str(e)}"
258
+ except Exception as e:
259
+ yield f"❌ λ„€νŠΈμ›Œν¬ 톡신 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€: {str(e)}"
260
+
261
+
262
+ # ── UI λΉŒλ“œ 및 API κ°œμ„€ ──────────────────────────────────────────────────────
263
+ with gr.Blocks() as demo:
264
+ gr.Markdown(
265
+ f"# πŸ€– GS-AI API Server (OpenRouter Mode)\nModel: `{MODEL_ID}`\n\n"
266
+ "ν…μŠ€νŠΈμ™€ ν•¨κ»˜ 이미지λ₯Ό μ²¨λΆ€ν•΄μ„œ μ§ˆλ¬Έν•˜μ‹€ 수 μžˆμŠ΅λ‹ˆλ‹€. "
267
+ "(단, κ·Έ μ‹œμ μ— 이미지 인식 κ°€λŠ₯ν•œ 무료 λͺ¨λΈμ΄ μ—†μœΌλ©΄ 였λ₯˜κ°€ λ‚  수 μžˆμŠ΅λ‹ˆλ‹€.)"
268
+ )
269
+
270
+ chatbot_ui = gr.Chatbot(height=480)
271
+ textbox_ui = gr.MultimodalTextbox(
272
+ placeholder="λ©”μ‹œμ§€λ₯Ό μž…λ ₯ν•˜κ±°λ‚˜ 이미지λ₯Ό μ²¨λΆ€ν•˜μ„Έμš”...",
273
+ file_types=["image"],
274
+ file_count="multiple",
275
+ )
276
+
277
+ gr.ChatInterface(
278
+ fn=predict_chat,
279
+ chatbot=chatbot_ui,
280
+ textbox=textbox_ui,
281
+ multimodal=True, # message μΈμžκ°€ {"text": ..., "files": [...]} λ”•μ…”λ„ˆλ¦¬λ‘œ μ „λ‹¬λ©λ‹ˆλ‹€.
282
+ api_name="chat", # μ™ΈλΆ€ 연동을 μœ„ν•œ /chat 경둜 λͺ…μ‹œ ν™œμ„±ν™”
283
+ )
284
+
285
+ if __name__ == "__main__":
286
+ demo.launch(
287
+ server_name="0.0.0.0",
288
+ server_port=7860,
289
+ theme=gr.themes.Soft(),
290
+ )NROUTER_API_KEY", None)
291
+
292
+ # λ„€νŠΈμ›Œν¬ 였λ₯˜ λ“±μœΌλ‘œ μ‹€μ‹œκ°„ μ‘°νšŒκ°€ μ™„μ „νžˆ μ‹€νŒ¨ν–ˆμ„ λ•Œλ§Œ μ‚¬μš©ν•˜λŠ”
293
+ # μ΅œν›„μ˜ μ•ˆμ „λ§(fallback) λͺ©λ‘μž…λ‹ˆλ‹€. 2026-07-02 κΈ°μ€€μœΌλ‘œ ν™•μΈλœ κ°’μž…λ‹ˆλ‹€.
294
+ _SAFETY_NET_VISION_MODELS = [
295
+ "google/gemma-4-31b-it:free",
296
+ "nvidia/nemotron-nano-12b-v2-vl:free",
297
+ ]
298
+
299
+ # ── 이미지 지원 무료 λͺ¨λΈ λͺ©λ‘ μΊμ‹œ ──────────────────────────────────────
300
+ # OpenRouter 곡식 λ¬Έμ„œ(openrouter.ai/docs/guides/overview/models)에 λ”°λ₯΄λ©΄
301
+ # /api/v1/models 응닡은 OpenRouter μͺ½μ—μ„œλ„ edge에 μΊμ‹±λ˜μ–΄ μ œκ³΅λ˜λ―€λ‘œ,
302
+ # 우리 μͺ½μ—μ„œλ„ λ§€ μš”μ²­λ§ˆλ‹€ μ‘°νšŒν•˜μ§€ μ•Šκ³  일정 μ‹œκ°„(TTL) λ™μ•ˆ μž¬μ‚¬μš©ν•©λ‹ˆλ‹€.
303
+ _vision_models_cache: dict = {"models": [], "fetched_at": 0.0}
304
+ _CACHE_TTL_SECONDS = 60 * 60 # 1μ‹œκ°„
305
+
306
+
307
+ def _fetch_vision_free_models() -> list:
308
+ """OpenRouter 곡식 API(/api/v1/models)λ₯Ό ν˜ΈμΆœν•΄, μ§€κΈˆ 이 μˆœκ°„
309
+ 이미지 μž…λ ₯을 μ§€μ›ν•˜λŠ” 무료(:free) λͺ¨λΈ λͺ©λ‘μ„ κ°€μ Έμ˜΅λ‹ˆλ‹€.
310
+ """
311
+ resp = requests.get("https://openrouter.ai/api/v1/models", timeout=10)
312
+ resp.raise_for_status()
313
+ data = resp.json().get("data", [])
314
+
315
+ result = []
316
+ for m in data:
317
+ model_id = m.get("id", "")
318
+ if not model_id.endswith(":free"):
319
+ continue
320
+ modalities = m.get("architecture", {}).get("input_modalities", [])
321
+ if "image" in modalities:
322
+ result.append(model_id)
323
+ return result
324
+
325
+
326
+ def get_vision_capable_free_models(force_refresh: bool = False) -> list:
327
+ """μΊμ‹œλœ 이미지 지원 무료 λͺ¨λΈ λͺ©λ‘μ„ λ°˜ν™˜ν•©λ‹ˆλ‹€.
328
+ μΊμ‹œκ°€ μ—†κ±°λ‚˜(졜초 호좜), TTL(1μ‹œκ°„)이 μ§€λ‚¬κ±°λ‚˜, force_refresh=True이면
329
+ OpenRouter APIλ₯Ό λ‹€μ‹œ ν˜ΈμΆœν•΄ κ°±μ‹ ν•©λ‹ˆλ‹€.
330
+ """
331
+ now = time.time()
332
+ cache_expired = (now - _vision_models_cache["fetched_at"]) > _CACHE_TTL_SECONDS
333
+
334
+ if force_refresh or cache_expired or not _vision_models_cache["models"]:
335
+ try:
336
+ fresh_models = _fetch_vision_free_models()
337
+ if fresh_models:
338
+ _vision_models_cache["models"] = fresh_models
339
+ _vision_models_cache["fetched_at"] = now
340
+ except Exception:
341
+ # 쑰회 μžμ²΄κ°€ μ‹€νŒ¨ν•˜λ©΄(λ„€νŠΈμ›Œν¬ 였λ₯˜ λ“±), κΈ°μ‘΄ μΊμ‹œκ°€ 있으면
342
+ # κ·ΈλŒ€λ‘œ μ“°κ³ , μΊμ‹œμ‘°μ°¨ μ—†μœΌλ©΄ μ΅œν›„μ˜ μ•ˆμ „λ§ λͺ©λ‘μ„ μ‚¬μš©ν•©λ‹ˆλ‹€.
343
+ pass
344
+
345
+ return _vision_models_cache["models"] or _SAFETY_NET_VISION_MODELS
346
+
347
+
348
+ # ── OpenRouter ν΄λΌμ΄μ–ΈνŠΈ μ΄ˆκΈ°ν™” ─────────────────────────────────────────
349
+ # Hugging Faceλ₯Ό μ „ν˜€ κ±°μΉ˜μ§€ μ•Šκ³ , OpenRouter 자체 μ„œλ²„(openrouter.ai)둜
350
+ # 직접 μš”μ²­μ„ λ³΄λƒ…λ‹ˆλ‹€. OpenAI SDK의 base_url만 OpenRouter μ£Όμ†Œλ‘œ
351
+ # λ°”κΎΈλŠ”, OpenRouter 곡식 λ¬Έμ„œκ°€ μ•ˆλ‚΄ν•˜λŠ” ν‘œμ€€ μ‚¬μš© λ°©μ‹μž…λ‹ˆλ‹€.
352
+ client = OpenAI(
353
+ base_url="https://openrouter.ai/api/v1",
354
+ api_key=OPENROUTER_API_KEY,
355
+ )
356
+
357
+
358
+ def _encode_image_to_data_url(path: str) -> str:
359
+ """둜컬 이미지 파일 경둜λ₯Ό OpenRouterκ°€ μš”κ΅¬ν•˜λŠ” base64 data URL
360
+ ν˜•μ‹(data:image/png;base64,...)으둜 λ³€ν™˜ν•©λ‹ˆλ‹€.
361
+ (OpenRouter 곡식 λ¬Έμ„œ: Image Inputs κ°€μ΄λ“œ κΈ°μ€€)
362
+ """
363
+ mime_type, _ = mimetypes.guess_type(path)
364
+ mime_type = mime_type or "image/png"
365
+ with open(path, "rb") as f:
366
+ encoded = base64.b64encode(f.read()).decode("utf-8")
367
+ return f"data:{mime_type};base64,{encoded}"
368
+
369
+
370
+ def _build_content(text: str | None, files: list) -> list | str:
371
+ """ν…μŠ€νŠΈμ™€ 이미지 νŒŒμΌλ“€μ„ OpenAI/OpenRouter ν˜Έν™˜ content λ°°μ—΄λ‘œ
372
+ λ³€ν™˜ν•©λ‹ˆλ‹€. 이미지가 μ—†μœΌλ©΄ ν…μŠ€νŠΈλ§Œ λ‹΄κΈ΄ λ¬Έμžμ—΄μ„ κ·ΈλŒ€λ‘œ λ°˜ν™˜ν•©λ‹ˆλ‹€.
373
+ """
374
+ if not files:
375
+ return text or ""
376
+
377
+ content = []
378
+ if text:
379
+ content.append({"type": "text", "text": text})
380
+ for path in files:
381
+ try:
382
+ content.append({
383
+ "type": "image_url",
384
+ "image_url": {"url": _encode_image_to_data_url(path)},
385
+ })
386
+ except Exception:
387
+ # 이미지가 μ•„λ‹ˆκ±°λ‚˜ 읽을 수 μ—†λŠ” νŒŒμΌμ€ 쑰용히 κ±΄λ„ˆλœλ‹ˆλ‹€.
388
+ continue
389
+ return content
390
+
391
+
392
+ # ── Gradio 버전/λ©€ν‹°λͺ¨λ‹¬ 여뢀에 따라 history ν˜•μ‹μ΄ λ‹€λ₯΄λ―€λ‘œ λ‹€μ–‘ν•œ ν˜•μ‹ 지원 ──
393
+ def _normalize_history(history: list) -> list:
394
+ """historyλŠ” Gradio 버전 및 λ©€ν‹°λͺ¨λ‹¬ 여뢀에 따라
395
+ - μ‹ ν˜•(messages) ν˜•μ‹: [{"role": "user", "content": "..."}, ...]
396
+ - κ΅¬ν˜•(tuples) ν˜•μ‹: [["user λ©”μ‹œμ§€", "bot λ©”μ‹œμ§€"], ...]
397
+ - 이미지 첨뢀 μ‹œ: contentκ°€ 파일 κ²½λ‘œκ°€ λ‹΄κΈ΄ νŠœν”Œ/리슀트인 κ²½μš°λ„ 있음
398
+ λ‹€μ–‘ν•œ ν˜•νƒœλ‘œ λ“€μ–΄μ˜¬ 수 μžˆμŠ΅λ‹ˆλ‹€. μ–΄λ–€ ν˜•μ‹μ΄ 와도 OpenAI/OpenRouter
399
+ ν˜Έν™˜ messages λ°°μ—΄λ‘œ ν†΅μΌν•΄μ„œ λ°˜ν™˜ν•©λ‹ˆλ‹€.
400
+ """
401
+ messages = []
402
+ if not history:
403
+ return messages
404
+
405
+ for turn in history:
406
+ if isinstance(turn, dict):
407
+ role = turn.get("role", "user")
408
+ content = turn.get("content", "")
409
+ elif isinstance(turn, (list, tuple)) and len(turn) == 2:
410
+ user_msg, bot_msg = turn
411
+ if user_msg:
412
+ messages.append({"role": "user", "content": user_msg})
413
+ if bot_msg:
414
+ messages.append({"role": "assistant", "content": bot_msg})
415
+ continue
416
+ else:
417
+ role = getattr(turn, "role", "user")
418
+ content = getattr(turn, "content", "")
419
+
420
+ if role == "model":
421
+ role = "assistant"
422
+
423
+ # contentκ°€ 이미지 파일 경둜(νŠœν”Œ/리슀트)인 경우, 이미지 λ©”μ‹œμ§€λ‘œ λ³€ν™˜ν•©λ‹ˆλ‹€.
424
+ if isinstance(content, (tuple, list)) and content:
425
+ path = content[0]
426
+ if isinstance(path, str) and os.path.isfile(path):
427
+ messages.append({
428
+ "role": role,
429
+ "content": _build_content(None, [path]),
430
+ })
431
+ continue
432
+
433
+ # contentκ°€ {"path": "..."} ν˜•νƒœλ‘œ λ“€μ–΄μ˜€λŠ” κ²½μš°λ„ μ²˜λ¦¬ν•©λ‹ˆλ‹€.
434
+ if isinstance(content, dict) and "path" in content:
435
+ messages.append({
436
+ "role": role,
437
+ "content": _build_content(None, [content["path"]]),
438
+ })
439
+ continue
440
+
441
+ messages.append({"role": role, "content": content})
442
+
443
+ return messages
444
+
445
+
446
+ # ── λŒ€ν™” ν…μŠ€νŠΈ 생성 ν•¨μˆ˜ (OpenRouter chat.completions 슀트리밍 방식) ────────
447
+ def _call_openrouter_stream(request_model: str, messages: list, extra_body: dict):
448
+ return client.chat.completions.create(
449
+ model=request_model,
450
+ messages=messages,
451
+ max_tokens=8192,
452
+ temperature=0.7,
453
+ top_p=0.95,
454
+ stream=True, # 응닡을 토큰(κΈ€μž) λ‹¨μœ„ 쑰각으둜 μ‹€μ‹œκ°„ μˆ˜μ‹ ν•©λ‹ˆλ‹€.
455
+ extra_headers={
456
+ # OpenRouter λ¦¬λ”λ³΄λ“œμ— 앱을 ν‘œμ‹œν•˜κ³  싢을 λ•Œ μ‚¬μš©ν•˜λŠ”
457
+ # 선택 ν•­λͺ©μž…λ‹ˆλ‹€. ν•„μˆ˜λŠ” μ•„λ‹ˆλ―€λ‘œ λΉ„μ›Œλ‘μ…”λ„ λ©λ‹ˆλ‹€.
458
+ "HTTP-Referer": "https://huggingface.co/spaces",
459
+ "X-Title": "GS AI",
460
+ },
461
+ extra_body=extra_body,
462
+ )
463
+
464
+
465
  def predict_chat(message, history: list):
466
  # multimodal=True일 λ•Œ messageλŠ” {"text": "...", "files": ["경둜", ...]}
467
  # ν˜•νƒœμ˜ λ”•μ…”λ„ˆλ¦¬λ‘œ λ“€μ–΄μ˜΅λ‹ˆλ‹€. (Gradio 곡식 λ¬Έμ„œ κΈ°μ€€)