# backend/app/llm/openai_compat.py # OpenAI 호환 Chat Completions 엔드포인트용 프로바이더. # OpenAI 본가뿐 아니라 OpenRouter·Groq·Together·vLLM·LM Studio·Gemini(OpenAI 호환 베이스) 등 # `/v1/chat/completions` + `/v1/models` 규약을 따르는 모든 엔드포인트를 host(base_url)로 받는다. import httpx from ..runtime_config import effective_llm from .ollama import _coerce, _lenient_json from .prompts import CLASSIFY_SYSTEM, build_classify_prompt from .provider import Classification, LLMProvider class OpenAICompatProvider(LLMProvider): name = "openai" def __init__( self, host: str | None = None, model: str | None = None, api_key: str | None = None, timeout: float | None = None, ): eff = effective_llm() # host = base_url(예: https://api.openai.com/v1). 끝 슬래시 정규화. self.base = (host or eff.host).rstrip("/") self.model = model or eff.model self.api_key = api_key if api_key is not None else eff.api_key self.timeout = timeout if timeout is not None else eff.timeout def _headers(self) -> dict: h = {"Content-Type": "application/json"} if self.api_key: h["Authorization"] = f"Bearer {self.api_key}" return h def _chat(self, messages: list[dict]) -> str: body = { "model": self.model, "messages": messages, "temperature": 0.2, "response_format": {"type": "json_object"}, } try: r = httpx.post( f"{self.base}/chat/completions", json=body, headers=self._headers(), timeout=self.timeout, ) r.raise_for_status() except httpx.HTTPStatusError as e: # 일부 호환 서버는 response_format 을 모름(400) → 빼고 1회 재시도. if e.response is not None and e.response.status_code == 400: body.pop("response_format", None) r = httpx.post( f"{self.base}/chat/completions", json=body, headers=self._headers(), timeout=self.timeout, ) r.raise_for_status() else: raise return r.json()["choices"][0]["message"]["content"] def list_models(self) -> list[str]: try: r = httpx.get(f"{self.base}/models", headers=self._headers(), timeout=5.0) if r.status_code != 200: return [] data = r.json().get("data", []) return [m.get("id", "") for m in data if m.get("id")] except Exception: return [] def health(self) -> dict: try: r = httpx.get(f"{self.base}/models", headers=self._headers(), timeout=5.0) ok = r.status_code == 200 return { "reachable": ok, "provider": self.name, "model": self.model, "host": self.base, "detail": "ok" if ok else f"HTTP {r.status_code}", } except Exception as e: return { "reachable": False, "provider": self.name, "model": self.model, "host": self.base, "detail": str(e), } def generate_json(self, prompt: str, schema: dict | None = None) -> dict: content = self._chat([{"role": "user", "content": prompt}]) return _lenient_json(content) def classify_capture(self, raw: str, context: dict) -> Classification: prompt = build_classify_prompt(raw, context) content = self._chat( [ {"role": "system", "content": CLASSIFY_SYSTEM}, {"role": "user", "content": prompt}, ] ) return _coerce(_lenient_json(content), raw, context, model=f"openai:{self.model}")