File size: 13,196 Bytes
95fa14a
 
f4af6c1
f5cfa88
 
4e2a4d9
fafebf2
4e2a4d9
ca96a0f
 
8dcf0c5
3924d4e
 
 
 
 
 
8dcf0c5
 
3924d4e
 
 
 
8dcf0c5
 
 
 
 
3924d4e
8dcf0c5
 
 
3924d4e
 
 
 
8dcf0c5
 
 
3924d4e
8dcf0c5
3924d4e
 
 
8dcf0c5
 
 
 
 
 
 
 
 
 
3924d4e
 
8dcf0c5
3924d4e
 
 
8dcf0c5
 
 
3924d4e
 
8dcf0c5
 
 
3924d4e
 
 
 
8dcf0c5
3924d4e
8dcf0c5
3924d4e
8dcf0c5
3924d4e
8dcf0c5
3924d4e
 
8dcf0c5
 
 
 
 
3924d4e
8dcf0c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96698e9
8dcf0c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3924d4e
 
 
 
 
 
 
8dcf0c5
 
3924d4e
 
8dcf0c5
 
 
39df5f0
f5cfa88
 
 
3924d4e
 
 
f5cfa88
 
 
 
ca96a0f
e10a386
f5cfa88
e10a386
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d266939
fafebf2
95fa14a
 
e10a386
39df5f0
e10a386
be0c985
d266939
cf261e1
0f74716
95fa14a
3924d4e
 
95fa14a
3924d4e
95fa14a
d266939
d8e81ba
95fa14a
 
 
 
 
d266939
39df5f0
 
 
 
95fa14a
d266939
f4af6c1
4e2a4d9
 
0f74716
d266939
0f74716
d266939
fafebf2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
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(),
    )