Files
2026-07-31 12:27:41 +08:00

143 lines
4.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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