Compare commits

...

17 Commits

Author SHA1 Message Date
Soulter
afa43fc0e2 chore: update version to 4.22.2 in multiple files 2026-03-29 00:32:31 +08:00
Soulter
551c956107 feat: implement EmptyModelOutputError for handling empty responses across providers and enhance retry logic in ToolLoopAgentRunner (#7104)
closes: #7044
2026-03-29 00:03:05 +08:00
Soulter
1070804b90 feat(dashboard): add SSL configuration resolution for dashboard (#7102)
fixes: #7058

program will not exit but fallback to non-ssl mode when ssl config is wrong
2026-03-28 23:22:24 +08:00
Soulter
7db7f4a16c feat(agent-runner): add tool_choice parameter to fix empty tool calls response in "skills-like" tool call mode (#7101)
fixes: #7049
2026-03-28 23:06:06 +08:00
Rainor_da!
77419e0bc7 fix(webui): handle history wheel on preview container (#6972) 2026-03-28 21:38:27 +08:00
Yokami
971bcbad10 fix(provider): fix Bailian rerank payload compatibility for qwen3-rerank (#6222)
* fix(provider): align bailian qwen3 rerank payload with latest API schema

* fix(provider): explicitly ignore unsupported return_documents for qwen3 rerank
2026-03-28 21:27:53 +08:00
Flartiny
da1eb65afe feat: allow copy config from existing configs (#6785)
* feat: allow copy config from existing configs

* fix: issues mentioned by reviewer bot

- duplicated logic for initializing/resetting the config
- check whitespace-only names
2026-03-28 21:25:19 +08:00
Foolllll
bbec8efa0d fix(dashboard): apply labels mapping for list options in config renderer (#6844) 2026-03-28 21:22:00 +08:00
Soulter
b98bd3898f feat: update QQOfficialPlatformAdapter to support async parsing and attachment preparation (#7007)
fixes: #6853
2026-03-28 21:18:33 +08:00
秋月白
81f4bd4e67 fix: allow multiple skills in a single zip archive (#7070)
* fix: allow multiple skills in a single zip archive

* refactor: address bot review comments

* fix: apply ruff format and fix return value
2026-03-28 21:16:40 +08:00
Stardust
4e9916caa4 fix(pipeline): skip waking on empty messages (#6893)
* fix(pipeline): skip waking on empty messages

* fix(pipeline): skip empty message LLM request even with provider_request

* fix(pipeline): skip empty messages in empty_mention_waiter
2026-03-28 20:50:42 +08:00
Rain-0x01_
995a318232 fix(gsvi_tts): Use the correct calling method (#7083)
* fix(gsvi_tts): Use the correct calling method (#5638)
Add some configuration items for GSVI

* fix(gsvi_tts): add default value for api_key in provider configuration

* fix(gsvi_tts): Adjust wherever the Authorization header is built to only include it when `self.api_key` is truthy
Delete some comments

* chore: ruff format

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-03-28 20:48:43 +08:00
エイカク
bcbf7dd8df fix: bundle httpx SOCKS proxy support (#7093)
* fix: bundle httpx SOCKS proxy support

* test: tighten SOCKS dependency regression coverage

* test: allow SOCKS dependency environment markers

* test: broaden SOCKS dependency regex coverage
2026-03-28 21:31:52 +09:00
奉眠
fcfd6a9e1c fix(weixin_oc): allow CDN uploads to use upload_full_url when provided (#7066)
* fix(weixin_oc): 处理微信开放平台CDN上传URL的新格式

适配微信开放平台`getuploadurl`接口返回的新格式,该接口现在可能返回`upload_full_url`字段。
优先使用`upload_full_url`作为上传地址,以保持上传功能正常。

* fix(weixin_oc): 改进CDN上传URL缺失时的错误信息

- 在适配器中,将通用错误信息具体化为“CDN上传URL缺失”
- 在客户端中,移除冗余的参数处理逻辑,使参数验证更清晰

* fix(weixin_oc): 调整CDN上传参数顺序并移除冗余检查

移除对weixin_oc_adapter中upload_param和upload_full_url同时为空的检查,因为逻辑上已由底层方法保证。
调整upload_to_cdn方法的参数顺序以匹配其内部实现,确保正确传递。
2026-03-28 14:45:58 +08:00
tlw00988
9238ad58ff docs: corrent weixin_oc wechat version requirements (#7068)
* docs:corrent weixin_oc wechat version requirements 

Update WeChat version requirements for the adapter.

* Update docs/zh/platform/weixin_oc.md

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-28 14:37:06 +08:00
Haoyuan Li
55ed0289c2 feat(weixin_oc): Add "typing" ("对方正在输入...") state control for weixin_oc plateform (#6977)
* feat: Add "typing" state control for weixin_oc plateform

* fix: avoid typing state mutation during cleanup

* fix: preserve typing error tracebacks in logs

* refactor: simplify typing task cancellation flow

* chore: remove tests

* fix: remove unnecessary platform check for stopping typing

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-03-27 15:54:41 +08:00
Soulter
777b831691 fix(weixin_oc): add error handling and retry logic for inbound updates polling (#7041)
fixes: #7022
2026-03-27 15:36:31 +08:00
38 changed files with 1556 additions and 254 deletions

View File

@@ -91,6 +91,8 @@ class Main(Star):
controller: SessionController,
event: AstrMessageEvent,
) -> None:
if not event.message_str or not event.message_str.strip():
return
event.message_obj.message.insert(
0,
Comp.At(qq=event.get_self_id(), name=event.get_self_id()),

View File

@@ -1 +1 @@
__version__ = "4.22.1"
__version__ = "4.22.2"

View File

@@ -16,11 +16,18 @@ from mcp.types import (
TextContent,
TextResourceContents,
)
from tenacity import (
AsyncRetrying,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
from astrbot import logger
from astrbot.core.agent.message import ImageURLPart, TextPart, ThinkPart
from astrbot.core.agent.tool import ToolSet
from astrbot.core.agent.tool_image_cache import tool_image_cache
from astrbot.core.exceptions import EmptyModelOutputError
from astrbot.core.message.components import Json
from astrbot.core.message.message_event_result import (
MessageChain,
@@ -95,11 +102,41 @@ USER_INTERRUPTION_MESSAGE = (
class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
EMPTY_OUTPUT_RETRY_ATTEMPTS = 3
EMPTY_OUTPUT_RETRY_WAIT_MIN_S = 1
EMPTY_OUTPUT_RETRY_WAIT_MAX_S = 4
def _get_persona_custom_error_message(self) -> str | None:
"""Read persona-level custom error message from event extras when available."""
event = getattr(self.run_context.context, "event", None)
return extract_persona_custom_error_message_from_event(event)
async def _complete_with_assistant_response(self, llm_resp: LLMResponse) -> None:
"""Finalize the current step as a plain assistant response with no tool calls."""
self.final_llm_resp = llm_resp
self._transition_state(AgentState.DONE)
self.stats.end_time = time.time()
parts = []
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
parts.append(
ThinkPart(
think=llm_resp.reasoning_content,
encrypted=llm_resp.reasoning_signature,
)
)
if llm_resp.completion_text:
parts.append(TextPart(text=llm_resp.completion_text))
if len(parts) == 0:
logger.warning("LLM returned empty assistant message with no tool calls.")
self.run_context.messages.append(Message(role="assistant", content=parts))
try:
await self.agent_hooks.on_agent_done(self.run_context, llm_resp)
except Exception as e:
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
self._resolve_unconsumed_follow_ups()
@override
async def reset(
self,
@@ -253,31 +290,61 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
candidate_id,
)
self.provider = candidate
has_stream_output = False
try:
async for resp in self._iter_llm_responses(include_model=idx == 0):
if resp.is_chunk:
has_stream_output = True
yield resp
continue
retrying = AsyncRetrying(
retry=retry_if_exception_type(EmptyModelOutputError),
stop=stop_after_attempt(self.EMPTY_OUTPUT_RETRY_ATTEMPTS),
wait=wait_exponential(
multiplier=1,
min=self.EMPTY_OUTPUT_RETRY_WAIT_MIN_S,
max=self.EMPTY_OUTPUT_RETRY_WAIT_MAX_S,
),
reraise=True,
)
if (
resp.role == "err"
and not has_stream_output
and (not is_last_candidate)
):
last_err_response = resp
logger.warning(
"Chat Model %s returns error response, trying fallback to next provider.",
candidate_id,
)
break
async for attempt in retrying:
has_stream_output = False
with attempt:
try:
async for resp in self._iter_llm_responses(
include_model=idx == 0
):
if resp.is_chunk:
has_stream_output = True
yield resp
continue
yield resp
return
if (
resp.role == "err"
and not has_stream_output
and (not is_last_candidate)
):
last_err_response = resp
logger.warning(
"Chat Model %s returns error response, trying fallback to next provider.",
candidate_id,
)
break
if has_stream_output:
return
yield resp
return
if has_stream_output:
return
except EmptyModelOutputError:
if has_stream_output:
logger.warning(
"Chat Model %s returned empty output after streaming started; skipping empty-output retry.",
candidate_id,
)
else:
logger.warning(
"Chat Model %s returned empty output on attempt %s/%s.",
candidate_id,
attempt.retry_state.attempt_number,
self.EMPTY_OUTPUT_RETRY_ATTEMPTS,
)
raise
except Exception as exc: # noqa: BLE001
last_exception = exc
logger.warning(
@@ -463,34 +530,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
return
if not llm_resp.tools_call_name:
# 如果没有工具调用,转换到完成状态
self.final_llm_resp = llm_resp
self._transition_state(AgentState.DONE)
self.stats.end_time = time.time()
# record the final assistant message
parts = []
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
parts.append(
ThinkPart(
think=llm_resp.reasoning_content,
encrypted=llm_resp.reasoning_signature,
)
)
if llm_resp.completion_text:
parts.append(TextPart(text=llm_resp.completion_text))
if len(parts) == 0:
logger.warning(
"LLM returned empty assistant message with no tool calls."
)
self.run_context.messages.append(Message(role="assistant", content=parts))
# call the on_agent_done hook
try:
await self.agent_hooks.on_agent_done(self.run_context, llm_resp)
except Exception as e:
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
self._resolve_unconsumed_follow_ups()
await self._complete_with_assistant_response(llm_resp)
# 返回 LLM 结果
if llm_resp.result_chain:
@@ -510,6 +550,24 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
if llm_resp.tools_call_name:
if self.tool_schema_mode == "skills_like":
llm_resp, _ = await self._resolve_tool_exec(llm_resp)
if not llm_resp.tools_call_name:
logger.warning(
"skills_like tool re-query returned no tool calls; fallback to assistant response."
)
if llm_resp.result_chain:
yield AgentResponse(
type="llm_result",
data=AgentResponseData(chain=llm_resp.result_chain),
)
elif llm_resp.completion_text:
yield AgentResponse(
type="llm_result",
data=AgentResponseData(
chain=MessageChain().message(llm_resp.completion_text),
),
)
await self._complete_with_assistant_response(llm_resp)
return
tool_call_result_blocks = []
cached_images = [] # Collect cached images for LLM visibility
@@ -873,7 +931,9 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
)
def _build_tool_requery_context(
self, tool_names: list[str]
self,
tool_names: list[str],
extra_instruction: str | None = None,
) -> list[dict[str, T.Any]]:
"""Build contexts for re-querying LLM with param-only tool schemas."""
contexts: list[dict[str, T.Any]] = []
@@ -888,6 +948,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
+ ". Now call the tool(s) with required arguments using the tool schema, "
"and follow the existing tool-use rules."
)
if extra_instruction:
instruction = f"{instruction}\n{extra_instruction}"
if contexts and contexts[0].get("role") == "system":
content = contexts[0].get("content") or ""
contexts[0]["content"] = f"{content}\n{instruction}"
@@ -895,6 +957,11 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
contexts.insert(0, {"role": "system", "content": instruction})
return contexts
@staticmethod
def _has_meaningful_assistant_reply(llm_resp: LLMResponse) -> bool:
text = (llm_resp.completion_text or "").strip()
return bool(text)
def _build_tool_subset(self, tool_set: ToolSet, tool_names: list[str]) -> ToolSet:
"""Build a subset of tools from the given tool set based on tool names."""
subset = ToolSet()
@@ -932,11 +999,45 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
model=self.req.model,
session_id=self.req.session_id,
extra_user_content_parts=self.req.extra_user_content_parts,
tool_choice="required",
abort_signal=self._abort_signal,
)
if requery_resp:
llm_resp = requery_resp
# If the re-query still returns no tool calls, and also does not have a meaningful assistant reply,
# we consider it as a failure of the LLM to follow the tool-use instruction,
# and we will retry once with a stronger instruction that explicitly requires the LLM to either call the tool or give an explanation.
if (
not llm_resp.tools_call_name
and not self._has_meaningful_assistant_reply(llm_resp)
):
logger.warning(
"skills_like tool re-query returned no tool calls and no explanation; retrying with stronger instruction."
)
repair_contexts = self._build_tool_requery_context(
tool_names,
extra_instruction=(
"This is the second-stage tool execution step. "
"You must do exactly one of the following: "
"1. Call one of the selected tools using the provided tool schema. "
"2. If calling a tool is no longer possible or appropriate, reply to the user with a brief explanation of why. "
"Do not return an empty response. "
"Do not ignore the selected tools without explanation."
),
)
repair_resp = await self.provider.text_chat(
contexts=repair_contexts,
func_tool=param_subset,
model=self.req.model,
session_id=self.req.session_id,
extra_user_content_parts=self.req.extra_user_content_parts,
tool_choice="required",
abort_signal=self._abort_signal,
)
if repair_resp:
llm_resp = repair_resp
return llm_resp, subset
def done(self) -> bool:

View File

@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.22.1"
VERSION = "4.22.2"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
PERSONAL_WECHAT_CONFIG_METADATA = {
"weixin_oc_base_url": {
@@ -1623,10 +1623,14 @@ CONFIG_METADATA_2 = {
"type": "gsvi_tts_api",
"provider": "gpt_sovits_inference",
"provider_type": "text_to_speech",
"api_base": "http://127.0.0.1:5000",
"character": "",
"emotion": "default",
"enable": False,
"api_key": "",
"api_base": "http://127.0.0.1:8000",
"version": "v4",
"character": "",
"prompt_text_lang": "中文",
"emotion": "默认",
"text_lang": "中文",
"timeout": 20,
},
"FishAudio TTS(API)": {

View File

@@ -7,3 +7,7 @@ class AstrBotError(Exception):
class ProviderNotFoundError(AstrBotError):
"""Raised when a specified provider is not found."""
class EmptyModelOutputError(AstrBotError):
"""Raised when the model response contains no usable assistant output."""

View File

@@ -76,8 +76,8 @@ class PreProcessStage(Stage):
return
message_chain = event.get_messages()
for idx, component in enumerate(message_chain):
if isinstance(component, Record) and component.url:
path = component.url.removeprefix("file://")
if isinstance(component, Record):
path = await component.convert_to_file_path()
retry = 5
for i in range(retry):
try:

View File

@@ -144,6 +144,7 @@ class InternalAgentSubStage(Stage):
follow_up_capture: FollowUpCapture | None = None
follow_up_consumed_marked = False
follow_up_activated = False
typing_requested = False
try:
streaming_response = self.streaming_response
if (enable_streaming := event.get_extra("enable_streaming")) is not None:
@@ -178,7 +179,11 @@ class InternalAgentSubStage(Stage):
)
return
await event.send_typing()
try:
typing_requested = True
await event.send_typing()
except Exception:
logger.warning("send_typing failed", exc_info=True)
await call_event_hook(event, EventType.OnWaitingLLMRequestEvent)
async with session_lock_manager.acquire_lock(event.unified_msg_origin):
@@ -377,6 +382,11 @@ class InternalAgentSubStage(Stage):
)
await event.send(MessageChain().message(error_text))
finally:
if typing_requested:
try:
await event.stop_typing()
except Exception:
logger.warning("stop_typing failed", exc_info=True)
if follow_up_capture:
await finalize_follow_up_capture(
follow_up_capture,

View File

@@ -293,6 +293,12 @@ class AstrMessageEvent(abc.ABC):
默认实现为空,由具体平台按需重写。
"""
async def stop_typing(self) -> None:
"""停止输入中状态。
默认实现为空,由具体平台按需重写。
"""
async def _pre_send(self) -> None:
"""调度器会在执行 send() 前调用该方法 deprecated in v3.5.18"""

View File

@@ -5,6 +5,8 @@ import logging
import os
import random
import time
import uuid
from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
@@ -24,6 +26,8 @@ from astrbot.api.platform import (
)
from astrbot.core.message.components import BaseMessageComponent
from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.io import download_file
from ...register import register_platform_adapter
from .qqofficial_message_event import QQOfficialMessageEvent
@@ -42,7 +46,7 @@ class botClient(Client):
async def on_group_at_message_create(
self, message: botpy.message.GroupMessage
) -> None:
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
message,
MessageType.GROUP_MESSAGE,
)
@@ -53,7 +57,7 @@ class botClient(Client):
# 收到频道消息
async def on_at_message_create(self, message: botpy.message.Message) -> None:
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
message,
MessageType.GROUP_MESSAGE,
)
@@ -66,7 +70,7 @@ class botClient(Client):
async def on_direct_message_create(
self, message: botpy.message.DirectMessage
) -> None:
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
message,
MessageType.FRIEND_MESSAGE,
)
@@ -76,7 +80,7 @@ class botClient(Client):
# 收到 C2C 消息
async def on_c2c_message_create(self, message: botpy.message.C2CMessage) -> None:
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
message,
MessageType.FRIEND_MESSAGE,
)
@@ -336,7 +340,22 @@ class QQOfficialPlatformAdapter(Platform):
return f"https://{url}"
@staticmethod
def _append_attachments(
async def _prepare_audio_attachment(
url: str,
filename: str,
) -> Record:
temp_dir = Path(get_astrbot_temp_path())
temp_dir.mkdir(parents=True, exist_ok=True)
ext = Path(filename).suffix.lower()
source_ext = ext or ".audio"
source_path = temp_dir / f"qqofficial_{uuid.uuid4().hex}{source_ext}"
await download_file(url, str(source_path))
return Record(file=str(source_path), url=str(source_path))
@staticmethod
async def _append_attachments(
msg: list[BaseMessageComponent],
attachments: list | None,
) -> None:
@@ -363,7 +382,7 @@ class QQOfficialPlatformAdapter(Platform):
or getattr(attachment, "name", None)
or "attachment",
)
ext = os.path.splitext(filename)[1].lower()
ext = Path(filename).suffix.lower()
image_exts = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
audio_exts = {
".mp3",
@@ -381,8 +400,21 @@ class QQOfficialPlatformAdapter(Platform):
".webm",
}
if content_type.startswith("audio") or ext in audio_exts:
msg.append(Record.fromURL(url))
if content_type.startswith("voice") or ext in audio_exts:
try:
msg.append(
await QQOfficialPlatformAdapter._prepare_audio_attachment(
url,
filename,
)
)
except Exception as e:
logger.warning(
"[QQOfficial] Failed to prepare audio attachment %s: %s",
url,
e,
)
msg.append(Record.fromURL(url))
elif content_type.startswith("video") or ext in video_exts:
msg.append(Video.fromURL(url))
elif content_type.startswith("image") or ext in image_exts:
@@ -432,13 +464,13 @@ class QQOfficialPlatformAdapter(Platform):
return re.sub(r"<faceType=\d+[^>]*>", replace_face, content)
@staticmethod
def _parse_from_qqofficial(
async def _parse_from_qqofficial(
message: botpy.message.Message
| botpy.message.GroupMessage
| botpy.message.DirectMessage
| botpy.message.C2CMessage,
message_type: MessageType,
):
) -> AstrBotMessage:
abm = AstrBotMessage()
abm.type = message_type
abm.timestamp = int(time.time())
@@ -463,7 +495,9 @@ class QQOfficialPlatformAdapter(Platform):
abm.self_id = "unknown_selfid"
msg.append(At(qq="qq_official"))
msg.append(Plain(abm.message_str))
QQOfficialPlatformAdapter._append_attachments(msg, message.attachments)
await QQOfficialPlatformAdapter._append_attachments(
msg, message.attachments
)
abm.message = msg
elif isinstance(message, botpy.message.Message) or isinstance(
@@ -482,7 +516,9 @@ class QQOfficialPlatformAdapter(Platform):
).strip()
)
QQOfficialPlatformAdapter._append_attachments(msg, message.attachments)
await QQOfficialPlatformAdapter._append_attachments(
msg, message.attachments
)
abm.message = msg
abm.message_str = plain_content
abm.sender = MessageMember(

View File

@@ -31,7 +31,7 @@ class botClient(Client):
async def on_group_at_message_create(
self, message: botpy.message.GroupMessage
) -> None:
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
message,
MessageType.GROUP_MESSAGE,
)
@@ -42,7 +42,7 @@ class botClient(Client):
# 收到频道消息
async def on_at_message_create(self, message: botpy.message.Message) -> None:
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
message,
MessageType.GROUP_MESSAGE,
)
@@ -55,7 +55,7 @@ class botClient(Client):
async def on_direct_message_create(
self, message: botpy.message.DirectMessage
) -> None:
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
message,
MessageType.FRIEND_MESSAGE,
)
@@ -65,7 +65,7 @@ class botClient(Client):
# 收到 C2C 消息
async def on_c2c_message_create(self, message: botpy.message.C2CMessage) -> None:
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
message,
MessageType.FRIEND_MESSAGE,
)

View File

@@ -6,7 +6,7 @@ import hashlib
import io
import time
import uuid
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from urllib.parse import quote
@@ -49,6 +49,17 @@ class OpenClawLoginSession:
error: str | None = None
@dataclass
class TypingSessionState:
ticket: str | None = None
ticket_context_token: str | None = None
refresh_after: float = 0.0
keepalive_task: asyncio.Task | None = None
cancel_task: asyncio.Task | None = None
owners: set[str] = field(default_factory=set)
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
@register_platform_adapter(
"weixin_oc",
"个人微信",
@@ -105,7 +116,16 @@ class WeixinOCAdapter(Platform):
self._sync_buf = ""
self._qr_expired_count = 0
self._context_tokens: dict[str, str] = {}
self._typing_states: dict[str, TypingSessionState] = {}
self._last_inbound_error = ""
self._typing_keepalive_interval_s = max(
1,
int(platform_config.get("weixin_oc_typing_keepalive_interval", 5)),
)
self._typing_ticket_ttl_s = max(
5,
int(platform_config.get("weixin_oc_typing_ticket_ttl", 60)),
)
self.token = str(platform_config.get("weixin_oc_token", "")).strip() or None
self.account_id = (
@@ -132,6 +152,316 @@ class WeixinOCAdapter(Platform):
self.client.api_timeout_ms = self.api_timeout_ms
self.client.token = self.token
def _get_typing_state(self, user_id: str) -> TypingSessionState:
state = self._typing_states.get(user_id)
if state is None:
state = TypingSessionState()
self._typing_states[user_id] = state
return state
def _typing_supported_for(self, user_id: str) -> bool:
if not self.token:
return False
return bool(self._context_tokens.get(user_id))
async def _cancel_task_safely(
self,
task: asyncio.Task | None,
*,
log_message: str | None = None,
log_args: tuple[Any, ...] = (),
) -> None:
if task is None or task.done():
return
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
except Exception:
if log_message is not None:
logger.warning(log_message, *log_args, exc_info=True)
async def _ensure_typing_ticket(
self,
user_id: str,
state: TypingSessionState,
) -> str | None:
now = time.monotonic()
context_token = self._context_tokens.get(user_id)
if not context_token:
return None
if (
state.ticket
and state.ticket_context_token == context_token
and state.refresh_after > now
):
return state.ticket
payload = await self.client.get_typing_config(user_id, context_token)
if int(payload.get("ret") or 0) != 0:
logger.warning(
"weixin_oc(%s): getconfig failed for %s: %s",
self.meta().id,
user_id,
payload.get("errmsg", ""),
)
return None
ticket = str(payload.get("typing_ticket", "")).strip()
if not ticket:
return None
state.ticket = ticket
state.ticket_context_token = context_token
state.refresh_after = time.monotonic() + self._typing_ticket_ttl_s
return ticket
async def _send_typing_state(
self,
user_id: str,
ticket: str,
*,
cancel: bool,
) -> None:
payload = await self.client.send_typing_state(user_id, ticket, cancel=cancel)
if int(payload.get("ret") or 0) != 0:
raise RuntimeError(
f"sendtyping failed for {user_id}: {payload.get('errmsg', '')}"
)
async def _run_typing_keepalive(self, user_id: str) -> None:
restart_needed = False
try:
await self._typing_keepalive_loop(user_id)
except asyncio.CancelledError:
raise
except Exception as e:
state = self._typing_states.get(user_id)
if state is not None:
async with state.lock:
state.refresh_after = 0.0
restart_needed = (
bool(state.owners) and not self._shutdown_event.is_set()
)
logger.warning(
"weixin_oc(%s): typing keepalive failed for %s: %s",
self.meta().id,
user_id,
e,
)
finally:
state = self._typing_states.get(user_id)
current_task = asyncio.current_task()
if state is not None and state.keepalive_task is current_task:
state.keepalive_task = None
if not restart_needed:
return
await asyncio.sleep(self._typing_keepalive_interval_s)
state = self._typing_states.get(user_id)
if state is None or self._shutdown_event.is_set():
return
async with state.lock:
if not state.owners or state.keepalive_task is not None:
return
state.keepalive_task = asyncio.create_task(
self._run_typing_keepalive(user_id)
)
async def _typing_keepalive_loop(self, user_id: str) -> None:
while not self._shutdown_event.is_set():
await asyncio.sleep(self._typing_keepalive_interval_s)
state = self._typing_states.get(user_id)
if state is None:
return
async with state.lock:
if not state.owners:
return
try:
ticket = await self._ensure_typing_ticket(user_id, state)
except Exception as e:
state.refresh_after = 0.0
logger.warning(
"weixin_oc(%s): refresh typing ticket failed for %s: %s",
self.meta().id,
user_id,
e,
)
continue
if not ticket:
continue
try:
await self._send_typing_state(user_id, ticket, cancel=False)
except Exception as e:
state.refresh_after = 0.0
logger.warning(
"weixin_oc(%s): typing keepalive send failed for %s: %s",
self.meta().id,
user_id,
e,
)
async def _delayed_cancel_typing(self, user_id: str, ticket: str) -> None:
await asyncio.sleep(0)
state = self._typing_states.get(user_id)
if state is None:
return
current_task = asyncio.current_task()
async with state.lock:
if state.cancel_task is not current_task:
return
if state.owners or state.keepalive_task is not None:
state.cancel_task = None
return
try:
await self._send_typing_state(user_id, ticket, cancel=True)
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning(
"weixin_oc(%s): cancel typing failed for %s: %s",
self.meta().id,
user_id,
e,
)
finally:
state = self._typing_states.get(user_id)
if state is None:
return
async with state.lock:
if state.cancel_task is current_task:
state.cancel_task = None
async def start_typing(self, user_id: str, owner_id: str) -> None:
state = self._get_typing_state(user_id)
cancel_task: asyncio.Task | None = None
async with state.lock:
if owner_id in state.owners:
return
if not self._typing_supported_for(user_id):
return
if state.cancel_task is not None and not state.cancel_task.done():
cancel_task = state.cancel_task
cancel_task.cancel()
state.cancel_task = None
try:
ticket = await self._ensure_typing_ticket(user_id, state)
except Exception as e:
logger.warning(
"weixin_oc(%s): ensure typing ticket failed for %s: %s",
self.meta().id,
user_id,
e,
)
return
if not ticket:
return
state.ticket = ticket
state.owners.add(owner_id)
if state.keepalive_task is not None and not state.keepalive_task.done():
return
try:
await self._send_typing_state(user_id, ticket, cancel=False)
except Exception as e:
state.refresh_after = 0.0
logger.warning(
"weixin_oc(%s): send typing failed for %s: %s",
self.meta().id,
user_id,
e,
)
task = asyncio.create_task(self._run_typing_keepalive(user_id))
state.keepalive_task = task
if cancel_task is not None:
await self._cancel_task_safely(
cancel_task,
log_message="weixin_oc(%s): ignored error from cancelled typing task",
log_args=(self.meta().id,),
)
async def stop_typing(self, user_id: str, owner_id: str) -> None:
state = self._typing_states.get(user_id)
if state is None:
return
task: asyncio.Task | None = None
async with state.lock:
if owner_id not in state.owners:
return
state.owners.remove(owner_id)
if state.owners:
return
task = state.keepalive_task
state.keepalive_task = None
await self._cancel_task_safely(
task,
log_message="weixin_oc(%s): typing keepalive stop failed for %s",
log_args=(self.meta().id, user_id),
)
async with state.lock:
if state.owners:
return
ticket = state.ticket
if ticket:
if state.cancel_task is None or state.cancel_task.done():
state.cancel_task = asyncio.create_task(
self._delayed_cancel_typing(user_id, ticket)
)
async def _cleanup_typing_tasks(self) -> None:
tasks: list[asyncio.Task] = []
cancels: list[tuple[str, str]] = []
for user_id, state in list(self._typing_states.items()):
if state.ticket and (
state.owners
or state.keepalive_task is not None
or state.cancel_task is not None
):
cancels.append((user_id, state.ticket))
state.owners.clear()
if state.keepalive_task is not None and not state.keepalive_task.done():
tasks.append(state.keepalive_task)
state.keepalive_task.cancel()
state.keepalive_task = None
if state.cancel_task is not None and not state.cancel_task.done():
tasks.append(state.cancel_task)
state.cancel_task.cancel()
state.cancel_task = None
for task in tasks:
await self._cancel_task_safely(
task,
log_message="weixin_oc(%s): typing cleanup failed",
log_args=(self.meta().id,),
)
for user_id, ticket in cancels:
try:
await self._send_typing_state(user_id, ticket, cancel=True)
except Exception as e:
logger.warning(
"weixin_oc(%s): typing cleanup cancel failed for %s: %s",
self.meta().id,
user_id,
e,
)
def _load_account_state(self) -> None:
if not self.token:
token = str(self.config.get("weixin_oc_token", "")).strip()
@@ -263,10 +593,10 @@ class WeixinOCAdapter(Platform):
len(str(payload.get("upload_param", ""))),
)
upload_param = str(payload.get("upload_param", "")).strip()
if not upload_param:
raise RuntimeError("getuploadurl returned empty upload_param")
upload_full_url = str(payload.get("upload_full_url", "")).strip()
encrypted_query_param = await self.client.upload_to_cdn(
upload_full_url,
upload_param,
file_key,
aes_key_hex,
@@ -902,15 +1232,24 @@ class WeixinOCAdapter(Platform):
"weixin_oc(%s): inbound long-poll timeout",
self.meta().id,
)
except Exception as e:
logger.error(
"weixin_oc(%s): poll inbound updates failed, will retry after 5 seconds: %s",
self.meta().id,
e,
)
await asyncio.sleep(5)
except asyncio.CancelledError:
raise
except Exception as e:
logger.exception("weixin_oc(%s): run failed: %s", self.meta().id, e)
finally:
await self._cleanup_typing_tasks()
await self.client.close()
async def terminate(self) -> None:
self._shutdown_event.set()
await self._cleanup_typing_tasks()
def get_stats(self) -> dict:
stat = super().get_stats()

View File

@@ -108,11 +108,21 @@ class WeixinOCClient:
async def upload_to_cdn(
self,
upload_full_url: str,
upload_param: str,
file_key: str,
aes_key_hex: str,
media_path: Path,
) -> str:
if upload_full_url:
cdn_url = upload_full_url
elif upload_param:
cdn_url = self._build_cdn_upload_url(upload_param, file_key)
else:
raise ValueError(
"CDN upload URL missing (need upload_full_url or upload_param)"
)
raw_data = media_path.read_bytes()
logger.debug(
"weixin_oc(%s): prepare CDN upload file=%s size=%s md5=%s filekey=%s",
@@ -135,7 +145,6 @@ class WeixinOCClient:
await self.ensure_http_session()
assert self._http_session is not None
timeout = aiohttp.ClientTimeout(total=self.api_timeout_ms / 1000)
cdn_url = self._build_cdn_upload_url(upload_param, file_key)
async with self._http_session.post(
cdn_url,
@@ -226,3 +235,44 @@ class WeixinOCClient:
if not text:
return {}
return cast(dict[str, Any], json.loads(text))
async def get_typing_config(
self,
user_id: str,
context_token: str,
) -> dict[str, Any]:
return await self.request_json(
"POST",
"ilink/bot/getconfig",
payload={
"ilink_user_id": user_id,
"context_token": context_token,
"base_info": {
"channel_version": "astrbot",
},
},
token_required=True,
timeout_ms=self.api_timeout_ms,
)
async def send_typing_state(
self,
user_id: str,
typing_ticket: str,
*,
cancel: bool,
) -> dict[str, Any]:
return await self.request_json(
"POST",
"ilink/bot/sendtyping",
payload={
"ilink_user_id": user_id,
"typing_ticket": typing_ticket,
"status": 2 if cancel else 1,
"base_info": {
"channel_version": "astrbot",
},
},
token_required=True,
timeout_ms=self.api_timeout_ms,
)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import uuid
from typing import TYPE_CHECKING
from astrbot.api.event import AstrMessageEvent, MessageChain
@@ -29,6 +30,12 @@ class WeixinOCMessageEvent(AstrMessageEvent):
) -> None:
super().__init__(message_str, message_obj, platform_meta, session_id)
self.platform = platform
self._typing_owner_id: str | None = None
def _get_typing_owner_id(self) -> str:
if not self._typing_owner_id:
self._typing_owner_id = uuid.uuid4().hex
return self._typing_owner_id
@staticmethod
def _segment_to_text(segment: BaseMessageComponent) -> str:
@@ -58,6 +65,18 @@ class WeixinOCMessageEvent(AstrMessageEvent):
await self.platform.send_by_session(self.session, message)
await super().send(message)
async def send_typing(self) -> None:
await self.platform.start_typing(
self.session.session_id,
self._get_typing_owner_id(),
)
async def stop_typing(self) -> None:
await self.platform.stop_typing(
self.session.session_id,
self._get_typing_owner_id(),
)
async def send_streaming(self, generator, use_fallback: bool = False):
if not use_fallback:
buffer = None

View File

@@ -2,7 +2,7 @@ import abc
import asyncio
import os
from collections.abc import AsyncGenerator
from typing import TypeAlias, Union
from typing import Literal, TypeAlias, Union
from astrbot.core.agent.message import ContentPart, Message
from astrbot.core.agent.tool import ToolSet
@@ -104,6 +104,7 @@ class Provider(AbstractProvider):
tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
model: str | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
tool_choice: Literal["auto", "required"] = "auto",
**kwargs,
) -> LLMResponse:
"""获得 LLM 的文本对话结果。会使用当前的模型进行对话。
@@ -113,6 +114,7 @@ class Provider(AbstractProvider):
session_id: 会话 ID(此属性已经被废弃)
image_urls: 图片 URL 列表
tools: tool set
tool_choice: 工具调用策略,`auto` 表示由模型自行决定,`required` 表示要求模型必须调用工具
contexts: 上下文,和 prompt 二选一使用
tool_calls_result: 回传给 LLM 的工具调用结果。参考: https://platform.openai.com/docs/guides/function-calling
extra_user_content_parts: 额外的内容块列表,用于在用户消息后添加额外的文本块(如系统提醒、指令等)
@@ -135,6 +137,7 @@ class Provider(AbstractProvider):
system_prompt: str | None = None,
tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
model: str | None = None,
tool_choice: Literal["auto", "required"] = "auto",
**kwargs,
) -> AsyncGenerator[LLMResponse, None]:
"""获得 LLM 的流式文本对话结果。会使用当前的模型进行对话。在生成的最后会返回一次完整的结果。
@@ -144,6 +147,7 @@ class Provider(AbstractProvider):
session_id: 会话 ID(此属性已经被废弃)
image_urls: 图片 URL 列表
tools: tool set
tool_choice: 工具调用策略,`auto` 表示由模型自行决定,`required` 表示要求模型必须调用工具
contexts: 上下文,和 prompt 二选一使用
tool_calls_result: 回传给 LLM 的工具调用结果。参考: https://platform.openai.com/docs/guides/function-calling
kwargs: 其他参数

View File

@@ -1,6 +1,7 @@
import base64
import json
from collections.abc import AsyncGenerator
from typing import Literal
import anthropic
import httpx
@@ -12,6 +13,7 @@ from anthropic.types.usage import Usage
from astrbot import logger
from astrbot.api.provider import Provider
from astrbot.core.agent.message import ContentPart, ImageURLPart, TextPart
from astrbot.core.exceptions import EmptyModelOutputError
from astrbot.core.provider.entities import LLMResponse, TokenUsage
from astrbot.core.provider.func_tool_manager import ToolSet
from astrbot.core.utils.io import download_image_by_url
@@ -28,6 +30,23 @@ from ..register import register_provider_adapter
"Anthropic Claude API 提供商适配器",
)
class ProviderAnthropic(Provider):
@staticmethod
def _ensure_usable_response(
llm_response: LLMResponse,
*,
completion_id: str | None = None,
stop_reason: str | None = None,
) -> None:
has_text_output = bool((llm_response.completion_text or "").strip())
has_reasoning_output = bool(llm_response.reasoning_content.strip())
has_tool_output = bool(llm_response.tools_call_args)
if has_text_output or has_reasoning_output or has_tool_output:
return
raise EmptyModelOutputError(
"Anthropic completion has no usable output. "
f"completion_id={completion_id}, stop_reason={stop_reason}"
)
@staticmethod
def _normalize_custom_headers(provider_config: dict) -> dict[str, str] | None:
custom_headers = provider_config.get("custom_headers", {})
@@ -258,6 +277,11 @@ class ProviderAnthropic(Provider):
if tools:
if tool_list := tools.get_func_desc_anthropic_style():
payloads["tools"] = tool_list
payloads["tool_choice"] = {
"type": "any"
if payloads.get("tool_choice") == "required"
else "auto"
}
extra_body = self.provider_config.get("custom_extra_body", {})
@@ -283,7 +307,9 @@ class ProviderAnthropic(Provider):
logger.debug(f"completion: {completion}")
if len(completion.content) == 0:
raise Exception("API 返回的 completion 为空。")
raise EmptyModelOutputError(
f"Anthropic completion is empty. completion_id={completion.id}"
)
llm_response = LLMResponse(role="assistant")
@@ -311,10 +337,9 @@ class ProviderAnthropic(Provider):
if not llm_response.completion_text and not llm_response.tools_call_args:
# Guard clause: raise early if no valid content at all
if not llm_response.reasoning_content:
raise ValueError(
f"Anthropic API returned unparsable completion: "
f"no text, tool_use, or thinking content found. "
f"Completion: {completion}"
raise EmptyModelOutputError(
"Anthropic completion has no usable output. "
f"completion_id={completion.id}, stop_reason={completion.stop_reason}"
)
# We have reasoning content (ThinkingBlock) - this is valid
@@ -324,6 +349,11 @@ class ProviderAnthropic(Provider):
)
llm_response.completion_text = "" # Ensure empty string, not None
self._ensure_usable_response(
llm_response,
completion_id=completion.id,
stop_reason=completion.stop_reason,
)
return llm_response
async def _query_stream(
@@ -334,6 +364,11 @@ class ProviderAnthropic(Provider):
if tools:
if tool_list := tools.get_func_desc_anthropic_style():
payloads["tools"] = tool_list
payloads["tool_choice"] = {
"type": "any"
if payloads.get("tool_choice") == "required"
else "auto"
}
# 用于累积工具调用信息
tool_use_buffer = {}
@@ -470,6 +505,11 @@ class ProviderAnthropic(Provider):
final_response.tools_call_name = [call["name"] for call in final_tool_calls]
final_response.tools_call_ids = [call["id"] for call in final_tool_calls]
self._ensure_usable_response(
final_response,
completion_id=id,
stop_reason=None,
)
yield final_response
async def text_chat(
@@ -483,6 +523,7 @@ class ProviderAnthropic(Provider):
tool_calls_result=None,
model=None,
extra_user_content_parts=None,
tool_choice: Literal["auto", "required"] = "auto",
**kwargs,
) -> LLMResponse:
if contexts is None:
@@ -516,6 +557,8 @@ class ProviderAnthropic(Provider):
model = model or self.get_model()
payloads = {"messages": new_messages, "model": model}
if func_tool and not func_tool.empty():
payloads["tool_choice"] = tool_choice
# Anthropic has a different way of handling system prompts
if system_prompt:
@@ -540,6 +583,7 @@ class ProviderAnthropic(Provider):
tool_calls_result=None,
model=None,
extra_user_content_parts=None,
tool_choice: Literal["auto", "required"] = "auto",
**kwargs,
):
if contexts is None:
@@ -572,6 +616,8 @@ class ProviderAnthropic(Provider):
model = model or self.get_model()
payloads = {"messages": new_messages, "model": model}
if func_tool and not func_tool.empty():
payloads["tool_choice"] = tool_choice
# Anthropic has a different way of handling system prompts
if system_prompt:

View File

@@ -33,6 +33,8 @@ class BailianNetworkError(BailianRerankError):
class BailianRerankProvider(RerankProvider):
"""阿里云百炼文本重排序适配器."""
QWEN3_RERANK_MODEL = "qwen3-rerank"
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
super().__init__(provider_config, provider_settings)
self.provider_config = provider_config
@@ -83,19 +85,35 @@ class BailianRerankProvider(RerankProvider):
Returns:
请求载荷字典
"""
normalized_model = self.model.strip().lower()
normalized_top_n = top_n if top_n is not None and top_n > 0 else None
# qwen3-rerank follows a model-specific payload:
# query/documents/top_n/instruct should be at the top level.
if normalized_model == self.QWEN3_RERANK_MODEL:
payload = {
"model": self.model,
"query": query,
"documents": documents,
}
if normalized_top_n is not None:
payload["top_n"] = normalized_top_n
if self.instruct:
payload["instruct"] = self.instruct
if self.return_documents:
logger.warning(
"qwen3-rerank does not support return_documents; "
"this option will be ignored."
)
return payload
base = {"model": self.model, "input": {"query": query, "documents": documents}}
params = {
k: v
for k, v in [
("top_n", top_n if top_n is not None and top_n > 0 else None),
("top_n", normalized_top_n),
("return_documents", True if self.return_documents else None),
(
"instruct",
self.instruct
if self.instruct and self.model == "qwen3-rerank"
else None,
),
]
if v is not None
}

View File

@@ -4,7 +4,7 @@ import json
import logging
import random
from collections.abc import AsyncGenerator
from typing import cast
from typing import Literal, cast
from google import genai
from google.genai import types
@@ -14,6 +14,7 @@ import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.api.provider import Provider
from astrbot.core.agent.message import ContentPart, ImageURLPart, TextPart
from astrbot.core.exceptions import EmptyModelOutputError
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse, TokenUsage
from astrbot.core.provider.func_tool_manager import ToolSet
@@ -131,6 +132,7 @@ class ProviderGoogleGenAI(Provider):
self,
payloads: dict,
tools: ToolSet | None = None,
tool_choice: Literal["auto", "required"] = "auto",
system_instruction: str | None = None,
modalities: list[str] | None = None,
temperature: float = 0.7,
@@ -207,6 +209,18 @@ class ProviderGoogleGenAI(Provider):
types.Tool(function_declarations=func_desc["function_declarations"]),
]
tool_config = None
if tools and tool_list:
tool_config = types.ToolConfig(
function_calling_config=types.FunctionCallingConfig(
mode=(
types.FunctionCallingConfigMode.ANY
if tool_choice == "required"
else types.FunctionCallingConfigMode.AUTO
)
)
)
# oper thinking config
thinking_config = None
if model_name in [
@@ -272,6 +286,7 @@ class ProviderGoogleGenAI(Provider):
seed=payloads.get("seed"),
response_modalities=modalities,
tools=cast(types.ToolListUnion | None, tool_list),
tool_config=tool_config,
safety_settings=self.safety_settings if self.safety_settings else None,
thinking_config=thinking_config,
automatic_function_calling=types.AutomaticFunctionCallingConfig(
@@ -430,6 +445,23 @@ class ProviderGoogleGenAI(Provider):
output=usage_metadata.candidates_token_count or 0,
)
@staticmethod
def _ensure_usable_response(
llm_response: LLMResponse,
*,
response_id: str | None = None,
finish_reason: str | None = None,
) -> None:
has_text_output = bool((llm_response.completion_text or "").strip())
has_reasoning_output = bool(llm_response.reasoning_content.strip())
has_tool_output = bool(llm_response.tools_call_args)
if has_text_output or has_reasoning_output or has_tool_output:
return
raise EmptyModelOutputError(
"Gemini completion has no usable output. "
f"response_id={response_id}, finish_reason={finish_reason}"
)
def _process_content_parts(
self,
candidate: types.Candidate,
@@ -438,7 +470,10 @@ class ProviderGoogleGenAI(Provider):
"""处理内容部分并构建消息链"""
if not candidate.content:
logger.warning(f"收到的 candidate.content 为空: {candidate}")
raise Exception("API 返回的 candidate.content 为空。")
raise EmptyModelOutputError(
"Gemini candidate content is empty. "
f"finish_reason={candidate.finish_reason}"
)
finish_reason = candidate.finish_reason
result_parts: list[types.Part] | None = candidate.content.parts
@@ -460,7 +495,10 @@ class ProviderGoogleGenAI(Provider):
if not result_parts:
logger.warning(f"收到的 candidate.content.parts 为空: {candidate}")
raise Exception("API 返回的 candidate.content.parts 为空。")
raise EmptyModelOutputError(
"Gemini candidate content parts are empty. "
f"finish_reason={candidate.finish_reason}"
)
# 提取 reasoning content
reasoning = self._extract_reasoning_content(candidate)
@@ -511,7 +549,14 @@ class ProviderGoogleGenAI(Provider):
if ts := part.thought_signature:
# only keep the last thinking signature
llm_response.reasoning_signature = base64.b64encode(ts).decode("utf-8")
return MessageChain(chain=chain)
chain_result = MessageChain(chain=chain)
llm_response.result_chain = chain_result
self._ensure_usable_response(
llm_response,
response_id=None,
finish_reason=str(finish_reason) if finish_reason is not None else None,
)
return chain_result
async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
"""非流式请求 Gemini API"""
@@ -535,6 +580,7 @@ class ProviderGoogleGenAI(Provider):
config = await self._prepare_query_config(
payloads,
tools,
payloads.get("tool_choice", "auto"),
system_instruction,
modalities,
temperature,
@@ -616,6 +662,7 @@ class ProviderGoogleGenAI(Provider):
config = await self._prepare_query_config(
payloads,
tools,
payloads.get("tool_choice", "auto"),
system_instruction,
)
result = await self.client.models.generate_content_stream(
@@ -711,9 +758,12 @@ class ProviderGoogleGenAI(Provider):
final_response.result_chain = MessageChain(
chain=[Comp.Plain(accumulated_text)],
)
elif not final_response.result_chain:
# If no text was accumulated and no final response was set, provide empty space
final_response.result_chain = MessageChain(chain=[Comp.Plain(" ")])
self._ensure_usable_response(
final_response,
response_id=getattr(final_response, "id", None),
finish_reason=None,
)
yield final_response
@@ -728,6 +778,7 @@ class ProviderGoogleGenAI(Provider):
tool_calls_result=None,
model=None,
extra_user_content_parts=None,
tool_choice: Literal["auto", "required"] = "auto",
**kwargs,
) -> LLMResponse:
if contexts is None:
@@ -758,6 +809,8 @@ class ProviderGoogleGenAI(Provider):
model = model or self.get_model()
payloads = {"messages": context_query, "model": model}
if func_tool and not func_tool.empty():
payloads["tool_choice"] = tool_choice
retry = 10
keys = self.api_keys.copy()
@@ -783,6 +836,7 @@ class ProviderGoogleGenAI(Provider):
tool_calls_result=None,
model=None,
extra_user_content_parts=None,
tool_choice: Literal["auto", "required"] = "auto",
**kwargs,
) -> AsyncGenerator[LLMResponse, None]:
if contexts is None:
@@ -813,6 +867,8 @@ class ProviderGoogleGenAI(Provider):
model = model or self.get_model()
payloads = {"messages": context_query, "model": model}
if func_tool and not func_tool.empty():
payloads["tool_choice"] = tool_choice
retry = 10
keys = self.api_keys.copy()

View File

@@ -1,6 +1,5 @@
import os
import urllib.parse
import uuid
from pathlib import Path
import aiohttp
@@ -23,37 +22,55 @@ class ProviderGSVITTS(TTSProvider):
provider_settings: dict,
) -> None:
super().__init__(provider_config, provider_settings)
self.api_base = provider_config.get("api_base", "http://127.0.0.1:5000")
self.api_key = provider_config.get("api_key", "")
self.api_base = provider_config.get("api_base", "http://127.0.0.1:8000")
self.api_base = self.api_base.removesuffix("/")
self.version = provider_config.get("version", "v4")
self.character = provider_config.get("character")
self.emotion = provider_config.get("emotion")
self.prompt_text_lang = provider_config.get("prompt_text_lang", "中文")
self.emotion = provider_config.get("emotion", "默认")
self.text_lang = provider_config.get("text_lang", "中文")
async def get_audio(self, text: str) -> str:
temp_dir = get_astrbot_temp_path()
path = os.path.join(temp_dir, f"gsvi_tts_{uuid.uuid4()}.wav")
params = {"text": text}
path = Path(temp_dir) / f"gsvi_tts_{uuid.uuid4()}.wav"
url = f"{self.api_base}/infer_single"
if self.character:
params["character"] = self.character
if self.emotion:
params["emotion"] = self.emotion
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
query_parts = []
for key, value in params.items():
encoded_value = urllib.parse.quote(str(value))
query_parts.append(f"{key}={encoded_value}")
url = f"{self.api_base}/tts?{'&'.join(query_parts)}"
data = {
"dl_url": self.api_base,
"version": self.version,
"model_name": self.character,
"prompt_text_lang": self.prompt_text_lang,
"emotion": self.emotion,
"text": text,
"text_lang": self.text_lang,
}
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
async with session.post(url, json=data, headers=headers) as response:
if response.status == 200:
with open(path, "wb") as f:
f.write(await response.read())
resp_json = await response.json()
msg = resp_json.get("msg")
audio_url = resp_json.get("audio_url")
if not msg or msg != "合成成功":
raise Exception(f"GSVI TTS API 合成失败: {msg}")
async with session.get(audio_url) as audio_response:
if audio_response.status == 200:
with open(path, "wb") as f:
f.write(await audio_response.read())
else:
error_text = await audio_response.text()
raise Exception(
f"GSVI TTS API 下载音频失败,状态码: {audio_response.status},错误: {error_text}",
)
else:
error_text = await response.text()
raise Exception(
f"GSVI TTS API 请求失败,状态码: {response.status},错误: {error_text}",
)
return path
return str(path)

View File

@@ -26,6 +26,7 @@ from astrbot import logger
from astrbot.api.provider import Provider
from astrbot.core.agent.message import ContentPart, ImageURLPart, Message, TextPart
from astrbot.core.agent.tool import ToolSet
from astrbot.core.exceptions import EmptyModelOutputError
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult
from astrbot.core.utils.io import download_image_by_url
@@ -436,6 +437,7 @@ class ProviderOpenAIOfficial(Provider):
)
if tool_list:
payloads["tools"] = tool_list
payloads["tool_choice"] = payloads.get("tool_choice", "auto")
# 不在默认参数中的参数放在 extra_body 中
extra_body = {}
@@ -486,6 +488,7 @@ class ProviderOpenAIOfficial(Provider):
)
if tool_list:
payloads["tools"] = tool_list
payloads["tool_choice"] = payloads.get("tool_choice", "auto")
# 不在默认参数中的参数放在 extra_body 中
extra_body = {}
@@ -694,7 +697,9 @@ class ProviderOpenAIOfficial(Provider):
llm_response = LLMResponse("assistant")
if not completion.choices:
raise Exception("API 返回的 completion 为空。")
raise EmptyModelOutputError(
f"OpenAI completion has no choices. response_id={completion.id}"
)
choice = completion.choices[0]
# parse the text completion
@@ -712,6 +717,10 @@ class ProviderOpenAIOfficial(Provider):
# Also clean up orphan </think> tags that may leak from some models
completion_text = re.sub(r"</think>\s*$", "", completion_text).strip()
llm_response.result_chain = MessageChain().message(completion_text)
elif refusal := getattr(choice.message, "refusal", None):
refusal_text = self._normalize_content(refusal)
if refusal_text:
llm_response.result_chain = MessageChain().message(refusal_text)
# parse the reasoning content if any
# the priority is higher than the <think> tag extraction
@@ -759,9 +768,18 @@ class ProviderOpenAIOfficial(Provider):
raise Exception(
"API 返回的 completion 由于内容安全过滤被拒绝(非 AstrBot)。",
)
if llm_response.completion_text is None and not llm_response.tools_call_args:
logger.error(f"API 返回的 completion 无法解析:{completion}")
raise Exception(f"API 返回的 completion 无法解析:{completion}")
has_text_output = bool((llm_response.completion_text or "").strip())
has_reasoning_output = bool(llm_response.reasoning_content.strip())
if (
not has_text_output
and not has_reasoning_output
and not llm_response.tools_call_args
):
logger.error(f"OpenAI completion has no usable output: {completion}.")
raise EmptyModelOutputError(
"OpenAI completion has no usable output. "
f"response_id={completion.id}, finish_reason={choice.finish_reason}"
)
llm_response.raw_completion = completion
llm_response.id = completion.id
@@ -965,6 +983,7 @@ class ProviderOpenAIOfficial(Provider):
tool_calls_result=None,
model=None,
extra_user_content_parts=None,
tool_choice: Literal["auto", "required"] = "auto",
**kwargs,
) -> LLMResponse:
payloads, context_query = await self._prepare_chat_payload(
@@ -977,6 +996,8 @@ class ProviderOpenAIOfficial(Provider):
extra_user_content_parts=extra_user_content_parts,
**kwargs,
)
if func_tool and not func_tool.empty():
payloads["tool_choice"] = tool_choice
llm_response = None
max_retries = 10
@@ -1032,6 +1053,7 @@ class ProviderOpenAIOfficial(Provider):
system_prompt=None,
tool_calls_result=None,
model=None,
tool_choice: Literal["auto", "required"] = "auto",
**kwargs,
) -> AsyncGenerator[LLMResponse, None]:
"""流式对话,与服务商交互并逐步返回结果"""
@@ -1044,6 +1066,8 @@ class ProviderOpenAIOfficial(Provider):
model=model,
**kwargs,
)
if func_tool and not func_tool.empty():
payloads["tool_choice"] = tool_choice
max_retries = 10
available_api_keys = self.api_keys.copy()

View File

@@ -548,6 +548,8 @@ class SkillManager:
if not zipfile.is_zipfile(zip_path):
raise ValueError("Uploaded file is not a valid zip archive.")
installed_skills = []
with zipfile.ZipFile(zip_path) as zf:
names = [
name
@@ -573,34 +575,6 @@ class SkillManager:
):
raise ValueError("Invalid skill name.")
if root_mode:
archive_hint = _normalize_skill_name(
archive_skill_name or zip_path_obj.stem
)
if not archive_hint or not _SKILL_NAME_RE.fullmatch(archive_hint):
raise ValueError("Invalid skill name.")
skill_name = archive_hint
else:
top_dirs = {
PurePosixPath(name).parts[0] for name in file_names if name.strip()
}
if len(top_dirs) != 1:
raise ValueError(
"Zip archive must contain a single top-level folder."
)
archive_root_name = next(iter(top_dirs))
archive_root_name_normalized = _normalize_skill_name(archive_root_name)
if archive_root_name in {".", "..", ""} or not _SKILL_NAME_RE.fullmatch(
archive_root_name_normalized
):
raise ValueError("Invalid skill folder name.")
if archive_skill_name:
if not _SKILL_NAME_RE.fullmatch(archive_skill_name):
raise ValueError("Invalid skill name.")
skill_name = archive_skill_name
else:
skill_name = archive_root_name_normalized
for name in names:
if not name:
continue
@@ -609,20 +583,38 @@ class SkillManager:
parts = PurePosixPath(name).parts
if ".." in parts:
raise ValueError("Zip archive contains invalid relative paths.")
if (not root_mode) and parts and parts[0] != archive_root_name:
raise ValueError(
"Zip archive contains unexpected top-level entries."
)
if root_mode:
if "SKILL.md" not in file_names and "skill.md" not in file_names:
raise ValueError("SKILL.md not found in the skill folder.")
else:
if (
f"{archive_root_name}/SKILL.md" not in file_names
and f"{archive_root_name}/skill.md" not in file_names
):
raise ValueError("SKILL.md not found in the skill folder.")
if not root_mode and not overwrite:
top_dirs = {PurePosixPath(n).parts[0] for n in file_names if n.strip()}
conflict_dirs: list[str] = []
for src_dir_name in top_dirs:
if (
f"{src_dir_name}/SKILL.md" not in file_names
and f"{src_dir_name}/skill.md" not in file_names
):
continue
candidate_name = _normalize_skill_name(src_dir_name)
if not candidate_name or not _SKILL_NAME_RE.fullmatch(
candidate_name
):
continue
if archive_skill_name and len(top_dirs) == 1:
target_name = archive_skill_name
else:
target_name = candidate_name
dest_dir = Path(self.skills_root) / target_name
if dest_dir.exists():
conflict_dirs.append(str(dest_dir))
if conflict_dirs:
raise FileExistsError(
"One or more skills from the archive already exist and "
"overwrite=False. No skills were installed. Conflicting "
f"paths: {', '.join(conflict_dirs)}"
)
with tempfile.TemporaryDirectory(dir=get_astrbot_temp_path()) as tmp_dir:
for member in zf.infolist():
@@ -630,21 +622,78 @@ class SkillManager:
if not member_name or _is_ignored_zip_entry(member_name):
continue
zf.extract(member, tmp_dir)
src_dir = (
Path(tmp_dir) if root_mode else Path(tmp_dir) / archive_root_name
)
normalized_path = _normalize_skill_markdown_path(src_dir)
if normalized_path is None:
raise ValueError("SKILL.md not found in the skill folder.")
_normalize_skill_markdown_path(src_dir)
if not src_dir.exists():
raise ValueError("Skill folder not found after extraction.")
dest_dir = Path(self.skills_root) / skill_name
if dest_dir.exists():
if not overwrite:
raise FileExistsError("Skill already exists.")
shutil.rmtree(dest_dir)
shutil.move(str(src_dir), str(dest_dir))
self.set_skill_active(skill_name, True)
return skill_name
if root_mode:
archive_hint = _normalize_skill_name(
archive_skill_name or zip_path_obj.stem
)
if not archive_hint or not _SKILL_NAME_RE.fullmatch(archive_hint):
raise ValueError("Invalid skill name.")
skill_name = archive_hint
src_dir = Path(tmp_dir)
normalized_path = _normalize_skill_markdown_path(src_dir)
if normalized_path is None:
raise ValueError(
"SKILL.md not found in the root of the zip archive."
)
dest_dir = Path(self.skills_root) / skill_name
if dest_dir.exists() and overwrite:
shutil.rmtree(dest_dir)
elif dest_dir.exists() and not overwrite:
raise FileExistsError(f"Skill {skill_name} already exists.")
shutil.move(str(src_dir), str(dest_dir))
self.set_skill_active(skill_name, True)
installed_skills.append(skill_name)
else:
top_dirs = {
PurePosixPath(n).parts[0] for n in file_names if n.strip()
}
for archive_root_name in top_dirs:
archive_root_name_normalized = _normalize_skill_name(
archive_root_name
)
if (
f"{archive_root_name}/SKILL.md" not in file_names
and f"{archive_root_name}/skill.md" not in file_names
):
continue
if archive_root_name in {".", "..", ""} or not (
_SKILL_NAME_RE.fullmatch(archive_root_name_normalized)
):
continue
if archive_skill_name and len(top_dirs) == 1:
skill_name = archive_skill_name
else:
skill_name = archive_root_name_normalized
src_dir = Path(tmp_dir) / archive_root_name
normalized_path = _normalize_skill_markdown_path(src_dir)
if normalized_path is None:
continue
dest_dir = Path(self.skills_root) / skill_name
if dest_dir.exists():
if not overwrite:
raise FileExistsError(
f"Skill {skill_name} already exists."
)
shutil.rmtree(dest_dir)
shutil.move(str(src_dir), str(dest_dir))
self.set_skill_active(skill_name, True)
installed_skills.append(skill_name)
if not installed_skills:
raise ValueError(
"No valid SKILL.md found in any folder of the zip archive."
)
return ", ".join(installed_skills)

View File

@@ -300,6 +300,61 @@ class AstrBotDashboard:
logger.info("Initialized random JWT secret for dashboard.")
self._jwt_secret = self.config["dashboard"]["jwt_secret"]
@staticmethod
def _resolve_dashboard_ssl_config(
ssl_config: dict,
) -> tuple[bool, dict[str, str]]:
cert_file = (
os.environ.get("DASHBOARD_SSL_CERT")
or os.environ.get("ASTRBOT_DASHBOARD_SSL_CERT")
or ssl_config.get("cert_file", "")
)
key_file = (
os.environ.get("DASHBOARD_SSL_KEY")
or os.environ.get("ASTRBOT_DASHBOARD_SSL_KEY")
or ssl_config.get("key_file", "")
)
ca_certs = (
os.environ.get("DASHBOARD_SSL_CA_CERTS")
or os.environ.get("ASTRBOT_DASHBOARD_SSL_CA_CERTS")
or ssl_config.get("ca_certs", "")
)
if not cert_file or not key_file:
logger.warning(
"dashboard.ssl.enable 已启用,但未同时配置 cert_file 和 key_fileSSL 配置将不会生效。",
)
return False, {}
cert_path = Path(cert_file).expanduser()
key_path = Path(key_file).expanduser()
if not cert_path.is_file():
logger.warning(
f"dashboard.ssl.enable 已启用,但 SSL 证书文件不存在: {cert_path}SSL 配置将不会生效。",
)
return False, {}
if not key_path.is_file():
logger.warning(
f"dashboard.ssl.enable 已启用,但 SSL 私钥文件不存在: {key_path}SSL 配置将不会生效。",
)
return False, {}
resolved_ssl_config = {
"certfile": str(cert_path.resolve()),
"keyfile": str(key_path.resolve()),
}
if ca_certs:
ca_path = Path(ca_certs).expanduser()
if not ca_path.is_file():
logger.warning(
f"dashboard.ssl.enable 已启用,但 SSL CA 证书文件不存在: {ca_path}SSL 配置将不会生效。",
)
return False, {}
resolved_ssl_config["ca_certs"] = str(ca_path.resolve())
return True, resolved_ssl_config
def run(self):
ip_addr = []
dashboard_config = self.core_lifecycle.astrbot_config.get("dashboard", {})
@@ -322,6 +377,11 @@ class AstrBotDashboard:
or os.environ.get("ASTRBOT_DASHBOARD_SSL_ENABLE"),
bool(ssl_config.get("enable", False)),
)
resolved_ssl_config: dict[str, str] = {}
if ssl_enable:
ssl_enable, resolved_ssl_config = self._resolve_dashboard_ssl_config(
ssl_config,
)
scheme = "https" if ssl_enable else "http"
if not enable:
@@ -373,41 +433,10 @@ class AstrBotDashboard:
config = HyperConfig()
config.bind = [f"{host}:{port}"]
if ssl_enable:
cert_file = (
os.environ.get("DASHBOARD_SSL_CERT")
or os.environ.get("ASTRBOT_DASHBOARD_SSL_CERT")
or ssl_config.get("cert_file", "")
)
key_file = (
os.environ.get("DASHBOARD_SSL_KEY")
or os.environ.get("ASTRBOT_DASHBOARD_SSL_KEY")
or ssl_config.get("key_file", "")
)
ca_certs = (
os.environ.get("DASHBOARD_SSL_CA_CERTS")
or os.environ.get("ASTRBOT_DASHBOARD_SSL_CA_CERTS")
or ssl_config.get("ca_certs", "")
)
cert_path = Path(cert_file).expanduser()
key_path = Path(key_file).expanduser()
if not cert_file or not key_file:
raise ValueError(
"dashboard.ssl.enable 为 true 时,必须配置 cert_file 和 key_file。",
)
if not cert_path.is_file():
raise ValueError(f"SSL 证书文件不存在: {cert_path}")
if not key_path.is_file():
raise ValueError(f"SSL 私钥文件不存在: {key_path}")
config.certfile = str(cert_path.resolve())
config.keyfile = str(key_path.resolve())
if ca_certs:
ca_path = Path(ca_certs).expanduser()
if not ca_path.is_file():
raise ValueError(f"SSL CA 证书文件不存在: {ca_path}")
config.ca_certs = str(ca_path.resolve())
config.certfile = resolved_ssl_config["certfile"]
config.keyfile = resolved_ssl_config["keyfile"]
if "ca_certs" in resolved_ssl_config:
config.ca_certs = resolved_ssl_config["ca_certs"]
# 根据配置决定是否禁用访问日志
disable_access_log = dashboard_config.get("disable_access_log", True)

55
changelogs/v4.22.2.md Normal file
View File

@@ -0,0 +1,55 @@
## What's Changed
### 新增
- 微信个人号开放平台适配器新增“对方正在输入...”状态控制能力。([#6977](https://github.com/AstrBotDevs/AstrBot/pull/6977)
- 配置管理页支持从现有配置复制生成新配置,减少重复创建成本。([#6785](https://github.com/AstrBotDevs/AstrBot/pull/6785)
### 优化
- Chat Provider text_chat 接口新增 `tool_choice` 参数,并为模型空响应引入统一的 `EmptyModelOutputError` 与增强重试逻辑,改善 “skills-like” 工具调用模式下的稳定性。([#7101](https://github.com/AstrBotDevs/AstrBot/pull/7101)、[#7104](https://github.com/AstrBotDevs/AstrBot/pull/7104)
- SSL 证书配置错误时自动回退到非 SSL 模式,而不是程序直接退出。([#7102](https://github.com/AstrBotDevs/AstrBot/pull/7102)
### 修复
- 修复 个人微信 Bot 无法发送多媒体文件与 CDN 上传地址兼容性问题,适配新的 `upload_full_url` 返回格式。[#7066](https://github.com/AstrBotDevs/AstrBot/pull/7066)
- 修复 个人微信 连接超时 / 中断后直接导致微信适配器实例退出的问题,增强了重试机制。([#7041](https://github.com/AstrBotDevs/AstrBot/pull/7041)
- 修复 WebUI Skills 上传对多技能压缩包的处理,允许单个 zip 包内包含多个技能。([#7070](https://github.com/AstrBotDevs/AstrBot/pull/7070)
- 修复 OpenAI 嵌入模型接口处理相关问题。[#7026](https://github.com/AstrBotDevs/AstrBot/pull/7026)
- QQ 机器人发送语音或音频文件没有响应 [#7007](https://github.com/AstrBotDevs/AstrBot/pull/7007)
- ChatUI 切换到某个会话,刷新后错误地跳转到最新会话 [#6535](https://github.com/AstrBotDevs/AstrBot/pull/6535)
- 修复百炼 `qwen3-rerank` 的请求负载兼容性问题,并忽略不受支持的 `return_documents` 参数。([#6222](https://github.com/AstrBotDevs/AstrBot/pull/6222)
- 修复 QQ 下,发送唤醒前缀后,多次空触发 LLM 的问题。([#6893](https://github.com/AstrBotDevs/AstrBot/pull/6893)
- 修复 GSVI TTS 调用方式与鉴权头构造问题,并补充默认配置值,适配新的 `/infer_single` API。[#7083](https://github.com/AstrBotDevs/AstrBot/pull/7083)
- 修复缺失 `httpx` SOCKS 代理依赖的问题,恢复 SOCKS 代理支持。([#7093](https://github.com/AstrBotDevs/AstrBot/pull/7093)
- 修复企业微信客服发送失败时无法回退普通消息接口的问题。([#7012](https://github.com/AstrBotDevs/AstrBot/pull/7012)
- 修复 WebUI 对话管理详情页无法滚动的问题。[#6972](https://github.com/AstrBotDevs/AstrBot/pull/6972)
- 修复 Telegram 附件消息 caption 丢失的问题。([#7020](https://github.com/AstrBotDevs/AstrBot/pull/7020)
## What's Changed (EN)
### New Features
- Added `tool_choice` to Tool Loop Agent Runner and introduced `EmptyModelOutputError` with stronger retry handling, improving stability for "skills-like" tool-call workflows.[#7101](https://github.com/AstrBotDevs/AstrBot/pull/7101)、[#7104](https://github.com/AstrBotDevs/AstrBot/pull/7104)
- Added SSL config resolution and validation for Dashboard, with automatic fallback to non-SSL mode when the certificate setup is invalid.[#7102](https://github.com/AstrBotDevs/AstrBot/pull/7102)
- Added the ability to create a new config by copying an existing one in the config management page.[#6785](https://github.com/AstrBotDevs/AstrBot/pull/6785)
- Updated the QQ Official adapter to support asynchronous message parsing and attachment preparation.[#7007](https://github.com/AstrBotDevs/AstrBot/pull/7007)
- Added typing-state control ("user is typing...") for the Weixin OC adapter.[#6977](https://github.com/AstrBotDevs/AstrBot/pull/6977)
### Improvements
- Refactored Chat UI routing and layout so the UI mode is URL-driven and related state is scoped to `sessionStorage`.[#6535](https://github.com/AstrBotDevs/AstrBot/pull/6535)
- Improved WebUI polish by centering extension-page toast hints and making code blocks more readable in dark mode.[#6043](https://github.com/AstrBotDevs/AstrBot/pull/6043)、[#7014](https://github.com/AstrBotDevs/AstrBot/pull/7014)
### Bug Fixes
- Fixed skill installer handling so a single zip archive can contain multiple skills.[#7070](https://github.com/AstrBotDevs/AstrBot/pull/7070)
- Fixed Bailian `qwen3-rerank` payload compatibility and ignored unsupported `return_documents` parameters.[#6222](https://github.com/AstrBotDevs/AstrBot/pull/6222)
- Fixed pipeline wake-up and request flow on empty messages.[#6893](https://github.com/AstrBotDevs/AstrBot/pull/6893)
- Fixed GSVI TTS invocation and authorization-header construction, and added safer default config values.[#7083](https://github.com/AstrBotDevs/AstrBot/pull/7083)
- Fixed missing `httpx` SOCKS proxy dependency to restore SOCKS proxy support.[#7093](https://github.com/AstrBotDevs/AstrBot/pull/7093)
- Fixed Weixin OC inbound polling retry behavior and CDN upload compatibility with the new `upload_full_url` response field.[#7041](https://github.com/AstrBotDevs/AstrBot/pull/7041)、[#7066](https://github.com/AstrBotDevs/AstrBot/pull/7066)
- Fixed WeCom fallback behavior so sending can retry through the regular message API when the KF API returns `40096`.[#7012](https://github.com/AstrBotDevs/AstrBot/pull/7012)
- Fixed missing attachment captions in Telegram messages.[#7020](https://github.com/AstrBotDevs/AstrBot/pull/7020)
- Hardened OpenAI attachment recovery and fixed Embedding URL suffix trimming to avoid recovery failures and accidental URL character removal.[#7004](https://github.com/AstrBotDevs/AstrBot/pull/7004)、[#7026](https://github.com/AstrBotDevs/AstrBot/pull/7026)
- Fixed Dashboard regressions around list-option label mapping, missing icons, and mouse-wheel handling in the preview container.[#6844](https://github.com/AstrBotDevs/AstrBot/pull/6844)、[#6970](https://github.com/AstrBotDevs/AstrBot/pull/6970)、[#6972](https://github.com/AstrBotDevs/AstrBot/pull/6972)

View File

@@ -86,11 +86,13 @@
></v-checkbox>
</div>
<v-combobox
<v-select
v-else-if="itemMeta?.type === 'list' && itemMeta?.options"
:model-value="modelValue"
@update:model-value="emitUpdate"
:items="itemMeta.options"
:items="getSelectItems(itemMeta)"
item-title="title"
item-value="value"
:disabled="itemMeta?.readonly"
density="compact"
variant="outlined"
@@ -98,7 +100,7 @@
hide-details
chips
multiple
></v-combobox>
></v-select>
<v-select
v-else-if="itemMeta?.options"

View File

@@ -77,13 +77,16 @@
"title": "Configuration Management",
"description": "AstrBot supports separate configuration files for different bots. The `default` configuration is used by default.",
"newConfig": "New Configuration",
"copyConfig": "Copy Configuration",
"editConfig": "Edit Configuration",
"manageConfigs": "Manage Configurations...",
"configName": "Name",
"fillConfigName": "Enter configuration name",
"confirmDelete": "Are you sure you want to delete the configuration \"{name}\"? This action cannot be undone.",
"pleaseEnterName": "Please enter a configuration name",
"nameExists": "Configuration name already exists. Please use another name.",
"createFailed": "Failed to create new configuration",
"copyFailed": "Failed to copy configuration",
"deleteFailed": "Failed to delete configuration",
"updateFailed": "Failed to update configuration"
},

View File

@@ -77,13 +77,16 @@
"title": "Управление конфигурациями",
"description": "AstrBot поддерживает несколько конфигураций для разных ботов. По умолчанию используется «default».",
"newConfig": "Новая конфигурация",
"copyConfig": "Копировать конфигурацию",
"editConfig": "Изменить конфигурацию",
"manageConfigs": "Управление файлами...",
"configName": "Имя",
"fillConfigName": "Введите имя конфигурации",
"confirmDelete": "Вы уверены, что хотите удалить конфигурацию «{name}»? Это действие необратимо.",
"pleaseEnterName": "Пожалуйста, введите имя",
"nameExists": "Имя конфигурации уже существует. Используйте другое имя.",
"createFailed": "Ошибка создания конфигурации",
"copyFailed": "Ошибка копирования конфигурации",
"deleteFailed": "Ошибка удаления",
"updateFailed": "Ошибка обновления"
},
@@ -126,4 +129,4 @@
"cancel": "Отмена"
}
}
}
}

View File

@@ -77,13 +77,16 @@
"title": "配置文件管理",
"description": "AstrBot 支持针对不同机器人分别设置配置文件。默认会使用 `default` 配置。",
"newConfig": "新建配置文件",
"copyConfig": "复制配置文件",
"editConfig": "编辑配置文件",
"manageConfigs": "管理配置文件...",
"configName": "名称",
"fillConfigName": "填写配置文件名称",
"confirmDelete": "确定要删除配置文件 \"{name}\" 吗?此操作不可恢复。",
"pleaseEnterName": "请填写配置名称",
"nameExists": "配置文件名称已存在,请使用其他名称",
"createFailed": "新配置文件创建失败",
"copyFailed": "复制配置文件失败",
"deleteFailed": "删除配置文件失败",
"updateFailed": "更新配置文件失败"
},

View File

@@ -126,11 +126,15 @@
<!-- Config List -->
<v-list lines="two">
<v-list-item v-for="config in configInfoList" :key="config.id" :title="config.name">
<template v-slot:append v-if="config.id !== 'default'">
<template v-slot:append>
<div class="d-flex align-center" style="gap: 8px;">
<v-btn icon="mdi-content-copy" size="small" variant="text" color="primary"
@click="startCopyConfig(config)"></v-btn>
<v-btn icon="mdi-pencil" size="small" variant="text" color="warning"
v-if="config.id !== 'default'"
@click="startEditConfig(config)"></v-btn>
<v-btn icon="mdi-delete" size="small" variant="text" color="error"
v-if="config.id !== 'default'"
@click="confirmDeleteConfig(config)"></v-btn>
</div>
</template>
@@ -141,7 +145,7 @@
<v-divider v-if="showConfigForm" class="my-6"></v-divider>
<div v-if="showConfigForm">
<h3 class="mb-4">{{ isEditingConfig ? tm('configManagement.editConfig') : tm('configManagement.newConfig') }}</h3>
<h3 class="mb-4">{{ configFormTitle }}</h3>
<h4>{{ tm('configManagement.configName') }}</h4>
@@ -151,7 +155,7 @@
<div class="d-flex justify-end mt-4" style="gap: 8px;">
<v-btn variant="text" @click="cancelConfigForm">{{ tm('buttons.cancel') }}</v-btn>
<v-btn color="primary" @click="saveConfigForm"
:disabled="!configFormData.name">
:disabled="isConfigFormSaveDisabled">
{{ isEditingConfig ? tm('buttons.update') : tm('buttons.create') }}
</v-btn>
</div>
@@ -297,6 +301,19 @@ export default {
selectedConfigInfo() {
return this.configInfoList.find(info => info.id === this.selectedConfigID) || {};
},
configFormTitle() {
if (this.isEditingConfig) {
return this.tm('configManagement.editConfig');
}
if (this.isCopyingConfig) {
return this.tm('configManagement.copyConfig');
}
return this.tm('configManagement.newConfig');
},
isConfigFormSaveDisabled() {
const isNameEmpty = !this.normalizeConfigName(this.configFormData.name);
return isNameEmpty || (this.isCopyingConfig && !this.copySourceConfigId);
},
configSelectItems() {
const items = [...this.configInfoList];
items.push({
@@ -343,6 +360,7 @@ export default {
configManageDialog: false,
showConfigForm: false,
isEditingConfig: false,
isCopyingConfig: false,
config_data_has_changed: false,
config_data_str: "",
config_data: {
@@ -371,6 +389,7 @@ export default {
name: '',
},
editingConfigId: null,
copySourceConfigId: '',
// 测试聊天
testChatDrawer: false,
@@ -567,9 +586,9 @@ export default {
this.save_message_snack = true;
}
},
createNewConfig() {
createNewConfig(configName) {
axios.post('/api/config/abconf/new', {
name: this.configFormData.name
name: configName
}).then((res) => {
if (res.data.status === "ok") {
this.save_message = res.data.message;
@@ -589,6 +608,24 @@ export default {
this.save_message_success = "error";
});
},
normalizeConfigName(name) {
return typeof name === 'string' ? name.trim() : '';
},
hasDuplicateConfigName(name, excludeId = null) {
const normalizedName = this.normalizeConfigName(name);
if (!normalizedName) {
return false;
}
return this.configInfoList.some((config) => {
if (!config || !config.name) {
return false;
}
if (excludeId && config.id === excludeId) {
return false;
}
return this.normalizeConfigName(config.name) === normalizedName;
});
},
async onConfigSelect(value) {
if (value === '_%manage%_') {
this.configManageDialog = true;
@@ -638,44 +675,91 @@ export default {
}
}
},
setConfigFormState({ mode = 'create', config = null, visible = true } = {}) {
this.showConfigForm = visible;
this.isEditingConfig = mode === 'edit';
this.isCopyingConfig = mode === 'copy';
this.editingConfigId = this.isEditingConfig && config ? config.id : null;
this.copySourceConfigId = this.isCopyingConfig && config ? config.id : '';
let name = '';
if (this.isEditingConfig && config) {
name = config.name || '';
} else if (this.isCopyingConfig && config) {
name = `${config.name || ''}-copy`;
}
this.configFormData = { name };
},
startCreateConfig() {
this.showConfigForm = true;
this.isEditingConfig = false;
this.configFormData = {
name: '',
};
this.editingConfigId = null;
this.setConfigFormState({ mode: 'create' });
},
startEditConfig(config) {
this.showConfigForm = true;
this.isEditingConfig = true;
this.editingConfigId = config.id;
this.configFormData = {
name: config.name || '',
};
this.setConfigFormState({ mode: 'edit', config });
},
startCopyConfig(config) {
this.setConfigFormState({ mode: 'copy', config });
},
cancelConfigForm() {
this.showConfigForm = false;
this.isEditingConfig = false;
this.editingConfigId = null;
this.configFormData = {
name: '',
};
this.setConfigFormState({ visible: false });
},
saveConfigForm() {
if (!this.configFormData.name) {
const normalizedName = this.normalizeConfigName(this.configFormData.name);
if (!normalizedName) {
this.save_message = this.tm('configManagement.pleaseEnterName');
this.save_message_snack = true;
this.save_message_success = "error";
return;
}
if (this.isEditingConfig) {
this.updateConfigInfo();
} else {
this.createNewConfig();
const excludeId = this.isEditingConfig ? this.editingConfigId : null;
if (this.hasDuplicateConfigName(normalizedName, excludeId)) {
this.save_message = this.tm('configManagement.nameExists');
this.save_message_snack = true;
this.save_message_success = "error";
return;
}
this.configFormData.name = normalizedName;
if (this.isEditingConfig) {
this.updateConfigInfo(normalizedName);
} else if (this.isCopyingConfig) {
this.copyConfig(normalizedName);
} else {
this.createNewConfig(normalizedName);
}
},
copyConfig(configName) {
axios.get('/api/config/abconf', {
params: { id: this.copySourceConfigId }
}).then((res) => {
const sourceConfig = res.data?.data?.config;
if (!sourceConfig) {
this.save_message = this.tm('configManagement.copyFailed');
this.save_message_snack = true;
this.save_message_success = "error";
return;
}
return axios.post('/api/config/abconf/new', {
name: configName,
config: sourceConfig
});
}).then((res) => {
if (!res) return;
if (res.data.status === "ok") {
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "success";
this.getConfigInfoList(res.data.data.conf_id);
this.cancelConfigForm();
} else {
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "error";
}
}).catch((err) => {
console.error(err);
this.save_message = err?.response?.data?.message || this.tm('configManagement.copyFailed');
this.save_message_snack = true;
this.save_message_success = "error";
});
},
async confirmDeleteConfig(config) {
const message = this.tm('configManagement.confirmDelete').replace('{name}', config.name);
@@ -706,10 +790,10 @@ export default {
this.save_message_success = "error";
});
},
updateConfigInfo() {
updateConfigInfo(configName) {
axios.post('/api/config/abconf/update', {
id: this.editingConfigId,
name: this.configFormData.name
name: configName
}).then((res) => {
if (res.data.status === "ok") {
this.save_message = res.data.message;

View File

@@ -198,7 +198,9 @@
</div>
<!-- 预览模式 - 聊天界面 -->
<div v-else class="conversation-messages-container" style="background-color: var(--v-theme-surface);">
<div v-else class="conversation-messages-container" style="background-color: var(--v-theme-surface);"
ref="messagesContainer"
@wheel.prevent="onContainerWheel">
<!-- 空对话提示 -->
<div v-if="conversationHistory.length === 0" class="text-center py-5">
<v-icon size="48" color="grey">mdi-chat-remove</v-icon>
@@ -1052,6 +1054,13 @@ export default {
return parts;
},
// Manually handle wheel scrolling inside the dialog preview container.
onContainerWheel(event) {
const el = this.$refs.messagesContainer;
if (!el) return;
el.scrollTop += event.deltaY;
},
// 从内容中提取文本(保留用于其他用途)
extractTextFromContent(content) {
if (typeof content === 'string') {

View File

@@ -5,7 +5,7 @@
AstrBot 支持通过 `个人微信` 适配器接入微信个人号。该适配器基于**腾讯微信官方** `openclaw-weixin` 接口实现,使用扫码登录和长轮询收发消息,不需要配置 Webhook 回调地址。
> [!NOTE]
> 需要升级到最新的手机微信版本:>= 8.0.70
> 需要升级到最新的手机微信版本:iOS >= 8.0.70Android >= 8.0.69,并确保微信中包含 ClawBot 插件
## 支持的消息类型

View File

@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.22.1"
version = "4.22.2"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
requires-python = ">=3.12"
@@ -27,6 +27,7 @@ dependencies = [
"faiss-cpu>=1.12.0",
"filelock>=3.18.0",
"google-genai>=1.56.0",
"httpx[socks]>=0.28.1",
"lark-oapi>=1.4.15",
"lxml-html-clean>=0.4.2",
"mcp>=1.8.0",

View File

@@ -17,6 +17,7 @@ docstring-parser>=0.16
faiss-cpu>=1.12.0
filelock>=3.18.0
google-genai>=1.56.0
httpx[socks]>=0.28.1
lark-oapi>=1.4.15
lxml-html-clean>=0.4.2
mcp>=1.8.0

View File

@@ -1,7 +1,10 @@
import httpx
import pytest
import astrbot.core.provider.sources.anthropic_source as anthropic_source
import astrbot.core.provider.sources.kimi_code_source as kimi_code_source
from astrbot.core.exceptions import EmptyModelOutputError
from astrbot.core.provider.entities import LLMResponse
class _FakeAsyncAnthropic:
@@ -79,3 +82,14 @@ def test_kimi_code_provider_restores_required_user_agent_when_blank(monkeypatch)
assert provider.custom_headers == {
"User-Agent": kimi_code_source.KIMI_CODE_USER_AGENT,
}
def test_anthropic_empty_output_raises_empty_model_output_error():
llm_response = LLMResponse(role="assistant")
with pytest.raises(EmptyModelOutputError):
anthropic_source.ProviderAnthropic._ensure_usable_response(
llm_response,
completion_id="msg_empty",
stop_reason="end_turn",
)

View File

@@ -108,6 +108,51 @@ async def test_get_stat(app: Quart, authenticated_header: dict):
assert data["status"] == "ok" and "platform" in data["data"]
@pytest.mark.asyncio
async def test_dashboard_ssl_missing_cert_and_key_falls_back_to_http(
core_lifecycle_td: AstrBotCoreLifecycle,
monkeypatch,
):
shutdown_event = asyncio.Event()
server = AstrBotDashboard(core_lifecycle_td, core_lifecycle_td.db, shutdown_event)
original_dashboard_config = copy.deepcopy(
core_lifecycle_td.astrbot_config.get("dashboard", {}),
)
warning_messages = []
info_messages = []
async def fake_serve(app, config, shutdown_trigger):
return config
try:
core_lifecycle_td.astrbot_config["dashboard"]["ssl"] = {
"enable": True,
"cert_file": "",
"key_file": "",
}
monkeypatch.setattr(server, "check_port_in_use", lambda port: False)
monkeypatch.setattr("astrbot.dashboard.server.serve", fake_serve)
monkeypatch.setattr(
"astrbot.dashboard.server.logger.warning",
lambda message: warning_messages.append(message),
)
monkeypatch.setattr(
"astrbot.dashboard.server.logger.info",
lambda message: info_messages.append(message),
)
config = await server.run()
assert getattr(config, "certfile", None) is None
assert getattr(config, "keyfile", None) is None
assert any("cert_file 和 key_file" in message for message in warning_messages)
assert any(
"正在启动 WebUI, 监听地址: http://" in message for message in info_messages
)
finally:
core_lifecycle_td.astrbot_config["dashboard"] = original_dashboard_config
@pytest.mark.asyncio
async def test_subagent_config_accepts_default_persona(
app: Quart,

View File

@@ -0,0 +1,29 @@
import pytest
from astrbot.core.exceptions import EmptyModelOutputError
from astrbot.core.provider.entities import LLMResponse
from astrbot.core.provider.sources.gemini_source import ProviderGoogleGenAI
def test_gemini_empty_output_raises_empty_model_output_error():
llm_response = LLMResponse(role="assistant")
with pytest.raises(EmptyModelOutputError):
ProviderGoogleGenAI._ensure_usable_response(
llm_response,
response_id="resp_empty",
finish_reason="STOP",
)
def test_gemini_reasoning_only_output_is_allowed():
llm_response = LLMResponse(
role="assistant",
reasoning_content="chain of thought placeholder",
)
ProviderGoogleGenAI._ensure_usable_response(
llm_response,
response_id="resp_reasoning",
finish_reason="STOP",
)

View File

@@ -0,0 +1,113 @@
import re
from pathlib import Path
import pytest
import tomllib
PROJECT_ROOT = Path(__file__).resolve().parents[1]
REQUIREMENTS_PATH = PROJECT_ROOT / "requirements.txt"
PYPROJECT_PATH = PROJECT_ROOT / "pyproject.toml"
HTTPX_SOCKS_PATTERN = re.compile(r"^httpx\[socks\](?:\s*[<>=!~][^;]*)?(?:\s*;.*)?$")
def _read_httpx_socks_dependency(entries: list[str]) -> str | None:
for entry in entries:
candidate = entry.strip()
if HTTPX_SOCKS_PATTERN.match(candidate):
return candidate
return None
def _read_requirements() -> list[str]:
entries = []
for line in REQUIREMENTS_PATH.read_text(encoding="utf-8").splitlines():
candidate = line.split("#", 1)[0].strip()
if candidate:
entries.append(candidate)
return entries
def _read_pyproject_dependencies() -> list[str]:
with PYPROJECT_PATH.open("rb") as file:
pyproject = tomllib.load(file)
return pyproject["project"]["dependencies"]
def test_requirements_include_httpx_socks_dependency() -> None:
requirements_dependency = _read_httpx_socks_dependency(_read_requirements())
assert requirements_dependency is not None, (
"Expected httpx[socks] dependency in requirements.txt for SOCKS proxy support"
)
def test_pyproject_declares_httpx_socks_dependency() -> None:
pyproject_dependency = _read_httpx_socks_dependency(_read_pyproject_dependencies())
assert pyproject_dependency is not None, (
"Expected httpx[socks] dependency in pyproject.toml for SOCKS proxy support"
)
def test_httpx_socks_dependency_spec_matches_between_dependency_files() -> None:
requirements_dependency = _read_httpx_socks_dependency(_read_requirements())
pyproject_dependency = _read_httpx_socks_dependency(_read_pyproject_dependencies())
assert requirements_dependency is not None, (
"Expected httpx[socks] dependency in requirements.txt for SOCKS proxy support"
)
assert pyproject_dependency is not None, (
"Expected httpx[socks] dependency in pyproject.toml for SOCKS proxy support"
)
assert requirements_dependency == pyproject_dependency, (
"Expected httpx[socks] dependency spec to match between requirements.txt "
"and pyproject.toml for SOCKS proxy support"
)
@pytest.mark.parametrize(
"entry",
[
"httpx[socks]",
"httpx[socks]==0.27.0",
"httpx[socks]==0.28.1",
"httpx[socks]>=0.27.0,<0.28.0",
"httpx[socks]>=0.27,<0.29",
'httpx[socks]; python_version >= "3.11"',
'httpx[socks]>=0.27.0 ; python_version < "3.13"',
'httpx[socks] ; python_version < "3.13"',
'httpx[socks] >=0.27 ; python_version < "3.13"',
],
)
def test_httpx_socks_pattern_matches_valid_variants(entry: str) -> None:
match = HTTPX_SOCKS_PATTERN.match(entry)
assert match is not None, (
f"Expected httpx[socks] dependency pattern to match valid entry for "
f"SOCKS proxy support: {entry}"
)
assert match.group(0) == entry, (
f"Expected httpx[socks] dependency pattern to fully match valid entry "
f"for SOCKS proxy support: {entry}"
)
@pytest.mark.parametrize(
"entry",
[
"httpx",
"httpx==0.27.0",
"httpx[http2]",
"httpx[socks-extra]",
"httpx [socks]",
"someprefix httpx[socks]",
"httpx[socks] trailing-text",
"httpx[socks] extra ; markers",
"httpx[socks]andmore",
],
)
def test_httpx_socks_pattern_rejects_invalid_variants(entry: str) -> None:
assert HTTPX_SOCKS_PATTERN.match(entry) is None, (
f"Expected httpx[socks] dependency pattern to reject invalid entry for "
f"SOCKS proxy support: {entry}"
)

View File

@@ -4,6 +4,7 @@ import pytest
from openai.types.chat.chat_completion import ChatCompletion
from PIL import Image as PILImage
from astrbot.core.exceptions import EmptyModelOutputError
from astrbot.core.provider.sources.groq_source import ProviderGroq
from astrbot.core.provider.sources.openai_source import ProviderOpenAIOfficial
@@ -1136,3 +1137,39 @@ async def test_query_injects_reasoning_effort_none_for_ollama(monkeypatch):
assert extra_body["temperature"] == 0.1
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_parse_openai_completion_raises_empty_model_output_error():
provider = _make_provider()
try:
completion = ChatCompletion.model_validate(
{
"id": "chatcmpl-empty",
"object": "chat.completion",
"created": 0,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": None,
"refusal": None,
"tool_calls": None,
},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 0,
"total_tokens": 1,
},
}
)
with pytest.raises(EmptyModelOutputError):
await provider._parse_openai_completion(completion, tools=None)
finally:
await provider.terminate()

View File

@@ -17,6 +17,7 @@ from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner
from astrbot.core.agent.tool import FunctionTool, ToolSet
from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor
from astrbot.core.exceptions import EmptyModelOutputError
from astrbot.core.provider.entities import LLMResponse, ProviderRequest, TokenUsage
from astrbot.core.provider.provider import Provider
@@ -134,6 +135,22 @@ class MockErrProvider(MockProvider):
)
class MockEmptyOutputThenSuccessProvider(MockProvider):
def __init__(self, failures_before_success: int = 1):
super().__init__()
self.failures_before_success = failures_before_success
async def text_chat(self, **kwargs) -> LLMResponse:
self.call_count += 1
if self.call_count <= self.failures_before_success:
raise EmptyModelOutputError("model returned no usable output")
return LLMResponse(
role="assistant",
completion_text="这是重试后的最终回答",
usage=TokenUsage(input_other=10, output=5),
)
class MockAbortableStreamProvider(MockProvider):
async def text_chat_stream(self, **kwargs):
abort_signal = kwargs.get("abort_signal")
@@ -579,6 +596,67 @@ async def test_fallback_provider_used_when_primary_returns_err(
assert fallback_provider.call_count == 1
@pytest.mark.asyncio
async def test_empty_output_is_retried_before_succeeding(
runner, provider_request, mock_tool_executor, mock_hooks, monkeypatch
):
monkeypatch.setattr(runner, "EMPTY_OUTPUT_RETRY_WAIT_MIN_S", 0)
monkeypatch.setattr(runner, "EMPTY_OUTPUT_RETRY_WAIT_MAX_S", 0)
provider = MockEmptyOutputThenSuccessProvider(failures_before_success=1)
await runner.reset(
provider=provider,
request=provider_request,
run_context=ContextWrapper(context=None),
tool_executor=mock_tool_executor,
agent_hooks=mock_hooks,
streaming=False,
)
async for _ in runner.step_until_done(5):
pass
final_resp = runner.get_final_llm_resp()
assert final_resp is not None
assert final_resp.role == "assistant"
assert final_resp.completion_text == "这是重试后的最终回答"
assert provider.call_count == 2
@pytest.mark.asyncio
async def test_empty_output_retries_exhausted_then_uses_fallback_provider(
runner, provider_request, mock_tool_executor, mock_hooks, monkeypatch
):
monkeypatch.setattr(runner, "EMPTY_OUTPUT_RETRY_WAIT_MIN_S", 0)
monkeypatch.setattr(runner, "EMPTY_OUTPUT_RETRY_WAIT_MAX_S", 0)
primary_provider = MockEmptyOutputThenSuccessProvider(
failures_before_success=runner.EMPTY_OUTPUT_RETRY_ATTEMPTS
)
fallback_provider = MockProvider()
fallback_provider.should_call_tools = False
await runner.reset(
provider=primary_provider,
request=provider_request,
run_context=ContextWrapper(context=None),
tool_executor=mock_tool_executor,
agent_hooks=mock_hooks,
streaming=False,
fallback_providers=[fallback_provider],
)
async for _ in runner.step_until_done(5):
pass
final_resp = runner.get_final_llm_resp()
assert final_resp is not None
assert final_resp.role == "assistant"
assert final_resp.completion_text == "这是我的最终回答"
assert primary_provider.call_count == runner.EMPTY_OUTPUT_RETRY_ATTEMPTS
assert fallback_provider.call_count == 1
@pytest.mark.asyncio
async def test_stop_signal_returns_aborted_and_persists_partial_message(
runner, provider_request, mock_tool_executor, mock_hooks

View File

@@ -651,6 +651,15 @@ class TestSendTyping:
await astr_message_event.send_typing()
class TestStopTyping:
"""Tests for stop_typing method."""
@pytest.mark.asyncio
async def test_stop_typing_default_empty(self, astr_message_event):
"""Test stop_typing default implementation is empty."""
await astr_message_event.stop_typing()
class TestReact:
"""Tests for react method."""
@@ -772,10 +781,12 @@ class TestDefensiveGetattr:
def test_get_message_type_with_non_enum_type(self, astr_message_event):
"""get_message_type should handle message_obj.type that is not a MessageType."""
class DummyMessage:
def __init__(self):
self.type = "not_an_enum"
self.message = []
astr_message_event.message_obj = DummyMessage()
message_type = astr_message_event.get_message_type()
assert isinstance(message_type, MessageType)