143 lines
4.6 KiB
Python
143 lines
4.6 KiB
Python
"""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
|