2026-07-21 11:05:57 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
# 模型 Provider(moduleId: core-providers, 可重生 ✅)
|
2026-07-28 02:12:46 +08:00
|
|
|
|
# 屏蔽 DeepSeek / KIMI / 豆包 / 任意 OpenAI 兼容端点差异
|
|
|
|
|
|
# 铁律:LLM 只有提议权(§3.2)——本模块只产出文本/JSON,绝不执行任何动作
|
2026-07-21 11:05:57 +08:00
|
|
|
|
# ============================================================
|
2026-07-28 02:12:46 +08:00
|
|
|
|
from __future__ import annotations
|
2026-07-21 11:05:57 +08:00
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
import json
|
|
|
|
|
|
import os
|
|
|
|
|
|
from typing import Any
|
2026-07-21 11:05:57 +08:00
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
import httpx
|
2026-07-21 11:05:57 +08:00
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
# 已知提供方默认端点与模型(也可被 LLM_BASE_URL / LLM_MODEL 覆盖)
|
2026-07-21 11:05:57 +08:00
|
|
|
|
_DEFAULTS = {
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"deepseek": {"base_url": "https://api.deepseek.com/v1", "model": "deepseek-chat"},
|
|
|
|
|
|
"kimi": {"base_url": "https://api.moonshot.cn/v1", "model": "moonshot-v1-8k"},
|
|
|
|
|
|
"moonshot": {"base_url": "https://api.moonshot.cn/v1", "model": "moonshot-v1-8k"},
|
|
|
|
|
|
"doubao": {"base_url": "https://ark.cn-beijing.volces.com/api/v3", "model": ""},
|
|
|
|
|
|
"openai": {"base_url": "https://api.openai.com/v1", "model": "gpt-4o-mini"},
|
|
|
|
|
|
"compatible": {"base_url": "", "model": ""}, # 完全自定义:必须配 BASE_URL + MODEL
|
2026-07-21 11:05:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ModelProvider:
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"""OpenAI 兼容对话接口:chat_json / chat_text;失败返回 None 由上游降级。"""
|
2026-07-21 11:05:57 +08:00
|
|
|
|
|
|
|
|
|
|
def __init__(self) -> None:
|
2026-07-28 02:12:46 +08:00
|
|
|
|
self.provider = os.environ.get("LLM_PROVIDER", "").strip().lower()
|
|
|
|
|
|
self.api_key = os.environ.get("LLM_API_KEY", "").strip()
|
|
|
|
|
|
defaults = _DEFAULTS.get(self.provider, {})
|
|
|
|
|
|
self.base_url = os.environ.get("LLM_BASE_URL", "").strip() or defaults.get("base_url", "")
|
|
|
|
|
|
self.model = os.environ.get("LLM_MODEL", "").strip() or defaults.get("model", "")
|
|
|
|
|
|
# 任意 OpenAI 兼容:有密钥 + 端点 + 模型即可(不限于白名单名)
|
|
|
|
|
|
known = self.provider in _DEFAULTS or bool(self.base_url)
|
|
|
|
|
|
self.enabled = bool(known and self.api_key and self.base_url and self.model)
|
2026-07-21 11:05:57 +08:00
|
|
|
|
|
|
|
|
|
|
async def chat_json(self, system: str, user: str, timeout: float = 20.0) -> dict[str, Any] | None:
|
2026-07-28 02:12:46 +08:00
|
|
|
|
if not self.enabled:
|
2026-07-21 11:05:57 +08:00
|
|
|
|
return None
|
|
|
|
|
|
try:
|
2026-07-28 02:12:46 +08:00
|
|
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
|
|
|
|
resp = await client.post(
|
2026-07-21 11:05:57 +08:00
|
|
|
|
f"{self.base_url.rstrip('/')}/chat/completions",
|
2026-07-28 02:12:46 +08:00
|
|
|
|
headers={"Authorization": f"Bearer {self.api_key}"},
|
2026-07-21 11:05:57 +08:00
|
|
|
|
json={
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"model": self.model,
|
|
|
|
|
|
"messages": [
|
2026-07-21 11:05:57 +08:00
|
|
|
|
{"role": "system", "content": system},
|
|
|
|
|
|
{"role": "user", "content": user},
|
|
|
|
|
|
],
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"temperature": 0.1,
|
|
|
|
|
|
"response_format": {"type": "json_object"},
|
2026-07-21 11:05:57 +08:00
|
|
|
|
},
|
|
|
|
|
|
)
|
2026-07-28 02:12:46 +08:00
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
content = resp.json()["choices"][0]["message"]["content"]
|
|
|
|
|
|
return json.loads(content)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
# 部分国产端点不支持 response_format → 再试无强制 JSON
|
|
|
|
|
|
try:
|
|
|
|
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
|
|
|
|
resp = await client.post(
|
|
|
|
|
|
f"{self.base_url.rstrip('/')}/chat/completions",
|
|
|
|
|
|
headers={"Authorization": f"Bearer {self.api_key}"},
|
|
|
|
|
|
json={
|
|
|
|
|
|
"model": self.model,
|
|
|
|
|
|
"messages": [
|
|
|
|
|
|
{"role": "system", "content": system + "\n只输出合法 JSON 对象。"},
|
|
|
|
|
|
{"role": "user", "content": user},
|
|
|
|
|
|
],
|
|
|
|
|
|
"temperature": 0.1,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
content = resp.json()["choices"][0]["message"]["content"]
|
|
|
|
|
|
# 容错:从 markdown 代码块里抠 JSON
|
|
|
|
|
|
text = content.strip()
|
|
|
|
|
|
if "```" in text:
|
|
|
|
|
|
text = re_strip_fence(text)
|
|
|
|
|
|
return json.loads(text)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return None
|
2026-07-21 11:05:57 +08:00
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
async def chat_text(self, system: str, user: str, timeout: float = 45.0) -> str | None:
|
|
|
|
|
|
if not self.enabled:
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
|
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
|
|
|
|
resp = await client.post(
|
|
|
|
|
|
f"{self.base_url.rstrip('/')}/chat/completions",
|
|
|
|
|
|
headers={"Authorization": f"Bearer {self.api_key}"},
|
|
|
|
|
|
json={
|
|
|
|
|
|
"model": self.model,
|
|
|
|
|
|
"messages": [
|
|
|
|
|
|
{"role": "system", "content": system},
|
|
|
|
|
|
{"role": "user", "content": user},
|
|
|
|
|
|
],
|
|
|
|
|
|
"temperature": 0.2,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
return resp.json()["choices"][0]["message"]["content"]
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def re_strip_fence(text: str) -> str:
|
|
|
|
|
|
import re
|
|
|
|
|
|
m = re.search(r"```(?:json)?\s*([\s\S]*?)```", text)
|
|
|
|
|
|
return (m.group(1) if m else text).strip()
|
2026-07-21 11:05:57 +08:00
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
|
|
|
|
|
|
_provider: ModelProvider | None = None
|
2026-07-21 11:05:57 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_provider() -> ModelProvider:
|
2026-07-28 02:12:46 +08:00
|
|
|
|
global _provider
|
|
|
|
|
|
if _provider is None:
|
|
|
|
|
|
_provider = ModelProvider()
|
|
|
|
|
|
return _provider
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def reset_provider() -> None:
|
|
|
|
|
|
"""测试用:清空单例以便重读环境变量。"""
|
|
|
|
|
|
global _provider
|
|
|
|
|
|
_provider = None
|