mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
fix(assistant): 工具调用回传 Gemini 3 的 thought_signature, 同 index 的并行调用按 id 拆开, 不再 400 / 拼接工具名 (#453)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user