76 lines
3.9 KiB
Python
76 lines
3.9 KiB
Python
"""通过配置接入 OpenAI 兼容的大模型接口,不记录密钥或聊天正文。"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import math
|
||
from pathlib import Path
|
||
from urllib.error import HTTPError, URLError
|
||
from urllib.parse import urlsplit
|
||
from urllib.request import Request, urlopen
|
||
|
||
|
||
class LLMError(RuntimeError):
|
||
pass
|
||
|
||
|
||
class LLMClient:
|
||
def __init__(self, options):
|
||
if not isinstance(options, dict):
|
||
raise ValueError("大模型配置必须是 JSON 对象")
|
||
base_url = options.get("base_url", "https://api.deepseek.com")
|
||
if not isinstance(base_url, str):
|
||
raise ValueError("大模型 base_url 必须是 HTTPS 地址字符串")
|
||
self.base_url = base_url.rstrip("/")
|
||
parsed = urlsplit(self.base_url)
|
||
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||
raise ValueError("大模型 base_url 应为不含认证信息的 HTTPS 地址")
|
||
self.model = options.get("model", "deepseek-flash")
|
||
self._api_key = options.get("api_key", "")
|
||
if not isinstance(self.model, str) or not self.model.strip():
|
||
raise ValueError("大模型 model 不能为空")
|
||
if not isinstance(self._api_key, str) or not self._api_key.strip():
|
||
raise ValueError("请在大模型配置文件中填写 api_key")
|
||
self.timeout = options.get("timeout_seconds", 20)
|
||
self.max_tokens = options.get("max_tokens", 512)
|
||
if type(self.timeout) not in (int, float) or not math.isfinite(self.timeout) or not 1 <= self.timeout <= 60:
|
||
raise ValueError("timeout_seconds 应为 1–60 秒")
|
||
if type(self.max_tokens) is not int or not 128 <= self.max_tokens <= 4096:
|
||
raise ValueError("max_tokens 应为 128–4096 的整数")
|
||
|
||
@classmethod
|
||
def from_file(cls, path):
|
||
return cls(json.loads(Path(path).read_text(encoding="utf-8-sig")))
|
||
|
||
def complete_json(self, system, user):
|
||
payload = {"model": self.model, "messages": [{"role": "system", "content": system},
|
||
{"role": "user", "content": user}],
|
||
"response_format": {"type": "json_object"}, "max_tokens": self.max_tokens,
|
||
"thinking": {"type": "disabled"}, "stream": False}
|
||
request = Request(self.base_url + "/chat/completions",
|
||
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||
headers={"Authorization": "Bearer " + self._api_key,
|
||
"Content-Type": "application/json"}, method="POST")
|
||
try:
|
||
with urlopen(request, timeout=self.timeout) as response:
|
||
raw = response.read(1024 * 1024 + 1)
|
||
if len(raw) > 1024 * 1024:
|
||
raise LLMError("大模型响应过大")
|
||
result = json.loads(raw)
|
||
choice = result["choices"][0]
|
||
if choice.get("finish_reason") == "length":
|
||
raise LLMError("大模型响应被截断,请增加 max_tokens")
|
||
content = choice["message"]["content"]
|
||
if not isinstance(content, str) or not content.strip():
|
||
raise LLMError("大模型返回了空结果")
|
||
data = json.loads(content)
|
||
if not isinstance(data, dict):
|
||
raise LLMError("大模型结果应为 JSON 对象")
|
||
return data
|
||
except HTTPError as error:
|
||
# 不输出响应正文或请求头,避免服务器错误回显密钥。
|
||
raise LLMError(f"大模型接口 HTTP {error.code};请核对密钥、余额和模型配置") from None
|
||
except (URLError, TimeoutError, OSError):
|
||
raise LLMError("大模型连接失败或超时,请稍后再试") from None
|
||
except (ValueError, KeyError, IndexError, TypeError, AttributeError):
|
||
raise LLMError("大模型响应格式不正确,请重试") from None
|