mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-09 14:30:12 +08:00
Compare commits
5 Commits
codex/fix-
...
codex/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c53b82534 | ||
|
|
df4d93d724 | ||
|
|
3c7956e8c8 | ||
|
|
5266d170a3 | ||
|
|
ae29a7eaf9 |
@@ -85,6 +85,8 @@ from astrbot.core.tools.web_search_tools import (
|
||||
BaiduWebSearchTool,
|
||||
BochaWebSearchTool,
|
||||
BraveWebSearchTool,
|
||||
ExaGetContentsTool,
|
||||
ExaWebSearchTool,
|
||||
FirecrawlExtractWebPageTool,
|
||||
FirecrawlWebSearchTool,
|
||||
TavilyExtractWebPageTool,
|
||||
@@ -130,6 +132,7 @@ WEB_SEARCH_CITATION_TOOL_NAMES = frozenset(
|
||||
"web_search_tavily",
|
||||
"web_search_bocha",
|
||||
"web_search_brave",
|
||||
"web_search_exa",
|
||||
}
|
||||
)
|
||||
WEB_SEARCH_CITATION_PROMPT = (
|
||||
@@ -1207,6 +1210,9 @@ async def _apply_web_search_tools(
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FirecrawlExtractWebPageTool))
|
||||
elif provider == "baidu_ai_search":
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(BaiduWebSearchTool))
|
||||
elif provider == "exa":
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(ExaWebSearchTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(ExaGetContentsTool))
|
||||
|
||||
|
||||
def _apply_web_search_citation_prompt(
|
||||
|
||||
@@ -115,6 +115,7 @@ DEFAULT_CONFIG = {
|
||||
"websearch_brave_key": [],
|
||||
"websearch_baidu_app_builder_key": "",
|
||||
"websearch_firecrawl_key": [],
|
||||
"websearch_exa_key": [],
|
||||
"web_search_link": False,
|
||||
"display_reasoning_text": False,
|
||||
"identifier": False,
|
||||
@@ -1168,18 +1169,6 @@ CONFIG_METADATA_2 = {
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"OpenAI Response": {
|
||||
"id": "openai_response",
|
||||
"provider": "openai",
|
||||
"type": "openai_responses",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"api_base": "https://api.openai.com/v1",
|
||||
"timeout": 120,
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"Google Gemini": {
|
||||
"id": "google_gemini",
|
||||
"provider": "google",
|
||||
@@ -3307,6 +3296,7 @@ CONFIG_METADATA_3 = {
|
||||
"bocha",
|
||||
"brave",
|
||||
"firecrawl",
|
||||
"exa",
|
||||
],
|
||||
"condition": {
|
||||
"provider_settings.web_search": True,
|
||||
@@ -3361,6 +3351,16 @@ CONFIG_METADATA_3 = {
|
||||
"provider_settings.web_search": True,
|
||||
},
|
||||
},
|
||||
"provider_settings.websearch_exa_key": {
|
||||
"description": "Exa API Key",
|
||||
"type": "list",
|
||||
"items": {"type": "string"},
|
||||
"hint": "可添加多个 Key 进行轮询。Get a key at https://dashboard.exa.ai",
|
||||
"condition": {
|
||||
"provider_settings.websearch_provider": "exa",
|
||||
"provider_settings.web_search": True,
|
||||
},
|
||||
},
|
||||
"provider_settings.web_search_link": {
|
||||
"description": "显示来源引用",
|
||||
"type": "bool",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -23,6 +24,70 @@ if TYPE_CHECKING:
|
||||
from astrbot.core.star.context import Context
|
||||
|
||||
|
||||
_CRONTAB_WEEKDAY_NAMES = ("sun", "mon", "tue", "wed", "thu", "fri", "sat")
|
||||
_CRONTAB_WEEKDAY_PATTERN = re.compile(r"^(?:(\*)|(\d+)(?:-(\d+))?)(?:/(\d+))?$")
|
||||
|
||||
|
||||
def _normalize_crontab_day_of_week(day_of_week: str) -> str:
|
||||
"""Normalize standard crontab weekdays for APScheduler.
|
||||
|
||||
APScheduler treats numeric weekdays as Monday=0, while standard crontab and
|
||||
AstrBot's WebUI use Sunday=0/7. Numeric weekday fields are expanded to
|
||||
weekday names so the scheduled day remains unambiguous.
|
||||
|
||||
Args:
|
||||
day_of_week: The day-of-week field from a five-part crontab expression.
|
||||
|
||||
Returns:
|
||||
A day-of-week field compatible with APScheduler.
|
||||
|
||||
Raises:
|
||||
ValueError: If a numeric weekday value or step is outside the supported
|
||||
crontab range.
|
||||
"""
|
||||
normalized_parts: list[str] = []
|
||||
for raw_part in day_of_week.split(","):
|
||||
part = raw_part.strip().lower()
|
||||
match = _CRONTAB_WEEKDAY_PATTERN.fullmatch(part)
|
||||
if not match:
|
||||
normalized_parts.append(part)
|
||||
continue
|
||||
|
||||
wildcard, start_text, end_text, step_text = match.groups()
|
||||
step = int(step_text or "1")
|
||||
if step < 1:
|
||||
raise ValueError("day_of_week step must be greater than 0")
|
||||
|
||||
if wildcard:
|
||||
if step == 1:
|
||||
normalized_parts.append("*")
|
||||
continue
|
||||
values = range(0, 7, step)
|
||||
else:
|
||||
start = int(start_text)
|
||||
end = int(end_text) if end_text is not None else None
|
||||
if start < 0 or start > 7 or (end is not None and (end < 0 or end > 7)):
|
||||
raise ValueError("day_of_week values must be between 0 and 7")
|
||||
if end is not None and start > end:
|
||||
raise ValueError("day_of_week range start must not exceed end")
|
||||
if end is None:
|
||||
end = 7 if step_text else start
|
||||
values = range(start, end + 1, step)
|
||||
|
||||
weekdays: list[int] = []
|
||||
for value in values:
|
||||
weekday = 0 if value == 7 else value
|
||||
if weekday not in weekdays:
|
||||
weekdays.append(weekday)
|
||||
|
||||
if len(weekdays) == 7:
|
||||
normalized_parts.append("*")
|
||||
else:
|
||||
normalized_parts.extend(_CRONTAB_WEEKDAY_NAMES[value] for value in weekdays)
|
||||
|
||||
return ",".join(normalized_parts)
|
||||
|
||||
|
||||
class CronJobSchedulingError(Exception):
|
||||
"""Raised when a cron job fails to be scheduled."""
|
||||
|
||||
@@ -177,7 +242,21 @@ class CronJobManager:
|
||||
run_at = run_at.replace(tzinfo=tzinfo)
|
||||
trigger = DateTrigger(run_date=run_at, timezone=tzinfo)
|
||||
else:
|
||||
trigger = CronTrigger.from_crontab(job.cron_expression, timezone=tzinfo)
|
||||
if not job.cron_expression:
|
||||
raise ValueError("recurring job missing cron_expression")
|
||||
minute, hour, day, month, day_of_week = job.cron_expression.split()
|
||||
normalized_cron_expression = " ".join(
|
||||
[
|
||||
minute,
|
||||
hour,
|
||||
day,
|
||||
month,
|
||||
_normalize_crontab_day_of_week(day_of_week),
|
||||
]
|
||||
)
|
||||
trigger = CronTrigger.from_crontab(
|
||||
normalized_cron_expression, timezone=tzinfo
|
||||
)
|
||||
self.scheduler.add_job(
|
||||
self._run_job,
|
||||
id=job.job_id,
|
||||
|
||||
@@ -361,10 +361,6 @@ class ProviderManager:
|
||||
from .sources.openai_source import (
|
||||
ProviderOpenAIOfficial as ProviderOpenAIOfficial,
|
||||
)
|
||||
case "openai_responses":
|
||||
from .sources.openai_responses_source import (
|
||||
ProviderOpenAIResponses as ProviderOpenAIResponses,
|
||||
)
|
||||
case "longcat_chat_completion":
|
||||
from .sources.longcat_source import ProviderLongCat as ProviderLongCat
|
||||
case "minimax_token_plan":
|
||||
|
||||
@@ -1,467 +0,0 @@
|
||||
import inspect
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
import astrbot.core.message.components as Comp
|
||||
from astrbot.core.agent.tool import ToolSet
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.provider.entities import LLMResponse, TokenUsage
|
||||
|
||||
from ..register import register_provider_adapter
|
||||
from .openai_source import ProviderOpenAIOfficial
|
||||
from .request_retry import retry_provider_request
|
||||
|
||||
|
||||
@register_provider_adapter(
|
||||
"openai_responses",
|
||||
"OpenAI Responses API 提供商适配器",
|
||||
)
|
||||
class ProviderOpenAIResponses(ProviderOpenAIOfficial):
|
||||
"""OpenAI-compatible provider that calls the Responses API."""
|
||||
|
||||
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
|
||||
super().__init__(provider_config, provider_settings)
|
||||
self.default_params = inspect.signature(
|
||||
self.client.responses.create,
|
||||
).parameters.keys()
|
||||
|
||||
@staticmethod
|
||||
def _get_field(obj: Any, name: str, default: Any = None) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(name, default)
|
||||
return getattr(obj, name, default)
|
||||
|
||||
@staticmethod
|
||||
def _arguments_to_json_string(arguments: Any) -> str:
|
||||
if arguments is None:
|
||||
return ""
|
||||
if isinstance(arguments, str):
|
||||
return arguments
|
||||
try:
|
||||
return json.dumps(arguments, ensure_ascii=False)
|
||||
except TypeError:
|
||||
return str(arguments)
|
||||
|
||||
@staticmethod
|
||||
def _message_content_to_response_content(content: Any, role: str) -> Any:
|
||||
if isinstance(content, str) or content is None:
|
||||
return content or ""
|
||||
if not isinstance(content, list):
|
||||
return content
|
||||
|
||||
converted: list[dict[str, Any]] = []
|
||||
text_type = "output_text" if role == "assistant" else "input_text"
|
||||
for part in content:
|
||||
if not isinstance(part, dict):
|
||||
converted.append({"type": text_type, "text": str(part)})
|
||||
continue
|
||||
part_type = part.get("type")
|
||||
if part_type == "text":
|
||||
converted.append({"type": text_type, "text": part.get("text", "")})
|
||||
elif part_type == "image_url":
|
||||
image_url = part.get("image_url", {})
|
||||
if isinstance(image_url, dict):
|
||||
url = image_url.get("url")
|
||||
detail = image_url.get("detail")
|
||||
else:
|
||||
url = image_url
|
||||
detail = None
|
||||
image_part = {"type": "input_image", "image_url": url}
|
||||
if detail:
|
||||
image_part["detail"] = detail
|
||||
converted.append(image_part)
|
||||
elif part_type in {"audio_url", "input_audio"}:
|
||||
converted.append({"type": text_type, "text": "[Audio]"})
|
||||
elif part_type == "think":
|
||||
continue
|
||||
else:
|
||||
converted.append(part)
|
||||
return converted
|
||||
|
||||
@staticmethod
|
||||
def _is_empty_response_content(content: Any) -> bool:
|
||||
if content is None:
|
||||
return True
|
||||
if isinstance(content, str):
|
||||
return not content
|
||||
if isinstance(content, list):
|
||||
return not content
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _chat_tool_call_to_response_function_call(tool_call: Any) -> dict:
|
||||
function = ProviderOpenAIResponses._get_field(tool_call, "function", {})
|
||||
call_id = (
|
||||
ProviderOpenAIResponses._get_field(tool_call, "id")
|
||||
or ProviderOpenAIResponses._get_field(tool_call, "call_id")
|
||||
or ""
|
||||
)
|
||||
name = ProviderOpenAIResponses._get_field(function, "name", "")
|
||||
arguments = ProviderOpenAIResponses._get_field(function, "arguments", "")
|
||||
|
||||
return {
|
||||
"type": "function_call",
|
||||
"call_id": call_id,
|
||||
"name": name or "",
|
||||
"arguments": ProviderOpenAIResponses._arguments_to_json_string(arguments),
|
||||
"status": "completed",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _message_to_response_input_items(cls, message: dict) -> list[dict]:
|
||||
role = message.get("role", "user")
|
||||
if role == "tool":
|
||||
return [
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": message.get("tool_call_id", ""),
|
||||
"output": message.get("content", ""),
|
||||
},
|
||||
]
|
||||
|
||||
content = cls._message_content_to_response_content(
|
||||
message.get("content", ""),
|
||||
role,
|
||||
)
|
||||
item = {
|
||||
"role": role,
|
||||
"content": content,
|
||||
}
|
||||
if role != "assistant" or not message.get("tool_calls"):
|
||||
return [item]
|
||||
|
||||
items = [] if cls._is_empty_response_content(content) else [item]
|
||||
items.extend(
|
||||
cls._chat_tool_call_to_response_function_call(tool_call)
|
||||
for tool_call in message["tool_calls"]
|
||||
)
|
||||
return items
|
||||
|
||||
@classmethod
|
||||
def _messages_to_response_input(cls, messages: list[dict]) -> list[dict]:
|
||||
items: list[dict] = []
|
||||
for message in messages:
|
||||
items.extend(cls._message_to_response_input_items(message))
|
||||
return items
|
||||
|
||||
@classmethod
|
||||
def _chat_payload_to_responses_payload(cls, payloads: dict) -> dict:
|
||||
response_payload = dict(payloads)
|
||||
messages = response_payload.pop("messages", [])
|
||||
if isinstance(messages, list):
|
||||
response_payload["input"] = cls._messages_to_response_input(messages)
|
||||
return response_payload
|
||||
|
||||
@staticmethod
|
||||
def _responses_function_tools(tools: ToolSet | None) -> list[dict]:
|
||||
if not tools:
|
||||
return []
|
||||
converted: list[dict] = []
|
||||
for tool in tools.openai_schema():
|
||||
if tool.get("type") != "function":
|
||||
converted.append(tool)
|
||||
continue
|
||||
function = tool.get("function", {})
|
||||
item = {
|
||||
"type": "function",
|
||||
"name": function.get("name", ""),
|
||||
"strict": False,
|
||||
}
|
||||
if function.get("description"):
|
||||
item["description"] = function["description"]
|
||||
if "parameters" in function:
|
||||
item["parameters"] = function["parameters"]
|
||||
converted.append(item)
|
||||
return converted
|
||||
|
||||
@staticmethod
|
||||
def _response_usage_to_token_usage(usage: Any) -> TokenUsage | None:
|
||||
if not usage:
|
||||
return None
|
||||
|
||||
def _get(name: str) -> int:
|
||||
value = ProviderOpenAIResponses._get_field(usage, name, 0)
|
||||
return value if isinstance(value, int) else 0
|
||||
|
||||
input_tokens = _get("input_tokens")
|
||||
output_tokens = _get("output_tokens")
|
||||
cached = 0
|
||||
details = ProviderOpenAIResponses._get_field(usage, "input_tokens_details")
|
||||
if details is not None:
|
||||
cached = ProviderOpenAIResponses._get_field(details, "cached_tokens", 0)
|
||||
cached = cached or 0
|
||||
cached = cached if isinstance(cached, int) else 0
|
||||
return TokenUsage(
|
||||
input_other=max(input_tokens - cached, 0),
|
||||
input_cached=cached,
|
||||
output=output_tokens,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_response_output_text(response: Any) -> str:
|
||||
output_text = ProviderOpenAIResponses._get_field(response, "output_text")
|
||||
if isinstance(output_text, str):
|
||||
return output_text.strip()
|
||||
|
||||
output = ProviderOpenAIResponses._get_field(response, "output", [])
|
||||
parts: list[str] = []
|
||||
if isinstance(output, list):
|
||||
for item in output:
|
||||
content = ProviderOpenAIResponses._get_field(item, "content", [])
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
for part in content:
|
||||
part_type = ProviderOpenAIResponses._get_field(part, "type")
|
||||
if part_type not in {"output_text", "text"}:
|
||||
continue
|
||||
text = ProviderOpenAIResponses._get_field(part, "text")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
return "".join(parts).strip()
|
||||
|
||||
@staticmethod
|
||||
def _iter_response_output_items(response: Any) -> list[Any]:
|
||||
output = ProviderOpenAIResponses._get_field(response, "output", [])
|
||||
return output if isinstance(output, list) else []
|
||||
|
||||
@classmethod
|
||||
def _iter_function_calls(cls, response: Any) -> list[dict[str, Any]]:
|
||||
calls: list[dict[str, Any]] = []
|
||||
for item in cls._iter_response_output_items(response):
|
||||
if cls._get_field(item, "type") != "function_call":
|
||||
continue
|
||||
calls.append(
|
||||
{
|
||||
"name": cls._get_field(item, "name"),
|
||||
"arguments": cls._get_field(item, "arguments"),
|
||||
"call_id": cls._get_field(item, "call_id"),
|
||||
}
|
||||
)
|
||||
return calls
|
||||
|
||||
@staticmethod
|
||||
def _parse_function_call_arguments(arguments: Any) -> dict:
|
||||
if isinstance(arguments, str):
|
||||
try:
|
||||
parsed_args = json.loads(arguments)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return parsed_args if isinstance(parsed_args, dict) else {}
|
||||
if isinstance(arguments, dict):
|
||||
return arguments
|
||||
return {}
|
||||
|
||||
async def _parse_responses_completion(
|
||||
self,
|
||||
response: Any,
|
||||
tools: ToolSet | None,
|
||||
) -> LLMResponse:
|
||||
llm_response = LLMResponse("assistant")
|
||||
response_id = self._get_field(response, "id")
|
||||
|
||||
if tools is not None:
|
||||
args_ls: list[dict] = []
|
||||
func_name_ls: list[str] = []
|
||||
tool_call_ids: list[str] = []
|
||||
for call in self._iter_function_calls(response):
|
||||
name = call["name"]
|
||||
if not name:
|
||||
continue
|
||||
args_ls.append(self._parse_function_call_arguments(call["arguments"]))
|
||||
func_name_ls.append(name)
|
||||
tool_call_ids.append(call["call_id"] or response_id or "")
|
||||
if args_ls:
|
||||
llm_response.role = "tool"
|
||||
llm_response.tools_call_args = args_ls
|
||||
llm_response.tools_call_name = func_name_ls
|
||||
llm_response.tools_call_ids = tool_call_ids
|
||||
|
||||
completion_text = self._extract_response_output_text(response)
|
||||
if completion_text:
|
||||
llm_response.result_chain = MessageChain().message(completion_text)
|
||||
llm_response.raw_completion = response
|
||||
llm_response.id = response_id
|
||||
usage = self._get_field(response, "usage")
|
||||
llm_response.usage = self._response_usage_to_token_usage(usage)
|
||||
return llm_response
|
||||
|
||||
def _split_responses_extra_body(self, payloads: dict) -> tuple[dict, dict]:
|
||||
request_payload = dict(payloads)
|
||||
extra_body = {}
|
||||
custom_extra_body = self.provider_config.get("custom_extra_body", {})
|
||||
if isinstance(custom_extra_body, dict):
|
||||
extra_body.update(custom_extra_body)
|
||||
|
||||
for key in list(request_payload):
|
||||
if key not in self.default_params:
|
||||
extra_body[key] = request_payload.pop(key)
|
||||
self._apply_provider_specific_extra_body_overrides(extra_body)
|
||||
return request_payload, extra_body
|
||||
|
||||
def _build_responses_request(
|
||||
self,
|
||||
payloads: dict,
|
||||
tools: ToolSet | None,
|
||||
) -> tuple[dict, dict]:
|
||||
self._sanitize_assistant_messages(payloads)
|
||||
response_payload = self._chat_payload_to_responses_payload(payloads)
|
||||
response_tools = self._responses_function_tools(tools)
|
||||
if response_tools:
|
||||
response_payload["tools"] = response_tools
|
||||
if tools and not tools.empty():
|
||||
response_payload["tool_choice"] = response_payload.get(
|
||||
"tool_choice", "auto"
|
||||
)
|
||||
else:
|
||||
response_payload.pop("tool_choice", None)
|
||||
return self._split_responses_extra_body(response_payload)
|
||||
|
||||
async def _query(
|
||||
self,
|
||||
payloads: dict,
|
||||
tools: ToolSet | None,
|
||||
*,
|
||||
request_max_retries: int | None = None,
|
||||
) -> LLMResponse:
|
||||
request_payload, extra_body = self._build_responses_request(payloads, tools)
|
||||
response = await retry_provider_request(
|
||||
"OpenAI",
|
||||
lambda: self.client.responses.create(
|
||||
**request_payload,
|
||||
stream=False,
|
||||
extra_body=extra_body,
|
||||
),
|
||||
max_attempts=request_max_retries,
|
||||
)
|
||||
return await self._parse_responses_completion(response, tools)
|
||||
|
||||
@staticmethod
|
||||
def _event_value(event: Any, name: str, default: Any = None) -> Any:
|
||||
return ProviderOpenAIResponses._get_field(event, name, default)
|
||||
|
||||
@classmethod
|
||||
def _stream_function_call_key(
|
||||
cls,
|
||||
event: Any,
|
||||
function_calls: dict[str, dict[str, Any]],
|
||||
) -> str:
|
||||
item = cls._event_value(event, "item")
|
||||
for value in (
|
||||
cls._event_value(event, "output_index"),
|
||||
cls._event_value(event, "item_id"),
|
||||
cls._get_field(item, "id"),
|
||||
cls._get_field(item, "call_id"),
|
||||
):
|
||||
if value is not None:
|
||||
return str(value)
|
||||
return str(len(function_calls))
|
||||
|
||||
@classmethod
|
||||
def _merge_stream_function_call_event(
|
||||
cls,
|
||||
event: Any,
|
||||
function_calls: dict[str, dict[str, Any]],
|
||||
) -> None:
|
||||
event_type = cls._event_value(event, "type", "")
|
||||
item = cls._event_value(event, "item")
|
||||
call_key = cls._stream_function_call_key(event, function_calls)
|
||||
|
||||
if event_type in {"response.output_item.added", "response.output_item.done"}:
|
||||
if cls._get_field(item, "type") != "function_call":
|
||||
return
|
||||
call = function_calls.setdefault(call_key, {})
|
||||
call["name"] = cls._get_field(item, "name", call.get("name"))
|
||||
call["call_id"] = cls._get_field(item, "call_id", call.get("call_id"))
|
||||
arguments = cls._get_field(item, "arguments")
|
||||
if arguments is not None:
|
||||
call["arguments"] = arguments
|
||||
return
|
||||
|
||||
if event_type == "response.function_call_arguments.delta":
|
||||
delta = cls._event_value(event, "delta", "")
|
||||
if delta:
|
||||
call = function_calls.setdefault(call_key, {})
|
||||
call["arguments"] = f"{call.get('arguments', '')}{delta}"
|
||||
return
|
||||
|
||||
if event_type == "response.function_call_arguments.done":
|
||||
arguments = cls._event_value(event, "arguments", "")
|
||||
function_calls.setdefault(call_key, {})["arguments"] = arguments
|
||||
|
||||
async def _stream_function_calls_to_response(
|
||||
self,
|
||||
function_calls: dict[str, dict[str, Any]],
|
||||
tools: ToolSet | None,
|
||||
) -> LLMResponse:
|
||||
output = []
|
||||
for call in function_calls.values():
|
||||
if not call.get("name"):
|
||||
continue
|
||||
output.append(
|
||||
{
|
||||
"type": "function_call",
|
||||
"name": call.get("name", ""),
|
||||
"call_id": call.get("call_id", ""),
|
||||
"arguments": call.get("arguments", ""),
|
||||
}
|
||||
)
|
||||
return await self._parse_responses_completion({"output": output}, tools)
|
||||
|
||||
async def _query_stream(
|
||||
self,
|
||||
payloads: dict,
|
||||
tools: ToolSet | None,
|
||||
*,
|
||||
request_max_retries: int | None = None,
|
||||
) -> AsyncGenerator[LLMResponse, None]:
|
||||
request_payload, extra_body = self._build_responses_request(payloads, tools)
|
||||
stream = await retry_provider_request(
|
||||
"OpenAI",
|
||||
lambda: self.client.responses.create(
|
||||
**request_payload,
|
||||
stream=True,
|
||||
extra_body=extra_body,
|
||||
),
|
||||
max_attempts=request_max_retries,
|
||||
)
|
||||
|
||||
output_text = ""
|
||||
final_response = None
|
||||
function_calls: dict[str, dict[str, Any]] = {}
|
||||
async for event in stream:
|
||||
event_type = self._event_value(event, "type", "")
|
||||
if event_type == "response.output_text.delta":
|
||||
delta = self._event_value(event, "delta", "")
|
||||
if not delta:
|
||||
continue
|
||||
output_text += str(delta)
|
||||
yield LLMResponse(
|
||||
"assistant",
|
||||
result_chain=MessageChain(chain=[Comp.Plain(str(delta))]),
|
||||
is_chunk=True,
|
||||
)
|
||||
elif event_type == "response.output_text.done":
|
||||
text = self._event_value(event, "text", "")
|
||||
if text:
|
||||
output_text = str(text)
|
||||
elif event_type == "response.completed":
|
||||
final_response = self._event_value(event, "response")
|
||||
else:
|
||||
self._merge_stream_function_call_event(event, function_calls)
|
||||
|
||||
if final_response is not None:
|
||||
llm_response = await self._parse_responses_completion(final_response, tools)
|
||||
if not llm_response.completion_text and output_text:
|
||||
llm_response.result_chain = MessageChain().message(output_text)
|
||||
elif function_calls:
|
||||
llm_response = await self._stream_function_calls_to_response(
|
||||
function_calls,
|
||||
tools,
|
||||
)
|
||||
else:
|
||||
llm_response = LLMResponse(
|
||||
"assistant",
|
||||
result_chain=MessageChain().message(output_text),
|
||||
)
|
||||
yield llm_response
|
||||
@@ -21,6 +21,8 @@ WEB_SEARCH_TOOL_NAMES = [
|
||||
"web_search_brave",
|
||||
"web_search_firecrawl",
|
||||
"firecrawl_extract_web_page",
|
||||
"web_search_exa",
|
||||
"exa_get_contents",
|
||||
]
|
||||
_TAVILY_WEB_SEARCH_TOOL_CONFIG = {
|
||||
"provider_settings.web_search": True,
|
||||
@@ -42,6 +44,10 @@ _BAIDU_WEB_SEARCH_TOOL_CONFIG = {
|
||||
"provider_settings.web_search": True,
|
||||
"provider_settings.websearch_provider": "baidu_ai_search",
|
||||
}
|
||||
_EXA_WEB_SEARCH_TOOL_CONFIG = {
|
||||
"provider_settings.web_search": True,
|
||||
"provider_settings.websearch_provider": "exa",
|
||||
}
|
||||
|
||||
|
||||
@std_dataclass
|
||||
@@ -76,6 +82,7 @@ _TAVILY_KEY_ROTATOR = _KeyRotator("websearch_tavily_key", "Tavily")
|
||||
_BOCHA_KEY_ROTATOR = _KeyRotator("websearch_bocha_key", "BoCha")
|
||||
_BRAVE_KEY_ROTATOR = _KeyRotator("websearch_brave_key", "Brave")
|
||||
_FIRECRAWL_KEY_ROTATOR = _KeyRotator("websearch_firecrawl_key", "Firecrawl")
|
||||
_EXA_KEY_ROTATOR = _KeyRotator("websearch_exa_key", "Exa")
|
||||
|
||||
|
||||
def normalize_legacy_web_search_config(cfg) -> None:
|
||||
@@ -99,6 +106,7 @@ def normalize_legacy_web_search_config(cfg) -> None:
|
||||
"websearch_bocha_key",
|
||||
"websearch_brave_key",
|
||||
"websearch_firecrawl_key",
|
||||
"websearch_exa_key",
|
||||
):
|
||||
value = provider_settings.get(setting_name)
|
||||
if isinstance(value, str):
|
||||
@@ -803,10 +811,231 @@ class BaiduWebSearchTool(FunctionTool[AstrAgentContext]):
|
||||
return _search_result_payload(results)
|
||||
|
||||
|
||||
async def _exa_search(
|
||||
provider_settings: dict,
|
||||
payload: dict,
|
||||
) -> list[SearchResult]:
|
||||
"""Call the Exa /search endpoint and return normalized results."""
|
||||
exa_key = await _EXA_KEY_ROTATOR.get(provider_settings)
|
||||
headers = {
|
||||
"x-api-key": exa_key,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.post(
|
||||
"https://api.exa.ai/search",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
reason = await response.text()
|
||||
raise Exception(
|
||||
f"Exa web search failed: {reason}, status: {response.status}",
|
||||
)
|
||||
data = await response.json()
|
||||
return [
|
||||
SearchResult(
|
||||
title=item.get("title", ""),
|
||||
url=item.get("url", ""),
|
||||
snippet=(
|
||||
item.get("text")
|
||||
or (item.get("highlights") or [""])[0]
|
||||
or item.get("summary", "")
|
||||
),
|
||||
)
|
||||
for item in data.get("results", [])
|
||||
if item.get("url")
|
||||
]
|
||||
|
||||
|
||||
async def _exa_get_contents(
|
||||
provider_settings: dict,
|
||||
payload: dict,
|
||||
) -> list[dict]:
|
||||
"""Call the Exa /contents endpoint and return raw result dicts."""
|
||||
exa_key = await _EXA_KEY_ROTATOR.get(provider_settings)
|
||||
headers = {
|
||||
"x-api-key": exa_key,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.post(
|
||||
"https://api.exa.ai/contents",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
reason = await response.text()
|
||||
raise Exception(
|
||||
f"Exa get contents failed: {reason}, status: {response.status}",
|
||||
)
|
||||
data = await response.json()
|
||||
return data.get("results", [])
|
||||
|
||||
|
||||
@builtin_tool(config=_EXA_WEB_SEARCH_TOOL_CONFIG)
|
||||
@pydantic_dataclass
|
||||
class ExaWebSearchTool(FunctionTool[AstrAgentContext]):
|
||||
"""Web search tool powered by the Exa Search API."""
|
||||
|
||||
name: str = "web_search_exa"
|
||||
description: str = (
|
||||
"A web search tool powered by Exa, an AI-native search engine. "
|
||||
"Supports keyword and semantic search with domain, date, and category filters."
|
||||
)
|
||||
parameters: dict = Field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Required. Search query."},
|
||||
"num_results": {
|
||||
"type": "integer",
|
||||
"description": "Optional. Number of results to return. Default is 10.",
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
'Optional. Search type. One of "auto", "keyword", "neural". '
|
||||
'Default is "auto".'
|
||||
),
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional. Category filter. One of "
|
||||
'"company", "research paper", "news", "github", '
|
||||
'"tweet", "personal site", "pdf", "linkedin profile".'
|
||||
),
|
||||
},
|
||||
"include_domains": {
|
||||
"type": "string",
|
||||
"description": "Optional. Comma-separated domains to restrict results to.",
|
||||
},
|
||||
"exclude_domains": {
|
||||
"type": "string",
|
||||
"description": "Optional. Comma-separated domains to exclude from results.",
|
||||
},
|
||||
"start_published_date": {
|
||||
"type": "string",
|
||||
"description": "Optional. Start date filter in ISO 8601 format (e.g. 2024-01-01T00:00:00.000Z).",
|
||||
},
|
||||
"end_published_date": {
|
||||
"type": "string",
|
||||
"description": "Optional. End date filter in ISO 8601 format.",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(self, context, **kwargs) -> ToolExecResult:
|
||||
_, provider_settings, _ = _get_runtime(context)
|
||||
if not provider_settings.get("websearch_exa_key", []):
|
||||
return "Error: Exa API key is not configured in AstrBot."
|
||||
|
||||
try:
|
||||
num_results = int(kwargs.get("num_results", 10))
|
||||
except (TypeError, ValueError):
|
||||
num_results = 10
|
||||
if num_results < 1:
|
||||
num_results = 1
|
||||
|
||||
search_type = kwargs.get("type", "auto")
|
||||
if search_type not in ("auto", "keyword", "neural"):
|
||||
search_type = "auto"
|
||||
|
||||
payload: dict = {
|
||||
"query": kwargs["query"],
|
||||
"numResults": num_results,
|
||||
"type": search_type,
|
||||
"contents": {"text": {"maxCharacters": 500}},
|
||||
}
|
||||
|
||||
category = kwargs.get("category", "")
|
||||
if category:
|
||||
payload["category"] = category
|
||||
|
||||
include_domains = str(kwargs.get("include_domains", "")).strip()
|
||||
if include_domains:
|
||||
payload["includeDomains"] = [
|
||||
d.strip() for d in include_domains.split(",") if d.strip()
|
||||
]
|
||||
|
||||
exclude_domains = str(kwargs.get("exclude_domains", "")).strip()
|
||||
if exclude_domains:
|
||||
payload["excludeDomains"] = [
|
||||
d.strip() for d in exclude_domains.split(",") if d.strip()
|
||||
]
|
||||
|
||||
if kwargs.get("start_published_date"):
|
||||
payload["startPublishedDate"] = kwargs["start_published_date"]
|
||||
if kwargs.get("end_published_date"):
|
||||
payload["endPublishedDate"] = kwargs["end_published_date"]
|
||||
|
||||
results = await _exa_search(provider_settings, payload)
|
||||
if not results:
|
||||
return "Error: Exa web search does not return any results."
|
||||
return _search_result_payload(results)
|
||||
|
||||
|
||||
@builtin_tool(config=_EXA_WEB_SEARCH_TOOL_CONFIG)
|
||||
@pydantic_dataclass
|
||||
class ExaGetContentsTool(FunctionTool[AstrAgentContext]):
|
||||
"""Extract full page content from URLs using the Exa Contents API."""
|
||||
|
||||
name: str = "exa_get_contents"
|
||||
description: str = "Extract the content of a web page using Exa."
|
||||
parameters: dict = Field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Required. A URL to extract content from.",
|
||||
},
|
||||
"max_characters": {
|
||||
"type": "integer",
|
||||
"description": "Optional. Maximum number of characters to return. Default is 3000.",
|
||||
},
|
||||
},
|
||||
"required": ["url"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(self, context, **kwargs) -> ToolExecResult:
|
||||
_, provider_settings, _ = _get_runtime(context)
|
||||
if not provider_settings.get("websearch_exa_key", []):
|
||||
return "Error: Exa API key is not configured in AstrBot."
|
||||
|
||||
url = str(kwargs.get("url", "")).strip()
|
||||
if not url:
|
||||
return "Error: url must be a non-empty string."
|
||||
|
||||
try:
|
||||
max_characters = int(kwargs.get("max_characters", 3000))
|
||||
except (TypeError, ValueError):
|
||||
max_characters = 3000
|
||||
results = await _exa_get_contents(
|
||||
provider_settings,
|
||||
{
|
||||
"ids": [url],
|
||||
"text": {"maxCharacters": max_characters},
|
||||
},
|
||||
)
|
||||
ret_ls = []
|
||||
for result in results:
|
||||
ret_ls.append(f"URL: {result.get('url', 'No URL')}")
|
||||
ret_ls.append(f"Content: {result.get('text', 'No content')}")
|
||||
ret = "\n".join(ret_ls)
|
||||
return ret or "Error: Exa get contents does not return any results."
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BaiduWebSearchTool",
|
||||
"BochaWebSearchTool",
|
||||
"BraveWebSearchTool",
|
||||
"ExaGetContentsTool",
|
||||
"ExaWebSearchTool",
|
||||
"TavilyExtractWebPageTool",
|
||||
"TavilyWebSearchTool",
|
||||
"WEB_SEARCH_TOOL_NAMES",
|
||||
|
||||
@@ -54,6 +54,7 @@ ALL_OPEN_API_SCOPES = (
|
||||
"im",
|
||||
"config",
|
||||
"chat",
|
||||
"data",
|
||||
"file",
|
||||
"plugin",
|
||||
"mcp",
|
||||
|
||||
@@ -495,7 +495,9 @@ class KnowledgeBaseService:
|
||||
|
||||
files_to_upload = []
|
||||
for file in file_list:
|
||||
file_name = file.filename
|
||||
file_name = Path(str(file.filename or "document").replace("\\", "/")).name
|
||||
if file_name in {"", ".", ".."}:
|
||||
file_name = "document"
|
||||
temp_file_path = (
|
||||
Path(get_astrbot_temp_path()) / f"kb_upload_{uuid.uuid4()}_{file_name}"
|
||||
)
|
||||
|
||||
@@ -872,9 +872,10 @@ class PluginService:
|
||||
) -> tuple[dict, str]:
|
||||
self._ensure_not_demo()
|
||||
logger.info(f"正在安装用户上传的插件 {upload_file.filename}")
|
||||
filename = str(upload_file.filename or "plugin.zip").replace("\\", "/")
|
||||
file_path = os.path.join(
|
||||
get_astrbot_temp_path(),
|
||||
f"plugin_upload_{upload_file.filename}",
|
||||
f"plugin_upload_{os.path.basename(filename) or 'plugin.zip'}",
|
||||
)
|
||||
await upload_file.save(file_path)
|
||||
try:
|
||||
|
||||
@@ -86,6 +86,9 @@ export type ChatProjectRequest = {
|
||||
};
|
||||
|
||||
export type ChatRequest = {
|
||||
/**
|
||||
* Caller-declared WebChat sender/session owner. This value is used as the message sender identity and may participate in sender-ID-based command permission checks. Treat chat-scoped API keys as trusted backend credentials and map or validate usernames before accepting end-user input.
|
||||
*/
|
||||
username?: string;
|
||||
session_id?: string;
|
||||
/**
|
||||
@@ -191,7 +194,7 @@ export type ConversationRef = {
|
||||
|
||||
export type CreateApiKeyRequest = {
|
||||
name: string;
|
||||
scopes?: Array<('bot' | 'provider' | 'persona' | 'im' | 'config' | 'chat' | 'file' | 'plugin' | 'mcp' | 'skill')>;
|
||||
scopes?: Array<('bot' | 'provider' | 'persona' | 'im' | 'config' | 'chat' | 'data' | 'file' | 'plugin' | 'mcp' | 'skill')>;
|
||||
expires_at?: string;
|
||||
expires_in_days?: number;
|
||||
};
|
||||
|
||||
@@ -139,6 +139,10 @@
|
||||
},
|
||||
"web_search_link": {
|
||||
"description": "Display Source Citations"
|
||||
},
|
||||
"websearch_exa_key": {
|
||||
"description": "Exa API Key",
|
||||
"hint": "Multiple keys can be added for rotation. Get a key at https://dashboard.exa.ai"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1221,22 +1225,22 @@
|
||||
"hint": "Only effective for qwen3-rerank models. Recommended to write in English."
|
||||
},
|
||||
"nvidia_rerank_api_base": {
|
||||
"description": "API Base URL"
|
||||
"description": "API Base URL"
|
||||
},
|
||||
"nvidia_rerank_api_key": {
|
||||
"description": "API Key"
|
||||
"description": "API Key"
|
||||
},
|
||||
"nvidia_rerank_model": {
|
||||
"description": "Rerank Model Name",
|
||||
"hint": "Please refer to the NVIDIA Docs for the model name."
|
||||
"description": "Rerank Model Name",
|
||||
"hint": "Please refer to the NVIDIA Docs for the model name."
|
||||
},
|
||||
"nvidia_rerank_model_endpoint": {
|
||||
"description": "Custom Model Endpoint",
|
||||
"hint": "Custom URL suffix endpoint, defaults to /reranking."
|
||||
"description": "Custom Model Endpoint",
|
||||
"hint": "Custom URL suffix endpoint, defaults to /reranking."
|
||||
},
|
||||
"nvidia_rerank_truncate": {
|
||||
"description": "Text Truncation Strategy",
|
||||
"hint": "Whether to truncate the input to fit the model's maximum context length when the input text is too long."
|
||||
"description": "Text Truncation Strategy",
|
||||
"hint": "Whether to truncate the input to fit the model's maximum context length when the input text is too long."
|
||||
},
|
||||
"launch_model_if_not_running": {
|
||||
"description": "Auto-start model if not running",
|
||||
|
||||
@@ -139,6 +139,10 @@
|
||||
},
|
||||
"web_search_link": {
|
||||
"description": "Показывать ссылки на источники"
|
||||
},
|
||||
"websearch_exa_key": {
|
||||
"description": "API-ключ Exa",
|
||||
"hint": "Можно добавить несколько ключей для ротации. Получить ключ: https://dashboard.exa.ai"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -141,6 +141,10 @@
|
||||
},
|
||||
"web_search_link": {
|
||||
"description": "显示来源引用"
|
||||
},
|
||||
"websearch_exa_key": {
|
||||
"description": "Exa API Key",
|
||||
"hint": "可添加多个 Key 进行轮询。获取 Key: https://dashboard.exa.ai"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -513,6 +513,7 @@ const availableScopes = [
|
||||
{ value: 'im', label: 'im' },
|
||||
{ value: 'config', label: 'config' },
|
||||
{ value: 'chat', label: 'chat' },
|
||||
{ value: 'data', label: 'data' },
|
||||
{ value: 'file', label: 'file' },
|
||||
{ value: 'plugin', label: 'plugin' },
|
||||
{ value: 'mcp', label: 'mcp' },
|
||||
|
||||
@@ -14,11 +14,11 @@ When using a large language model that supports function calling with the web se
|
||||
|
||||
And other prompts with search intent to trigger the model to invoke the search tool.
|
||||
|
||||
AstrBot currently supports 5 web search providers: `Tavily`, `BoCha`, `Baidu AI Search`, `Brave`, and `Firecrawl`.
|
||||
AstrBot currently supports 6 web search providers: `Tavily`, `BoCha`, `Baidu AI Search`, `Brave`, `Firecrawl`, and `Exa`.
|
||||
|
||||

|
||||
|
||||
Go to `Configuration`, scroll down to find Web Search, where you can select `Tavily`, `BoCha`, `Baidu AI Search`, `Brave`, or `Firecrawl`.
|
||||
Go to `Configuration`, scroll down to find Web Search, where you can select `Tavily`, `BoCha`, `Baidu AI Search`, `Brave`, `Firecrawl`, or `Exa`.
|
||||
|
||||
### Tavily
|
||||
|
||||
@@ -40,6 +40,10 @@ Get an API Key from Brave Search, then fill it in the corresponding configuratio
|
||||
|
||||
Go to [Firecrawl](https://firecrawl.dev) to get an API Key, then fill it in the corresponding configuration item.
|
||||
|
||||
### Exa
|
||||
|
||||
Go to [Exa](https://dashboard.exa.ai) to get an API Key, then fill it in the corresponding configuration item. Exa is an AI-native search engine that supports keyword and semantic search with category filters, domain restrictions, and date ranges.
|
||||
|
||||
If you use Tavily as your web search source, you will get a better experience optimization on AstrBot ChatUI, including citation source display and more:
|
||||
|
||||

|
||||
|
||||
@@ -13,11 +13,11 @@ AstrBot 内置的网页搜索功能依赖大模型提供 `函数调用` 能力
|
||||
|
||||
等等带有搜索意味的提示让大模型触发调用搜索工具。
|
||||
|
||||
AstrBot 当前支持 5 种网页搜索源接入方式:`Tavily`、`BoCha`、`百度 AI 搜索`、`Brave`、`Firecrawl`。
|
||||
AstrBot 当前支持 6 种网页搜索源接入方式:`Tavily`、`BoCha`、`百度 AI 搜索`、`Brave`、`Firecrawl`、`Exa`。
|
||||
|
||||

|
||||
|
||||
进入 `配置`,下拉找到网页搜索,您可选择 `Tavily`、`BoCha`、`百度 AI 搜索`、`Brave` 或 `Firecrawl`。
|
||||
进入 `配置`,下拉找到网页搜索,您可选择 `Tavily`、`BoCha`、`百度 AI 搜索`、`Brave`、`Firecrawl` 或 `Exa`。
|
||||
|
||||
### Tavily
|
||||
|
||||
@@ -39,6 +39,10 @@ AstrBot 当前支持 5 种网页搜索源接入方式:`Tavily`、`BoCha`、`
|
||||
|
||||
前往 [Firecrawl](https://firecrawl.dev) 获取 API Key,然后填写在相应的配置项。
|
||||
|
||||
### Exa
|
||||
|
||||
前往 [Exa](https://dashboard.exa.ai) 获取 API Key,然后填写在相应的配置项。Exa 是一个 AI 原生搜索引擎,支持关键词和语义搜索,提供分类过滤、域名限制和日期范围等高级搜索功能。
|
||||
|
||||
如果您使用 Tavily 作为网页搜索源,在 AstrBot ChatUI 上将会获得更好的体验优化,包括引用来源展示等:
|
||||
|
||||

|
||||
|
||||
@@ -8,7 +8,7 @@ info:
|
||||
JSON objects because their schemas are provided at runtime by template
|
||||
endpoints.
|
||||
Developer API keys currently support these scopes only: bot, provider,
|
||||
persona, im, config, chat, file, plugin, mcp, skill. The config scope also
|
||||
persona, im, config, chat, data, file, plugin, mcp, skill. The config scope also
|
||||
grants bot and provider access.
|
||||
servers:
|
||||
- url: http://localhost:6185
|
||||
@@ -5127,8 +5127,8 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum: [bot, provider, persona, im, config, chat, file, plugin, mcp, skill]
|
||||
example: [bot, provider, persona, im, config, chat, file, plugin, mcp, skill]
|
||||
enum: [bot, provider, persona, im, config, chat, data, file, plugin, mcp, skill]
|
||||
example: [bot, provider, persona, im, config, chat, data, file, plugin, mcp, skill]
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.core.agent.tool import FunctionTool, ToolSet
|
||||
from astrbot.core.provider.sources.openai_responses_source import (
|
||||
ProviderOpenAIResponses,
|
||||
)
|
||||
|
||||
|
||||
class _Responses:
|
||||
async def create(self, **kwargs):
|
||||
return SimpleNamespace(output_text="ok", output=[], usage=None)
|
||||
|
||||
|
||||
class _Client:
|
||||
def __init__(self) -> None:
|
||||
self.responses = _Responses()
|
||||
|
||||
|
||||
def _make_provider() -> ProviderOpenAIResponses:
|
||||
provider = ProviderOpenAIResponses.__new__(ProviderOpenAIResponses)
|
||||
provider.client = _Client()
|
||||
provider.default_params = {
|
||||
"model",
|
||||
"input",
|
||||
"tools",
|
||||
"tool_choice",
|
||||
"stream",
|
||||
"extra_body",
|
||||
}
|
||||
provider.provider_config = {"custom_extra_body": {"metadata": {"test": True}}}
|
||||
provider._apply_provider_specific_extra_body_overrides = lambda extra_body: None
|
||||
return provider
|
||||
|
||||
|
||||
def _make_tool_set() -> ToolSet:
|
||||
return ToolSet(
|
||||
tools=[
|
||||
FunctionTool(
|
||||
name="lookup_weather",
|
||||
description="Look up weather",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"],
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_chat_payload_to_responses_payload_converts_messages_and_tool_calls():
|
||||
payload = {
|
||||
"model": "gpt-4.1",
|
||||
"messages": [
|
||||
{"role": "system", "content": "Be brief."},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "weather"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png,abc"}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"function": {
|
||||
"name": "lookup_weather",
|
||||
"arguments": {"city": "Shanghai"},
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "sunny"},
|
||||
],
|
||||
}
|
||||
|
||||
converted = ProviderOpenAIResponses._chat_payload_to_responses_payload(payload)
|
||||
|
||||
assert "messages" not in converted
|
||||
assert converted["model"] == "gpt-4.1"
|
||||
assert converted["input"] == [
|
||||
{"role": "system", "content": "Be brief."},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "weather"},
|
||||
{"type": "input_image", "image_url": "data:image/png,abc"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call_1",
|
||||
"name": "lookup_weather",
|
||||
"arguments": '{"city": "Shanghai"}',
|
||||
"status": "completed",
|
||||
},
|
||||
{"type": "function_call_output", "call_id": "call_1", "output": "sunny"},
|
||||
]
|
||||
|
||||
|
||||
def test_chat_payload_to_responses_payload_replaces_audio_parts_with_placeholder():
|
||||
payload = {
|
||||
"model": "gpt-4.1",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "listen"},
|
||||
{
|
||||
"type": "input_audio",
|
||||
"input_audio": {"data": "abc", "format": "wav"},
|
||||
},
|
||||
{"type": "audio_url", "audio_url": {"url": "data:audio/wav,abc"}},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
converted = ProviderOpenAIResponses._chat_payload_to_responses_payload(payload)
|
||||
|
||||
assert converted["input"] == [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "listen"},
|
||||
{"type": "input_text", "text": "[Audio]"},
|
||||
{"type": "input_text", "text": "[Audio]"},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_build_responses_request_shares_tool_and_extra_body_handling():
|
||||
provider = _make_provider()
|
||||
payload = {
|
||||
"model": "gpt-4.1",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"unknown_param": "kept-in-extra-body",
|
||||
}
|
||||
|
||||
request_payload, extra_body = provider._build_responses_request(
|
||||
payload,
|
||||
_make_tool_set(),
|
||||
)
|
||||
|
||||
assert request_payload["input"] == [{"role": "user", "content": "hello"}]
|
||||
assert request_payload["tool_choice"] == "auto"
|
||||
assert request_payload["tools"] == [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "lookup_weather",
|
||||
"description": "Look up weather",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"],
|
||||
},
|
||||
"strict": False,
|
||||
}
|
||||
]
|
||||
assert extra_body == {
|
||||
"metadata": {"test": True},
|
||||
"unknown_param": "kept-in-extra-body",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_responses_completion_extracts_function_call_and_usage():
|
||||
provider = _make_provider()
|
||||
response = SimpleNamespace(
|
||||
id="resp_1",
|
||||
output=[
|
||||
SimpleNamespace(
|
||||
type="function_call",
|
||||
name="lookup_weather",
|
||||
call_id="call_1",
|
||||
arguments='{"city":"Guangzhou"}',
|
||||
)
|
||||
],
|
||||
usage=SimpleNamespace(
|
||||
input_tokens=10,
|
||||
output_tokens=3,
|
||||
input_tokens_details=SimpleNamespace(cached_tokens=4),
|
||||
),
|
||||
)
|
||||
|
||||
result = await provider._parse_responses_completion(response, _make_tool_set())
|
||||
|
||||
assert result.role == "tool"
|
||||
assert result.tools_call_name == ["lookup_weather"]
|
||||
assert result.tools_call_ids == ["call_1"]
|
||||
assert result.tools_call_args == [{"city": "Guangzhou"}]
|
||||
assert result.usage.input_other == 6
|
||||
assert result.usage.input_cached == 4
|
||||
assert result.usage.output == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_function_call_events_are_converted_to_tool_response():
|
||||
provider = _make_provider()
|
||||
function_calls: dict[str, dict] = {}
|
||||
|
||||
ProviderOpenAIResponses._merge_stream_function_call_event(
|
||||
{
|
||||
"type": "response.output_item.added",
|
||||
"output_index": 0,
|
||||
"item": {
|
||||
"type": "function_call",
|
||||
"name": "lookup_weather",
|
||||
"call_id": "call_1",
|
||||
},
|
||||
},
|
||||
function_calls,
|
||||
)
|
||||
ProviderOpenAIResponses._merge_stream_function_call_event(
|
||||
{
|
||||
"type": "response.function_call_arguments.delta",
|
||||
"output_index": 0,
|
||||
"delta": '{"city"',
|
||||
},
|
||||
function_calls,
|
||||
)
|
||||
ProviderOpenAIResponses._merge_stream_function_call_event(
|
||||
{
|
||||
"type": "response.function_call_arguments.delta",
|
||||
"output_index": 0,
|
||||
"delta": ':"Shanghai"}',
|
||||
},
|
||||
function_calls,
|
||||
)
|
||||
|
||||
result = await provider._stream_function_calls_to_response(
|
||||
function_calls,
|
||||
_make_tool_set(),
|
||||
)
|
||||
|
||||
assert result.role == "tool"
|
||||
assert result.tools_call_name == ["lookup_weather"]
|
||||
assert result.tools_call_ids == ["call_1"]
|
||||
assert result.tools_call_args == [{"city": "Shanghai"}]
|
||||
@@ -2,10 +2,15 @@
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.core.cron.manager import CronJobManager, CronJobSchedulingError
|
||||
from astrbot.core.cron.manager import (
|
||||
CronJobManager,
|
||||
CronJobSchedulingError,
|
||||
_normalize_crontab_day_of_week,
|
||||
)
|
||||
from astrbot.core.db.po import CronJob
|
||||
|
||||
|
||||
@@ -369,6 +374,15 @@ class TestRemoveScheduled:
|
||||
class TestScheduleJob:
|
||||
"""Tests for _schedule_job method."""
|
||||
|
||||
def test_normalize_crontab_day_of_week(self):
|
||||
"""Test standard crontab weekday numbers are normalized."""
|
||||
assert _normalize_crontab_day_of_week("0") == "sun"
|
||||
assert _normalize_crontab_day_of_week("7") == "sun"
|
||||
assert _normalize_crontab_day_of_week("1-5") == "mon,tue,wed,thu,fri"
|
||||
assert _normalize_crontab_day_of_week("*/2") == "sun,tue,thu,sat"
|
||||
assert _normalize_crontab_day_of_week("0-6") == "*"
|
||||
assert _normalize_crontab_day_of_week("mon-fri") == "mon-fri"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_job_basic(
|
||||
self, cron_manager, sample_cron_job, mock_context
|
||||
@@ -383,6 +397,30 @@ class TestScheduleJob:
|
||||
# Verify job was added to scheduler
|
||||
assert cron_manager.scheduler.get_job("test-job-id") is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_job_uses_standard_crontab_weekday_numbers(
|
||||
self, cron_manager, sample_cron_job, mock_context
|
||||
):
|
||||
"""Test Sunday=0 crontab jobs are scheduled for Sunday."""
|
||||
sample_cron_job.cron_expression = "0 9 * * 0"
|
||||
sample_cron_job.timezone = "Asia/Shanghai"
|
||||
mock_db = cron_manager.db
|
||||
mock_db.list_cron_jobs = AsyncMock(return_value=[])
|
||||
mock_db.update_cron_job = AsyncMock()
|
||||
|
||||
await cron_manager.start(mock_context)
|
||||
cron_manager._schedule_job(sample_cron_job)
|
||||
|
||||
aps_job = cron_manager.scheduler.get_job("test-job-id")
|
||||
assert aps_job is not None
|
||||
next_fire_time = aps_job.trigger.get_next_fire_time(
|
||||
None,
|
||||
datetime(2026, 6, 22, tzinfo=ZoneInfo("Asia/Shanghai")),
|
||||
)
|
||||
assert next_fire_time == datetime(
|
||||
2026, 6, 28, 9, 0, tzinfo=ZoneInfo("Asia/Shanghai")
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_job_with_timezone(
|
||||
self, cron_manager, sample_cron_job, mock_context
|
||||
|
||||
@@ -378,3 +378,138 @@ def _context_with_provider_settings(provider_settings):
|
||||
event=SimpleNamespace(unified_msg_origin="test:private:session"),
|
||||
)
|
||||
return SimpleNamespace(context=agent_context)
|
||||
|
||||
|
||||
# --- Exa tests ---
|
||||
|
||||
|
||||
def test_normalize_legacy_web_search_config_migrates_exa_key():
|
||||
config = _FakeConfig({"provider_settings": {"websearch_exa_key": "exa-key"}})
|
||||
|
||||
tools.normalize_legacy_web_search_config(config)
|
||||
|
||||
assert config["provider_settings"]["websearch_exa_key"] == ["exa-key"]
|
||||
assert config.saved is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exa_search_maps_results(monkeypatch):
|
||||
async def fake_exa_search(provider_settings, payload):
|
||||
assert provider_settings["websearch_exa_key"] == ["exa-key"]
|
||||
assert payload["query"] == "AstrBot"
|
||||
assert payload["numResults"] == 5
|
||||
return [
|
||||
tools.SearchResult(
|
||||
title="AstrBot",
|
||||
url="https://example.com",
|
||||
snippet="AI Agent Assistant",
|
||||
)
|
||||
]
|
||||
|
||||
monkeypatch.setattr(tools, "_exa_search", fake_exa_search)
|
||||
tool = tools.ExaWebSearchTool()
|
||||
context = _context_with_provider_settings({"websearch_exa_key": ["exa-key"]})
|
||||
|
||||
result = await tool.call(context, query="AstrBot", num_results=5)
|
||||
|
||||
parsed = json.loads(result)
|
||||
assert parsed["results"][0]["title"] == "AstrBot"
|
||||
assert parsed["results"][0]["url"] == "https://example.com"
|
||||
assert parsed["results"][0]["snippet"] == "AI Agent Assistant"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exa_search_raw_api_call(monkeypatch):
|
||||
session = _FakeFirecrawlSession(
|
||||
_FakeFirecrawlResponse(
|
||||
status=200,
|
||||
json_data={
|
||||
"results": [
|
||||
{
|
||||
"title": "AstrBot",
|
||||
"url": "https://example.com",
|
||||
"text": "AI Agent Assistant",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def fake_client_session(*, trust_env):
|
||||
session.trust_env = trust_env
|
||||
return session
|
||||
|
||||
monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
|
||||
|
||||
results = await tools._exa_search(
|
||||
{"websearch_exa_key": ["exa-key"]},
|
||||
{"query": "AstrBot", "numResults": 10, "type": "auto"},
|
||||
)
|
||||
|
||||
assert session.posted["url"] == "https://api.exa.ai/search"
|
||||
assert session.posted["headers"]["x-api-key"] == "exa-key"
|
||||
assert results == [
|
||||
tools.SearchResult(
|
||||
title="AstrBot", url="https://example.com", snippet="AI Agent Assistant"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exa_search_raises_on_http_error(monkeypatch):
|
||||
session = _FakeFirecrawlSession(
|
||||
_FakeFirecrawlResponse(status=401, text_data="Unauthorized")
|
||||
)
|
||||
|
||||
def fake_client_session(*, trust_env):
|
||||
session.trust_env = trust_env
|
||||
return session
|
||||
|
||||
monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
|
||||
|
||||
with pytest.raises(
|
||||
Exception,
|
||||
match="Exa web search failed: Unauthorized, status: 401",
|
||||
):
|
||||
await tools._exa_search(
|
||||
{"websearch_exa_key": ["exa-key"]},
|
||||
{"query": "AstrBot"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exa_get_contents_returns_text(monkeypatch):
|
||||
async def fake_exa_get_contents(provider_settings, payload):
|
||||
assert provider_settings["websearch_exa_key"] == ["exa-key"]
|
||||
assert payload["ids"] == ["https://example.com"]
|
||||
return [{"url": "https://example.com", "text": "# Example Content"}]
|
||||
|
||||
monkeypatch.setattr(tools, "_exa_get_contents", fake_exa_get_contents)
|
||||
tool = tools.ExaGetContentsTool()
|
||||
context = _context_with_provider_settings({"websearch_exa_key": ["exa-key"]})
|
||||
|
||||
result = await tool.call(context, url="https://example.com")
|
||||
|
||||
assert result == "URL: https://example.com\nContent: # Example Content"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exa_get_contents_raises_on_http_error(monkeypatch):
|
||||
session = _FakeFirecrawlSession(
|
||||
_FakeFirecrawlResponse(status=403, text_data="Forbidden")
|
||||
)
|
||||
|
||||
def fake_client_session(*, trust_env):
|
||||
session.trust_env = trust_env
|
||||
return session
|
||||
|
||||
monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
|
||||
|
||||
with pytest.raises(
|
||||
Exception,
|
||||
match="Exa get contents failed: Forbidden, status: 403",
|
||||
):
|
||||
await tools._exa_get_contents(
|
||||
{"websearch_exa_key": ["exa-key"]},
|
||||
{"ids": ["https://example.com"]},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user