Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions astrbot/core/agent/runners/tool_loop_agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
5 changes: 5 additions & 0 deletions astrbot/core/astr_agent_run_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down
4 changes: 2 additions & 2 deletions astrbot/core/config/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -1304,6 +1304,7 @@
"timeout": 120,
"proxy": "",
"custom_headers": {},
"xai_native_search": False,
},
"DeepSeek": {
"id": "deepseek",
Expand Down Expand Up @@ -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": {
Expand Down
5 changes: 5 additions & 0 deletions astrbot/core/provider/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
54 changes: 40 additions & 14 deletions astrbot/core/provider/sources/openai_responses_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,21 @@ 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():
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,
Expand All @@ -314,13 +329,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
response_tools = self._build_response_tools(tools)
if response_tools:
payloads["tools"] = response_tools
if tools:
payloads["tool_choice"] = payloads.get("tool_choice", "auto")

extra_body: dict[str, Any] = {}
Expand Down Expand Up @@ -383,13 +395,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
response_tools = self._build_response_tools(tools)
if response_tools:
payloads["tools"] = response_tools
if tools:
payloads["tool_choice"] = payloads.get("tool_choice", "auto")

extra_body: dict[str, Any] = {}
Expand Down Expand Up @@ -523,6 +532,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")
Expand All @@ -531,6 +541,20 @@ 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": str(len(web_search_sources) + 1),
"url": url,
"title": title,
}
)
elif content_type == "refusal":
text_parts.append(str(self._field(content, "refusal", "")))
continue
Expand Down Expand Up @@ -591,6 +615,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")
Expand Down
24 changes: 24 additions & 0 deletions astrbot/dashboard/services/chat_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -921,6 +936,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}",
Expand Down Expand Up @@ -969,6 +986,11 @@ async def flush_pending_bot_message():
)
continue

if chain_type == "web_search_sources":
pending_refs = parse_web_search_sources(result_text)
run.refs = pending_refs
continue

attachment_saved_payload = None
if msg_type == "plain":
for accumulator in (pending_accumulator, display_accumulator):
Expand Down Expand Up @@ -1036,6 +1058,7 @@ async def flush_pending_bot_message():
saved_record.created_at
),
"llm_checkpoint_id": run.llm_checkpoint_id,
"refs": run.refs,
},
},
)
Expand Down Expand Up @@ -1063,6 +1086,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,
},
},
)
Expand Down
7 changes: 7 additions & 0 deletions astrbot/dashboard/services/live_chat_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
BotMessageAccumulator,
build_bot_history_content,
collect_plain_text_from_message_parts,
parse_web_search_sources,
)

SendJson = Callable[[dict], Awaitable[None]]
Expand Down Expand Up @@ -630,6 +631,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,
Expand Down Expand Up @@ -701,6 +704,10 @@ async def send_attachment_saved_event(part: dict | None) -> None:
pass
continue

if chain_type == "web_search_sources":
refs = parse_web_search_sources(result_text)
continue

outgoing = {"ct": "chat", **result}
await self.send_chat_payload(session, outgoing, send_json)

Expand Down
8 changes: 8 additions & 0 deletions astrbot/dashboard/services/open_api_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down Expand Up @@ -485,6 +486,10 @@ async def handle_chat_ws_send(
pass
continue

if chain_type == "web_search_sources":
refs = parse_web_search_sources(result_text)
continue

await send_json(result)

if msg_type == "plain":
Expand Down Expand Up @@ -517,6 +522,7 @@ async def handle_chat_ws_send(
plain_text = collect_plain_text_from_message_parts(
message_parts_to_save
)
fallback_refs = refs
try:
refs = chat_bridge.extract_web_search_refs(
plain_text,
Expand All @@ -527,6 +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 fallback_refs:
refs = fallback_refs

saved_record = await chat_bridge.save_bot_message(
session_id,
Expand Down
4 changes: 4 additions & 0 deletions dashboard/src/composables/useProviderSources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion tests/test_openai_responses_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
Loading