AstrBot 4.5.8__py3-none-any.whl → 4.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. astrbot/core/agent/mcp_client.py +152 -26
  2. astrbot/core/agent/message.py +7 -0
  3. astrbot/core/config/default.py +8 -1
  4. astrbot/core/core_lifecycle.py +8 -0
  5. astrbot/core/db/__init__.py +50 -1
  6. astrbot/core/db/migration/migra_webchat_session.py +131 -0
  7. astrbot/core/db/po.py +49 -13
  8. astrbot/core/db/sqlite.py +102 -3
  9. astrbot/core/knowledge_base/kb_helper.py +314 -33
  10. astrbot/core/knowledge_base/kb_mgr.py +45 -1
  11. astrbot/core/knowledge_base/parsers/url_parser.py +103 -0
  12. astrbot/core/knowledge_base/prompts.py +65 -0
  13. astrbot/core/pipeline/process_stage/method/llm_request.py +28 -14
  14. astrbot/core/pipeline/process_stage/utils.py +60 -16
  15. astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py +13 -10
  16. astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py +8 -4
  17. astrbot/core/platform/sources/wecom_ai_bot/wecomai_queue_mgr.py +0 -4
  18. astrbot/core/provider/entities.py +22 -9
  19. astrbot/core/provider/func_tool_manager.py +12 -9
  20. astrbot/core/provider/sources/gemini_source.py +25 -8
  21. astrbot/core/provider/sources/openai_source.py +9 -16
  22. astrbot/dashboard/routes/chat.py +134 -77
  23. astrbot/dashboard/routes/knowledge_base.py +172 -0
  24. {astrbot-4.5.8.dist-info → astrbot-4.6.0.dist-info}/METADATA +4 -3
  25. {astrbot-4.5.8.dist-info → astrbot-4.6.0.dist-info}/RECORD +28 -25
  26. {astrbot-4.5.8.dist-info → astrbot-4.6.0.dist-info}/WHEEL +0 -0
  27. {astrbot-4.5.8.dist-info → astrbot-4.6.0.dist-info}/entry_points.txt +0 -0
  28. {astrbot-4.5.8.dist-info → astrbot-4.6.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,65 @@
1
+ TEXT_REPAIR_SYSTEM_PROMPT = """You are a meticulous digital archivist. Your mission is to reconstruct a clean, readable article from raw, noisy text chunks.
2
+
3
+ **Core Task:**
4
+ 1. **Analyze:** Examine the text chunk to separate "signal" (substantive information) from "noise" (UI elements, ads, navigation, footers).
5
+ 2. **Process:** Clean and repair the signal. **Do not translate it.** Keep the original language.
6
+
7
+ **Crucial Rules:**
8
+ - **NEVER discard a chunk if it contains ANY valuable information.** Your primary duty is to salvage content.
9
+ - **If a chunk contains multiple distinct topics, split them.** Enclose each topic in its own `<repaired_text>` tag.
10
+ - Your output MUST be ONLY `<repaired_text>...</repaired_text>` tags or a single `<discard_chunk />` tag.
11
+
12
+ ---
13
+ **Example 1: Chunk with Noise and Signal**
14
+
15
+ *Input Chunk:*
16
+ "Home | About | Products | **The Llama is a domesticated South American camelid.** | © 2025 ACME Corp."
17
+
18
+ *Your Thought Process:*
19
+ 1. "Home | About | Products..." and "© 2025 ACME Corp." are noise.
20
+ 2. "The Llama is a domesticated..." is the signal.
21
+ 3. I must extract the signal and wrap it.
22
+
23
+ *Your Output:*
24
+ <repaired_text>
25
+ The Llama is a domesticated South American camelid.
26
+ </repaired_text>
27
+
28
+ ---
29
+ **Example 2: Chunk with ONLY Noise**
30
+
31
+ *Input Chunk:*
32
+ "Next Page > | Subscribe to our newsletter | Follow us on X"
33
+
34
+ *Your Thought Process:*
35
+ 1. This entire chunk is noise. There is no signal.
36
+ 2. I must discard this.
37
+
38
+ *Your Output:*
39
+ <discard_chunk />
40
+
41
+ ---
42
+ **Example 3: Chunk with Multiple Topics (Requires Splitting)**
43
+
44
+ *Input Chunk:*
45
+ "## Chapter 1: The Sun
46
+ The Sun is the star at the center of the Solar System.
47
+
48
+ ## Chapter 2: The Moon
49
+ The Moon is Earth's only natural satellite."
50
+
51
+ *Your Thought Process:*
52
+ 1. This chunk contains two distinct topics.
53
+ 2. I must process them separately to maintain semantic integrity.
54
+ 3. I will create two `<repaired_text>` blocks.
55
+
56
+ *Your Output:*
57
+ <repaired_text>
58
+ ## Chapter 1: The Sun
59
+ The Sun is the star at the center of the Solar System.
60
+ </repaired_text>
61
+ <repaired_text>
62
+ ## Chapter 2: The Moon
63
+ The Moon is Earth's only natural satellite.
64
+ </repaired_text>
65
+ """
@@ -32,7 +32,7 @@ from ....astr_agent_run_util import AgentRunner, run_agent
32
32
  from ....astr_agent_tool_exec import FunctionToolExecutor
33
33
  from ...context import PipelineContext, call_event_hook
34
34
  from ..stage import Stage
35
- from ..utils import inject_kb_context
35
+ from ..utils import KNOWLEDGE_BASE_QUERY_TOOL, retrieve_knowledge_base
36
36
 
37
37
 
38
38
  class LLMRequestSubStage(Stage):
@@ -57,6 +57,7 @@ class LLMRequestSubStage(Stage):
57
57
  self.max_step = 30
58
58
  self.show_tool_use: bool = settings.get("show_tool_use_status", True)
59
59
  self.show_reasoning = settings.get("display_reasoning_text", False)
60
+ self.kb_agentic_mode: bool = conf.get("kb_agentic_mode", False)
60
61
 
61
62
  for bwp in self.bot_wake_prefixs:
62
63
  if self.provider_wake_prefix.startswith(bwp):
@@ -95,20 +96,33 @@ class LLMRequestSubStage(Stage):
95
96
  raise RuntimeError("无法创建新的对话。")
96
97
  return conversation
97
98
 
98
- async def _apply_kb_context(
99
+ async def _apply_kb(
99
100
  self,
100
101
  event: AstrMessageEvent,
101
102
  req: ProviderRequest,
102
103
  ):
103
- """应用知识库上下文到请求中"""
104
- try:
105
- await inject_kb_context(
106
- umo=event.unified_msg_origin,
107
- p_ctx=self.ctx,
108
- req=req,
109
- )
110
- except Exception as e:
111
- logger.error(f"调用知识库时遇到问题: {e}")
104
+ """Apply knowledge base context to the provider request"""
105
+ if not self.kb_agentic_mode:
106
+ if req.prompt is None:
107
+ return
108
+ try:
109
+ kb_result = await retrieve_knowledge_base(
110
+ query=req.prompt,
111
+ umo=event.unified_msg_origin,
112
+ context=self.ctx.plugin_manager.context,
113
+ )
114
+ if not kb_result:
115
+ return
116
+ if req.system_prompt is not None:
117
+ req.system_prompt += (
118
+ f"\n\n[Related Knowledge Base Results]:\n{kb_result}"
119
+ )
120
+ except Exception as e:
121
+ logger.error(f"Error occurred while retrieving knowledge base: {e}")
122
+ else:
123
+ if req.func_tool is None:
124
+ req.func_tool = ToolSet()
125
+ req.func_tool.add_tool(KNOWLEDGE_BASE_QUERY_TOOL)
112
126
 
113
127
  def _truncate_contexts(
114
128
  self,
@@ -356,13 +370,13 @@ class LLMRequestSubStage(Stage):
356
370
  if not req.prompt and not req.image_urls:
357
371
  return
358
372
 
359
- # apply knowledge base context
360
- await self._apply_kb_context(event, req)
361
-
362
373
  # call event hook
363
374
  if await call_event_hook(event, EventType.OnLLMRequestEvent, req):
364
375
  return
365
376
 
377
+ # apply knowledge base feature
378
+ await self._apply_kb(event, req)
379
+
366
380
  # fix contexts json str
367
381
  if isinstance(req.contexts, str):
368
382
  req.contexts = json.loads(req.contexts)
@@ -1,23 +1,64 @@
1
- from astrbot.api import logger, sp
2
- from astrbot.core.provider.entities import ProviderRequest
3
-
4
- from ..context import PipelineContext
1
+ from pydantic import Field
2
+ from pydantic.dataclasses import dataclass
5
3
 
4
+ from astrbot.api import logger, sp
5
+ from astrbot.core.agent.run_context import ContextWrapper
6
+ from astrbot.core.agent.tool import FunctionTool, ToolExecResult
7
+ from astrbot.core.astr_agent_context import AstrAgentContext
8
+ from astrbot.core.star.context import Context
9
+
10
+
11
+ @dataclass
12
+ class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]):
13
+ name: str = "astr_kb_search"
14
+ description: str = (
15
+ "Query the knowledge base for facts or relevant context. "
16
+ "Use this tool when the user's question requires factual information, "
17
+ "definitions, background knowledge, or previously indexed content. "
18
+ "Only send short keywords or a concise question as the query."
19
+ )
20
+ parameters: dict = Field(
21
+ default_factory=lambda: {
22
+ "type": "object",
23
+ "properties": {
24
+ "query": {
25
+ "type": "string",
26
+ "description": "A concise keyword query for the knowledge base.",
27
+ },
28
+ },
29
+ "required": ["query"],
30
+ }
31
+ )
6
32
 
7
- async def inject_kb_context(
33
+ async def call(
34
+ self, context: ContextWrapper[AstrAgentContext], **kwargs
35
+ ) -> ToolExecResult:
36
+ query = kwargs.get("query", "")
37
+ if not query:
38
+ return "error: Query parameter is empty."
39
+ result = await retrieve_knowledge_base(
40
+ query=kwargs.get("query", ""),
41
+ umo=context.context.event.unified_msg_origin,
42
+ context=context.context.context,
43
+ )
44
+ if not result:
45
+ return "No relevant knowledge found."
46
+ return result
47
+
48
+
49
+ async def retrieve_knowledge_base(
50
+ query: str,
8
51
  umo: str,
9
- p_ctx: PipelineContext,
10
- req: ProviderRequest,
11
- ) -> None:
52
+ context: Context,
53
+ ) -> str | None:
12
54
  """Inject knowledge base context into the provider request
13
55
 
14
56
  Args:
15
57
  umo: Unique message object (session ID)
16
58
  p_ctx: Pipeline context
17
- req: Provider request
18
-
19
59
  """
20
- kb_mgr = p_ctx.plugin_manager.context.kb_manager
60
+ kb_mgr = context.kb_manager
61
+ config = context.get_config(umo=umo)
21
62
 
22
63
  # 1. 优先读取会话级配置
23
64
  session_config = await sp.session_get(umo, "kb_config", default={})
@@ -54,18 +95,18 @@ async def inject_kb_context(
54
95
 
55
96
  logger.debug(f"[知识库] 使用会话级配置,知识库数量: {len(kb_names)}")
56
97
  else:
57
- kb_names = p_ctx.astrbot_config.get("kb_names", [])
58
- top_k = p_ctx.astrbot_config.get("kb_final_top_k", 5)
98
+ kb_names = config.get("kb_names", [])
99
+ top_k = config.get("kb_final_top_k", 5)
59
100
  logger.debug(f"[知识库] 使用全局配置,知识库数量: {len(kb_names)}")
60
101
 
61
- top_k_fusion = p_ctx.astrbot_config.get("kb_fusion_top_k", 20)
102
+ top_k_fusion = config.get("kb_fusion_top_k", 20)
62
103
 
63
104
  if not kb_names:
64
105
  return
65
106
 
66
107
  logger.debug(f"[知识库] 开始检索知识库,数量: {len(kb_names)}, top_k={top_k}")
67
108
  kb_context = await kb_mgr.retrieve(
68
- query=req.prompt,
109
+ query=query,
69
110
  kb_names=kb_names,
70
111
  top_k_fusion=top_k_fusion,
71
112
  top_m_final=top_k,
@@ -78,4 +119,7 @@ async def inject_kb_context(
78
119
  if formatted:
79
120
  results = kb_context.get("results", [])
80
121
  logger.debug(f"[知识库] 为会话 {umo} 注入了 {len(results)} 条相关知识块")
81
- req.system_prompt = f"{formatted}\n\n{req.system_prompt or ''}"
122
+ return formatted
123
+
124
+
125
+ KNOWLEDGE_BASE_QUERY_TOOL = KnowledgeBaseQueryTool()
@@ -30,7 +30,7 @@ from .wecomai_api import (
30
30
  WecomAIBotStreamMessageBuilder,
31
31
  )
32
32
  from .wecomai_event import WecomAIBotMessageEvent
33
- from .wecomai_queue_mgr import WecomAIQueueMgr, wecomai_queue_mgr
33
+ from .wecomai_queue_mgr import WecomAIQueueMgr
34
34
  from .wecomai_server import WecomAIBotServer
35
35
  from .wecomai_utils import (
36
36
  WecomAIBotConstants,
@@ -144,9 +144,12 @@ class WecomAIBotAdapter(Platform):
144
144
  # 事件循环和关闭信号
145
145
  self.shutdown_event = asyncio.Event()
146
146
 
147
+ # 队列管理器
148
+ self.queue_mgr = WecomAIQueueMgr()
149
+
147
150
  # 队列监听器
148
151
  self.queue_listener = WecomAIQueueListener(
149
- wecomai_queue_mgr,
152
+ self.queue_mgr,
150
153
  self._handle_queued_message,
151
154
  )
152
155
 
@@ -189,7 +192,7 @@ class WecomAIBotAdapter(Platform):
189
192
  stream_id,
190
193
  session_id,
191
194
  )
192
- wecomai_queue_mgr.set_pending_response(stream_id, callback_params)
195
+ self.queue_mgr.set_pending_response(stream_id, callback_params)
193
196
 
194
197
  resp = WecomAIBotStreamMessageBuilder.make_text_stream(
195
198
  stream_id,
@@ -207,7 +210,7 @@ class WecomAIBotAdapter(Platform):
207
210
  elif msgtype == "stream":
208
211
  # wechat server is requesting for updates of a stream
209
212
  stream_id = message_data["stream"]["id"]
210
- if not wecomai_queue_mgr.has_back_queue(stream_id):
213
+ if not self.queue_mgr.has_back_queue(stream_id):
211
214
  logger.error(f"Cannot find back queue for stream_id: {stream_id}")
212
215
 
213
216
  # 返回结束标志,告诉微信服务器流已结束
@@ -222,7 +225,7 @@ class WecomAIBotAdapter(Platform):
222
225
  callback_params["timestamp"],
223
226
  )
224
227
  return resp
225
- queue = wecomai_queue_mgr.get_or_create_back_queue(stream_id)
228
+ queue = self.queue_mgr.get_or_create_back_queue(stream_id)
226
229
  if queue.empty():
227
230
  logger.debug(
228
231
  f"No new messages in back queue for stream_id: {stream_id}",
@@ -242,10 +245,9 @@ class WecomAIBotAdapter(Platform):
242
245
  elif msg["type"] == "end":
243
246
  # stream end
244
247
  finish = True
245
- wecomai_queue_mgr.remove_queues(stream_id)
248
+ self.queue_mgr.remove_queues(stream_id)
246
249
  break
247
- else:
248
- pass
250
+
249
251
  logger.debug(
250
252
  f"Aggregated content: {latest_plain_content}, image: {len(image_base64)}, finish: {finish}",
251
253
  )
@@ -313,8 +315,8 @@ class WecomAIBotAdapter(Platform):
313
315
  session_id: str,
314
316
  ):
315
317
  """将消息放入队列进行异步处理"""
316
- input_queue = wecomai_queue_mgr.get_or_create_queue(stream_id)
317
- _ = wecomai_queue_mgr.get_or_create_back_queue(stream_id)
318
+ input_queue = self.queue_mgr.get_or_create_queue(stream_id)
319
+ _ = self.queue_mgr.get_or_create_back_queue(stream_id)
318
320
  message_payload = {
319
321
  "message_data": message_data,
320
322
  "callback_params": callback_params,
@@ -453,6 +455,7 @@ class WecomAIBotAdapter(Platform):
453
455
  platform_meta=self.meta(),
454
456
  session_id=message.session_id,
455
457
  api_client=self.api_client,
458
+ queue_mgr=self.queue_mgr,
456
459
  )
457
460
 
458
461
  self.commit_event(message_event)
@@ -8,7 +8,7 @@ from astrbot.api.message_components import (
8
8
  )
9
9
 
10
10
  from .wecomai_api import WecomAIBotAPIClient
11
- from .wecomai_queue_mgr import wecomai_queue_mgr
11
+ from .wecomai_queue_mgr import WecomAIQueueMgr
12
12
 
13
13
 
14
14
  class WecomAIBotMessageEvent(AstrMessageEvent):
@@ -21,6 +21,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
21
21
  platform_meta,
22
22
  session_id: str,
23
23
  api_client: WecomAIBotAPIClient,
24
+ queue_mgr: WecomAIQueueMgr,
24
25
  ):
25
26
  """初始化消息事件
26
27
 
@@ -34,14 +35,16 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
34
35
  """
35
36
  super().__init__(message_str, message_obj, platform_meta, session_id)
36
37
  self.api_client = api_client
38
+ self.queue_mgr = queue_mgr
37
39
 
38
40
  @staticmethod
39
41
  async def _send(
40
42
  message_chain: MessageChain,
41
43
  stream_id: str,
44
+ queue_mgr: WecomAIQueueMgr,
42
45
  streaming: bool = False,
43
46
  ):
44
- back_queue = wecomai_queue_mgr.get_or_create_back_queue(stream_id)
47
+ back_queue = queue_mgr.get_or_create_back_queue(stream_id)
45
48
 
46
49
  if not message_chain:
47
50
  await back_queue.put(
@@ -94,7 +97,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
94
97
  "wecom_ai_bot platform event raw_message should be a dict"
95
98
  )
96
99
  stream_id = raw.get("stream_id", self.session_id)
97
- await WecomAIBotMessageEvent._send(message, stream_id)
100
+ await WecomAIBotMessageEvent._send(message, stream_id, self.queue_mgr)
98
101
  await super().send(message)
99
102
 
100
103
  async def send_streaming(self, generator, use_fallback=False):
@@ -105,7 +108,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
105
108
  "wecom_ai_bot platform event raw_message should be a dict"
106
109
  )
107
110
  stream_id = raw.get("stream_id", self.session_id)
108
- back_queue = wecomai_queue_mgr.get_or_create_back_queue(stream_id)
111
+ back_queue = self.queue_mgr.get_or_create_back_queue(stream_id)
109
112
 
110
113
  # 企业微信智能机器人不支持增量发送,因此我们需要在这里将增量内容累积起来,积累发送
111
114
  increment_plain = ""
@@ -134,6 +137,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
134
137
  final_data += await WecomAIBotMessageEvent._send(
135
138
  chain,
136
139
  stream_id=stream_id,
140
+ queue_mgr=self.queue_mgr,
137
141
  streaming=True,
138
142
  )
139
143
 
@@ -151,7 +151,3 @@ class WecomAIQueueMgr:
151
151
  "output_queues": len(self.back_queues),
152
152
  "pending_responses": len(self.pending_responses),
153
153
  }
154
-
155
-
156
- # 全局队列管理器实例
157
- wecomai_queue_mgr = WecomAIQueueMgr()
@@ -211,6 +211,8 @@ class LLMResponse:
211
211
  """Tool call names."""
212
212
  tools_call_ids: list[str] = field(default_factory=list)
213
213
  """Tool call IDs."""
214
+ tools_call_extra_content: dict[str, dict[str, Any]] = field(default_factory=dict)
215
+ """Tool call extra content. tool_call_id -> extra_content dict"""
214
216
  reasoning_content: str = ""
215
217
  """The reasoning content extracted from the LLM, if any."""
216
218
 
@@ -233,6 +235,7 @@ class LLMResponse:
233
235
  tools_call_args: list[dict[str, Any]] | None = None,
234
236
  tools_call_name: list[str] | None = None,
235
237
  tools_call_ids: list[str] | None = None,
238
+ tools_call_extra_content: dict[str, dict[str, Any]] | None = None,
236
239
  raw_completion: ChatCompletion
237
240
  | GenerateContentResponse
238
241
  | AnthropicMessage
@@ -256,6 +259,8 @@ class LLMResponse:
256
259
  tools_call_name = []
257
260
  if tools_call_ids is None:
258
261
  tools_call_ids = []
262
+ if tools_call_extra_content is None:
263
+ tools_call_extra_content = {}
259
264
 
260
265
  self.role = role
261
266
  self.completion_text = completion_text
@@ -263,6 +268,7 @@ class LLMResponse:
263
268
  self.tools_call_args = tools_call_args
264
269
  self.tools_call_name = tools_call_name
265
270
  self.tools_call_ids = tools_call_ids
271
+ self.tools_call_extra_content = tools_call_extra_content
266
272
  self.raw_completion = raw_completion
267
273
  self.is_chunk = is_chunk
268
274
 
@@ -288,16 +294,19 @@ class LLMResponse:
288
294
  """Convert to OpenAI tool calls format. Deprecated, use to_openai_to_calls_model instead."""
289
295
  ret = []
290
296
  for idx, tool_call_arg in enumerate(self.tools_call_args):
291
- ret.append(
292
- {
293
- "id": self.tools_call_ids[idx],
294
- "function": {
295
- "name": self.tools_call_name[idx],
296
- "arguments": json.dumps(tool_call_arg),
297
- },
298
- "type": "function",
297
+ payload = {
298
+ "id": self.tools_call_ids[idx],
299
+ "function": {
300
+ "name": self.tools_call_name[idx],
301
+ "arguments": json.dumps(tool_call_arg),
299
302
  },
300
- )
303
+ "type": "function",
304
+ }
305
+ if self.tools_call_extra_content.get(self.tools_call_ids[idx]):
306
+ payload["extra_content"] = self.tools_call_extra_content[
307
+ self.tools_call_ids[idx]
308
+ ]
309
+ ret.append(payload)
301
310
  return ret
302
311
 
303
312
  def to_openai_to_calls_model(self) -> list[ToolCall]:
@@ -311,6 +320,10 @@ class LLMResponse:
311
320
  name=self.tools_call_name[idx],
312
321
  arguments=json.dumps(tool_call_arg),
313
322
  ),
323
+ # the extra_content will not serialize if it's None when calling ToolCall.model_dump()
324
+ extra_content=self.tools_call_extra_content.get(
325
+ self.tools_call_ids[idx]
326
+ ),
314
327
  ),
