Files
qihuaiflow/backend/python/app/core/llm_client.py
T
QihuAI Design 256d9f33e6 chore(align): sync Git with running container as authoritative source
Phase 0 SSE 流式聊天 landed in the running container and captured in Git so
the three sources (container := image := Git) agree.

Phase 0 — SSE 流式聊天(Web 端,IM bot 路径保持不变、共用引擎):
- app/api/chat.py: 新增独立 SSE 端点
  POST /app/api/chat/conversations/{conv_id}/messages/stream
  基于 asyncio.Queue + create_task 将 _send_message_core 桥接为
  text/event-stream 生成器,吐出 delta / reasoning / done / error 事件
- app/api/chat_service.py: _send_message_core 增加 stream + on_event 参数;
  普通(无工具)聊天 round-0 主调用走 achat_complete(stream=True) 流式;
  done 事件携带 message_id / content / process_steps / used_skills /
  used_mcp / generating
- app/core/llm_client.py: 修复 _create_kwargs 硬编码 stream=False 的根因 bug
  (流式调用传 stream=True 导致 TypeError: multiple values for keyword),
  现 _create_kwargs(request_body, stream=True) 字段随参数变化
- frontend/src/pages/front/ChatPage.tsx + ChatPage.css + types/index.ts:
  发消息改为 fetch SSE 消费,实时增量渲染 AI 回复文本与思考链(reasoning),
  done 事件用 message_id 定稿并渲染过程步骤;网络/流式失败自动回退非流式

Alignment performed 2026-07-24 18:25:11. No deletions; secrets/venv/node_modules/
caches were not touched (tracked-source-only copy).
2026-07-24 18:25:15 +08:00

223 lines
9.4 KiB
Python
Raw 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
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) -> AsyncOpenAI:
base = _base_url(api_url)
# 始终 trust_env=False:避免容器 HTTP_PROXY 对国内端点(siliconflow 等)造成 502;
# 代理经 proxy 显式传入(AGNES 等),未传则直连。
http_client = httpx.AsyncClient(proxy=proxy, trust_env=False)
return AsyncOpenAI(
base_url=base,
api_key=api_key or "EMPTY",
timeout=timeout,
http_client=http_client,
)
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")
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):
"""统一的 OpenAI 兼容聊天补全。详见模块 docstring。"""
if stream:
async def _gen():
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout)
_content_parts = []
_reasoning_parts = []
_tool_acc = {} # index -> {id, type, name, arguments}
try:
gen = await client.chat.completions.create(
**_create_kwargs(request_body, stream=True)
)
async for chunk in gen:
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:
await client.close()
# 流式结束时吐出已组装的完整 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)
async with client:
resp = await client.chat.completions.create(**_create_kwargs(request_body))
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 {}),
}
def build_openai_client_sync(api_url, api_key, *, proxy=None, timeout=120) -> OpenAI:
"""同步客户端工厂(供 IM bot 等同步上下文复用同一标准模型层)。"""
base = _base_url(api_url)
http_client = httpx.Client(proxy=proxy, trust_env=False)
return OpenAI(base_url=base, api_key=api_key or "EMPTY", timeout=timeout, http_client=http_client)
def chat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120):
"""同步版(与 achat_complete 行为/返回形状完全一致),供非 async 上下文使用。"""
client = build_openai_client_sync(api_url, api_key, proxy=proxy, timeout=timeout)
with client:
resp = client.chat.completions.create(**_create_kwargs(request_body))
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 {}),
}
# ---------------------------------------------------------------------------
# 嵌入(Embeddings):OpenAI 兼容 /v1/embeddings,收敛散落的裸 httpx 嵌入调用
# ---------------------------------------------------------------------------
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)
async with client:
resp = await client.embeddings.create(**_embed_kwargs(input, model))
return [d.embedding for d in resp.data]
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)
with client:
resp = client.embeddings.create(**_embed_kwargs(input, model))
return [d.embedding for d in resp.data]