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
+13
View File
@@ -15,6 +15,19 @@ LLM_BACKOFF_BASE=2.0
LLM_BACKOFF_MAX=60.0
LLM_TIMEOUT_S=180
# --- 호출 규격 (docs/llm-provider-plan.md). 기본은 openai(Authorization: Bearer LLM_API_KEY)
# 고객사 사내 LLM(FabriX/Gauss)은 아래처럼. 확인: python -m summarize.ping
#LLM_PROVIDER=fabrix
#LLM_BASE_URL=https://<고객사 LLM 호스트>/<경로> # 완성 주소면 LLM_CHAT_PATH= (빈값), 아니면 /chat/completions 가 붙는다
#LLM_CHAT_PATH=/chat/completions
#LLM_MODEL=339 # x-llm-model-id (모델 번호)
#LLM_BODY_MODEL=/mnt/models # body 의 model (FabriX 는 고정값)
#LLM_JSON_MODE=1 # response_format 을 서버가 거부(400)하면 0
#LLM_MAX_TOKENS=0 # 0 이면 안 보냄
#FABRIX_CLIENT_KEY= # x-generative-ai-client
#FABRIX_OPENAPI_TOKEN= # x-openapi-token (Bearer 는 코드가 붙임)
#FABRIX_USER_EMAIL= # x-generative-ai-user-email (신원 정보)
# /ingest 가 뒤이어 돌리는 요약의 백엔드: api | file | off
SUMMARIZE_BACKEND=api
+24
View File
@@ -16,6 +16,22 @@ class Settings(BaseSettings):
llm_base_url: str = ""
llm_api_key: str = ""
llm_model: str = "glm-5.2"
# 호출 규격 (docs/llm-provider-plan.md)
# openai — Authorization: Bearer <LLM_API_KEY> (OpenRouter 등)
# fabrix — 고객사 사내 LLM(FabriX/Gauss). 키 대신 커스텀 헤더 4종 (아래 fabrix_*)
llm_provider: str = "openai"
# base_url 뒤에 붙일 경로. 고객사 주소가 이미 완성형이면 빈 문자열로 둔다
llm_chat_path: str = "/chat/completions"
# 요청 body 의 model 값. 비우면 LLM_MODEL. (FabriX 는 body 는 "/mnt/models" 고정, 모델은 헤더가 고름)
llm_body_model: str = ""
# response_format={"type":"json_object"} 전송 여부. 서버가 400 을 주면 끈다 (파서가 본문에서 JSON 을 뽑는다)
llm_json_mode: bool = True
# 출력 토큰 상한. 0 이면 보내지 않음 (OpenRouter 는 잔액 견적에 쓰고, 추론 폭주 방어도 됨)
llm_max_tokens: int = 0
# FabriX 헤더 값 — x-generative-ai-client / x-openapi-token(Bearer 접두는 코드가 붙임) / x-generative-ai-user-email
fabrix_client_key: str = ""
fabrix_openapi_token: str = ""
fabrix_user_email: str = ""
# 요약 배치의 동시 LLM 호출 수. DB 쓰기는 메인 스레드에 남기고 호출만 병렬로 돈다.
llm_concurrency: int = 4
llm_timeout_s: int = 180
@@ -53,5 +69,13 @@ class Settings(BaseSettings):
wiki_search_backend: str = "pg"
qmd_url: str = "http://127.0.0.1:8181"
def llm_enabled(self) -> bool:
"""api 백엔드로 호출할 수 있는 상태인가 — 규격별 필수값이 다 있는지."""
if not self.llm_base_url:
return False
if self.llm_provider == "fabrix":
return bool(self.fabrix_client_key and self.fabrix_openapi_token)
return bool(self.llm_api_key)
settings = Settings()
+85
View File
@@ -0,0 +1,85 @@
# 고객사 사내 LLM(FabriX/Gauss) 호출 규격 맞추기 — 계획과 적용 (2026-09-21)
고객사 PC 에서는 외부 LLM(OpenRouter) 을 못 쓴다(보안). Stage 3(로직 조각 추출)을 고객사 전용 LLM 으로
돌리려면 호출 규격을 바꿔야 한다. 코드는 이미 바꿨고, **고객사 PC 에서는 `.env` 만 채우면 된다.**
## 1. 무엇이 다른가
| | 지금까지 (OpenRouter) | 고객사 LLM (serving_llm.py 예시 + 팀원 실측) |
|---|---|---|
| 인증 | `Authorization: Bearer <키>` 헤더 1개 | 커스텀 헤더 4개: `x-openapi-token`(Bearer 접두 필수) · `x-generative-ai-client` · `x-llm-model-id` · `x-generative-ai-user-email`(신원) |
| 주소 | `https://openrouter.ai/api/v1` + `/chat/completions` | 예시는 `https://genai-openapi.sec.samsung.net/dxhq/prod/api-llm` (완성 주소인지, 뒤에 `/chat/completions` 가 붙는지 **미확인**) |
| 모델 지정 | body `model: "z-ai/glm-5.2"` | 헤더 `x-llm-model-id: 581`(예시) 또는 `339`(팀원). body 의 `model` 은 팀원 실측상 `/mnt/models` 고정 |
| 응답 형식 강제 | `response_format: json_object` | 받는지 **미확인** — 400 이면 빼야 함 |
| 응답 모양 | OpenAI 규격 `choices[0].message.content` | 같다고 가정 (팀원 가이드도 같은 전제). 다르면 파서 추가 |
바뀌는 건 **헤더와 body 의 model 값**뿐이고 프롬프트·파이프라인은 그대로다.
## 2. 적용한 코드 변경
| 파일 | 내용 |
|---|---|
| `config/settings.py` | `LLM_PROVIDER`(openai/fabrix), `LLM_CHAT_PATH`, `LLM_BODY_MODEL`, `LLM_JSON_MODE`, `LLM_MAX_TOKENS`, `FABRIX_CLIENT_KEY/OPENAPI_TOKEN/USER_EMAIL`. `llm_enabled()` 가 규격별 필수값을 본다 |
| `summarize/llm_client.py` | `OpenAICompatClient``headers()`/`body()` 를 규격에 따라 만든다. fabrix 면 Bearer 접두를 코드가 붙인다 |
| `summarize/ping.py` | **접속 점검 명령.** `python -m summarize.ping` 한 번으로 주소·헤더·body 와 서버 응답을 확인 |
| `query/api.py` | "LLM 켜짐" 판정을 `settings.llm_enabled()` 로 |
| `.env.example` | fabrix 블록(주석) |
기본값은 전부 openai 라 기존 OpenRouter 동작은 그대로다.
## 3. 고객사 PC 에서 할 일 (순서대로)
### 3.1 `.env` 에 추가
```ini
LLM_PROVIDER=fabrix
LLM_BASE_URL=https://genai-openapi.sec.samsung.net/dxhq/prod/api-llm # 고객사 파일(serving_llm.py)의 ENDPOINT_URL 그대로
LLM_CHAT_PATH= # 위 주소가 완성 주소면 빈값. 404 나면 /chat/completions 로
LLM_MODEL=581 # 고객사 파일의 YOUR_MODEL. 팀원은 339(GaussO Flash) 를 씀 — 텍스트 모델이면 됨
LLM_BODY_MODEL=/mnt/models # 400 나면 LLM_MODEL 과 같은 값으로 바꿔 본다
LLM_JSON_MODE=1 # 400 나면 0
FABRIX_CLIENT_KEY=<YOUR_CLIENT_KEY 값>
FABRIX_OPENAPI_TOKEN=<YOUR_PASS_KEY 값. "Bearer " 는 있어도 없어도 됨>
FABRIX_USER_EMAIL=<YOUR_EMAIL 값>
LLM_CONCURRENCY=2
LLM_API_KEY= # 안 씀. 비워도 됨
```
### 3.2 점검 (Stage 3 돌리기 전에 반드시)
```powershell
.venv\Scripts\python -m summarize.ping --show # 보낼 내용만 출력 (비밀값 가림)
.venv\Scripts\python -m summarize.ping # 실제 한 번 호출
```
| 결과 | 뜻 | 조치 |
|---|---|---|
| `성공` + `JSON 파싱: OK` | 규격 맞음 | 3.3 으로 |
| HTTP 401 / 403 | 헤더 문제 | 토큰·클라이언트키·이메일 값 확인. 토큰이 `Bearer ` 로 시작하는지 서버 응답 본문을 본다 |
| HTTP 404 | 주소 문제 | `LLM_CHAT_PATH=/chat/completions` 로 바꾸거나, 고객사 파일의 실제 `requests.post(...)` URL 을 확인 |
| HTTP 400 | body 문제 | 먼저 `LLM_JSON_MODE=0`, 그래도면 `LLM_BODY_MODEL` 을 비우거나 `LLM_MODEL` 값으로 |
| `응답 형태가 OpenAI 규격이 아닙니다` | 응답 모양이 다름 | 출력된 JSON 을 가져오면 파서를 맞춘다 (코드 수정 필요) |
| `JSON 파싱: 실패` | 모델이 JSON 을 안 지킴 | `LLM_JSON_MODE=1` 로 켜 보고, 안 되면 모델 번호를 바꾼다 |
### 3.3 Stage 3 실행
```powershell
.venv\Scripts\python -m summarize.runner --llm api --program <프로그램> --limit 3 # 작게
.venv\Scripts\python -m summarize.jobs stats # failed 0 인지
.venv\Scripts\python -m summarize.runner --llm api --program <프로그램> # 한 본
.venv\Scripts\python -m summarize.runner --llm api # 전체
.venv\Scripts\python -m wiki_out.run --all
```
## 4. 확인이 필요한 것 (고객사 파일 원본을 보면 바로 답이 나온다)
1. `requests.post(...)` 에 넣는 **URL 이 ENDPOINT_URL 그대로인지, 뒤에 경로가 붙는지**`LLM_CHAT_PATH`
2. **body 의 `model` 값** — 예시 파일이 `"model": ...` 을 뭐로 보내는지 → `LLM_BODY_MODEL`
3. **응답 JSON 모양**`choices[0].message.content` 인지
4. **동시 호출·일일 토큰 한도** — 55본에 조각 3,212개(LLM 호출 약 5,600회)였다. 1만 본이면 호출 수십만 회라 한도를 미리 물어야 한다
5. `tools`(function calling) 는 예시에 있지만 **우리는 안 쓴다** — 무시해도 된다
## 5. 대안 (직결이 안 될 때)
팀원 가이드의 -12 컨테이너 게이트웨이(`/api/ito`) 경유: `LLM_PROVIDER=openai`, `LLM_BASE_URL=http://10.196.81.34:8914/api/ito`,
`LLM_API_KEY=<AAF_GATEWAY_KEY>`, `LLM_MODEL=339`. 게이트웨이가 헤더를 대신 얹는다. 백엔드가 떠 있어야 하고 부하가 거기 걸린다.
+3 -3
View File
@@ -142,7 +142,7 @@ def ingest(payload: dict = Body(), background_tasks: BackgroundTasks = None) ->
# SUMMARIZE_BACKEND=file 이면 키 없이 프롬프트만 큐에 쌓는다 (/summaries/jobs 로 확인).
backend = settings.summarize_backend
summarize = "disabled"
if backend != "off" and (backend == "file" or (settings.llm_base_url and settings.llm_api_key)):
if backend != "off" and (backend == "file" or settings.llm_enabled()):
if status == "loaded" or pending:
with _summarizing_lock:
already = program in _summarizing
@@ -328,7 +328,7 @@ def summaries_status() -> dict:
running = sorted(_summarizing)
return {
"model": settings.llm_model,
"llm_enabled": bool(settings.llm_base_url and settings.llm_api_key),
"llm_enabled": settings.llm_enabled(),
"running": running,
"programs": per_program,
"failures": failures,
@@ -392,7 +392,7 @@ def _persist_env(key: str, value: str) -> None:
def get_llm_settings() -> dict:
return {
"model": settings.llm_model,
"enabled": bool(settings.llm_base_url and settings.llm_api_key),
"enabled": settings.llm_enabled(),
}
+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:
+85
View File
@@ -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()
+62
View File
@@ -201,3 +201,65 @@ def test_ingest_backend_file_queues_without_key(api_client, monkeypatch):
q = c.get("/summaries/jobs").json()
assert q["pending"] >= 1, q
assert any(n["task"] == "program_summary" for n in q["next"]), q
# ------------------------------------------------------------------ 호출 규격 (LLM_PROVIDER)
def _configure(monkeypatch, **kw):
from config.settings import settings
base = {"llm_base_url": "http://x", "llm_api_key": "", "llm_provider": "openai",
"llm_chat_path": "/chat/completions", "llm_body_model": "", "llm_json_mode": True,
"llm_max_tokens": 0, "llm_model": "m", "fabrix_client_key": "", "fabrix_openapi_token": "",
"fabrix_user_email": ""}
base.update(kw)
for k, v in base.items():
monkeypatch.setattr(settings, k, v)
def test_openai_provider_headers_and_body(monkeypatch):
from summarize import llm_client
_configure(monkeypatch, llm_api_key="k", llm_max_tokens=800)
c = llm_client.OpenAICompatClient()
assert c.url == "http://x/chat/completions"
assert c.headers() == {"Content-Type": "application/json", "Authorization": "Bearer k"}
b = c.body("s", "u")
assert b["model"] == "m" and b["response_format"] == {"type": "json_object"} and b["max_tokens"] == 800
def test_fabrix_provider_headers_and_body(monkeypatch):
from summarize import llm_client
_configure(monkeypatch, llm_provider="fabrix", llm_base_url="https://h/dxhq/prod/api-llm",
llm_chat_path="", llm_model="581", llm_body_model="/mnt/models", llm_json_mode=False,
fabrix_client_key="CK", fabrix_openapi_token="TOK", fabrix_user_email="me@x.com")
c = llm_client.OpenAICompatClient()
assert c.url == "https://h/dxhq/prod/api-llm" # 완성 주소면 경로를 안 붙인다
h = c.headers()
assert h["x-openapi-token"] == "Bearer TOK" # Bearer 접두를 코드가 붙인다
assert h["x-generative-ai-client"] == "CK"
assert h["x-llm-model-id"] == "581"
assert h["x-generative-ai-user-email"] == "me@x.com"
assert "Authorization" not in h
b = c.body("s", "u")
assert b["model"] == "/mnt/models" and "response_format" not in b and "max_tokens" not in b
def test_fabrix_keeps_existing_bearer_prefix(monkeypatch):
from summarize import llm_client
_configure(monkeypatch, llm_provider="fabrix", fabrix_client_key="CK", fabrix_openapi_token="Bearer TOK")
assert llm_client.OpenAICompatClient().headers()["x-openapi-token"] == "Bearer TOK"
def test_provider_requires_its_credentials(monkeypatch):
from config.settings import settings
from summarize import llm_client
_configure(monkeypatch, llm_provider="fabrix") # 키 없음
with pytest.raises(RuntimeError):
llm_client.OpenAICompatClient()
assert settings.llm_enabled() is False
_configure(monkeypatch, llm_provider="fabrix", fabrix_client_key="a", fabrix_openapi_token="b")
assert settings.llm_enabled() is True
_configure(monkeypatch, llm_provider="openai") # api key 없음
assert settings.llm_enabled() is False
_configure(monkeypatch, llm_provider="bogus", llm_api_key="k")
with pytest.raises(RuntimeError):
llm_client.OpenAICompatClient()