Files
QihuAI Design 29a1ce2e54 fix: 双Claw能力对齐 + MCP/LLM代理绕过(P0止血)
- 新增 app/core/capability_discovery.py:统一能力发现层,Web/IM 双Claw共用单一实现
  (三级降级:用户安装 → 平台published → 相关性裁剪;MCP 自动挂载已启用服务)
- im_llm.py:IM Bot Claw 改用统一发现层,恢复 skills(12) + MCP(26工具) 能力,
  修复 #866 部署回退导致的 agents=[]/mcp=[] 回归;mcp__ 前缀分流到 call_mcp_tool
- mcp_client.py:注入 httpx_client_factory(trust_env=False) 绕过容器 HTTP_PROXY,
  修复外部 MCP(xiaobenyang.com / anysearch.com) tools/list 全部静默失败(mcp_tools 0→26)
- llm_client.py / im_bot_user.py:2026-07-31 代理劫持内部请求 + 媒体断连修复(随镜像已上线)
2026-08-04 10:21:19 +08:00

342 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Shared, standards-based LLM client for web chat + IM bot.
平台红线(07-24):模型层以成熟/开源标准为设计基准。本模块使用官方 OpenAI SDK
(\"openai.AsyncOpenAI\")——它是 OpenAI 兼容端点的事实标准客户端。平台本身已在讲
OpenAI 协议(\"/v1/chat/completions\"),因此零协议改造成本,并天然获得:
* 原生 SSE 流式(\"stream=True\");
* 结构化的 tool_calls / usage 解析;
* 统一的代理(trust_env=False + 显式 proxy)与超时处理。
本模块取代 chat_service.py 与 im_llm.py 中两套手写 httpx 循环,使 web chat 与
IM bot 共用**同一模型层**、同一套 failover/代理/超时语义——这是「各端能力统一对齐」
红线在模型层的落地。
API:
achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, stream=False)
- stream=False -> dict,形状对齐上游 \"data\" 响应:
{"choices":[{"message":{"content","tool_calls","reasoning_content"}}], "usage":{...}}
- stream=True -> async generator,逐块产出 ("content"|"reasoning", text) 增量
"""
from __future__ import annotations
import httpx
import asyncio
from openai import AsyncOpenAI, OpenAI
def _base_url(api_url: str) -> str:
"""从完整 /v1/chat/completions 或 /v1/embeddings 地址推导 OpenAI SDK 所需的 base_url。"""
u = (api_url or "").rstrip()
for suf in ("/v1/chat/completions", "/chat/completions", "/v1/embeddings", "/embeddings"):
if u.endswith(suf):
return u[: -len(suf)]
return u
def build_openai_client(api_url, api_key, *, proxy=None, timeout=120, auth_header=None) -> AsyncOpenAI:
base = _base_url(api_url)
# 始终 trust_env=False:避免容器 HTTP_PROXY 对国内端点(siliconflow 等)造成 502;
# 代理经 proxy 显式传入(AGNES 等),未传则直连。
http_client = httpx.AsyncClient(proxy=proxy, trust_env=False)
# 故障快速失败(全栈统一,覆盖 web chat / IM bot / embedding):
# connect 封顶 10s —— 坏端点不再让请求挂死事件循环;
# read/write 封顶 30s —— 长流式回复只要持续有分块即不受影响;
# 保留数值型 timeout 接口,旧调用方无需改动。
_num = float(timeout) if isinstance(timeout, (int, float)) else 120.0
_to = httpx.Timeout(
connect=min(10.0, _num),
read=min(30.0, _num),
write=min(30.0, _num),
pool=10.0,
)
_hdrs = {}
if auth_header and ":" in auth_header:
_k, _v = auth_header.split(":", 1)
_hdrs[_k.strip()] = _v.strip()
elif auth_header:
_hdrs[auth_header.strip()] = api_key
return AsyncOpenAI(
base_url=base,
api_key=api_key or "EMPTY",
timeout=_to,
http_client=http_client,
default_headers=_hdrs if _hdrs else None,
)
def _create_kwargs(request_body: dict, stream: bool = False) -> dict:
kw = {
"model": request_body.get("model"),
"messages": request_body.get("messages", []),
"stream": stream,
}
if request_body.get("temperature") is not None:
kw["temperature"] = request_body["temperature"]
if request_body.get("top_p") is not None:
kw["top_p"] = request_body["top_p"]
if request_body.get("max_tokens") is not None:
kw["max_tokens"] = request_body["max_tokens"]
if request_body.get("stop") is not None:
kw["stop"] = request_body["stop"]
tools = request_body.get("tools")
if tools:
kw["tools"] = tools
kw["tool_choice"] = request_body.get("tool_choice", "auto")
# 2026-07-30: 将未在白名单的额外字段(如 enable_thinking)作为 extra_body 传递
_whitelist = {"model", "messages", "stream", "temperature", "top_p", "max_tokens", "stop", "tools", "tool_choice"}
_extras = {k: v for k, v in request_body.items() if k not in _whitelist}
if _extras:
kw["extra_body"] = _extras
return kw
def _normalize_tool_calls(tcs):
if not tcs:
return None
out = []
for tc in tcs:
out.append(
{
"id": getattr(tc, "id", None),
"type": "function",
"function": {
"name": getattr(tc.function, "name", None),
"arguments": getattr(tc.function, "arguments", "") or "",
},
}
)
return out
async def achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, stream=False, auth_header=None):
"""统一的 OpenAI 兼容聊天补全。详见模块 docstring。"""
if stream:
async def _gen():
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout, auth_header=auth_header)
_content_parts = []
_reasoning_parts = []
_tool_acc = {} # index -> {id, type, name, arguments}
try:
_to = min(30.0, float(timeout) if isinstance(timeout, (int, float)) else 120.0)
gen = await asyncio.wait_for(
client.chat.completions.create(
**_create_kwargs(request_body, stream=True)
),
timeout=_to,
)
_it = gen.__aiter__()
while True:
try:
chunk = await asyncio.wait_for(_it.__anext__(), timeout=_to)
except StopAsyncIteration:
break
except asyncio.TimeoutError:
raise TimeoutError("stream chunk read timeout")
if not chunk.choices:
continue
delta = chunk.choices[0].delta
rc = getattr(delta, "reasoning_content", None)
if rc:
_reasoning_parts.append(rc)
yield ("reasoning", rc)
ct = getattr(delta, "content", None)
if ct:
_content_parts.append(ct)
yield ("content", ct)
# 累积 OpenAI 流式 tool_calls(供后续工具循环执行)
tcs = getattr(delta, "tool_calls", None)
if tcs:
for tc in tcs:
_idx = getattr(tc, "index", 0)
_slot = _tool_acc.setdefault(
_idx, {"id": None, "type": "function", "name": "", "arguments": ""}
)
_tcid = getattr(tc, "id", None)
if _tcid:
_slot["id"] = _tcid
_fn = getattr(tc, "function", None)
if _fn is not None:
_fn_name = getattr(_fn, "name", None)
if _fn_name:
_slot["name"] += _fn_name
_fn_args = getattr(_fn, "arguments", None)
if _fn_args:
_slot["arguments"] += _fn_args
finally:
try:
await asyncio.wait_for(client.close(), timeout=5)
except Exception:
pass
# 流式结束时吐出已组装的完整 message(含 tool_calls),供调用方无缝衔接工具循环
_tool_calls = None
if _tool_acc:
_tool_calls = []
for _i in sorted(_tool_acc.keys()):
_s = _tool_acc[_i]
_tool_calls.append({
"id": _s["id"],
"type": "function",
"function": {"name": _s["name"], "arguments": _s["arguments"]},
})
yield ("message", {
"content": "".join(_content_parts),
"tool_calls": _tool_calls,
"reasoning_content": "".join(_reasoning_parts) or None,
})
return _gen()
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout, auth_header=auth_header)
try:
_to = min(30.0, float(timeout) if isinstance(timeout, (int, float)) else 120.0)
resp = await asyncio.wait_for(
client.chat.completions.create(**_create_kwargs(request_body)),
timeout=_to,
)
msg = resp.choices[0].message
return {
"choices": [
{
"message": {
"content": msg.content or "",
"tool_calls": _normalize_tool_calls(getattr(msg, "tool_calls", None)),
"reasoning_content": getattr(msg, "reasoning_content", None),
}
}
],
"usage": (resp.usage.model_dump() if resp.usage else {}),
}
finally:
try:
await asyncio.wait_for(client.close(), timeout=5)
except Exception:
pass
def build_openai_client_sync(api_url, api_key, *, proxy=None, timeout=120, auth_header=None) -> OpenAI:
"""同步客户端工厂(供 IM bot 等同步上下文复用同一标准模型层)。"""
base = _base_url(api_url)
http_client = httpx.Client(proxy=proxy, trust_env=False)
_hdrs = {}
if auth_header and ":" in auth_header:
_k, _v = auth_header.split(":", 1)
_hdrs[_k.strip()] = _v.strip()
elif auth_header:
_hdrs[auth_header.strip()] = api_key
return OpenAI(base_url=base, api_key=api_key or "EMPTY", timeout=timeout, http_client=http_client, default_headers=_hdrs if _hdrs else None)
def chat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, auth_header=None):
"""同步版(与 achat_complete 行为/返回形状完全一致),供非 async 上下文使用。
2026-07-30 重写:直接使用 requests 替代 OpenAI SDK/httpx。
OpenAI SDK 的 httpx 连接池在慢长轮询(如 iLink QR 状态 12-19s)后会出现
Connection error,已实测复现。requests 库没有这个问题。
"""
import requests
import base64
import random
body = dict(request_body)
body["stream"] = False
# enable_thinking 等额外字段已在 request_body 中
# Headers: Bearer token + X-WECHAT-UIN (兼容 iLink)
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}" if api_key else "Bearer empty",
}
if auth_header and ":" in auth_header:
_k, _v = auth_header.split(":", 1)
headers[_k.strip()] = _v.strip()
else:
# X-WECHAT-UIN 防重放
headers["X-WECHAT-UIN"] = base64.b64encode(str(random.randint(0, 2**32-1)).encode()).decode()
# 2026-07-31 修复根因: proxy 为 None 时传 proxies={} 而非 None。
# requests 在 proxies=None 时会继续读取环境 HTTP_PROXY(trust_env 默认 True),
# 而 uvicorn 进程(run.sh)带有 HTTP_PROXY=172.18.0.1:7890, 该代理无法解析
# Docker 内部域名(如 qihu-aiflow-litellm), 导致所有内部模型调用静默失败、返回空。
# 显式 proxies={} + trust_env=False 可完全绕过环境代理, 使 _proxy_for_url 的
# 决策(返回 None=不代理)真正生效。
_proxies = {"http": proxy, "https": proxy} if proxy else {}
_session = requests.Session()
_session.trust_env = False
last_body = {}
for attempt in range(2):
try:
resp = _session.post(
api_url.rstrip("/"),
headers=headers,
json=body,
proxies=_proxies,
timeout=timeout,
)
try:
last_body = resp.json() if resp.content else {}
except Exception:
last_body = {}
if resp.ok:
import logging as _l6
_l6.warning(f'[chat_complete] body keys={list(last_body.keys()) if isinstance(last_body, dict) else type(last_body)}, content_len={len(resp.content)}, content_preview={str(resp.content)[:200]}')
return _shape_response(last_body)
except Exception:
pass
return {"choices": [{"message": {"content": "", "tool_calls": None, "reasoning_content": None}}]}
def _shape_response(body: dict) -> dict:
"""Normalize OpenAI-compatible response to standard shape."""
choices = body.get("choices", [])
import logging as _l7
if choices:
msg = choices[0].get("message", {})
result = {
"choices": [{
"message": {
"content": msg.get("content") or "",
"tool_calls": _normalize_tool_calls(msg.get("tool_calls")),
"reasoning_content": msg.get("reasoning_content") or "",
}
}]
}
_l7.warning(f'[_shape] content_len={len(result["choices"][0]["message"]["content"])}, keys={list(result["choices"][0]["message"].keys())}')
return result
_l7.warning(f'[_shape] NO choices in body, keys={list(body.keys())[:5]}')
return body
def _embed_kwargs(input, model):
return {"model": model, "input": input if isinstance(input, list) else [input]}
async def aembed(api_url, api_key, input, model, *, proxy=None, timeout=60) -> list:
"""统一的 OpenAI 兼容文本嵌入(异步)。返回 list[list[float]]。
供 pgvector_search.get_embedding 等 embeddings 路径复用,把散落的裸 httpx
嵌入调用收敛到同一标准模型层(与聊天共用代理/超时/failover 语义)。
"""
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout, auth_header=auth_header)
try:
_to = min(60.0, float(timeout) if isinstance(timeout, (int, float)) else 60.0)
resp = await asyncio.wait_for(
client.embeddings.create(**_embed_kwargs(input, model)),
timeout=_to,
)
return [d.embedding for d in resp.data]
finally:
try:
await asyncio.wait_for(client.close(), timeout=5)
except Exception:
pass
def embed(api_url, api_key, input, model, *, proxy=None, timeout=60) -> list:
"""同步版嵌入(与非 async 上下文复用同一标准模型层)。"""
client = build_openai_client_sync(api_url, api_key, proxy=proxy, timeout=timeout, auth_header=auth_header)
with client:
resp = client.embeddings.create(**_embed_kwargs(input, model))
return [d.embedding for d in resp.data]