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:
byeongwook.choi
2026-09-21 15:18:55 +09:00
co-authored by Claude Fable 5.1
parent 89deb108b0
commit 0f70c0d245
7 changed files with 319 additions and 11 deletions
+47 -8
View File
@@ -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: