26-7-31-1

This commit is contained in:
shuai
2026-07-31 12:27:41 +08:00
commit 9b8b8b57b3
142 changed files with 22408 additions and 0 deletions
+142
View File
@@ -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