From 6237bce44788bfa361ffc4de59cafa3bae51f9d4 Mon Sep 17 00:00:00 2001 From: Rail1bc <3271405327@qq.com> Date: Thu, 30 Jul 2026 11:44:19 +0800 Subject: [PATCH 1/5] fix: reorder fake tool call messages at tail to correct timing in OpenAI provider When plugins inject assistant(tool_calls) + tool(result) pairs into contexts to force tool invocation (fake tool call), _prepare_chat_payload deepcopies contexts (with pairs at tail) then appends user_msg, resulting in: ..., assistant(tc), tool, user LLM sees assistant "predicting" user query. Reorder to: ..., user, assistant(tc), tool Co-Authored-By: deepseek-v4-flash --- .../core/provider/sources/openai_source.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index f7870b7137..1a35d386f5 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -528,6 +528,58 @@ def _is_empty(content: Any) -> bool: ) payloads["messages"] = final + ProviderOpenAIOfficial._reorder_tailing_tool_call_user(payloads) + + @staticmethod + def _reorder_tailing_tool_call_user(payloads: dict) -> None: + """重排因伪造工具调用导致尾部 assistant(tc) → tool → user 乱序的消息。 + + 此为轻量妥协修复。未来若实现专用的上下文操作钩子或伪造工具调用钩子, + 可考虑移除此方法。 + """ + messages = payloads.get("messages") + if not isinstance(messages, list) or len(messages) < 2: + return + + # 从尾部弹出 user 消息 + if messages[-1].get("role") != "user": + return + user_msg = messages.pop() + + # 从新尾部持续收集 assistant(tool_calls) + tool 成对消息 + pairs: list[tuple[dict, dict]] = [] + while len(messages) >= 2: + tool_msg = messages[-1] + asst_msg = messages[-2] + + if asst_msg.get("role") != "assistant" or tool_msg.get("role") != "tool": + break + + tool_calls = asst_msg.get("tool_calls") + if not tool_calls: + break + + tc_ids = {tc.get("id") for tc in tool_calls if isinstance(tc, dict)} + if tool_msg.get("tool_call_id") not in tc_ids: + break + + # 确认是一对,弹出 + messages.pop() # tool + messages.pop() # assistant + pairs.append((asst_msg, tool_msg)) + + if not pairs: + # 没有伪造对,把 user 放回去 + messages.append(user_msg) + return + + # 重排:user → assistant_1, tool_1 → ... → assistant_N, tool_N + # pairs 是从外到内收集的(N, N-1, ..., 1),反转后按 1..N 顺序回插 + messages.append(user_msg) + for asst_msg, tool_msg in reversed(pairs): + messages.append(asst_msg) + messages.append(tool_msg) + async def _query( self, payloads: dict, From 42bc8fedaefe171be70df10da9566fe776e5f17d Mon Sep 17 00:00:00 2001 From: Rail1bc <3271405327@qq.com> Date: Fri, 31 Jul 2026 09:25:00 +0800 Subject: [PATCH 2/5] test: add coverage for fake tool call reordering in OpenAI provider 16 new tests covering: single pair, multiple pairs, no-op branches (tool_call_id mismatch, no tool_calls, no trailing user, empty list), real tool call unaffected, non-contiguous pair stop, and 7 parametrized subclass tests (Groq, LongCat, AIHubMix, OpenRouter, XAI, Xiaomi, Zhipu) confirming the fix applies through inheritance. Co-Authored-By: deepseek-v4-flash --- tests/test_openai_source.py | 286 ++++++++++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) diff --git a/tests/test_openai_source.py b/tests/test_openai_source.py index cf15e28846..6c0d727b3b 100644 --- a/tests/test_openai_source.py +++ b/tests/test_openai_source.py @@ -14,7 +14,13 @@ from astrbot.core.exceptions import EmptyModelOutputError from astrbot.core.provider.entities import LLMResponse from astrbot.core.provider.sources.groq_source import ProviderGroq +from astrbot.core.provider.sources.longcat_source import ProviderLongCat +from astrbot.core.provider.sources.oai_aihubmix_source import ProviderAIHubMix from astrbot.core.provider.sources.openai_source import ProviderOpenAIOfficial +from astrbot.core.provider.sources.openrouter_source import ProviderOpenRouter +from astrbot.core.provider.sources.xai_source import ProviderXAI +from astrbot.core.provider.sources.xiaomi_source import ProviderXiaomi +from astrbot.core.provider.sources.zhipu_source import ProviderZhipu from astrbot.core.utils.media_utils import ResolvedMediaData, file_uri_to_path @@ -2193,3 +2199,283 @@ async def fake_create(**kwargs): assert messages[1] == {"role": "user", "content": "again"} finally: await provider.terminate() + + +# ── _reorder_tailing_tool_call_user ────────────────────────────────────────── + + +def test_reorder_single_fake_pair(): + """Single fake tool call pair at tail: assistant(tc) → tool → user.""" + payloads = { + "messages": [ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "call_01", "type": "function", "function": {"name": "search", "arguments": "{}"}} + ], + }, + {"role": "tool", "tool_call_id": "call_01", "content": "mem result"}, + {"role": "user", "content": "帮我处理"}, + ] + } + + ProviderOpenAIOfficial._reorder_tailing_tool_call_user(payloads) + + assert payloads["messages"] == [ + {"role": "user", "content": "hello"}, + {"role": "user", "content": "帮我处理"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "call_01", "type": "function", "function": {"name": "search", "arguments": "{}"}} + ], + }, + {"role": "tool", "tool_call_id": "call_01", "content": "mem result"}, + ] + + +def test_reorder_multiple_fake_pairs(): + """Multiple fake pairs from different plugins: asst₁→tool₁→asst₂→tool₂→user.""" + payloads = { + "messages": [ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "call_01", "type": "function", "function": {"name": "plugin_a", "arguments": "{}"}} + ], + }, + {"role": "tool", "tool_call_id": "call_01", "content": "result_a"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "call_02", "type": "function", "function": {"name": "plugin_b", "arguments": "{}"}} + ], + }, + {"role": "tool", "tool_call_id": "call_02", "content": "result_b"}, + {"role": "user", "content": "帮我处理"}, + ] + } + + ProviderOpenAIOfficial._reorder_tailing_tool_call_user(payloads) + + assert payloads["messages"] == [ + {"role": "user", "content": "hello"}, + {"role": "user", "content": "帮我处理"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "call_01", "type": "function", "function": {"name": "plugin_a", "arguments": "{}"}} + ], + }, + {"role": "tool", "tool_call_id": "call_01", "content": "result_a"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "call_02", "type": "function", "function": {"name": "plugin_b", "arguments": "{}"}} + ], + }, + {"role": "tool", "tool_call_id": "call_02", "content": "result_b"}, + ] + + +def test_reorder_noop_when_no_fake_pair(): + """No fake pair: normal user message at tail, should be unchanged.""" + payloads = { + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "ok"}, + {"role": "user", "content": "next"}, + ] + } + expected = payloads["messages"][:] + + ProviderOpenAIOfficial._reorder_tailing_tool_call_user(payloads) + + assert payloads["messages"] == expected + + +def test_reorder_noop_when_tool_call_id_mismatch(): + """tool_call_id does not match assistant's tool_calls — stop collecting.""" + payloads = { + "messages": [ + { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "call_01", "type": "function", "function": {"name": "search", "arguments": "{}"}} + ], + }, + {"role": "tool", "tool_call_id": "call_99", "content": "mismatched id"}, + {"role": "user", "content": "hello"}, + ] + } + expected = payloads["messages"][:] + + ProviderOpenAIOfficial._reorder_tailing_tool_call_user(payloads) + + assert payloads["messages"] == expected + + +def test_reorder_noop_when_assistant_has_no_tool_calls(): + """Assistant has no tool_calls — stop collecting.""" + payloads = { + "messages": [ + {"role": "assistant", "content": "some reply"}, + {"role": "user", "content": "hello"}, + ] + } + expected = payloads["messages"][:] + + ProviderOpenAIOfficial._reorder_tailing_tool_call_user(payloads) + + assert payloads["messages"] == expected + + +def test_reorder_noop_when_no_trailing_user(): + """Tail message is not user — no-op.""" + payloads = { + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "reply"}, + ] + } + expected = payloads["messages"][:] + + ProviderOpenAIOfficial._reorder_tailing_tool_call_user(payloads) + + assert payloads["messages"] == expected + + +def test_reorder_noop_on_empty_or_short_list(): + """Empty or too-short messages list — no-op, no crash.""" + for msgs in [None, [], [{"role": "user", "content": "hi"}]]: + payloads = {"messages": msgs} + ProviderOpenAIOfficial._reorder_tailing_tool_call_user(payloads) + assert payloads.get("messages") is msgs + + +def test_reorder_real_tool_call_not_affected(): + """Real tool call: tool → assistant(content) before user — should not reorder.""" + payloads = { + "messages": [ + {"role": "user", "content": "search plz"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "call_01", "type": "function", "function": {"name": "search", "arguments": "{}"}} + ], + }, + {"role": "tool", "tool_call_id": "call_01", "content": "results"}, + {"role": "assistant", "content": "here are the results"}, + {"role": "user", "content": "thanks"}, + ] + } + expected = payloads["messages"][:] + + ProviderOpenAIOfficial._reorder_tailing_tool_call_user(payloads) + + assert payloads["messages"] == expected + + +@pytest.mark.parametrize( + "provider_cls", + [ + ProviderGroq, + ProviderLongCat, + ProviderAIHubMix, + ProviderOpenRouter, + ProviderXAI, + ProviderXiaomi, + ProviderZhipu, + ], +) +def test_reorder_applied_through_inherited_sanitize(provider_cls): + """All OpenAI-compatible subclasses inherit _sanitize_assistant_messages + which includes the reorder fix — verify it works on each.""" + payloads = { + "messages": [ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "call_01", "type": "function", "function": {"name": "search", "arguments": "{}"}} + ], + }, + {"role": "tool", "tool_call_id": "call_01", "content": "mem result"}, + {"role": "user", "content": "帮我处理"}, + ] + } + + provider_cls._sanitize_assistant_messages(payloads) + + assert payloads["messages"] == [ + {"role": "user", "content": "hello"}, + {"role": "user", "content": "帮我处理"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "call_01", "type": "function", "function": {"name": "search", "arguments": "{}"}} + ], + }, + {"role": "tool", "tool_call_id": "call_01", "content": "mem result"}, + ], f"{provider_cls.__name__} did not reorder fake tool call messages" + + +def test_reorder_stops_at_first_non_pair(): + """If a non-pair message sits between pairs, only collect from the outermost contiguous block.""" + payloads = { + "messages": [ + { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "call_01", "type": "function", "function": {"name": "plugin_a", "arguments": "{}"}} + ], + }, + {"role": "tool", "tool_call_id": "call_01", "content": "result_a"}, + {"role": "assistant", "content": "intermediate reply"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "call_02", "type": "function", "function": {"name": "plugin_b", "arguments": "{}"}} + ], + }, + {"role": "tool", "tool_call_id": "call_02", "content": "result_b"}, + {"role": "user", "content": "thanks"}, + ] + } + + ProviderOpenAIOfficial._reorder_tailing_tool_call_user(payloads) + + assert payloads["messages"] == [ + { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "call_01", "type": "function", "function": {"name": "plugin_a", "arguments": "{}"}} + ], + }, + {"role": "tool", "tool_call_id": "call_01", "content": "result_a"}, + {"role": "assistant", "content": "intermediate reply"}, + {"role": "user", "content": "thanks"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "call_02", "type": "function", "function": {"name": "plugin_b", "arguments": "{}"}} + ], + }, + {"role": "tool", "tool_call_id": "call_02", "content": "result_b"}, + ] From f6618e3d638c8e074c7dad6b06bc1c4e3c84ebb7 Mon Sep 17 00:00:00 2001 From: Rail1bc <3271405327@qq.com> Date: Fri, 31 Jul 2026 11:58:20 +0800 Subject: [PATCH 3/5] fix: share fake tool call reorder helper and apply to Anthropic format Move the tail reorder logic out of openai_source's static method into a shared module-level function reorder_tailing_tool_call_user in astrbot/core/provider/provider.py, and apply it in the Anthropic provider (text_chat / text_chat_stream) before _prepare_payload converts contexts to tool_use / tool_result blocks. This aligns the fake tool call sequence with real tool-call timing (user -> assistant(tool_use) -> user(tool_result)) in both formats. Co-Authored-By: deepseek-v4-flash --- astrbot/core/provider/__init__.py | 9 +- astrbot/core/provider/provider.py | 49 ++++++++ .../core/provider/sources/anthropic_source.py | 5 + .../core/provider/sources/openai_source.py | 53 +-------- tests/test_anthropic_kimi_code_provider.py | 94 +++++++++++++++ tests/test_openai_source.py | 107 +++++++++++++----- 6 files changed, 238 insertions(+), 79 deletions(-) diff --git a/astrbot/core/provider/__init__.py b/astrbot/core/provider/__init__.py index 812e021715..0c288e4eea 100644 --- a/astrbot/core/provider/__init__.py +++ b/astrbot/core/provider/__init__.py @@ -1,4 +1,9 @@ from .entities import ProviderMetaData -from .provider import Provider, STTProvider +from .provider import Provider, STTProvider, reorder_tailing_tool_call_user -__all__ = ["Provider", "ProviderMetaData", "STTProvider"] +__all__ = [ + "Provider", + "ProviderMetaData", + "STTProvider", + "reorder_tailing_tool_call_user", +] diff --git a/astrbot/core/provider/provider.py b/astrbot/core/provider/provider.py index 891bfdea9e..ec3cb66016 100644 --- a/astrbot/core/provider/provider.py +++ b/astrbot/core/provider/provider.py @@ -211,6 +211,55 @@ async def test(self, timeout: float = 45.0) -> None: ) +def reorder_tailing_tool_call_user(messages: list) -> None: + """重排因伪造工具调用导致尾部 assistant(tc) → tool → user 乱序的消息。 + + 此为轻量妥协修复。未来若实现专用的上下文操作钩子或伪造工具调用钩子, + 可考虑移除此函数。 + """ + if not isinstance(messages, list) or len(messages) < 2: + return + + # 从尾部弹出 user 消息 + if messages[-1].get("role") != "user": + return + user_msg = messages.pop() + + # 从新尾部持续收集 assistant(tool_calls) + tool 成对消息 + pairs: list[tuple[dict, dict]] = [] + while len(messages) >= 2: + tool_msg = messages[-1] + asst_msg = messages[-2] + + if asst_msg.get("role") != "assistant" or tool_msg.get("role") != "tool": + break + + tool_calls = asst_msg.get("tool_calls") + if not tool_calls: + break + + tc_ids = {tc.get("id") for tc in tool_calls if isinstance(tc, dict)} + if tool_msg.get("tool_call_id") not in tc_ids: + break + + # 确认是一对,弹出 + messages.pop() # tool + messages.pop() # assistant + pairs.append((asst_msg, tool_msg)) + + if not pairs: + # 没有伪造对,把 user 放回去 + messages.append(user_msg) + return + + # 重排:user → assistant_1, tool_1 → ... → assistant_N, tool_N + # pairs 是从外到内收集的(N, N-1, ..., 1),反转后按 1..N 顺序回插 + messages.append(user_msg) + for asst_msg, tool_msg in reversed(pairs): + messages.append(asst_msg) + messages.append(tool_msg) + + class STTProvider(AbstractProvider): def __init__(self, provider_config: dict, provider_settings: dict) -> None: super().__init__(provider_config) diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index 27cc459622..c4cd00388b 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -14,6 +14,7 @@ from astrbot.api.provider import Provider from astrbot.core.agent.message import AudioURLPart, ContentPart, ImageURLPart, TextPart from astrbot.core.exceptions import EmptyModelOutputError +from astrbot.core.provider import reorder_tailing_tool_call_user from astrbot.core.provider.entities import LLMResponse, TokenUsage from astrbot.core.provider.func_tool_manager import ToolSet from astrbot.core.utils.media_utils import ( @@ -789,6 +790,8 @@ async def text_chat( for tool_call_result in tool_calls_result: context_query.extend(tool_call_result.to_openai_messages()) + # 伪造工具调用对需前置到用户消息之后,与真实工具调用时序对齐 + reorder_tailing_tool_call_user(context_query) system_prompt, new_messages = self._prepare_payload(context_query) model = model or self.get_model() @@ -861,6 +864,8 @@ async def text_chat_stream( for tool_call_result in tool_calls_result: context_query.extend(tool_call_result.to_openai_messages()) + # 伪造工具调用对需前置到用户消息之后,与真实工具调用时序对齐 + reorder_tailing_tool_call_user(context_query) system_prompt, new_messages = self._prepare_payload(context_query) model = model or self.get_model() diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 1a35d386f5..42d253d628 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -28,6 +28,7 @@ from astrbot.core.agent.tool import ToolSet from astrbot.core.exceptions import EmptyModelOutputError from astrbot.core.message.message_event_result import MessageChain +from astrbot.core.provider import reorder_tailing_tool_call_user from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult from astrbot.core.utils.media_utils import ( describe_media_ref, @@ -528,57 +529,7 @@ def _is_empty(content: Any) -> bool: ) payloads["messages"] = final - ProviderOpenAIOfficial._reorder_tailing_tool_call_user(payloads) - - @staticmethod - def _reorder_tailing_tool_call_user(payloads: dict) -> None: - """重排因伪造工具调用导致尾部 assistant(tc) → tool → user 乱序的消息。 - - 此为轻量妥协修复。未来若实现专用的上下文操作钩子或伪造工具调用钩子, - 可考虑移除此方法。 - """ - messages = payloads.get("messages") - if not isinstance(messages, list) or len(messages) < 2: - return - - # 从尾部弹出 user 消息 - if messages[-1].get("role") != "user": - return - user_msg = messages.pop() - - # 从新尾部持续收集 assistant(tool_calls) + tool 成对消息 - pairs: list[tuple[dict, dict]] = [] - while len(messages) >= 2: - tool_msg = messages[-1] - asst_msg = messages[-2] - - if asst_msg.get("role") != "assistant" or tool_msg.get("role") != "tool": - break - - tool_calls = asst_msg.get("tool_calls") - if not tool_calls: - break - - tc_ids = {tc.get("id") for tc in tool_calls if isinstance(tc, dict)} - if tool_msg.get("tool_call_id") not in tc_ids: - break - - # 确认是一对,弹出 - messages.pop() # tool - messages.pop() # assistant - pairs.append((asst_msg, tool_msg)) - - if not pairs: - # 没有伪造对,把 user 放回去 - messages.append(user_msg) - return - - # 重排:user → assistant_1, tool_1 → ... → assistant_N, tool_N - # pairs 是从外到内收集的(N, N-1, ..., 1),反转后按 1..N 顺序回插 - messages.append(user_msg) - for asst_msg, tool_msg in reversed(pairs): - messages.append(asst_msg) - messages.append(tool_msg) + reorder_tailing_tool_call_user(payloads["messages"]) async def _query( self, diff --git a/tests/test_anthropic_kimi_code_provider.py b/tests/test_anthropic_kimi_code_provider.py index 0dc33f58ba..92f2fc9e91 100644 --- a/tests/test_anthropic_kimi_code_provider.py +++ b/tests/test_anthropic_kimi_code_provider.py @@ -864,3 +864,97 @@ async def test_tool_choice_empty_tool_list_skips_tool_choice(monkeypatch): kwargs = _capture_payloads_create.last_kwargs assert "tools" not in kwargs assert "tool_choice" not in kwargs + + +# ── fake tool call 重排 ─────────────────────────────────────────────────────── + +_FAKE_TOOL_CALL_CONTEXTS = [ + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "fake_recall_abc", + "type": "function", + "function": { + "name": "recall_long_term_memory", + "arguments": '{"query": "我的名字是?", "k": 5}', + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "fake_recall_abc", + "content": "memory json", + }, +] + +_EXPECTED_REORDERED_MESSAGES = [ + {"role": "user", "content": "我的名字是?"}, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "name": "recall_long_term_memory", + "input": {"query": "我的名字是?", "k": 5}, + "id": "fake_recall_abc", + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "fake_recall_abc", + "content": "memory json", + } + ], + }, +] + + +@pytest.mark.asyncio +async def test_text_chat_reorders_fake_tool_call_pair(monkeypatch): + """伪造工具调用对应重排到用户消息之后,与真实工具调用时序对齐。""" + provider = _setup_provider_with_mock_client(monkeypatch) + + await provider.text_chat(prompt="我的名字是?", contexts=_FAKE_TOOL_CALL_CONTEXTS) + + assert _capture_payloads_create.last_kwargs["messages"] == ( + _EXPECTED_REORDERED_MESSAGES + ) + + +@pytest.mark.asyncio +async def test_text_chat_stream_reorders_fake_tool_call_pair(monkeypatch): + """流式路径同样应将伪造工具调用对重排到用户消息之后。""" + monkeypatch.setattr(anthropic_source, "AsyncAnthropic", _FakeAsyncAnthropic) + + provider = anthropic_source.ProviderAnthropic( + provider_config={ + "id": "anthropic-test", + "type": "anthropic_chat_completion", + "model": "claude-test", + "key": ["test-key"], + }, + provider_settings={}, + ) + + captured: dict[str, object] = {} + + async def fake_query_stream(payloads, tools, *, request_max_retries=None): + captured["messages"] = payloads["messages"] + return + yield # pragma: no cover # 保持 async generator 形态 + + monkeypatch.setattr(provider, "_query_stream", fake_query_stream) + + async for _ in provider.text_chat_stream( + prompt="我的名字是?", contexts=_FAKE_TOOL_CALL_CONTEXTS + ): + pass + + assert captured["messages"] == _EXPECTED_REORDERED_MESSAGES diff --git a/tests/test_openai_source.py b/tests/test_openai_source.py index 6c0d727b3b..7d29a784e0 100644 --- a/tests/test_openai_source.py +++ b/tests/test_openai_source.py @@ -12,6 +12,7 @@ import astrbot.core.provider.sources.openai_source as openai_source_module import astrbot.core.provider.sources.request_retry as request_retry from astrbot.core.exceptions import EmptyModelOutputError +from astrbot.core.provider import reorder_tailing_tool_call_user from astrbot.core.provider.entities import LLMResponse from astrbot.core.provider.sources.groq_source import ProviderGroq from astrbot.core.provider.sources.longcat_source import ProviderLongCat @@ -2201,7 +2202,7 @@ async def fake_create(**kwargs): await provider.terminate() -# ── _reorder_tailing_tool_call_user ────────────────────────────────────────── +# ── reorder_tailing_tool_call_user ─────────────────────────────────────────── def test_reorder_single_fake_pair(): @@ -2213,7 +2214,11 @@ def test_reorder_single_fake_pair(): "role": "assistant", "content": None, "tool_calls": [ - {"id": "call_01", "type": "function", "function": {"name": "search", "arguments": "{}"}} + { + "id": "call_01", + "type": "function", + "function": {"name": "search", "arguments": "{}"}, + } ], }, {"role": "tool", "tool_call_id": "call_01", "content": "mem result"}, @@ -2221,7 +2226,7 @@ def test_reorder_single_fake_pair(): ] } - ProviderOpenAIOfficial._reorder_tailing_tool_call_user(payloads) + reorder_tailing_tool_call_user(payloads["messages"]) assert payloads["messages"] == [ {"role": "user", "content": "hello"}, @@ -2230,7 +2235,11 @@ def test_reorder_single_fake_pair(): "role": "assistant", "content": None, "tool_calls": [ - {"id": "call_01", "type": "function", "function": {"name": "search", "arguments": "{}"}} + { + "id": "call_01", + "type": "function", + "function": {"name": "search", "arguments": "{}"}, + } ], }, {"role": "tool", "tool_call_id": "call_01", "content": "mem result"}, @@ -2246,7 +2255,11 @@ def test_reorder_multiple_fake_pairs(): "role": "assistant", "content": None, "tool_calls": [ - {"id": "call_01", "type": "function", "function": {"name": "plugin_a", "arguments": "{}"}} + { + "id": "call_01", + "type": "function", + "function": {"name": "plugin_a", "arguments": "{}"}, + } ], }, {"role": "tool", "tool_call_id": "call_01", "content": "result_a"}, @@ -2254,7 +2267,11 @@ def test_reorder_multiple_fake_pairs(): "role": "assistant", "content": None, "tool_calls": [ - {"id": "call_02", "type": "function", "function": {"name": "plugin_b", "arguments": "{}"}} + { + "id": "call_02", + "type": "function", + "function": {"name": "plugin_b", "arguments": "{}"}, + } ], }, {"role": "tool", "tool_call_id": "call_02", "content": "result_b"}, @@ -2262,7 +2279,7 @@ def test_reorder_multiple_fake_pairs(): ] } - ProviderOpenAIOfficial._reorder_tailing_tool_call_user(payloads) + reorder_tailing_tool_call_user(payloads["messages"]) assert payloads["messages"] == [ {"role": "user", "content": "hello"}, @@ -2271,7 +2288,11 @@ def test_reorder_multiple_fake_pairs(): "role": "assistant", "content": None, "tool_calls": [ - {"id": "call_01", "type": "function", "function": {"name": "plugin_a", "arguments": "{}"}} + { + "id": "call_01", + "type": "function", + "function": {"name": "plugin_a", "arguments": "{}"}, + } ], }, {"role": "tool", "tool_call_id": "call_01", "content": "result_a"}, @@ -2279,7 +2300,11 @@ def test_reorder_multiple_fake_pairs(): "role": "assistant", "content": None, "tool_calls": [ - {"id": "call_02", "type": "function", "function": {"name": "plugin_b", "arguments": "{}"}} + { + "id": "call_02", + "type": "function", + "function": {"name": "plugin_b", "arguments": "{}"}, + } ], }, {"role": "tool", "tool_call_id": "call_02", "content": "result_b"}, @@ -2297,7 +2322,7 @@ def test_reorder_noop_when_no_fake_pair(): } expected = payloads["messages"][:] - ProviderOpenAIOfficial._reorder_tailing_tool_call_user(payloads) + reorder_tailing_tool_call_user(payloads["messages"]) assert payloads["messages"] == expected @@ -2310,7 +2335,11 @@ def test_reorder_noop_when_tool_call_id_mismatch(): "role": "assistant", "content": None, "tool_calls": [ - {"id": "call_01", "type": "function", "function": {"name": "search", "arguments": "{}"}} + { + "id": "call_01", + "type": "function", + "function": {"name": "search", "arguments": "{}"}, + } ], }, {"role": "tool", "tool_call_id": "call_99", "content": "mismatched id"}, @@ -2319,7 +2348,7 @@ def test_reorder_noop_when_tool_call_id_mismatch(): } expected = payloads["messages"][:] - ProviderOpenAIOfficial._reorder_tailing_tool_call_user(payloads) + reorder_tailing_tool_call_user(payloads["messages"]) assert payloads["messages"] == expected @@ -2334,7 +2363,7 @@ def test_reorder_noop_when_assistant_has_no_tool_calls(): } expected = payloads["messages"][:] - ProviderOpenAIOfficial._reorder_tailing_tool_call_user(payloads) + reorder_tailing_tool_call_user(payloads["messages"]) assert payloads["messages"] == expected @@ -2349,7 +2378,7 @@ def test_reorder_noop_when_no_trailing_user(): } expected = payloads["messages"][:] - ProviderOpenAIOfficial._reorder_tailing_tool_call_user(payloads) + reorder_tailing_tool_call_user(payloads["messages"]) assert payloads["messages"] == expected @@ -2357,9 +2386,7 @@ def test_reorder_noop_when_no_trailing_user(): def test_reorder_noop_on_empty_or_short_list(): """Empty or too-short messages list — no-op, no crash.""" for msgs in [None, [], [{"role": "user", "content": "hi"}]]: - payloads = {"messages": msgs} - ProviderOpenAIOfficial._reorder_tailing_tool_call_user(payloads) - assert payloads.get("messages") is msgs + reorder_tailing_tool_call_user(msgs) def test_reorder_real_tool_call_not_affected(): @@ -2371,7 +2398,11 @@ def test_reorder_real_tool_call_not_affected(): "role": "assistant", "content": None, "tool_calls": [ - {"id": "call_01", "type": "function", "function": {"name": "search", "arguments": "{}"}} + { + "id": "call_01", + "type": "function", + "function": {"name": "search", "arguments": "{}"}, + } ], }, {"role": "tool", "tool_call_id": "call_01", "content": "results"}, @@ -2381,7 +2412,7 @@ def test_reorder_real_tool_call_not_affected(): } expected = payloads["messages"][:] - ProviderOpenAIOfficial._reorder_tailing_tool_call_user(payloads) + reorder_tailing_tool_call_user(payloads["messages"]) assert payloads["messages"] == expected @@ -2408,7 +2439,11 @@ def test_reorder_applied_through_inherited_sanitize(provider_cls): "role": "assistant", "content": None, "tool_calls": [ - {"id": "call_01", "type": "function", "function": {"name": "search", "arguments": "{}"}} + { + "id": "call_01", + "type": "function", + "function": {"name": "search", "arguments": "{}"}, + } ], }, {"role": "tool", "tool_call_id": "call_01", "content": "mem result"}, @@ -2425,7 +2460,11 @@ def test_reorder_applied_through_inherited_sanitize(provider_cls): "role": "assistant", "content": None, "tool_calls": [ - {"id": "call_01", "type": "function", "function": {"name": "search", "arguments": "{}"}} + { + "id": "call_01", + "type": "function", + "function": {"name": "search", "arguments": "{}"}, + } ], }, {"role": "tool", "tool_call_id": "call_01", "content": "mem result"}, @@ -2440,7 +2479,11 @@ def test_reorder_stops_at_first_non_pair(): "role": "assistant", "content": None, "tool_calls": [ - {"id": "call_01", "type": "function", "function": {"name": "plugin_a", "arguments": "{}"}} + { + "id": "call_01", + "type": "function", + "function": {"name": "plugin_a", "arguments": "{}"}, + } ], }, {"role": "tool", "tool_call_id": "call_01", "content": "result_a"}, @@ -2449,7 +2492,11 @@ def test_reorder_stops_at_first_non_pair(): "role": "assistant", "content": None, "tool_calls": [ - {"id": "call_02", "type": "function", "function": {"name": "plugin_b", "arguments": "{}"}} + { + "id": "call_02", + "type": "function", + "function": {"name": "plugin_b", "arguments": "{}"}, + } ], }, {"role": "tool", "tool_call_id": "call_02", "content": "result_b"}, @@ -2457,14 +2504,18 @@ def test_reorder_stops_at_first_non_pair(): ] } - ProviderOpenAIOfficial._reorder_tailing_tool_call_user(payloads) + reorder_tailing_tool_call_user(payloads["messages"]) assert payloads["messages"] == [ { "role": "assistant", "content": None, "tool_calls": [ - {"id": "call_01", "type": "function", "function": {"name": "plugin_a", "arguments": "{}"}} + { + "id": "call_01", + "type": "function", + "function": {"name": "plugin_a", "arguments": "{}"}, + } ], }, {"role": "tool", "tool_call_id": "call_01", "content": "result_a"}, @@ -2474,7 +2525,11 @@ def test_reorder_stops_at_first_non_pair(): "role": "assistant", "content": None, "tool_calls": [ - {"id": "call_02", "type": "function", "function": {"name": "plugin_b", "arguments": "{}"}} + { + "id": "call_02", + "type": "function", + "function": {"name": "plugin_b", "arguments": "{}"}, + } ], }, {"role": "tool", "tool_call_id": "call_02", "content": "result_b"}, From 9ea4e1d408d13dcda6ea5bb4a99e8e44071cb99d Mon Sep 17 00:00:00 2001 From: Rail1bc <3271405327@qq.com> Date: Fri, 31 Jul 2026 12:14:12 +0800 Subject: [PATCH 4/5] fix: apply fake tool call reorder to Gemini provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemini API rejects functionCall turns that do not immediately follow a user turn or a function response turn (400 INVALID_ARGUMENT). The fake tool call pair injected at the contexts tail converts to model(functionCall) -> user([functionResponse, 新问题]), so the functionCall follows a model turn and is rejected outright. Apply the shared reorder_tailing_tool_call_user in text_chat / text_chat_stream before _prepare_conversation, producing the legal and correctly timed user(新问题) -> model(functionCall) -> user(functionResponse). Co-Authored-By: deepseek-v4-flash --- .../core/provider/sources/gemini_source.py | 9 ++ tests/test_gemini_source.py | 86 ++++++++++++++++++- 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py index abf7bb7cf8..05dbc49ca6 100644 --- a/astrbot/core/provider/sources/gemini_source.py +++ b/astrbot/core/provider/sources/gemini_source.py @@ -17,6 +17,7 @@ from astrbot.core.agent.message import AudioURLPart, ContentPart, ImageURLPart, TextPart from astrbot.core.exceptions import EmptyModelOutputError from astrbot.core.message.message_event_result import MessageChain +from astrbot.core.provider import reorder_tailing_tool_call_user from astrbot.core.provider.entities import LLMResponse, TokenUsage from astrbot.core.provider.func_tool_manager import ToolSet from astrbot.core.utils.media_utils import ( @@ -857,6 +858,10 @@ async def text_chat( for tcr in tool_calls_result: context_query.extend(tcr.to_openai_messages()) + # 伪造工具调用对需前置到用户消息之后,与真实工具调用时序对齐。 + # Gemini API 要求 functionCall 回合必须紧跟 user 回合,否则返回 400。 + reorder_tailing_tool_call_user(context_query) + model = model or self.get_model() payloads = {"messages": context_query, "model": model} @@ -924,6 +929,10 @@ async def text_chat_stream( for tcr in tool_calls_result: context_query.extend(tcr.to_openai_messages()) + # 伪造工具调用对需前置到用户消息之后,与真实工具调用时序对齐。 + # Gemini API 要求 functionCall 回合必须紧跟 user 回合,否则返回 400。 + reorder_tailing_tool_call_user(context_query) + model = model or self.get_model() payloads = {"messages": context_query, "model": model} diff --git a/tests/test_gemini_source.py b/tests/test_gemini_source.py index 9294ea46b2..5f6fe069f1 100644 --- a/tests/test_gemini_source.py +++ b/tests/test_gemini_source.py @@ -3,8 +3,8 @@ import httpx import pytest -from astrbot.core.exceptions import EmptyModelOutputError import astrbot.core.provider.sources.request_retry as request_retry +from astrbot.core.exceptions import EmptyModelOutputError from astrbot.core.provider.entities import LLMResponse from astrbot.core.provider.sources.gemini_source import ProviderGoogleGenAI @@ -33,6 +33,90 @@ def test_gemini_reasoning_only_output_is_allowed(): ) +_FAKE_TOOL_CALL_CONTEXTS = [ + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "fake_recall_abc", + "type": "function", + "function": { + "name": "recall_long_term_memory", + "arguments": '{"query": "我的名字是?", "k": 5}', + }, + } + ], + }, + {"role": "tool", "tool_call_id": "fake_recall_abc", "content": "memory json"}, +] + + +def _make_provider() -> ProviderGoogleGenAI: + provider = ProviderGoogleGenAI.__new__(ProviderGoogleGenAI) + provider.api_keys = [""] + provider.model_name = "" + return provider + + +def _assert_reordered_tail(messages: list[dict]) -> None: + assert messages[-3]["role"] == "user" + assert messages[-3]["content"] == "我的名字是?" + assert messages[-2]["role"] == "assistant" + assert messages[-2]["tool_calls"][0]["id"] == "fake_recall_abc" + assert ( + messages[-2]["tool_calls"][0]["function"]["name"] == "recall_long_term_memory" + ) + assert messages[-1]["role"] == "tool" + assert messages[-1]["tool_call_id"] == "fake_recall_abc" + + +@pytest.mark.asyncio +async def test_text_chat_reorders_fake_tool_call_pair(monkeypatch): + captured = {} + + async def fake_query(payloads, tools, *, request_max_retries=None): + captured["payloads"] = payloads + return LLMResponse(role="assistant") + + provider = _make_provider() + monkeypatch.setattr(provider, "_query", fake_query) + + await provider.text_chat(prompt="我的名字是?", contexts=_FAKE_TOOL_CALL_CONTEXTS) + + messages = captured["payloads"]["messages"] + _assert_reordered_tail(messages) + + # 转换后的 contents 应为 user → model(functionCall) → user(functionResponse) + contents = provider._prepare_conversation(captured["payloads"]) + assert [content.role for content in contents] == ["user", "model", "user"] + assert contents[0].parts[0].text == "我的名字是?" + assert contents[1].parts[0].function_call.name == "recall_long_term_memory" + assert contents[2].parts[0].function_response.name == "fake_recall_abc" + + +@pytest.mark.asyncio +async def test_text_chat_stream_reorders_fake_tool_call_pair(monkeypatch): + captured = {} + + async def fake_query_stream(payloads, tools, *, request_max_retries=None): + captured["payloads"] = payloads + return + yield # pragma: no cover + + provider = _make_provider() + monkeypatch.setattr(provider, "_query_stream", fake_query_stream) + + async for _ in provider.text_chat_stream( + prompt="我的名字是?", + contexts=_FAKE_TOOL_CALL_CONTEXTS, + ): + pass + + messages = captured["payloads"]["messages"] + _assert_reordered_tail(messages) + + @pytest.mark.asyncio async def test_gemini_get_models_retries_transient_request_error(monkeypatch): monkeypatch.setattr(request_retry, "REQUEST_RETRY_WAIT_MIN_S", 0) From fd46839d5745fc76ede601412c6cb5abb75fb676 Mon Sep 17 00:00:00 2001 From: Rail1bc <3271405327@qq.com> Date: Fri, 31 Jul 2026 12:27:22 +0800 Subject: [PATCH 5/5] refactor: index-based reorder scan, shared test fixture Address AI review feedback: - Rewrite reorder_tailing_tool_call_user to scan backwards with indices and rebuild the tail in one shot instead of in-place popping during pattern matching; extract the tool-call pairing check into the _is_valid_tool_pair helper. Behavior is unchanged (verified against the previous implementation). - Tighten the messages parameter type to list[dict[str, Any]]. - Extract the duplicated FAKE_TOOL_CALL_CONTEXTS into tests/fixtures/fake_tool_call.py, shared by the Anthropic and Gemini provider tests. Co-Authored-By: deepseek-v4-flash --- astrbot/core/provider/provider.py | 59 ++++++++++------------ tests/fixtures/fake_tool_call.py | 23 +++++++++ tests/test_anthropic_kimi_code_provider.py | 27 ++-------- tests/test_gemini_source.py | 24 ++------- 4 files changed, 57 insertions(+), 76 deletions(-) create mode 100644 tests/fixtures/fake_tool_call.py diff --git a/astrbot/core/provider/provider.py b/astrbot/core/provider/provider.py index ec3cb66016..21ae2137b3 100644 --- a/astrbot/core/provider/provider.py +++ b/astrbot/core/provider/provider.py @@ -2,7 +2,7 @@ import asyncio import os from collections.abc import AsyncGenerator -from typing import Literal, TypeAlias, Union +from typing import Any, Literal, TypeAlias, Union from astrbot.core.agent.message import ContentPart, Message, is_checkpoint_message from astrbot.core.agent.tool import ToolSet @@ -211,7 +211,18 @@ async def test(self, timeout: float = 45.0) -> None: ) -def reorder_tailing_tool_call_user(messages: list) -> None: +def _is_valid_tool_pair(asst_msg: dict[str, Any], tool_msg: dict[str, Any]) -> bool: + """判断 assistant(tool_calls) 与 tool 是否为 tool_call_id 匹配的一对。""" + if asst_msg.get("role") != "assistant" or tool_msg.get("role") != "tool": + return False + tool_calls = asst_msg.get("tool_calls") + if not tool_calls: + return False + tc_ids = {tc.get("id") for tc in tool_calls if isinstance(tc, dict)} + return tool_msg.get("tool_call_id") in tc_ids + + +def reorder_tailing_tool_call_user(messages: list[dict[str, Any]]) -> None: """重排因伪造工具调用导致尾部 assistant(tc) → tool → user 乱序的消息。 此为轻量妥协修复。未来若实现专用的上下文操作钩子或伪造工具调用钩子, @@ -220,44 +231,30 @@ def reorder_tailing_tool_call_user(messages: list) -> None: if not isinstance(messages, list) or len(messages) < 2: return - # 从尾部弹出 user 消息 - if messages[-1].get("role") != "user": + last = messages[-1] + if last.get("role") != "user": return - user_msg = messages.pop() - - # 从新尾部持续收集 assistant(tool_calls) + tool 成对消息 - pairs: list[tuple[dict, dict]] = [] - while len(messages) >= 2: - tool_msg = messages[-1] - asst_msg = messages[-2] - if asst_msg.get("role") != "assistant" or tool_msg.get("role") != "tool": + # 从最后一个非 user 元素向前扫描 assistant(tool_calls) + tool 成对消息 + pairs: list[tuple[dict[str, Any], dict[str, Any]]] = [] + i = len(messages) - 2 + while i >= 1: + if not _is_valid_tool_pair(messages[i - 1], messages[i]): break - - tool_calls = asst_msg.get("tool_calls") - if not tool_calls: - break - - tc_ids = {tc.get("id") for tc in tool_calls if isinstance(tc, dict)} - if tool_msg.get("tool_call_id") not in tc_ids: - break - - # 确认是一对,弹出 - messages.pop() # tool - messages.pop() # assistant - pairs.append((asst_msg, tool_msg)) + pairs.append((messages[i - 1], messages[i])) + i -= 2 if not pairs: - # 没有伪造对,把 user 放回去 - messages.append(user_msg) return # 重排:user → assistant_1, tool_1 → ... → assistant_N, tool_N - # pairs 是从外到内收集的(N, N-1, ..., 1),反转后按 1..N 顺序回插 - messages.append(user_msg) + # pairs 从内到外收集(N, N-1, ..., 1),反转后按 1..N 顺序回插 + new_tail: list[dict[str, Any]] = [last] for asst_msg, tool_msg in reversed(pairs): - messages.append(asst_msg) - messages.append(tool_msg) + new_tail.append(asst_msg) + new_tail.append(tool_msg) + + messages[:] = messages[: i + 1] + new_tail class STTProvider(AbstractProvider): diff --git a/tests/fixtures/fake_tool_call.py b/tests/fixtures/fake_tool_call.py new file mode 100644 index 0000000000..ed996a9dcf --- /dev/null +++ b/tests/fixtures/fake_tool_call.py @@ -0,0 +1,23 @@ +"""伪造工具调用(fake tool call)共享测试数据。 + +各 provider 测试(OpenAI / Anthropic / Gemini 格式)共用同一组伪造 +assistant(tool_calls) + tool 消息对,避免场景漂移。 +""" + +FAKE_TOOL_CALL_CONTEXTS = [ + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "fake_recall_abc", + "type": "function", + "function": { + "name": "recall_long_term_memory", + "arguments": '{"query": "我的名字是?", "k": 5}', + }, + } + ], + }, + {"role": "tool", "tool_call_id": "fake_recall_abc", "content": "memory json"}, +] diff --git a/tests/test_anthropic_kimi_code_provider.py b/tests/test_anthropic_kimi_code_provider.py index 92f2fc9e91..b8bf055aaf 100644 --- a/tests/test_anthropic_kimi_code_provider.py +++ b/tests/test_anthropic_kimi_code_provider.py @@ -9,6 +9,7 @@ import astrbot.core.provider.sources.request_retry as request_retry from astrbot.core.exceptions import EmptyModelOutputError from astrbot.core.provider.entities import LLMResponse +from tests.fixtures.fake_tool_call import FAKE_TOOL_CALL_CONTEXTS class _FakeAsyncAnthropic: @@ -868,28 +869,6 @@ async def test_tool_choice_empty_tool_list_skips_tool_choice(monkeypatch): # ── fake tool call 重排 ─────────────────────────────────────────────────────── -_FAKE_TOOL_CALL_CONTEXTS = [ - { - "role": "assistant", - "content": None, - "tool_calls": [ - { - "id": "fake_recall_abc", - "type": "function", - "function": { - "name": "recall_long_term_memory", - "arguments": '{"query": "我的名字是?", "k": 5}', - }, - } - ], - }, - { - "role": "tool", - "tool_call_id": "fake_recall_abc", - "content": "memory json", - }, -] - _EXPECTED_REORDERED_MESSAGES = [ {"role": "user", "content": "我的名字是?"}, { @@ -921,7 +900,7 @@ async def test_text_chat_reorders_fake_tool_call_pair(monkeypatch): """伪造工具调用对应重排到用户消息之后,与真实工具调用时序对齐。""" provider = _setup_provider_with_mock_client(monkeypatch) - await provider.text_chat(prompt="我的名字是?", contexts=_FAKE_TOOL_CALL_CONTEXTS) + await provider.text_chat(prompt="我的名字是?", contexts=FAKE_TOOL_CALL_CONTEXTS) assert _capture_payloads_create.last_kwargs["messages"] == ( _EXPECTED_REORDERED_MESSAGES @@ -953,7 +932,7 @@ async def fake_query_stream(payloads, tools, *, request_max_retries=None): monkeypatch.setattr(provider, "_query_stream", fake_query_stream) async for _ in provider.text_chat_stream( - prompt="我的名字是?", contexts=_FAKE_TOOL_CALL_CONTEXTS + prompt="我的名字是?", contexts=FAKE_TOOL_CALL_CONTEXTS ): pass diff --git a/tests/test_gemini_source.py b/tests/test_gemini_source.py index 5f6fe069f1..e01b083d80 100644 --- a/tests/test_gemini_source.py +++ b/tests/test_gemini_source.py @@ -7,6 +7,7 @@ from astrbot.core.exceptions import EmptyModelOutputError from astrbot.core.provider.entities import LLMResponse from astrbot.core.provider.sources.gemini_source import ProviderGoogleGenAI +from tests.fixtures.fake_tool_call import FAKE_TOOL_CALL_CONTEXTS def test_gemini_empty_output_raises_empty_model_output_error(): @@ -33,25 +34,6 @@ def test_gemini_reasoning_only_output_is_allowed(): ) -_FAKE_TOOL_CALL_CONTEXTS = [ - { - "role": "assistant", - "content": None, - "tool_calls": [ - { - "id": "fake_recall_abc", - "type": "function", - "function": { - "name": "recall_long_term_memory", - "arguments": '{"query": "我的名字是?", "k": 5}', - }, - } - ], - }, - {"role": "tool", "tool_call_id": "fake_recall_abc", "content": "memory json"}, -] - - def _make_provider() -> ProviderGoogleGenAI: provider = ProviderGoogleGenAI.__new__(ProviderGoogleGenAI) provider.api_keys = [""] @@ -82,7 +64,7 @@ async def fake_query(payloads, tools, *, request_max_retries=None): provider = _make_provider() monkeypatch.setattr(provider, "_query", fake_query) - await provider.text_chat(prompt="我的名字是?", contexts=_FAKE_TOOL_CALL_CONTEXTS) + await provider.text_chat(prompt="我的名字是?", contexts=FAKE_TOOL_CALL_CONTEXTS) messages = captured["payloads"]["messages"] _assert_reordered_tail(messages) @@ -109,7 +91,7 @@ async def fake_query_stream(payloads, tools, *, request_max_retries=None): async for _ in provider.text_chat_stream( prompt="我的名字是?", - contexts=_FAKE_TOOL_CALL_CONTEXTS, + contexts=FAKE_TOOL_CALL_CONTEXTS, ): pass