315
328
  )
316
329
  return ret
@@ -280,19 +280,22 @@ class FunctionToolManager:
280
280
  async def _terminate_mcp_client(self, name: str) -> None:
281
281
  """关闭并清理MCP客户端"""
282
282
  if name in self.mcp_client_dict:
283
+ client = self.mcp_client_dict[name]
283
284
  try:
284
285
  # 关闭MCP连接
285
- await self.mcp_client_dict[name].cleanup()
286
- self.mcp_client_dict.pop(name)
286
+ await client.cleanup()
287
287
  except Exception as e:
288
288
  logger.error(f"清空 MCP 客户端资源 {name}: {e}。")
289
- # 移除关联的FuncTool
290
- self.func_list = [
291
- f
292
- for f in self.func_list
293
- if not (isinstance(f, MCPTool) and f.mcp_server_name == name)
294
- ]
295
- logger.info(f"已关闭 MCP 服务 {name}")
289
+ finally:
290
+ # Remove client from dict after cleanup attempt (successful or not)
291
+ self.mcp_client_dict.pop(name, None)
292
+ # 移除关联的FuncTool
293
+ self.func_list = [
294
+ f
295
+ for f in self.func_list
296
+ if not (isinstance(f, MCPTool) and f.mcp_server_name == name)
297
+ ]
298
+ logger.info(f"已关闭 MCP 服务 {name}")
296
299
 
