""" 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)