From 1a56d3fd3a080557686fbda4e3e1932ba1cff43f Mon Sep 17 00:00:00 2001 From: wcqqq1214 Date: Tue, 18 Aug 2026 16:02:41 +0800 Subject: [PATCH 1/5] fix: enable native search for xAI Responses providers --- astrbot/core/config/default.py | 4 +- .../sources/openai_responses_source.py | 38 +++++++++++-------- .../src/composables/useProviderSources.ts | 4 ++ .../en-US/features/config-metadata.json | 2 +- .../zh-CN/features/config-metadata.json | 2 +- 5 files changed, 30 insertions(+), 20 deletions(-) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 780e070098..31f3a65098 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -1304,6 +1304,7 @@ "timeout": 120, "proxy": "", "custom_headers": {}, + "xai_native_search": False, }, "DeepSeek": { "id": "deepseek", @@ -2032,10 +2033,9 @@ "xai_native_search": { "description": "启用原生搜索功能", "type": "bool", - "hint": "启用后,将通过 xAI 的 Chat Completions 原生 Live Search 进行联网检索(按需计费)。仅对 xAI 提供商生效。", + "hint": "启用后,将通过 xAI 原生 Web Search 进行联网检索(按需计费)。仅对 xAI 提供商生效。", "condition": { "provider": "xai", - "type": "xai_chat_completion", }, }, "rerank_api_base": { diff --git a/astrbot/core/provider/sources/openai_responses_source.py b/astrbot/core/provider/sources/openai_responses_source.py index c5cb9bdb82..49813b673e 100644 --- a/astrbot/core/provider/sources/openai_responses_source.py +++ b/astrbot/core/provider/sources/openai_responses_source.py @@ -294,6 +294,20 @@ async def _prepare_chat_payload( return payloads, context_query + def _build_response_tools(self, tools: ToolSet | None) -> list[dict]: + response_tools: list[dict] = [] + if tools: + for tool in tools.openai_schema(): + function = tool.get("function", {}) + response_tools.append({"type": "function", **function}) + + if self.provider_config.get("provider") == "xai" and bool( + self.provider_config.get("xai_native_search", False) + ): + response_tools.append({"type": "web_search"}) + + return response_tools + async def _query( self, payloads: dict, @@ -314,14 +328,10 @@ async def _query( Raises: TypeError: If the SDK returns an unexpected response type. """ - if tools: - response_tools = [] - for tool in tools.openai_schema(): - function = tool.get("function", {}) - response_tools.append({"type": "function", **function}) - if response_tools: - payloads["tools"] = response_tools - payloads["tool_choice"] = payloads.get("tool_choice", "auto") + response_tools = self._build_response_tools(tools) + if response_tools: + payloads["tools"] = response_tools + payloads["tool_choice"] = payloads.get("tool_choice", "auto") extra_body: dict[str, Any] = {} custom_extra_body = self.provider_config.get("custom_extra_body", {}) @@ -383,14 +393,10 @@ async def _query_stream( Raises: EmptyModelOutputError: If the stream ends without a terminal event. """ - if tools: - response_tools = [] - for tool in tools.openai_schema(): - function = tool.get("function", {}) - response_tools.append({"type": "function", **function}) - if response_tools: - payloads["tools"] = response_tools - payloads["tool_choice"] = payloads.get("tool_choice", "auto") + response_tools = self._build_response_tools(tools) + if response_tools: + payloads["tools"] = response_tools + payloads["tool_choice"] = payloads.get("tool_choice", "auto") extra_body: dict[str, Any] = {} custom_extra_body = self.provider_config.get("custom_extra_body", {}) diff --git a/dashboard/src/composables/useProviderSources.ts b/dashboard/src/composables/useProviderSources.ts index 7a6a70430c..fc74a0fb73 100644 --- a/dashboard/src/composables/useProviderSources.ts +++ b/dashboard/src/composables/useProviderSources.ts @@ -393,6 +393,10 @@ export function useProviderSources(options: UseProviderSourcesOptions) { source.ollama_disable_thinking = false } + if (source.provider === 'xai' && source.xai_native_search === undefined) { + source.xai_native_search = false + } + return source } diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index 0e6905cc5c..5fe5f19d21 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -1206,7 +1206,7 @@ }, "xai_native_search": { "description": "Enable native search", - "hint": "When enabled, uses xAI Chat Completions native Live Search for web queries (billed on demand). Only applies to xAI providers." + "hint": "When enabled, uses xAI native Web Search for web queries (billed on demand). Only applies to xAI providers." }, "rerank_api_base": { "description": "Rerank Model API Base URL", diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 089e9ba91f..c3aa7debfb 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -1208,7 +1208,7 @@ }, "xai_native_search": { "description": "启用原生搜索功能", - "hint": "启用后,将通过 xAI 的 Chat Completions 原生 Live Search 进行联网检索(按需计费)。仅对 xAI 提供商生效。" + "hint": "启用后,将通过 xAI 原生 Web Search 进行联网检索(按需计费)。仅对 xAI 提供商生效。" }, "rerank_api_base": { "description": "重排序模型 API Base URL", From 3e19a93622a2815673bff62b5cb5e99899baab7a Mon Sep 17 00:00:00 2001 From: wcqqq1214 Date: Tue, 18 Aug 2026 16:26:14 +0800 Subject: [PATCH 2/5] test: update xAI native search template expectation --- tests/test_openai_responses_source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_openai_responses_source.py b/tests/test_openai_responses_source.py index 6b2d5e6718..b7bc06881f 100644 --- a/tests/test_openai_responses_source.py +++ b/tests/test_openai_responses_source.py @@ -61,7 +61,7 @@ def test_responses_provider_templates_are_independent_and_stateless(): assert templates["DeepSeek Responses"]["api_base"] == "https://api.deepseek.com/v1" assert templates["xAI"]["type"] == "openai_responses" assert templates["xAI"]["api_base"] == "https://api.x.ai/v1" - assert "xai_native_search" not in templates["xAI"] + assert templates["xAI"]["xai_native_search"] is False def test_convert_chat_history_preserves_response_items_and_function_calls(): From 024bbf0a55dbfd69e60ded18fdfe960bac5e067c Mon Sep 17 00:00:00 2001 From: wcqqq1214 Date: Tue, 18 Aug 2026 20:22:45 +0800 Subject: [PATCH 3/5] feat: surface xAI native search sources in WebChat --- .../agent/runners/tool_loop_agent_runner.py | 11 ++++++++++ astrbot/core/astr_agent_run_util.py | 5 +++++ astrbot/core/provider/entities.py | 5 +++++ .../sources/openai_responses_source.py | 13 ++++++++++++ astrbot/dashboard/services/chat_service.py | 20 +++++++++++++++++++ .../dashboard/services/live_chat_service.py | 15 ++++++++++++++ .../dashboard/services/open_api_service.py | 16 +++++++++++++++ 7 files changed, 85 insertions(+) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 8c91adbbfd..a707030a80 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -924,6 +924,17 @@ async def step(self): ), ) + if llm_resp.web_search_sources: + yield AgentResponse( + type="web_search_sources", + data=AgentResponseData( + chain=MessageChain( + type="web_search_sources", + chain=[Json(data={"sources": llm_resp.web_search_sources})], + ) + ), + ) + # 如果有工具调用,还需处理工具调用 if llm_resp.tools_call_name: if self.tool_schema_mode == "skills_like": diff --git a/astrbot/core/astr_agent_run_util.py b/astrbot/core/astr_agent_run_util.py index 7e0844ee48..24f193eeae 100644 --- a/astrbot/core/astr_agent_run_util.py +++ b/astrbot/core/astr_agent_run_util.py @@ -187,6 +187,11 @@ async def run_agent( await astr_event.send(resp.data["chain"]) continue + if resp.type == "web_search_sources": + if astr_event.get_platform_name() == "webchat": + await astr_event.send(resp.data["chain"]) + continue + if resp.type == "tool_call_result": msg_chain = resp.data["chain"] diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py index 2fab40ca78..d8271c6006 100644 --- a/astrbot/core/provider/entities.py +++ b/astrbot/core/provider/entities.py @@ -312,6 +312,9 @@ class LLMResponse: reasoning_signature: str | None = None """The signature of the reasoning content, if any.""" + web_search_sources: list[dict[str, Any]] = field(default_factory=list) + """Structured web search sources (e.g. xAI url_citation), each with index/url/title.""" + raw_completion: ( ChatCompletion | Response | GenerateContentResponse | AnthropicMessage | None ) = None @@ -339,6 +342,7 @@ def __init__( tools_call_extra_content: dict[str, dict[str, Any]] | None = None, reasoning_content: str | None = None, reasoning_signature: str | None = None, + web_search_sources: list[dict[str, Any]] | None = None, raw_completion: ChatCompletion | Response | GenerateContentResponse @@ -377,6 +381,7 @@ def __init__( self.tools_call_extra_content = tools_call_extra_content self.reasoning_content = reasoning_content self.reasoning_signature = reasoning_signature + self.web_search_sources = web_search_sources or [] self.raw_completion = raw_completion self.is_chunk = is_chunk diff --git a/astrbot/core/provider/sources/openai_responses_source.py b/astrbot/core/provider/sources/openai_responses_source.py index 49813b673e..c906f07b93 100644 --- a/astrbot/core/provider/sources/openai_responses_source.py +++ b/astrbot/core/provider/sources/openai_responses_source.py @@ -529,6 +529,7 @@ async def _parse_response( text_parts: list[str] = [] reasoning_parts: list[str] = [] serialized_reasoning_items: list[dict] = [] + web_search_sources: list[dict[str, Any]] = [] for item in self._field(response, "output", []) or []: item_type = self._field(item, "type") @@ -537,6 +538,16 @@ async def _parse_response( content_type = self._field(content, "type") if content_type == "output_text": text_parts.append(str(self._field(content, "text", ""))) + for annotation in self._field(content, "annotations", []) or []: + if self._field(annotation, "type") != "url_citation": + continue + url = self._field(annotation, "url") + if not url: + continue + title = self._field(annotation, "title") + web_search_sources.append( + {"index": title, "url": url, "title": title} + ) elif content_type == "refusal": text_parts.append(str(self._field(content, "refusal", ""))) continue @@ -597,6 +608,8 @@ async def _parse_response( if llm_response.tools_call_args: llm_response.role = "tool" + llm_response.web_search_sources = web_search_sources + usage = self._field(response, "usage") if usage is not None: input_details = self._field(usage, "input_tokens_details") diff --git a/astrbot/dashboard/services/chat_service.py b/astrbot/dashboard/services/chat_service.py index 0b72b582d7..aefcaa27a3 100644 --- a/astrbot/dashboard/services/chat_service.py +++ b/astrbot/dashboard/services/chat_service.py @@ -921,6 +921,8 @@ async def flush_pending_bot_message(): plain_text, message_parts_to_save, ) + if not extracted_refs and pending_refs: + extracted_refs = pending_refs except Exception as exc: logger.exception( f"Failed to extract web search refs: {exc}", @@ -969,6 +971,22 @@ async def flush_pending_bot_message(): ) continue + if chain_type == "web_search_sources": + try: + parsed = json.loads(result_text) + sources = ( + parsed.get("sources", []) + if isinstance(parsed, dict) + else [] + ) + pending_refs = ( + {"used": sources} if isinstance(sources, list) else {} + ) + except (TypeError, json.JSONDecodeError): + pending_refs = {} + run.refs = pending_refs + continue + attachment_saved_payload = None if msg_type == "plain": for accumulator in (pending_accumulator, display_accumulator): @@ -1036,6 +1054,7 @@ async def flush_pending_bot_message(): saved_record.created_at ), "llm_checkpoint_id": run.llm_checkpoint_id, + "refs": run.refs, }, }, ) @@ -1063,6 +1082,7 @@ async def flush_pending_bot_message(): "id": saved_record.id, "created_at": to_utc_isoformat(saved_record.created_at), "llm_checkpoint_id": run.llm_checkpoint_id, + "refs": run.refs, }, }, ) diff --git a/astrbot/dashboard/services/live_chat_service.py b/astrbot/dashboard/services/live_chat_service.py index 16b7eed0ad..a626026a8e 100644 --- a/astrbot/dashboard/services/live_chat_service.py +++ b/astrbot/dashboard/services/live_chat_service.py @@ -630,6 +630,8 @@ async def flush_pending_bot_message(): exc_info=True, ) extracted_refs = refs + if not extracted_refs and refs: + extracted_refs = refs saved_record = await self.save_bot_message( session_id, @@ -701,6 +703,19 @@ async def send_attachment_saved_event(part: dict | None) -> None: pass continue + if chain_type == "web_search_sources": + try: + parsed = json.loads(result_text) + sources = ( + parsed.get("sources", []) + if isinstance(parsed, dict) + else [] + ) + refs = {"used": sources} if isinstance(sources, list) else {} + except Exception: + refs = {} + continue + outgoing = {"ct": "chat", **result} await self.send_chat_payload(session, outgoing, send_json) diff --git a/astrbot/dashboard/services/open_api_service.py b/astrbot/dashboard/services/open_api_service.py index 131b8e3b98..cdff234f88 100644 --- a/astrbot/dashboard/services/open_api_service.py +++ b/astrbot/dashboard/services/open_api_service.py @@ -485,6 +485,19 @@ async def handle_chat_ws_send( pass continue + if chain_type == "web_search_sources": + try: + parsed = json.loads(result_text) + sources = ( + parsed.get("sources", []) + if isinstance(parsed, dict) + else [] + ) + refs = {"used": sources} if isinstance(sources, list) else {} + except Exception: + refs = {} + continue + await send_json(result) if msg_type == "plain": @@ -517,6 +530,7 @@ async def handle_chat_ws_send( plain_text = collect_plain_text_from_message_parts( message_parts_to_save ) + pending_xai_refs = refs try: refs = chat_bridge.extract_web_search_refs( plain_text, @@ -527,6 +541,8 @@ async def handle_chat_ws_send( f"Open API WS failed to extract web search refs: {exc}", exc_info=True, ) + if not refs and pending_xai_refs: + refs = pending_xai_refs saved_record = await chat_bridge.save_bot_message( session_id, From a1b47736eb1018ccea8b708a5d72ae5fac982682 Mon Sep 17 00:00:00 2001 From: wcqqq1214 Date: Tue, 18 Aug 2026 22:15:49 +0800 Subject: [PATCH 4/5] refactor: dedupe web search sources handling and fix citation index --- .../sources/openai_responses_source.py | 13 +++++++-- astrbot/dashboard/services/chat_service.py | 28 +++++++++++-------- .../dashboard/services/live_chat_service.py | 12 ++------ .../dashboard/services/open_api_service.py | 18 ++++-------- 4 files changed, 33 insertions(+), 38 deletions(-) diff --git a/astrbot/core/provider/sources/openai_responses_source.py b/astrbot/core/provider/sources/openai_responses_source.py index c906f07b93..9b027c3434 100644 --- a/astrbot/core/provider/sources/openai_responses_source.py +++ b/astrbot/core/provider/sources/openai_responses_source.py @@ -295,6 +295,7 @@ async def _prepare_chat_payload( return payloads, context_query def _build_response_tools(self, tools: ToolSet | None) -> list[dict]: + """Build the Responses tools list, appending xAI's native web_search when enabled.""" response_tools: list[dict] = [] if tools: for tool in tools.openai_schema(): @@ -331,7 +332,8 @@ async def _query( response_tools = self._build_response_tools(tools) if response_tools: payloads["tools"] = response_tools - payloads["tool_choice"] = payloads.get("tool_choice", "auto") + if tools: + payloads["tool_choice"] = payloads.get("tool_choice", "auto") extra_body: dict[str, Any] = {} custom_extra_body = self.provider_config.get("custom_extra_body", {}) @@ -396,7 +398,8 @@ async def _query_stream( response_tools = self._build_response_tools(tools) if response_tools: payloads["tools"] = response_tools - payloads["tool_choice"] = payloads.get("tool_choice", "auto") + if tools: + payloads["tool_choice"] = payloads.get("tool_choice", "auto") extra_body: dict[str, Any] = {} custom_extra_body = self.provider_config.get("custom_extra_body", {}) @@ -546,7 +549,11 @@ async def _parse_response( continue title = self._field(annotation, "title") web_search_sources.append( - {"index": title, "url": url, "title": title} + { + "index": str(len(web_search_sources) + 1), + "url": url, + "title": title, + } ) elif content_type == "refusal": text_parts.append(str(self._field(content, "refusal", ""))) diff --git a/astrbot/dashboard/services/chat_service.py b/astrbot/dashboard/services/chat_service.py index aefcaa27a3..cb3598b3f4 100644 --- a/astrbot/dashboard/services/chat_service.py +++ b/astrbot/dashboard/services/chat_service.py @@ -272,6 +272,21 @@ def extract_web_search_refs(accumulated_text: str, accumulated_parts: list) -> d return {"used": used_refs} if used_refs else {} +def parse_web_search_sources(result_text: str) -> dict: + """Parse a web_search_sources back-queue payload into a refs dict. + + The payload is ``{"sources": [...]}``; the refs shape mirrors + ``extract_web_search_refs`` so the frontend renders native search + sources as the same clickable cards. + """ + try: + parsed = json.loads(result_text) + sources = parsed.get("sources", []) if isinstance(parsed, dict) else [] + return {"used": sources} if isinstance(sources, list) else {} + except (TypeError, json.JSONDecodeError): + return {} + + def sanitize_message_content(content: dict) -> dict: if not isinstance(content, dict): raise ValueError("Missing key: content") @@ -972,18 +987,7 @@ async def flush_pending_bot_message(): continue if chain_type == "web_search_sources": - try: - parsed = json.loads(result_text) - sources = ( - parsed.get("sources", []) - if isinstance(parsed, dict) - else [] - ) - pending_refs = ( - {"used": sources} if isinstance(sources, list) else {} - ) - except (TypeError, json.JSONDecodeError): - pending_refs = {} + pending_refs = parse_web_search_sources(result_text) run.refs = pending_refs continue diff --git a/astrbot/dashboard/services/live_chat_service.py b/astrbot/dashboard/services/live_chat_service.py index a626026a8e..d7eb24ec2a 100644 --- a/astrbot/dashboard/services/live_chat_service.py +++ b/astrbot/dashboard/services/live_chat_service.py @@ -33,6 +33,7 @@ BotMessageAccumulator, build_bot_history_content, collect_plain_text_from_message_parts, + parse_web_search_sources, ) SendJson = Callable[[dict], Awaitable[None]] @@ -704,16 +705,7 @@ async def send_attachment_saved_event(part: dict | None) -> None: continue if chain_type == "web_search_sources": - try: - parsed = json.loads(result_text) - sources = ( - parsed.get("sources", []) - if isinstance(parsed, dict) - else [] - ) - refs = {"used": sources} if isinstance(sources, list) else {} - except Exception: - refs = {} + refs = parse_web_search_sources(result_text) continue outgoing = {"ct": "chat", **result} diff --git a/astrbot/dashboard/services/open_api_service.py b/astrbot/dashboard/services/open_api_service.py index cdff234f88..2a9129a891 100644 --- a/astrbot/dashboard/services/open_api_service.py +++ b/astrbot/dashboard/services/open_api_service.py @@ -29,6 +29,7 @@ from astrbot.dashboard.services.chat_service import ( BotMessageAccumulator, collect_plain_text_from_message_parts, + parse_web_search_sources, ) SendJson = Callable[[dict], Awaitable[None]] @@ -486,16 +487,7 @@ async def handle_chat_ws_send( continue if chain_type == "web_search_sources": - try: - parsed = json.loads(result_text) - sources = ( - parsed.get("sources", []) - if isinstance(parsed, dict) - else [] - ) - refs = {"used": sources} if isinstance(sources, list) else {} - except Exception: - refs = {} + refs = parse_web_search_sources(result_text) continue await send_json(result) @@ -530,7 +522,7 @@ async def handle_chat_ws_send( plain_text = collect_plain_text_from_message_parts( message_parts_to_save ) - pending_xai_refs = refs + fallback_refs = refs try: refs = chat_bridge.extract_web_search_refs( plain_text, @@ -541,8 +533,8 @@ async def handle_chat_ws_send( f"Open API WS failed to extract web search refs: {exc}", exc_info=True, ) - if not refs and pending_xai_refs: - refs = pending_xai_refs + if not refs and fallback_refs: + refs = fallback_refs saved_record = await chat_bridge.save_bot_message( session_id, From 5867d69ea92bde5dd9e8d30ae5a9b66c52e5338c Mon Sep 17 00:00:00 2001 From: wcqqq1214 Date: Tue, 18 Aug 2026 22:58:16 +0800 Subject: [PATCH 5/5] fix: set tool_choice auto whenever responses tools are sent --- astrbot/core/provider/sources/openai_responses_source.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/astrbot/core/provider/sources/openai_responses_source.py b/astrbot/core/provider/sources/openai_responses_source.py index 9b027c3434..5480c887b2 100644 --- a/astrbot/core/provider/sources/openai_responses_source.py +++ b/astrbot/core/provider/sources/openai_responses_source.py @@ -332,8 +332,7 @@ async def _query( response_tools = self._build_response_tools(tools) if response_tools: payloads["tools"] = response_tools - if tools: - payloads["tool_choice"] = payloads.get("tool_choice", "auto") + payloads["tool_choice"] = payloads.get("tool_choice", "auto") extra_body: dict[str, Any] = {} custom_extra_body = self.provider_config.get("custom_extra_body", {}) @@ -398,8 +397,7 @@ async def _query_stream( response_tools = self._build_response_tools(tools) if response_tools: payloads["tools"] = response_tools - if tools: - payloads["tool_choice"] = payloads.get("tool_choice", "auto") + payloads["tool_choice"] = payloads.get("tool_choice", "auto") extra_body: dict[str, Any] = {} custom_extra_body = self.provider_config.get("custom_extra_body", {})