fix(assistant): 工具调用回传 Gemini 3 的 thought_signature, 同 index 的并行调用按 id 拆开, 不再 400 / 拼接工具名 (#453)

This commit is contained in:
kevin
2026-10-05 00:21:02 +08:00
committed by GitHub
parent 490c62bca7
commit 4cb4ae1a7d
4 changed files with 245 additions and 27 deletions
+16 -8
View File
@@ -124,6 +124,21 @@ def _parse_tool_arguments(raw: str) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
def _assistant_tool_call(call: dict[str, Any]) -> dict[str, Any]:
"""把本轮累积的工具调用还原成回传给上游的 assistant.tool_calls[] 元素。
extra_content (Gemini 3 的 thought_signature) 必须原样带回, 否则上游 400。
"""
item: dict[str, Any] = {
"id": call["id"],
"type": "function",
"function": {"name": call.get("name", ""), "arguments": call.get("arguments", "")},
}
if call.get("extra_content"):
item["extra_content"] = call["extra_content"]
return item
async def chat_stream(
*,
history: list[dict[str, str]],
@@ -295,14 +310,7 @@ async def chat_stream(
assistant_message: dict[str, Any] = {
"role": "assistant",
"tool_calls": [
{
"id": call["id"],
"type": "function",
"function": {"name": call.get("name", ""), "arguments": call.get("arguments", "")},
}
for call in tool_calls
],
"tool_calls": [_assistant_tool_call(call) for call in tool_calls],
}
if round_text:
assistant_message["content"] = "".join(round_text)
+18 -8
View File
@@ -11,7 +11,8 @@ tool_calls 分片, 流结束时统一返回本轮结果, 由 chat_service 决定
- {"type": "text", "delta": str} 正文增量(可能多次)
- {"type": "round_end", "tool_calls": [...], "finish_reason": str}
本轮结束; tool_calls 非空表示模型请求工具, 元素形如
{"id", "name", "arguments"(JSON 字符串)}。
{"id", "name", "arguments"(JSON 字符串)}, 上游附带 extra_content 时
(如 Gemini 的 thought_signature) 另有 "extra_content" 键, 需原样回传。
"""
from __future__ import annotations
@@ -111,8 +112,8 @@ async def stream_openai_round(
content_seen = False
reasoning_seen = False
finish_reason = ""
calls: dict[int, dict[str, str]] = {}
order: list[int] = []
calls: dict[int, dict[str, Any]] = {}
ordered: list[dict[str, Any]] = []
async for chunk in stream:
choices = getattr(chunk, "choices", None) or []
@@ -135,13 +136,22 @@ async def stream_openai_round(
for tc in getattr(delta, "tool_calls", None) or []:
idx = int(getattr(tc, "index", 0) or 0)
tc_id = str(getattr(tc, "id", None) or "")
entry = calls.get(idx)
if entry is None:
# Gemini 的兼容接口会把并行调用逐个整条下发且 index 相同(或缺省),
# 只靠 index 累积会把两个调用的 name/arguments 拼成一个; 同一 index
# 上出现不同的 id 即视为新的调用。
if entry is None or (tc_id and entry["id"] and tc_id != entry["id"]):
entry = {"id": "", "name": "", "arguments": ""}
calls[idx] = entry
order.append(idx)
if getattr(tc, "id", None):
entry["id"] = str(tc.id)
ordered.append(entry)
if tc_id:
entry["id"] = tc_id
# Gemini 3 在 extra_content.google.thought_signature 里返回思考签名,
# 下一轮回传历史时缺了它会被 400 拒绝。
extra = getattr(tc, "extra_content", None)
if extra and "extra_content" not in entry:
entry["extra_content"] = extra
fn = getattr(tc, "function", None)
if fn is not None:
if getattr(fn, "name", None):
@@ -157,7 +167,7 @@ async def stream_openai_round(
if calls:
yield {
"type": "round_end",
"tool_calls": [calls[i] for i in order],
"tool_calls": ordered,
"finish_reason": finish_reason,
}
return
+18 -11
View File
@@ -374,17 +374,7 @@ async def generate_ai_text_with_tools(
assistant = {
"role": "assistant",
"tool_calls": [
{
"id": tc.id,
"type": getattr(tc, "type", "function"),
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in tool_calls
],
"tool_calls": [_assistant_tool_call(tc) for tc in tool_calls],
}
if message.content:
assistant["content"] = message.content
@@ -406,6 +396,23 @@ async def generate_ai_text_with_tools(
return req_messages
def _assistant_tool_call(tc) -> dict:
"""SDK 返回的 tool_call 转成回传用的 dict; Gemini 3 的 extra_content
(thought_signature) 需原样带回, 否则下一轮被上游 400 拒绝。"""
item = {
"id": tc.id,
"type": getattr(tc, "type", "function"),
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
extra = getattr(tc, "extra_content", None)
if extra:
item["extra_content"] = extra
return item
def _parse_tool_arguments(raw: str) -> dict:
"""把 tool call 的 arguments JSON 字符串解析为 dict, 解析失败返回空 dict。"""
if not raw:
@@ -0,0 +1,193 @@
"""Gemini 3 (OpenAI 兼容接口) 的工具调用: thought_signature 回传与并行调用拆分。
Gemini 3 在 tool_calls[].extra_content.google.thought_signature 返回思考签名,
下一轮回传 assistant 消息时缺了它, 上游直接 400 "Function call is missing a
thought_signature"。另外它的流式接口把并行调用逐个整条下发且 index 相同,
只按 index 累积会把工具名拼成 get_market_overviewget_sector_rotation。
"""
from __future__ import annotations
import json
from typing import Any
import pytest
from openai.types.chat import ChatCompletionChunk, ChatCompletionMessage
from app.custom.assistant import chat_service, streaming
from app.custom.assistant import tools as assistant_tools
from app.services import ai_provider
SIGNATURE = {"google": {"thought_signature": "c2lnLWE="}}
def _chunk(tool_calls: list[dict[str, Any]], finish_reason: str | None = None) -> ChatCompletionChunk:
return ChatCompletionChunk.model_validate({
"id": "chunk",
"object": "chat.completion.chunk",
"created": 0,
"model": "gemini-3.8-flash",
"choices": [{
"index": 0,
"delta": {"role": "assistant", "tool_calls": tool_calls},
"finish_reason": finish_reason,
}],
})
class _FakeStream:
def __init__(self, chunks: list[ChatCompletionChunk]) -> None:
self._chunks = list(chunks)
def __aiter__(self) -> _FakeStream:
return self
async def __anext__(self) -> ChatCompletionChunk:
if not self._chunks:
raise StopAsyncIteration
return self._chunks.pop(0)
class _FakeClient:
def __init__(self, chunks: list[ChatCompletionChunk]) -> None:
self.chat = self
self.completions = self
self._chunks = chunks
async def create(self, **kwargs: Any) -> _FakeStream:
return _FakeStream(self._chunks)
async def _round_end(monkeypatch: pytest.MonkeyPatch, chunks: list[ChatCompletionChunk]) -> dict[str, Any]:
monkeypatch.setattr(streaming, "_client", lambda timeout: _FakeClient(chunks))
monkeypatch.setattr(streaming, "current_ai_model", lambda: "gemini-3.8-flash")
monkeypatch.setattr(streaming, "_build_kwargs", lambda temperature: {})
events = [e async for e in streaming.stream_openai_round([{"role": "user", "content": "hi"}], [])]
assert events[-1]["type"] == "round_end"
return events[-1]
async def test_stream_keeps_thought_signature(monkeypatch: pytest.MonkeyPatch) -> None:
end = await _round_end(monkeypatch, [_chunk([{
"index": 0,
"id": "function-call-1",
"type": "function",
"function": {"name": "check_data_coverage", "arguments": "{}"},
"extra_content": SIGNATURE,
}], finish_reason="tool_calls")])
assert end["tool_calls"] == [{
"id": "function-call-1",
"name": "check_data_coverage",
"arguments": "{}",
"extra_content": SIGNATURE,
}]
async def test_stream_splits_parallel_calls_sharing_an_index(monkeypatch: pytest.MonkeyPatch) -> None:
end = await _round_end(monkeypatch, [
_chunk([{
"index": 0,
"id": "function-call-1",
"type": "function",
"function": {"name": "get_market_overview", "arguments": "{}"},
"extra_content": SIGNATURE,
}]),
_chunk([{
"index": 0,
"id": "function-call-2",
"type": "function",
"function": {"name": "get_sector_rotation", "arguments": '{"days": 5}'},
}], finish_reason="tool_calls"),
])
assert [(c["id"], c["name"], c["arguments"]) for c in end["tool_calls"]] == [
("function-call-1", "get_market_overview", "{}"),
("function-call-2", "get_sector_rotation", '{"days": 5}'),
]
assert end["tool_calls"][0]["extra_content"] == SIGNATURE
assert "extra_content" not in end["tool_calls"][1]
async def test_stream_still_joins_fragments_of_one_call(monkeypatch: pytest.MonkeyPatch) -> None:
end = await _round_end(monkeypatch, [
_chunk([{"index": 0, "id": "call_1", "type": "function",
"function": {"name": "get_stock_quote", "arguments": '{"symbols": '}}]),
_chunk([{"index": 0, "function": {"arguments": '["600519.SH"]}'}}]),
_chunk([{"index": 1, "id": "call_2", "type": "function",
"function": {"name": "get_stock_daily", "arguments": "{}"}}], finish_reason="tool_calls"),
])
assert [(c["id"], c["name"], c["arguments"]) for c in end["tool_calls"]] == [
("call_1", "get_stock_quote", '{"symbols": ["600519.SH"]}'),
("call_2", "get_stock_daily", "{}"),
]
async def test_chat_service_sends_thought_signature_back(monkeypatch: pytest.MonkeyPatch) -> None:
captured: list[list[dict[str, Any]]] = []
script = [
{"tool_calls": [{"id": "function-call-1", "name": "check_data_coverage",
"arguments": "{}", "extra_content": SIGNATURE}]},
{"text": "数据是最新的"},
]
async def fake_round(messages, tool_schemas, *, temperature=0.3, timeout=240.0):
captured.append([dict(m) for m in messages])
step = script.pop(0)
if step.get("text"):
yield {"type": "text", "delta": step["text"]}
yield {"type": "round_end", "tool_calls": step.get("tool_calls", []), "finish_reason": "stop"}
async def fake_execute(name: str, args: dict[str, Any], ctx: Any) -> dict[str, Any]:
return {"ok": True, "result": {}}
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", fake_round)
monkeypatch.setattr(assistant_tools, "execute_assistant_tool", fake_execute)
lines = [line async for line in chat_service.chat_stream(
history=[{"role": "user", "content": "帮我检查本地数据完整性"}],
)]
assert [json.loads(line) for line in lines if '"error"' in line] == []
assistant_msg = captured[1][-2]
assert assistant_msg["tool_calls"] == [{
"id": "function-call-1",
"type": "function",
"function": {"name": "check_data_coverage", "arguments": "{}"},
"extra_content": SIGNATURE,
}]
async def test_tool_loop_sends_thought_signature_back(monkeypatch: pytest.MonkeyPatch) -> None:
replies = [
ChatCompletionMessage.model_validate({
"role": "assistant",
"content": None,
"tool_calls": [{
"id": "function-call-1",
"type": "function",
"function": {"name": "run_backtest", "arguments": "{}"},
"extra_content": SIGNATURE,
}],
}),
ChatCompletionMessage.model_validate({"role": "assistant", "content": "完成"}),
]
async def fake_once(messages, **kwargs):
return replies.pop(0)
async def fake_execute(name: str, args: dict) -> dict:
return {"ok": True, "result": {}}
monkeypatch.setattr(ai_provider, "is_codex_cli_provider", lambda provider=None: False)
monkeypatch.setattr(ai_provider, "_check_input_budget", lambda messages, *, max_tokens: None)
monkeypatch.setattr(ai_provider, "_run_openai_message_once", fake_once)
messages = await ai_provider.generate_ai_text_with_tools(
[{"role": "user", "content": "迭代策略"}],
[{"type": "function", "function": {"name": "run_backtest", "parameters": {}}}],
execute_tool=fake_execute,
)
assert messages[1]["tool_calls"][0]["extra_content"] == SIGNATURE