mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
fix(assistant): 流式 tool_calls 缺 id 时补一次并复用, tool 回填的 tool_call_id 与助手消息一致
部分 OpenAI 兼容网关的流式 tool_calls 分片不带 id。chat_stream 构造助手消息时
用 uuid 兜底了 tool_calls[].id, 但这个值是在列表推导里现场生成的, 没有写回 call;
紧接着的 role:tool 回填取的是 call.get("id") or "", 于是 tool_call_id 为空串,
与助手消息对不上, 严格校验协议的上游会拒绝下一轮请求。
构造助手消息前给缺 id 的 call 生成一次 id 并写回, 助手消息与回填统一用 call["id"]。
上游自带 id 时不变。
Closes #351
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5eafc0ad0e
commit
ec5764e58d
@@ -220,11 +220,17 @@ async def chat_stream(
|
||||
))
|
||||
return
|
||||
|
||||
# 部分兼容网关的流式 tool_calls 不带 id: 补一次并写回 call, 助手消息的
|
||||
# tool_calls[].id 与下方 role:tool 回填的 tool_call_id 必须是同一个值。
|
||||
for call in tool_calls:
|
||||
if not call.get("id"):
|
||||
call["id"] = uuid.uuid4().hex[:8]
|
||||
|
||||
assistant_message: dict[str, Any] = {
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": call.get("id") or uuid.uuid4().hex[:8],
|
||||
"id": call["id"],
|
||||
"type": "function",
|
||||
"function": {"name": call.get("name", ""), "arguments": call.get("arguments", "")},
|
||||
}
|
||||
@@ -243,7 +249,7 @@ async def chat_stream(
|
||||
)
|
||||
req_messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": call.get("id") or "",
|
||||
"tool_call_id": call["id"],
|
||||
"content": json.dumps(result, ensure_ascii=False),
|
||||
})
|
||||
except Exception as exc: # 单轮失败收敛为 error 事件, 不打断流
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""AI 助手工具轮次: 流式 tool_calls 缺 id 时, 回填消息的 tool_call_id 必须与助手消息一致。
|
||||
|
||||
部分 OpenAI 兼容网关的流式 tool_calls 分片不带 id (streaming 累积后 id 为空串)。
|
||||
chat_service 给助手消息的 tool_calls[].id 补了随机 id, 但 role:tool 回填消息的
|
||||
tool_call_id 仍取原始空串 —— 两者对不上, 下一轮请求会被上游按协议拒绝
|
||||
(tool 消息找不到对应的 tool_call), 对话在第一次取数后直接失败。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.custom.assistant import chat_service
|
||||
from app.custom.assistant import tools as assistant_tools
|
||||
|
||||
|
||||
def _script_round(script: list[dict[str, Any]], capture: list[list[dict[str, Any]]]):
|
||||
async def fake_round(messages, tool_schemas, *, temperature=0.3, timeout=240.0):
|
||||
capture.append([dict(m) for m in messages])
|
||||
step = script.pop(0)
|
||||
for piece in step.get("text_pieces", []):
|
||||
yield {"type": "text", "delta": piece}
|
||||
yield {"type": "round_end", "tool_calls": step.get("tool_calls", []), "finish_reason": "stop"}
|
||||
|
||||
return fake_round
|
||||
|
||||
|
||||
async def _fake_execute(name: str, args: dict[str, Any], ctx: Any) -> dict[str, Any]:
|
||||
return {"ok": True, "result": {"rows": [], "count": 0}}
|
||||
|
||||
|
||||
async def _run(monkeypatch: pytest.MonkeyPatch, tool_calls: list[dict[str, Any]]) -> list[list[dict[str, Any]]]:
|
||||
captured: list[list[dict[str, Any]]] = []
|
||||
script = [{"tool_calls": tool_calls}, {"text_pieces": ["好的"]}]
|
||||
monkeypatch.setattr(chat_service, "ai_configured", lambda: True)
|
||||
monkeypatch.setattr(chat_service, "is_codex_cli_provider", lambda provider=None: False)
|
||||
monkeypatch.setattr(chat_service, "stream_openai_round", _script_round(script, captured))
|
||||
monkeypatch.setattr(assistant_tools, "execute_assistant_tool", _fake_execute)
|
||||
|
||||
lines = [line async for line in chat_service.chat_stream(
|
||||
history=[{"role": "user", "content": "看看贵州茅台"}],
|
||||
)]
|
||||
events = [json.loads(line) for line in lines]
|
||||
assert [e["type"] for e in events if e["type"] == "error"] == []
|
||||
assert len(captured) == 2
|
||||
return captured
|
||||
|
||||
|
||||
async def test_missing_tool_call_id_backfill_matches_assistant_message(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured = await _run(monkeypatch, [
|
||||
{"id": "", "name": "get_stock_quote", "arguments": '{"symbols": ["600519.SH"]}'},
|
||||
])
|
||||
|
||||
second_round = captured[1]
|
||||
assistant_msg, tool_msg = second_round[-2], second_round[-1]
|
||||
call_id = assistant_msg["tool_calls"][0]["id"]
|
||||
assert call_id
|
||||
assert tool_msg["role"] == "tool"
|
||||
assert tool_msg["tool_call_id"] == call_id
|
||||
|
||||
|
||||
async def test_parallel_calls_without_ids_get_distinct_matching_ids(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured = await _run(monkeypatch, [
|
||||
{"id": "", "name": "get_stock_quote", "arguments": '{"symbols": ["600519.SH"]}'},
|
||||
{"id": "", "name": "get_stock_daily", "arguments": '{"symbol": "600519.SH"}'},
|
||||
])
|
||||
|
||||
second_round = captured[1]
|
||||
assistant_msg = second_round[-3]
|
||||
tool_msgs = second_round[-2:]
|
||||
call_ids = [call["id"] for call in assistant_msg["tool_calls"]]
|
||||
assert all(call_ids)
|
||||
assert len(set(call_ids)) == 2
|
||||
assert [m["tool_call_id"] for m in tool_msgs] == call_ids
|
||||
|
||||
|
||||
async def test_provider_supplied_tool_call_id_is_kept(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured = await _run(monkeypatch, [
|
||||
{"id": "call_abc", "name": "get_stock_quote", "arguments": '{"symbols": ["600519.SH"]}'},
|
||||
])
|
||||
|
||||
second_round = captured[1]
|
||||
assert second_round[-2]["tool_calls"][0]["id"] == "call_abc"
|
||||
assert second_round[-1]["tool_call_id"] == "call_abc"
|
||||
Reference in New Issue
Block a user