207 lines
7.3 KiB
Python
207 lines
7.3 KiB
Python
"""
|
|
LLM 出站 HTTP 客户端(DeepSeek / 千问兼容 OpenAI Chat)。
|
|
|
|
本文件职责:带超时的 chat.completions 调用;缺 key / 未确认数据用途时明确失败。
|
|
禁止:在企微回调线程同步长等;正式长调用走 Worker 槽 + 队列。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from typing import Any, Optional
|
|
|
|
import httpx
|
|
|
|
from agent.config import Settings, get_settings
|
|
from agent.llm.modes import LlmMode
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def message_plain_text(msg: dict[str, Any]) -> str:
|
|
"""
|
|
把 assistant message 收成纯文本。
|
|
|
|
千问视觉的 content 经常是 [{text: ...}] 数组,不是字符串;
|
|
思考模式打开时正文可能只在 reasoning_content。两者都漏会让识图变「没看清」。
|
|
"""
|
|
if not isinstance(msg, dict):
|
|
return ""
|
|
text = _content_to_text(msg.get("content"))
|
|
if text:
|
|
return text
|
|
reason = msg.get("reasoning_content")
|
|
if isinstance(reason, str) and reason.strip():
|
|
return reason.strip()
|
|
return ""
|
|
|
|
|
|
def _content_to_text(content: Any) -> str:
|
|
"""字符串或千问多模态数组 → 纯文本。"""
|
|
if isinstance(content, str):
|
|
return content.strip()
|
|
if isinstance(content, list):
|
|
parts: list[str] = []
|
|
for item in content:
|
|
if isinstance(item, str) and item.strip():
|
|
parts.append(item.strip())
|
|
elif isinstance(item, dict):
|
|
piece = item.get("text") if "text" in item else item.get("content")
|
|
got = _content_to_text(piece)
|
|
if got:
|
|
parts.append(got)
|
|
return "\n".join(parts).strip()
|
|
return ""
|
|
|
|
|
|
@dataclass
|
|
class LlmHttpResult:
|
|
ok: bool
|
|
content: str = ""
|
|
raw: Optional[dict[str, Any]] = None
|
|
error: str = ""
|
|
provider: str = ""
|
|
tool_calls: list[dict[str, Any]] | None = None
|
|
|
|
|
|
@dataclass
|
|
class LlmHttpClient:
|
|
"""
|
|
兼容 OpenAI 的 Chat Completions 客户端。
|
|
|
|
DeepSeek / 千问 DashScope compatible-mode 共用此壳。
|
|
"""
|
|
|
|
settings: Settings
|
|
timeout_seconds: float = 60.0
|
|
|
|
@classmethod
|
|
def from_settings(cls, settings: Optional[Settings] = None) -> "LlmHttpClient":
|
|
return cls(settings=settings or get_settings())
|
|
|
|
def _provider_for_mode(self, mode: LlmMode | str) -> tuple[str, str, str]:
|
|
"""
|
|
返回 (provider, base_url, api_key)。
|
|
|
|
A/B 文本 → DeepSeek;附件/视觉 → 千问。
|
|
"""
|
|
mode_val = mode.value if isinstance(mode, LlmMode) else str(mode)
|
|
visionish = mode_val in {
|
|
LlmMode.ATTACHMENT_TEXT.value,
|
|
LlmMode.VISION.value,
|
|
LlmMode.SHEET_MAP.value,
|
|
}
|
|
if visionish:
|
|
return (
|
|
"qwen",
|
|
(self.settings.qwen_base_url or "").rstrip("/"),
|
|
self.settings.qwen_api_key or "",
|
|
)
|
|
return (
|
|
"deepseek",
|
|
(self.settings.deepseek_base_url or "").rstrip("/"),
|
|
self.settings.deepseek_api_key or "",
|
|
)
|
|
|
|
def _model_for(self, provider: str, mode: LlmMode | str) -> str:
|
|
mode_val = mode.value if isinstance(mode, LlmMode) else str(mode)
|
|
if provider == "qwen":
|
|
if mode_val == LlmMode.VISION.value:
|
|
return self.settings.qwen_vl_model or "qwen3-vl-plus"
|
|
return self.settings.qwen_text_model or "qwen-plus"
|
|
return self.settings.deepseek_model or "deepseek-chat"
|
|
|
|
def chat(
|
|
self,
|
|
*,
|
|
mode: LlmMode | str,
|
|
messages: list[dict[str, Any]],
|
|
temperature: float = 0.0,
|
|
tools: Optional[list[dict[str, Any]]] = None,
|
|
tool_choice: Any = None,
|
|
) -> LlmHttpResult:
|
|
"""
|
|
发起一次 chat.completions(可带 tools)。
|
|
|
|
副作用:出站 HTTPS;超时由 timeout_seconds 限制。
|
|
未确认 LLM_DATA_USAGE_CONFIRMED 时拒绝调用(prompt/04)。
|
|
"""
|
|
if not self.settings.llm_data_usage_confirmed:
|
|
return LlmHttpResult(
|
|
ok=False,
|
|
error="llm_data_usage_not_confirmed",
|
|
provider="none",
|
|
)
|
|
provider, base, key = self._provider_for_mode(mode)
|
|
if not key or not base:
|
|
return LlmHttpResult(
|
|
ok=False,
|
|
error=f"{provider}_not_configured",
|
|
provider=provider,
|
|
)
|
|
model = self._model_for(provider, mode)
|
|
url = f"{base}/chat/completions"
|
|
headers = {
|
|
"Authorization": f"Bearer {key}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
body: dict[str, Any] = {
|
|
"model": model,
|
|
"messages": messages,
|
|
"temperature": temperature,
|
|
}
|
|
if provider == "qwen":
|
|
# 识图只要可见原文。思考开着时 content 常为空,销售侧就会「认不到」。
|
|
body["enable_thinking"] = False
|
|
# 报价模板 mappingJson 体积大,显式抬高上限,降低半截 JSON
|
|
mode_val = mode.value if isinstance(mode, LlmMode) else str(mode)
|
|
if mode_val == LlmMode.SHEET_MAP.value:
|
|
body["max_tokens"] = 8192
|
|
if tools:
|
|
body["tools"] = tools
|
|
if tool_choice is not None:
|
|
body["tool_choice"] = tool_choice
|
|
# deepseek-v4-flash 思考模式拒绝 named/required tool_choice,抽字段会 400
|
|
if provider == "deepseek":
|
|
body["thinking"] = {"type": "disabled"}
|
|
try:
|
|
with httpx.Client(timeout=self.timeout_seconds) as client:
|
|
resp = client.post(url, headers=headers, json=body)
|
|
data = resp.json() if resp.content else {}
|
|
if resp.status_code >= 400:
|
|
err_msg = ""
|
|
if isinstance(data, dict) and isinstance(data.get("error"), dict):
|
|
err_msg = str(data["error"].get("message") or "")[:200]
|
|
logger.warning(
|
|
"llm.http 失败 provider=%s status=%s err=%s",
|
|
provider,
|
|
resp.status_code,
|
|
err_msg,
|
|
)
|
|
return LlmHttpResult(
|
|
ok=False,
|
|
error=f"http_{resp.status_code}",
|
|
raw=data if isinstance(data, dict) else None,
|
|
provider=provider,
|
|
)
|
|
choices = (data or {}).get("choices") or []
|
|
content = ""
|
|
tool_calls: list[dict[str, Any]] = []
|
|
if choices:
|
|
msg = (choices[0].get("message") or {})
|
|
content = message_plain_text(msg if isinstance(msg, dict) else {})
|
|
raw_calls = msg.get("tool_calls") or []
|
|
if isinstance(raw_calls, list):
|
|
tool_calls = [c for c in raw_calls if isinstance(c, dict)]
|
|
return LlmHttpResult(
|
|
ok=True,
|
|
content=content,
|
|
raw=data if isinstance(data, dict) else None,
|
|
provider=provider,
|
|
tool_calls=tool_calls or None,
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
logger.warning("llm.http 异常 provider=%s err=%s", provider, exc)
|
|
return LlmHttpResult(ok=False, error=str(exc), provider=provider)
|