297
300
  @staticmethod
298
301
  async def test_mcp_server_connection(config: dict) -> list[str]:
@@ -290,13 +290,24 @@ class ProviderGoogleGenAI(Provider):
290
290
  parts = [types.Part.from_text(text=content)]
291
291
  append_or_extend(gemini_contents, parts, types.ModelContent)
292
292
  elif not native_tool_enabled and "tool_calls" in message:
293
- parts = [
294
- types.Part.from_function_call(
293
+ parts = []
294
+ for tool in message["tool_calls"]:
295
+ part = types.Part.from_function_call(
295
296
  name=tool["function"]["name"],
296
297
  args=json.loads(tool["function"]["arguments"]),
297
298
  )
298
- for tool in message["tool_calls"]
299
- ]
299
+ # we should set thought_signature back to part if exists
300
+ # for more info about thought_signature, see:
301
+ # https://ai.google.dev/gemini-api/docs/thought-signatures
302
+ if "extra_content" in tool and tool["extra_content"]:
303
+ ts_bs64 = (
304
+ tool["extra_content"]
305
+ .get("google", {})
306
+ .get("thought_signature")
307
+ )
308
+ if ts_bs64:
309
+ part.thought_signature = base64.b64decode(ts_bs64)
310
+ parts.append(part)
300
311
  append_or_extend(gemini_contents, parts, types.ModelContent)
301
312
  else:
302
313
  logger.warning("assistant 角色的消息内容为空,已添加空格占位")
@@ -393,10 +404,15 @@ class ProviderGoogleGenAI(Provider):
393
404
  llm_response.role = "tool"
394
405
  llm_response.tools_call_name.append(part.function_call.name)
395
406
  llm_response.tools_call_args.append(part.function_call.args)
396
- # gemini 返回的 function_call.id 可能为 None
397
- llm_response.tools_call_ids.append(
398
- part.function_call.id or part.function_call.name,
399
- )
407
+ # function_call.id might be None, use name as fallback
408
+ tool_call_id = part.function_call.id or part.function_call.name
409
+ llm_response.tools_call_ids.append(tool_call_id)
410
+ # extra_content
411
+ if part.thought_signature:
412
+ ts_bs64 = base64.b64encode(part.thought_signature).decode("utf-8")
413
+ llm_response.tools_call_extra_content[tool_call_id] = {
414
+ "google": {"thought_signature": ts_bs64}
415
+ }
400
416
  elif (
401
417
  part.inline_data
402
418
  and part.inline_data.mime_type
@@ -435,6 +451,7 @@ class ProviderGoogleGenAI(Provider):
435
451
  contents=conversation,
436
452
  config=config,
437
453
  )
454
+ logger.debug(f"genai result: {result}")
438
455
 
439
456
  if not result.candidates:
440
457
  logger.error(f"请求失败, 返回的 candidates 为空: {result}")
@@ -8,7 +8,7 @@ import re
8
8
  from collections.abc import AsyncGenerator
9
9
 
10
10
  from openai import AsyncAzureOpenAI, AsyncOpenAI
11
- from openai._exceptions import NotFoundError, UnprocessableEntityError
11
+ from openai._exceptions import NotFoundError
12
12
  from openai.lib.streaming.chat._completions import ChatCompletionStreamState
13
13
  from openai.types.chat.chat_completion import ChatCompletion
14
14
  from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
@@ -279,6 +279,7 @@ class ProviderOpenAIOfficial(Provider):
279
279
  args_ls = []
280
280
  func_name_ls = []
281
281
  tool_call_ids = []
282
+ tool_call_extra_content_dict = {}
282
283
  for tool_call in choice.message.tool_calls:
283
284
  if isinstance(tool_call, str):
284
285
  # workaround for #1359
@@ -296,11 +297,16 @@ class ProviderOpenAIOfficial(Provider):
296
297
  args_ls.append(args)
297
298
  func_name_ls.append(tool_call.function.name)
298
299
  tool_call_ids.append(tool_call.id)
300
+
301
+ # gemini-2.5 / gemini-3 series extra_content handling
302
+ extra_content = getattr(tool_call, "extra_content", None)
303
+ if extra_content is not None:
304
+ tool_call_extra_content_dict[tool_call.id] = extra_content
299
305
  llm_response.role = "tool"
300
306
  llm_response.tools_call_args = args_ls
301
307
  llm_response.tools_call_name = func_name_ls
302
308
  llm_response.tools_call_ids = tool_call_ids
303
-
309
+ llm_response.tools_call_extra_content = tool_call_extra_content_dict
304
310
  # specially handle finish reason
305
311
  if choice.finish_reason == "content_filter":
306
312
  raise Exception(
@@ -353,7 +359,7 @@ class ProviderOpenAIOfficial(Provider):
353
359
 
354
360
  payloads = {"messages": context_query, **model_config}
355
361
 
356
- # xAI 原生搜索参数(最小侵入地在此处注入)
362
+ # xAI origin search tool inject
357
363
  self._maybe_inject_xai_search(payloads, **kwargs)
358
364
 
359
365
  return payloads, context_query
@@ -475,12 +481,6 @@ class ProviderOpenAIOfficial(Provider):
475
481
  self.client.api_key = chosen_key
476
482
  llm_response = await self._query(payloads, func_tool)
477
483
  break
478
- except UnprocessableEntityError as e:
479
- logger.warning(f"不可处理的实体错误:{e},尝试删除图片。")
480
- # 尝试删除所有 image
481
- new_contexts = await self._remove_image_from_context(context_query)
482
- payloads["messages"] = new_contexts
483
- context_query = new_contexts
484
484
  except Exception as e:
485
485
  last_exception = e
486
486
  (
@@ -545,12 +545,6 @@ class ProviderOpenAIOfficial(Provider):
545
545
  async for response in self._query_stream(payloads, func_tool):
546
546
  yield response
547
547
  break
548
- except UnprocessableEntityError as e:
549
- logger.warning(f"不可处理的实体错误:{e},尝试删除图片。")
550
- # 尝试删除所有 image
551
- new_contexts = await self._remove_image_from_context(context_query)
552
- payloads["messages"] = new_contexts
553
- context_query = new_contexts
554
548
  except Exception as e:
555
549
  last_exception = e
556
550
  (
@@ -646,4 +640,3 @@ class ProviderOpenAIOfficial(Provider):
646
640
  with open(image_url, "rb") as f:
647
641
  image_bs64 = base64.b64encode(f.read()).decode("utf-8")
648
642
  return "data:image/jpeg;base64," + image_bs64
649
- return ""