26-7-31-1
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user