| 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) |
|
|
| |
| |
| |
| |
| _NON_CHAT_MODEL_KEYWORDS = ["content-safety", "guard", "rerank", "embed", "moderation"] |
|
|
| |
| |
| _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", |
| ] |
|
|
| |
| |
| |
| |
| _models_cache: dict = { |
| "text": {"models": [], "fetched_at": 0.0}, |
| "vision": {"models": [], "fetched_at": 0.0}, |
| } |
| _CACHE_TTL_SECONDS = 60 * 60 |
|
|
|
|
| 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 |
|
|
|
|
| |
| |
| |
| |
| 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 |
|
|
|
|
| |
| 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" |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
|
|
| |
| 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={ |
| |
| |
| "HTTP-Referer": "https://huggingface.co/spaces", |
| "X-Title": "GS AI", |
| }, |
| extra_body=extra_body, |
| ) |
|
|
|
|
| def predict_chat(message, history: list): |
| |
| |
| 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): |
| |
| |
| |
| |
| candidate_models = get_chat_capable_free_models( |
| require_image=has_image, force_refresh=force_refresh |
| ) |
| |
| |
| 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: |
| |
| |
| 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: |
| |
| |
| 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 partial_output |
|
|
| |
| yield f"{partial_output}\n\n---\n_(μ€μ μ¬μ© λͺ¨λΈ: `{resolved_model}`)_" |
|
|
| except APIError as e: |
| |
| |
| yield f"β OpenRouter API μ€λ₯: {str(e)}" |
| except Exception as e: |
| yield f"β λ€νΈμν¬ ν΅μ μ€ μ€λ₯κ° λ°μνμ΅λλ€: {str(e)}" |
|
|
|
|
| |
| 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, |
| api_name="chat", |
| ) |
|
|
| if __name__ == "__main__": |
| demo.launch( |
| server_name="0.0.0.0", |
| server_port=7860, |
| theme=gr.themes.Soft(), |
| ) |