From 0f70c0d2453e34f9884a6f60a067a3ef0e482cd5 Mon Sep 17 00:00:00 2001 From: "byeongwook.choi" Date: Mon, 21 Sep 2026 15:18:55 +0900 Subject: [PATCH] =?UTF-8?q?LLM=20=ED=98=B8=EC=B6=9C=20=EA=B7=9C=EA=B2=A9?= =?UTF-8?q?=20=EC=A0=84=ED=99=98:=20=EA=B3=A0=EA=B0=9D=EC=82=AC=20?= =?UTF-8?q?=EC=82=AC=EB=82=B4=20LLM(FabriX/Gauss)=20=ED=97=A4=EB=8D=94=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=20(LLM=5FPROVIDER=3Dfabrix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 13 ++++++ config/settings.py | 24 +++++++++++ docs/llm-provider-plan.md | 85 ++++++++++++++++++++++++++++++++++++++ query/api.py | 6 +-- summarize/llm_client.py | 55 ++++++++++++++++++++---- summarize/ping.py | 85 ++++++++++++++++++++++++++++++++++++++ tests/test_llm_backends.py | 62 +++++++++++++++++++++++++++ 7 files changed, 319 insertions(+), 11 deletions(-) create mode 100644 docs/llm-provider-plan.md create mode 100644 summarize/ping.py diff --git a/.env.example b/.env.example index 222dcfb..6e7b9fa 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/config/settings.py b/config/settings.py index e271b75..43a5578 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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 (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() diff --git a/docs/llm-provider-plan.md b/docs/llm-provider-plan.md new file mode 100644 index 0000000..f060a2a --- /dev/null +++ b/docs/llm-provider-plan.md @@ -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= +FABRIX_OPENAPI_TOKEN= +FABRIX_USER_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=`, `LLM_MODEL=339`. 게이트웨이가 헤더를 대신 얹는다. 백엔드가 떠 있어야 하고 부하가 거기 걸린다. diff --git a/query/api.py b/query/api.py index 1445fc9..f2ebd46 100644 --- a/query/api.py +++ b/query/api.py @@ -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(), } diff --git a/summarize/llm_client.py b/summarize/llm_client.py index 44fe511..9b41eca 100644 --- a/summarize/llm_client.py +++ b/summarize/llm_client.py @@ -55,10 +55,27 @@ def _extract_json(content: str) -> dict: class OpenAICompatClient(LLMClient): + """OpenAI 호환 `/chat/completions` 호출. 규격(LLM_PROVIDER)에 따라 헤더만 다르다. + + - openai : Authorization: Bearer + - 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: diff --git a/summarize/ping.py b/summarize/ping.py new file mode 100644 index 0000000..55d3b72 --- /dev/null +++ b/summarize/ping.py @@ -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() diff --git a/tests/test_llm_backends.py b/tests/test_llm_backends.py index a80f1bb..d620dab 100644 --- a/tests/test_llm_backends.py +++ b/tests/test_llm_backends.py @@ -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()