"""OpenAI 兼容 LLM 客户端(DeepSeek / Qwen)。支持 .env + 管理端运行时覆盖。""" from __future__ import annotations import json import logging import re from typing import Any import httpx from app.config import Settings, get_settings from app.services.llm_runtime import get_runtime_llm logger = logging.getLogger(__name__) class LlmClient: def __init__(self, settings: Settings | None = None): self.settings = settings or get_settings() def _effective_key(self) -> str: rt = get_runtime_llm() if rt.api_key is not None: return rt.api_key.strip() return (self.settings.llm_api_key or "").strip() def _effective_base_url(self) -> str: rt = get_runtime_llm() if rt.api_base_url: return rt.api_base_url.rstrip("/") return (self.settings.llm_base_url or "https://api.deepseek.com").rstrip("/") def _effective_model(self) -> str: rt = get_runtime_llm() if rt.model: return rt.model return self.settings.llm_model or "deepseek-chat" def _effective_temperature(self, override: float | None = None) -> float: if override is not None: return override rt = get_runtime_llm() if rt.temperature is not None: return float(rt.temperature) return float(self.settings.llm_temperature) @property def enabled(self) -> bool: """管理端 enabled=false 强制关闭;否则有可用 API Key 即启用。""" key = self._effective_key() if not key: return False rt = get_runtime_llm() if rt.enabled is False: return False if rt.enabled is True: return True # 未下发 enabled 时:有 key(env 或 runtime)即视为可用 return True def info(self) -> dict[str, Any]: rt = get_runtime_llm() return { "enabled": self.enabled, "api_key_configured": bool(self._effective_key()), "api_base_url": self._effective_base_url(), "model": self._effective_model(), "temperature": self._effective_temperature(), "source": rt.source if (rt.api_key or rt.enabled is not None) else "env", } def chat(self, messages: list[dict[str, str]], temperature: float | None = None) -> str: if not self.enabled: raise RuntimeError("未配置 LLM(请在管理端「AI 配置」启用并填写 API Key,或设置 ai-service/.env 的 LLM_API_KEY)") base = self._effective_base_url() if base.endswith("/v1"): url = base + "/chat/completions" else: url = base + "/v1/chat/completions" payload = { "model": self._effective_model(), "temperature": self._effective_temperature(temperature), "messages": messages, } headers = { "Authorization": f"Bearer {self._effective_key()}", "Content-Type": "application/json", } with httpx.Client(timeout=90.0) as client: resp = client.post(url, headers=headers, json=payload) if resp.status_code >= 400: raise RuntimeError(f"LLM HTTP {resp.status_code}: {resp.text[:300]}") data = resp.json() content = ( data.get("choices", [{}])[0] .get("message", {}) .get("content", "") ) if not content: raise RuntimeError("LLM 返回空内容") return content.strip() def chat_json(self, messages: list[dict[str, str]]) -> dict[str, Any]: text = self.chat(messages, temperature=0.2) return extract_json(text) def extract_json(text: str) -> dict[str, Any]: text = text.strip() try: return json.loads(text) except json.JSONDecodeError: pass fence = re.search(r"```(?:json)?\s*([\s\S]*?)```", text) if fence: try: return json.loads(fence.group(1).strip()) except json.JSONDecodeError: pass start, end = text.find("{"), text.rfind("}") if start >= 0 and end > start: try: return json.loads(text[start : end + 1]) except json.JSONDecodeError: pass raise ValueError("无法从模型输出解析 JSON") _llm: LlmClient | None = None def get_llm() -> LlmClient: global _llm if _llm is None: _llm = LlmClient() return _llm def reset_llm_client() -> None: """测试或热更新后可重置单例(配置本身已从 runtime 动态读取,一般无需调用)。""" global _llm _llm = None