LLM 호출 규격 전환: 고객사 사내 LLM(FabriX/Gauss) 헤더 지원 (LLM_PROVIDER=fabrix)
- settings: LLM_PROVIDER/LLM_CHAT_PATH/LLM_BODY_MODEL/LLM_JSON_MODE/LLM_MAX_TOKENS, FABRIX_* 3종, llm_enabled() - llm_client: headers()/body() 를 규격별로 구성. fabrix 는 x-openapi-token(Bearer)/x-generative-ai-client/ x-llm-model-id/x-generative-ai-user-email, body model 은 LLM_BODY_MODEL - summarize/ping: 접속 점검 명령 (--show 로 요청만 확인) - docs/llm-provider-plan.md: 계획·.env 값·오류별 조치. 기본값은 openai 라 기존 동작 불변 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
89deb108b0
commit
0f70c0d245
+47
-8
@@ -55,10 +55,27 @@ def _extract_json(content: str) -> dict:
|
||||
|
||||
|
||||
class OpenAICompatClient(LLMClient):
|
||||
"""OpenAI 호환 `/chat/completions` 호출. 규격(LLM_PROVIDER)에 따라 헤더만 다르다.
|
||||
|
||||
- openai : Authorization: Bearer <LLM_API_KEY>
|
||||
- fabrix : 고객사 사내 LLM. x-openapi-token(Bearer ...) / x-generative-ai-client /
|
||||
x-llm-model-id / x-generative-ai-user-email. body 의 model 은 LLM_BODY_MODEL.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
if not settings.llm_base_url or not settings.llm_api_key:
|
||||
raise RuntimeError("LLM_BASE_URL / LLM_API_KEY 환경변수가 필요합니다")
|
||||
if not settings.llm_base_url:
|
||||
raise RuntimeError("LLM_BASE_URL 환경변수가 필요합니다")
|
||||
self.provider = settings.llm_provider
|
||||
if self.provider == "fabrix":
|
||||
if not (settings.fabrix_client_key and settings.fabrix_openapi_token):
|
||||
raise RuntimeError("LLM_PROVIDER=fabrix 는 FABRIX_CLIENT_KEY / FABRIX_OPENAPI_TOKEN 이 필요합니다")
|
||||
elif self.provider == "openai":
|
||||
if not settings.llm_api_key:
|
||||
raise RuntimeError("LLM_BASE_URL / LLM_API_KEY 환경변수가 필요합니다")
|
||||
else:
|
||||
raise RuntimeError(f"알 수 없는 LLM_PROVIDER: {self.provider} (openai | fabrix)")
|
||||
self.base = settings.llm_base_url.rstrip("/")
|
||||
self.url = self.base + settings.llm_chat_path
|
||||
self.key = settings.llm_api_key
|
||||
self.model = settings.llm_model
|
||||
# 호출 누적 사용량 — runner가 stats에 실어 보고한다
|
||||
@@ -66,22 +83,44 @@ class OpenAICompatClient(LLMClient):
|
||||
"retries": 0}
|
||||
self._lock = threading.Lock() # 병렬 호출에서 usage 집계가 어긋나지 않게
|
||||
|
||||
def _post_once(self, system: str, user: str) -> dict:
|
||||
def headers(self) -> dict:
|
||||
h = {"Content-Type": "application/json"}
|
||||
if self.provider == "fabrix":
|
||||
token = settings.fabrix_openapi_token.strip()
|
||||
if not token.lower().startswith("bearer "):
|
||||
token = "Bearer " + token # 날것으로 보내면 401 (팀원 실측)
|
||||
h.update({
|
||||
"x-openapi-token": token,
|
||||
"x-generative-ai-client": settings.fabrix_client_key,
|
||||
"x-llm-model-id": self.model,
|
||||
"x-generative-ai-user-email": settings.fabrix_user_email,
|
||||
})
|
||||
else:
|
||||
h["Authorization"] = f"Bearer {self.key}"
|
||||
return h
|
||||
|
||||
def body(self, system: str, user: str) -> dict:
|
||||
body = {
|
||||
"model": self.model,
|
||||
"model": settings.llm_body_model or self.model,
|
||||
"temperature": 0.1,
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
"response_format": {"type": "json_object"},
|
||||
}
|
||||
if settings.llm_json_mode:
|
||||
body["response_format"] = {"type": "json_object"}
|
||||
if settings.llm_max_tokens > 0:
|
||||
body["max_tokens"] = settings.llm_max_tokens
|
||||
if "openrouter" in self.base:
|
||||
body["usage"] = {"include": True} # OpenRouter 확장 — usage.cost(USD 크레딧) 포함
|
||||
return body
|
||||
|
||||
def _post_once(self, system: str, user: str) -> dict:
|
||||
req = urllib.request.Request(
|
||||
f"{self.base}/chat/completions",
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
headers={"Authorization": f"Bearer {self.key}", "Content-Type": "application/json"},
|
||||
self.url,
|
||||
data=json.dumps(self.body(system, user), ensure_ascii=False).encode("utf-8"),
|
||||
headers=self.headers(),
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=settings.llm_timeout_s) as res:
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""LLM 접속 점검 — Stage 3 를 돌리기 전에 규격(주소·헤더·body)이 맞는지 한 번 호출해 본다.
|
||||
|
||||
python -m summarize.ping # .env 대로 한 번 호출
|
||||
python -m summarize.ping --show # 보낼 주소·헤더(비밀값은 가림)·body 만 출력, 호출 안 함
|
||||
|
||||
성공하면 모델 답(JSON)과 usage 를, 실패하면 HTTP 상태와 서버 응답 본문을 그대로 찍는다.
|
||||
400 이면 대개 body 규격(response_format / model) 문제 → LLM_JSON_MODE=0, LLM_BODY_MODEL 조정.
|
||||
401/403 이면 헤더(토큰·클라이언트키·이메일) 문제. 404 면 주소(LLM_BASE_URL + LLM_CHAT_PATH).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
|
||||
from config.settings import settings
|
||||
from summarize.llm_client import OpenAICompatClient
|
||||
|
||||
SYSTEM = "너는 JSON 만 출력하는 도우미다."
|
||||
USER = '다음 스키마로만 답해라: {"ok": true, "model_says": "<짧은 인사 한 줄>"}'
|
||||
|
||||
_SECRET_HEADERS = {"authorization", "x-openapi-token", "x-generative-ai-client"}
|
||||
|
||||
|
||||
def _mask(v: str) -> str:
|
||||
if len(v) <= 12:
|
||||
return "*" * len(v)
|
||||
return v[:8] + "…" + v[-4:]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="LLM 접속 점검")
|
||||
ap.add_argument("--show", action="store_true", help="요청만 출력하고 호출하지 않음")
|
||||
args = ap.parse_args()
|
||||
|
||||
try:
|
||||
client = OpenAICompatClient()
|
||||
except RuntimeError as e:
|
||||
print(f"설정 오류: {e}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
headers = {k: (_mask(v) if k.lower() in _SECRET_HEADERS else v) for k, v in client.headers().items()}
|
||||
print(f"규격 : {client.provider}")
|
||||
print(f"주소 : {client.url}")
|
||||
print(f"헤더 : {json.dumps(headers, ensure_ascii=False)}")
|
||||
body = client.body(SYSTEM, USER)
|
||||
print(f"body : {json.dumps({k: v for k, v in body.items() if k != 'messages'}, ensure_ascii=False)}")
|
||||
if args.show:
|
||||
return
|
||||
|
||||
t0 = time.time()
|
||||
try:
|
||||
raw = client._post_once(SYSTEM, USER)
|
||||
except urllib.error.HTTPError as e:
|
||||
text = e.read().decode("utf-8", errors="replace")
|
||||
print(f"\n실패 HTTP {e.code} ({int((time.time() - t0) * 1000)} ms)")
|
||||
print(f"서버 응답: {text[:2000]}")
|
||||
sys.exit(1)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"\n실패 {type(e).__name__}: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
ms = int((time.time() - t0) * 1000)
|
||||
print(f"\n성공 ({ms} ms)")
|
||||
try:
|
||||
content = raw["choices"][0]["message"]["content"]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
print("응답 형태가 OpenAI 규격(choices[0].message.content)이 아닙니다:")
|
||||
print(json.dumps(raw, ensure_ascii=False)[:2000])
|
||||
sys.exit(1)
|
||||
print(f"모델 답 : {content[:500]}")
|
||||
print(f"usage : {json.dumps(raw.get('usage') or {}, ensure_ascii=False)}")
|
||||
from summarize.llm_client import _extract_json
|
||||
try:
|
||||
_extract_json(content)
|
||||
print("JSON 파싱: OK")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"JSON 파싱: 실패 ({e}) — LLM_JSON_MODE 와 프롬프트 확인")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user