GS-AI / app.py
GgaammGo's picture
Update app.py
3924d4e verified
Raw
History Blame Contribute Delete
13.2 kB
import base64
import mimetypes
import os
import time
import requests
import gradio as gr
from openai import OpenAI, APIError
MODEL_ID = "openrouter/free"
OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", None)
# μ±„νŒ… λͺ©μ μ— λ§žμ§€ μ•ŠλŠ” λͺ¨λΈ(κ²€μ—΄/νŒμ • μ „μš©, μž¬λž­ν‚Ή, μž„λ² λ”© λ“±)을 κ±ΈλŸ¬λ‚΄κΈ°
# μœ„ν•œ ν‚€μ›Œλ“œμž…λ‹ˆλ‹€. 이런 λͺ¨λΈμ€ ':free'이고 ν…μŠ€νŠΈ/이미지 μž…λ ₯을 μ§€μ›ν•˜λ”λΌλ„
# μ‹€μ œ λŒ€ν™” 응닡 λŒ€μ‹  νŒμ • 라벨 λ“± μ—‰λš±ν•œ 좜λ ₯을 내놓기 λ•Œλ¬Έμ— μ œμ™Έν•©λ‹ˆλ‹€.
# (예: nvidia/nemotron-3.5-content-safety:free β€” μ‹€μ œλ‘œ κ²ͺμœΌμ‹  사둀)
_NON_CHAT_MODEL_KEYWORDS = ["content-safety", "guard", "rerank", "embed", "moderation"]
# λ„€νŠΈμ›Œν¬ 였λ₯˜ λ“±μœΌλ‘œ μ‹€μ‹œκ°„ μ‘°νšŒκ°€ μ™„μ „νžˆ μ‹€νŒ¨ν–ˆμ„ λ•Œλ§Œ μ‚¬μš©ν•˜λŠ”
# μ΅œν›„μ˜ μ•ˆμ „λ§(fallback) λͺ©λ‘μž…λ‹ˆλ‹€. 2026-07-02 κΈ°μ€€μœΌλ‘œ ν™•μΈλœ κ°’μž…λ‹ˆλ‹€.
_SAFETY_NET_TEXT_MODELS = [
"meta-llama/llama-4-scout:free",
"deepseek/deepseek-chat-v3.1:free",
]
_SAFETY_NET_VISION_MODELS = [
"google/gemma-4-31b-it:free",
"nvidia/nemotron-nano-12b-v2-vl:free",
]
# ── μ±„νŒ…μš© 무료 λͺ¨λΈ λͺ©λ‘ μΊμ‹œ (ν…μŠ€νŠΈ μ „μš© / 이미지 지원 각각) ────────────
# OpenRouter 곡식 λ¬Έμ„œ(openrouter.ai/docs/guides/overview/models)에 λ”°λ₯΄λ©΄
# /api/v1/models 응닡은 OpenRouter μͺ½μ—μ„œλ„ edge에 μΊμ‹±λ˜μ–΄ μ œκ³΅λ˜λ―€λ‘œ,
# 우리 μͺ½μ—μ„œλ„ λ§€ μš”μ²­λ§ˆλ‹€ μ‘°νšŒν•˜μ§€ μ•Šκ³  일정 μ‹œκ°„(TTL) λ™μ•ˆ μž¬μ‚¬μš©ν•©λ‹ˆλ‹€.
_models_cache: dict = {
"text": {"models": [], "fetched_at": 0.0},
"vision": {"models": [], "fetched_at": 0.0},
}
_CACHE_TTL_SECONDS = 60 * 60 # 1μ‹œκ°„
def _fetch_chat_capable_free_models(require_image: bool) -> list:
"""OpenRouter 곡식 API(/api/v1/models)λ₯Ό ν˜ΈμΆœν•΄, μ§€κΈˆ 이 μˆœκ°„
μ‹€μ œ 'λŒ€ν™” 응닡'을 μƒμ„±ν•˜λŠ” 무료(:free) λͺ¨λΈ λͺ©λ‘μ„ κ°€μ Έμ˜΅λ‹ˆλ‹€.
κ²€μ—΄/μž¬λž­ν‚Ή/μž„λ² λ”© λ“± λΉ„μ±„νŒ… λͺ©μ  λͺ¨λΈμ€ 항상 μ œμ™Έν•˜λ©°,
require_image=True이면 이미지 μž…λ ₯κΉŒμ§€ μ§€μ›ν•˜λŠ” λͺ¨λΈλ§Œ λ‚¨κΉλ‹ˆλ‹€.
"""
resp = requests.get("https://openrouter.ai/api/v1/models", timeout=10)
resp.raise_for_status()
data = resp.json().get("data", [])
result = []
for m in data:
model_id = m.get("id", "")
if not model_id.endswith(":free"):
continue
if any(kw in model_id.lower() for kw in _NON_CHAT_MODEL_KEYWORDS):
continue
modalities = m.get("architecture", {}).get("input_modalities", [])
if require_image and "image" not in modalities:
continue
result.append(model_id)
return result
def get_chat_capable_free_models(require_image: bool = False, force_refresh: bool = False) -> list:
"""μΊμ‹œλœ 'μ‹€μ œ λŒ€ν™”μš©' 무료 λͺ¨λΈ λͺ©λ‘μ„ λ°˜ν™˜ν•©λ‹ˆλ‹€.
μΊμ‹œκ°€ μ—†κ±°λ‚˜(졜초 호좜), TTL(1μ‹œκ°„)이 μ§€λ‚¬κ±°λ‚˜, force_refresh=True이면
OpenRouter APIλ₯Ό λ‹€μ‹œ ν˜ΈμΆœν•΄ κ°±μ‹ ν•©λ‹ˆλ‹€.
"""
cache_key = "vision" if require_image else "text"
cache = _models_cache[cache_key]
safety_net = _SAFETY_NET_VISION_MODELS if require_image else _SAFETY_NET_TEXT_MODELS
now = time.time()
cache_expired = (now - cache["fetched_at"]) > _CACHE_TTL_SECONDS
if force_refresh or cache_expired or not cache["models"]:
try:
fresh_models = _fetch_chat_capable_free_models(require_image)
if fresh_models:
cache["models"] = fresh_models
cache["fetched_at"] = now
except Exception:
# 쑰회 μžμ²΄κ°€ μ‹€νŒ¨ν•˜λ©΄(λ„€νŠΈμ›Œν¬ 였λ₯˜ λ“±), κΈ°μ‘΄ μΊμ‹œκ°€ 있으면
# κ·ΈλŒ€λ‘œ μ“°κ³ , μΊμ‹œμ‘°μ°¨ μ—†μœΌλ©΄ μ΅œν›„μ˜ μ•ˆμ „λ§ λͺ©λ‘μ„ μ‚¬μš©ν•©λ‹ˆλ‹€.
pass
return cache["models"] or safety_net
# ── OpenRouter ν΄λΌμ΄μ–ΈνŠΈ μ΄ˆκΈ°ν™” ─────────────────────────────────────────
# Hugging Faceλ₯Ό μ „ν˜€ κ±°μΉ˜μ§€ μ•Šκ³ , OpenRouter 자체 μ„œλ²„(openrouter.ai)둜
# 직접 μš”μ²­μ„ λ³΄λƒ…λ‹ˆλ‹€. OpenAI SDK의 base_url만 OpenRouter μ£Όμ†Œλ‘œ
# λ°”κΎΈλŠ”, OpenRouter 곡식 λ¬Έμ„œκ°€ μ•ˆλ‚΄ν•˜λŠ” ν‘œμ€€ μ‚¬μš© λ°©μ‹μž…λ‹ˆλ‹€.
client = OpenAI(
base_url="https://openrouter.ai/api/v1",
api_key=OPENROUTER_API_KEY,
)
def _encode_image_to_data_url(path: str) -> str:
"""둜컬 이미지 파일 경둜λ₯Ό OpenRouterκ°€ μš”κ΅¬ν•˜λŠ” base64 data URL
ν˜•μ‹(data:image/png;base64,...)으둜 λ³€ν™˜ν•©λ‹ˆλ‹€.
(OpenRouter 곡식 λ¬Έμ„œ: Image Inputs κ°€μ΄λ“œ κΈ°μ€€)
"""
mime_type, _ = mimetypes.guess_type(path)
mime_type = mime_type or "image/png"
with open(path, "rb") as f:
encoded = base64.b64encode(f.read()).decode("utf-8")
return f"data:{mime_type};base64,{encoded}"
def _build_content(text: str | None, files: list) -> list | str:
"""ν…μŠ€νŠΈμ™€ 이미지 νŒŒμΌλ“€μ„ OpenAI/OpenRouter ν˜Έν™˜ content λ°°μ—΄λ‘œ
λ³€ν™˜ν•©λ‹ˆλ‹€. 이미지가 μ—†μœΌλ©΄ ν…μŠ€νŠΈλ§Œ λ‹΄κΈ΄ λ¬Έμžμ—΄μ„ κ·ΈλŒ€λ‘œ λ°˜ν™˜ν•©λ‹ˆλ‹€.
"""
if not files:
return text or ""
content = []
if text:
content.append({"type": "text", "text": text})
for path in files:
try:
content.append({
"type": "image_url",
"image_url": {"url": _encode_image_to_data_url(path)},
})
except Exception:
# 이미지가 μ•„λ‹ˆκ±°λ‚˜ 읽을 수 μ—†λŠ” νŒŒμΌμ€ 쑰용히 κ±΄λ„ˆλœλ‹ˆλ‹€.
continue
return content
# ── Gradio 버전/λ©€ν‹°λͺ¨λ‹¬ 여뢀에 따라 history ν˜•μ‹μ΄ λ‹€λ₯΄λ―€λ‘œ λ‹€μ–‘ν•œ ν˜•μ‹ 지원 ──
def _normalize_history(history: list) -> list:
"""historyλŠ” Gradio 버전 및 λ©€ν‹°λͺ¨λ‹¬ 여뢀에 따라
- μ‹ ν˜•(messages) ν˜•μ‹: [{"role": "user", "content": "..."}, ...]
- κ΅¬ν˜•(tuples) ν˜•μ‹: [["user λ©”μ‹œμ§€", "bot λ©”μ‹œμ§€"], ...]
- 이미지 첨뢀 μ‹œ: contentκ°€ 파일 κ²½λ‘œκ°€ λ‹΄κΈ΄ νŠœν”Œ/리슀트인 κ²½μš°λ„ 있음
λ‹€μ–‘ν•œ ν˜•νƒœλ‘œ λ“€μ–΄μ˜¬ 수 μžˆμŠ΅λ‹ˆλ‹€. μ–΄λ–€ ν˜•μ‹μ΄ 와도 OpenAI/OpenRouter
ν˜Έν™˜ messages λ°°μ—΄λ‘œ ν†΅μΌν•΄μ„œ λ°˜ν™˜ν•©λ‹ˆλ‹€.
"""
messages = []
if not history:
return messages
for turn in history:
if isinstance(turn, dict):
role = turn.get("role", "user")
content = turn.get("content", "")
elif isinstance(turn, (list, tuple)) and len(turn) == 2:
user_msg, bot_msg = turn
if user_msg:
messages.append({"role": "user", "content": user_msg})
if bot_msg:
messages.append({"role": "assistant", "content": bot_msg})
continue
else:
role = getattr(turn, "role", "user")
content = getattr(turn, "content", "")
if role == "model":
role = "assistant"
# contentκ°€ 이미지 파일 경둜(νŠœν”Œ/리슀트)인 경우, 이미지 λ©”μ‹œμ§€λ‘œ λ³€ν™˜ν•©λ‹ˆλ‹€.
if isinstance(content, (tuple, list)) and content:
path = content[0]
if isinstance(path, str) and os.path.isfile(path):
messages.append({
"role": role,
"content": _build_content(None, [path]),
})
continue
# contentκ°€ {"path": "..."} ν˜•νƒœλ‘œ λ“€μ–΄μ˜€λŠ” κ²½μš°λ„ μ²˜λ¦¬ν•©λ‹ˆλ‹€.
if isinstance(content, dict) and "path" in content:
messages.append({
"role": role,
"content": _build_content(None, [content["path"]]),
})
continue
messages.append({"role": role, "content": content})
return messages
# ── λŒ€ν™” ν…μŠ€νŠΈ 생성 ν•¨μˆ˜ (OpenRouter chat.completions 슀트리밍 방식) ────────
def _call_openrouter_stream(request_model: str, messages: list, extra_body: dict):
return client.chat.completions.create(
model=request_model,
messages=messages,
max_tokens=8192,
temperature=0.7,
top_p=0.95,
stream=True, # 응닡을 토큰(κΈ€μž) λ‹¨μœ„ 쑰각으둜 μ‹€μ‹œκ°„ μˆ˜μ‹ ν•©λ‹ˆλ‹€.
extra_headers={
# OpenRouter λ¦¬λ”λ³΄λ“œμ— 앱을 ν‘œμ‹œν•˜κ³  싢을 λ•Œ μ‚¬μš©ν•˜λŠ”
# 선택 ν•­λͺ©μž…λ‹ˆλ‹€. ν•„μˆ˜λŠ” μ•„λ‹ˆλ―€λ‘œ λΉ„μ›Œλ‘μ…”λ„ λ©λ‹ˆλ‹€.
"HTTP-Referer": "https://huggingface.co/spaces",
"X-Title": "GS AI",
},
extra_body=extra_body,
)
def predict_chat(message, history: list):
# multimodal=True일 λ•Œ messageλŠ” {"text": "...", "files": ["경둜", ...]}
# ν˜•νƒœμ˜ λ”•μ…”λ„ˆλ¦¬λ‘œ λ“€μ–΄μ˜΅λ‹ˆλ‹€. (Gradio 곡식 λ¬Έμ„œ κΈ°μ€€)
if isinstance(message, dict):
text = message.get("text") or ""
files = message.get("files") or []
else:
text = message
files = []
messages = _normalize_history(history)
messages.append({"role": "user", "content": _build_content(text, files)})
has_image = bool(files)
def _resolve_request_target(force_refresh: bool = False):
# ν…μŠ€νŠΈ μ „μš©μ΄λ“  이미지 첨뢀든, "κ²€μ—΄/νŒμ • μ „μš© λͺ¨λΈ"이 μ‹€μ œ λŒ€ν™”
# 응닡 μžλ¦¬μ— λ½‘νžˆλŠ” 문제λ₯Ό 막기 μœ„ν•΄ 항상 ν•„ν„°λ§λœ μΊμ‹œ λͺ©λ‘μ„
# μ‚¬μš©ν•©λ‹ˆλ‹€. (openrouter/freeλ₯Ό κ·ΈλŒ€λ‘œ μ“°λ©΄, μ±„νŒ… λͺ©μ μ— λ§žμ§€ μ•ŠλŠ”
# λͺ¨λΈμ΄ λ¬΄μž‘μœ„λ‘œ 선택될 수 μžˆλ‹€λŠ” 것을 μ‹€μ œλ‘œ ν™•μΈν–ˆμŠ΅λ‹ˆλ‹€.)
candidate_models = get_chat_capable_free_models(
require_image=has_image, force_refresh=force_refresh
)
# OpenRouter의 fallback κΈ°λŠ₯은 'models' 배열에 μ΅œλŒ€ 3κ°œκΉŒμ§€λ§Œ ν—ˆμš©ν•©λ‹ˆλ‹€.
# (OpenRouter μ„œλ²„ 응닡 κΈ°μ€€: "'models' array must have 3 items or fewer.")
candidate_models = candidate_models[:3]
return candidate_models[0], {"models": candidate_models}
request_model, extra_body = _resolve_request_target()
try:
try:
stream = _call_openrouter_stream(request_model, messages, extra_body)
except APIError as e:
# "지원 λͺ¨λΈμ„ λͺ» 찾음(404)" 였λ₯˜κ°€ λ‚˜λ©΄, μΊμ‹œκ°€ λ‚‘μ•˜μ„ κ°€λŠ₯성이
# μžˆμœΌλ―€λ‘œ κ°•μ œλ‘œ ν•œ 번 더 μƒˆλ‘œ μ‘°νšŒν•΄μ„œ λ”± ν•œ 번만 μž¬μ‹œλ„ν•©λ‹ˆλ‹€.
if getattr(e, "status_code", None) == 404:
request_model, extra_body = _resolve_request_target(force_refresh=True)
stream = _call_openrouter_stream(request_model, messages, extra_body)
else:
raise
partial_output = ""
resolved_model = request_model
for chunk in stream:
# 각 쑰각(chunk)에도 μ‹€μ œ 응닡 쀑인 λͺ¨λΈλͺ…이 담겨 μžˆμ–΄,
# 슀트리밍 도쀑 계속 μ΅œμ‹ κ°’μœΌλ‘œ κ°±μ‹ ν•©λ‹ˆλ‹€.
if getattr(chunk, "model", None):
resolved_model = chunk.model
if not chunk.choices:
continue
delta = chunk.choices[0].delta
token = getattr(delta, "content", None)
if token:
partial_output += token
# yieldν•  λ•Œλ§ˆλ‹€ Gradio 화면이 μ‹€μ‹œκ°„μœΌλ‘œ κ°±μ‹ λ©λ‹ˆλ‹€.
yield partial_output
# 슀트리밍이 λλ‚œ λ’€, μ‹€μ œ μ‚¬μš© λͺ¨λΈλͺ…을 λ§ˆμ§€λ§‰μ— 덧뢙여 μ΅œμ’… κ°±μ‹ ν•©λ‹ˆλ‹€.
yield f"{partial_output}\n\n---\n_(μ‹€μ œ μ‚¬μš© λͺ¨λΈ: `{resolved_model}`)_"
except APIError as e:
# 무료 λͺ¨λΈμ˜ λΆ„λ‹Ή/일일 μš”μ²­ ν•œλ„ 초과, 이미지 미지원 λͺ¨λΈ 선택,
# λͺ¨λΈ μΌμ‹œ 쀑단 λ“± OpenRouter μͺ½ μ‚¬μœ λ‘œ 였λ₯˜κ°€ λ‚˜λŠ” κ²½μš°μž…λ‹ˆλ‹€.
yield f"❌ OpenRouter API 였λ₯˜: {str(e)}"
except Exception as e:
yield f"❌ λ„€νŠΈμ›Œν¬ 톡신 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€: {str(e)}"
# ── UI λΉŒλ“œ 및 API κ°œμ„€ ──────────────────────────────────────────────────────
with gr.Blocks() as demo:
gr.Markdown(
"# πŸ€– GS-AI API Server (OpenRouter Mode)\n"
"무료 λͺ¨λΈ 쀑 μ±„νŒ…μ— μ ν•©ν•œ λͺ¨λΈμ„ μžλ™μœΌλ‘œ 골라 μ‘λ‹΅ν•©λ‹ˆλ‹€.\n\n"
"ν…μŠ€νŠΈμ™€ ν•¨κ»˜ 이미지λ₯Ό μ²¨λΆ€ν•΄μ„œ μ§ˆλ¬Έν•˜μ‹€ 수 μžˆμŠ΅λ‹ˆλ‹€. "
"(단, κ·Έ μ‹œμ μ— 이용 κ°€λŠ₯ν•œ 무료 λͺ¨λΈμ΄ μ—†μœΌλ©΄ 였λ₯˜κ°€ λ‚  수 μžˆμŠ΅λ‹ˆλ‹€.)"
)
chatbot_ui = gr.Chatbot(height=480)
textbox_ui = gr.MultimodalTextbox(
placeholder="λ©”μ‹œμ§€λ₯Ό μž…λ ₯ν•˜κ±°λ‚˜ 이미지λ₯Ό μ²¨λΆ€ν•˜μ„Έμš”...",
file_types=["image"],
file_count="multiple",
)
gr.ChatInterface(
fn=predict_chat,
chatbot=chatbot_ui,
textbox=textbox_ui,
multimodal=True, # message μΈμžκ°€ {"text": ..., "files": [...]} λ”•μ…”λ„ˆλ¦¬λ‘œ μ „λ‹¬λ©λ‹ˆλ‹€.
api_name="chat", # μ™ΈλΆ€ 연동을 μœ„ν•œ /chat 경둜 λͺ…μ‹œ ν™œμ„±ν™”
)
if __name__ == "__main__":
demo.launch(
server_name="0.0.0.0",
server_port=7860,
theme=gr.themes.Soft(),
)