feat(gateway): 이미지 붙은 요청만 vision 모델(Gemma4)로 — 텍스트는 기본 339

Gemma4 가 '안녕' 한 단어에 94s(580 은 188s) 라 텍스트 기본으로는 못 씀.
AAF_FABRIX_VISION_MODEL_ID 가 있으면 messages 에 image_url 파트가 있을 때만 그 모델로 x-llm-model-id 를 바꿈.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
lee-hyeon-cheol
2026-09-18 10:34:55 +09:00
co-authored by Claude Fable 5.1
parent 2b8c2d7742
commit 7ad48732ae
5 changed files with 51 additions and 3 deletions
+4 -2
View File
@@ -19,11 +19,13 @@ CORS_ORIGINS=http://localhost:15173,http://tauri.localhost,tauri://localhost
# ── 사내 LLM(FabriX) — 고객사 전용. 개발(OpenRouter)에선 전부 비워둠 ──
# OpenCode 는 opencode/opencode.json 을 FabriX 용으로 렌더해서(opencode/render_opencode.py) 이 서버의 /api/ito 로 붙음.
AAF_FABRIX_BASE_URL=
AAF_FABRIX_MODEL_ID=605
AAF_FABRIX_MODELS=605:Gemma4,339:GaussO Flash,581:GaussO Think
AAF_FABRIX_MODEL_ID=339
AAF_FABRIX_MODELS=339:GaussO Flash,581:GaussO Think,605:Gemma4
AAF_FABRIX_CLIENT_KEY=
AAF_FABRIX_OPENAPI_TOKEN=
AAF_FABRIX_USER_EMAIL=
# 이미지 붙은 요청만 이 모델로. Gemma4 가 텍스트엔 너무 느려서(안녕 94s vs 339 0.7s) 기본은 339
AAF_FABRIX_VISION_MODEL_ID=605
# 게이트웨이마다 다를 수 있는 것 — 보통 비워둠(401 나면 자동으로 다른 형식도 시도함)
AAF_FABRIX_CLIENT_HEADER=
AAF_FABRIX_TOKEN_PREFIX=bearer
+18 -1
View File
@@ -30,9 +30,21 @@ ENV_KEYS = (
"AAF_FABRIX_MAX_TOKENS", # 있으면 max_completion_tokens 로 실음
"AAF_RELAY_STREAM_USAGE", # "1" 이면 stream_options.include_usage — 제공자가 거부하면 끔
"AAF_GATEWAY_KEY", # OpenCode 가 Authorization: Bearer 로 보내는 키. 비우면 검사 안 함
"AAF_FABRIX_VISION_MODEL_ID", # 이미지가 붙은 요청만 이 모델로(Gemma4). 비우면 분기 안 함
)
def has_image(payload: dict) -> bool:
"""OpenAI 형식 messages 안에 image_url 파트가 하나라도 있으면 True."""
for m in payload.get("messages") or []:
content = m.get("content") if isinstance(m, dict) else None
if isinstance(content, list) and any(
isinstance(p, dict) and p.get("type") == "image_url" for p in content
):
return True
return False
def parse_models(spec: str) -> dict[str, str]:
""""339:GaussO Flash,581:GaussO Think"{"339": "GaussO Flash", "581": "GaussO Think"}."""
out: dict[str, str] = {}
@@ -67,6 +79,7 @@ class FabrixConfig:
max_tokens: int | None = None
stream_usage: bool = False
gateway_key: str = ""
vision_model_id: str = "" # 이미지 있을 때만 쓰는 모델. Gemma 가 느려서 텍스트는 기본 모델로
connect_timeout_s: float = 10.0
read_timeout_s: float = 120.0 # 스트림 조각 사이 무수신 한계
total_timeout_s: float = 600.0
@@ -88,6 +101,7 @@ class FabrixConfig:
max_tokens=int(max_tokens) if max_tokens.isdigit() else None,
stream_usage=g("AAF_RELAY_STREAM_USAGE") == "1",
gateway_key=g("AAF_GATEWAY_KEY"),
vision_model_id=g("AAF_FABRIX_VISION_MODEL_ID"),
)
def missing(self) -> list[str]:
@@ -125,7 +139,10 @@ class FabrixConfig:
def prepare(self, payload: dict) -> tuple[dict, dict[str, str]]:
"""OpenCode 가 보낸 OpenAI 요청 → FabriX 로 보낼 (body, headers)."""
body = dict(payload)
headers = self.headers(self.pick_model_id(str(payload.get("model") or "")))
model_id = self.pick_model_id(str(payload.get("model") or ""))
if self.vision_model_id and has_image(payload):
model_id = self.vision_model_id # 이미지 있으면 무조건 vision 모델(느려도 이미지는 얘만 읽음)
headers = self.headers(model_id)
body["model"] = self.body_model
if body.get("stream") and self.stream_usage:
so = dict(body.get("stream_options") or {})
+13
View File
@@ -38,6 +38,19 @@ def test_prepare_picks_model_by_header_not_body():
assert headers["x-llm-model-id"] == "339"
def test_vision_model_only_when_image_attached():
cfg = FabrixConfig.from_env({**FULL_ENV, "AAF_FABRIX_VISION_MODEL_ID": "605"})
text = {"model": "339", "messages": [{"role": "user", "content": "hi"}]}
img = {"model": "339", "messages": [{"role": "user", "content": [
{"type": "text", "text": "what is this"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
]}]}
assert cfg.prepare(text)[1]["x-llm-model-id"] == "339"
assert cfg.prepare(img)[1]["x-llm-model-id"] == "605"
# 설정 없으면 분기 안 함
assert FabrixConfig.from_env(FULL_ENV).prepare(img)[1]["x-llm-model-id"] == "339"
def test_missing_config_names():
assert FabrixConfig.from_env({}).missing() == [
"AAF_FABRIX_BASE_URL", "AAF_FABRIX_MODEL_ID", "AAF_FABRIX_CLIENT_KEY", "AAF_FABRIX_OPENAPI_TOKEN",