Files
inquiry_robot/inquiry-agent/agent/llm/http_client.py
T

157 lines
5.2 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__)
@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 "qwen-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 tools:
body["tools"] = tools
if tool_choice is not None:
body["tool_choice"] = tool_choice
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:
logger.warning(
"llm.http 失败 provider=%s status=%s",
provider,
resp.status_code,
)
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 = str(msg.get("content") or "")
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)