Compare commits

...

10 Commits

Author SHA1 Message Date
Soulter
7ab750c088 chore: bump version to 4.22.3 2026-04-05 01:50:12 +08:00
Rico0919x
70872cd44b feat(provider/vllm_rerank): add configurable rerank_api_suffix option (#7278)
* feat(provider/vllm_rerank): add configurable rerank_api_suffix option

Add rerank_api_suffix config option to the VLLM Rerank provider so
users can control the API URL path suffix instead of having /v1/rerank
hardcoded.

- Default value is /v1/rerank (preserves existing behavior)
- Users can set it to empty string to disable auto-append
- Handles suffix without leading slash by auto-adding one
- Schema, default config, and i18n metadata all updated

Issue: Fixes #7238

* fix(provider/vllm_rerank): handle null suffix and improve hint descriptions

- Add explicit None check for rerank_api_suffix (fixes HIGH from Gemini)
- Update rerank_api_base hint to describe actual behavior without
  mentioning specific provider options (fixes 3x MEDIUM from Gemini)
- Add ru-RU i18n for rerank_api_suffix (fixes P2 from Codex)

Co-authored-by: gemini-code-assist[bot]
Co-authored-by: chatgpt-codex-connector[bot]

---------

Co-authored-by: LehaoLin <linlehao@cuhk.edu.cn>
2026-04-05 00:15:27 +08:00
Soulter
dc9c17c195 feat: support token usage extraction for llama.cpp (#7358)
* feat: support token usage extraction for llama.cpp

* chore: ruff format
2026-04-04 23:49:18 +08:00
SoloLevelingAI
77d5d5cc6a fix(windows): inherit all system environment variables into MCP runner for Windows (#7054)
- Merge environment variables case‑insensitively
- Auto‑configure dotnet PATH and suppress console window
- Keep PATHEXT inheritance (from old implementation)

Replaces the minimal PATHEXT fix with a robust solution that resolves
common Windows subprocess issues, especially for .NET servers.

* fix(stdio): improve Windows environment setup for subprocesses

- Merge environment variables case‑insensitively
- Auto‑configure dotnet PATH and suppress console window
- Keep PATHEXT inheritance (from old implementation)
-Add `show_console` option to control `CREATE_NO_WINDOW` flag.Default remains hidden (backward compatible) but can be overridden.

Replaces the minimal PATHEXT fix with a robust solution that resolves
common Windows subprocess issues, especially for .NET servers.

* fix(stdio): improve Windows environment setup for subprocesses 修复(标准输入输出):改进 Windows 环境设置以支持子进程

- Merge environment variables case‑insensitively
- Auto‑configure dotnet PATH and suppress console window
- Keep PATHEXT inheritance (from old implementation)
-Add `show_console` option to control `CREATE_NO_WINDOW` flag.Default remains hidden (backward compatible) but can be overridden.

Replaces the minimal PATHEXT fix with a robust solution that resolves
common Windows subprocess issues, especially for .NET servers.

* fix(stdio): improve Windows environment setup for subprocesses 修复(标准输入输出):改进 Windows 环境设置以支持子进程

- Merge environment variables case‑insensitively
- Auto‑configure dotnet PATH and suppress console window
- Keep PATHEXT inheritance (from old implementation)
-Add `no_console` option to control `CREATE_NO_WINDOW` flag.Default remains hidden (backward compatible) but can be overridden.

Replaces the minimal PATHEXT fix with a robust solution that resolves
common Windows subprocess issues, especially for .NET servers.

* fix(stdio): improve Windows environment setup for subprocesses 修复(标准输入输出):改进 Windows 环境设置以支持子进程

- Merge environment variables case‑insensitively
- Auto‑configure dotnet PATH and suppress console window
- Keep PATHEXT inheritance (from old implementation)
-Add `no_console` option to control `CREATE_NO_WINDOW` flag.Default remains hidden (backward compatible) but can be overridden.

Replaces the minimal PATHEXT fix with a robust solution that resolves
common Windows subprocess issues, especially for .NET servers.

* fix(windows): inherit all system environment variables for subprocesses

- Merge environment variables case‑insensitively
- Auto‑configure dotnet PATH and suppress console window
- Keep PATHEXT inheritance (from old implementation)
- No longer handle as a special case for C#.

Replaces the minimal PATHEXT fix with a robust solution that resolves
common Windows subprocess issues, especially for .NET servers.

* chore: ruff format

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-04-03 16:51:20 +08:00
Yufeng He
1408a8449e perf: Set content to None when the OpenAI message content list is empty (#6551)
_finally_convert_payload 提取 think 部分后,如果 assistant 消息的
所有 content 都是 think 类型,new_content 会变成空列表 []。
Grok 等 provider 不接受空 content list,直接报 400。

改为 new_content or None,空列表时回退到 None(OpenAI 兼容 API
普遍接受 null content 的 assistant 消息)。

Fixes #6447

Co-authored-by: Yufeng He <40085740+universeplayer@users.noreply.github.com>
2026-04-03 16:38:22 +08:00
Yufeng He
8f95ca9d98 fix: filter Gemini thinking parts from user-facing message chain (#7196)
Gemini 3 models return thinking parts (part.thought=True) alongside the
actual response text.  _process_content_parts was including these thinking
parts in the message chain sent to the user, effectively leaking internal
reasoning into the output.  On platforms that split long messages (e.g.
aiocqhttp with realtime segmenting), this caused duplicate or triple
replies since the thinking text often mirrors the actual response.

The streaming path already handled this correctly via chunk.text which
skips thinking parts, but the non-streaming path and the final-chunk
processing in streaming both went through _process_content_parts.

Also switch the Gemini 3 model name matching from an exhaustive list to
prefix matching (gemini-3- / gemini-3.) so new variants like gemini-3.1
get proper thinkingLevel config without code changes.

Fixes #7183
2026-04-03 16:35:25 +08:00
Yufeng He
5e78a24d63 fix: satisfy Google Gemini's function_response requirements to avoid 400 Invalid argument errors (#7216)
Gemini API requires function_response to be a google.protobuf.Struct
(JSON object). When tool results are plain text strings, the API
returns 400 Invalid argument. Detect non-JSON tool content for Gemini
models and wrap it in {"result": content} before sending.

Fixes #7134
2026-04-03 16:32:49 +08:00
Soulter
6a7b622c48 fix: resolve Discord/Misskey hot reload issue by fixing client_self_id misuse (#7331)
* fix: resolve Discord/Misskey hot reload issue by fixing client_self_id misuse

fixes: #7187
closes: #7188

* fix: english logging
2026-04-03 14:03:23 +08:00
Soulter
5886c43752 fix: add qrcode package for QR code generation support
closes: #7327
2026-04-03 13:15:04 +08:00
氕氙
88d70a8013 docs: 在 uv 部署文档中添加不支持 WebUI 升级的说明 (#7298)
* docs: 在 uv 部署文档中添加不支持 WebUI 升级的说明

通过 astrbot run(CLI 模式)启动时,会设置 ASTRBOT_CLI 环境变量,
updator 会拒绝 WebUI 触发的升级操作以避免版本管理混乱。
用户需要通过命令行执行 uv tool upgrade astrbot 来更新。

Closes #7291

* Update README.md

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

* Update README_fr.md

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

* Update README_ja.md

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

* Update README_ru.md

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

* Update README_zh-TW.md

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

* Update README_zh.md

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

---------

Co-authored-by: LIghtJUNction <lightjunction.me@gmail.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-03 09:03:29 +08:00
28 changed files with 365 additions and 119 deletions

View File

@@ -92,6 +92,9 @@ Update `astrbot`:
uv tool upgrade astrbot
```
> [!WARNING]
> AstrBot deployed via `uv` **does not support upgrading through the WebUI**. To update, please run the command above from the command line.
### Docker Deployment
For users familiar with containers and looking for a more stable, production-ready deployment method, we recommend deploying AstrBot with Docker / Docker Compose.

View File

@@ -92,6 +92,9 @@ Mettre à jour `astrbot` :
uv tool upgrade astrbot
```
> [!WARNING]
> AstrBot déployé via `uv` **ne prend pas en charge la mise à jour via le WebUI**. Pour mettre à jour, exécutez la commande ci-dessus depuis le terminal.
### Déploiement Docker
Pour les utilisateurs familiers avec les conteneurs et qui souhaitent une méthode plus stable et adaptée à la production, nous recommandons de déployer AstrBot avec Docker / Docker Compose.

View File

@@ -92,6 +92,9 @@ astrbot run
uv tool upgrade astrbot
```
> [!WARNING]
> `uv` 経由でデプロイした AstrBot は、**WebUI からのバージョンアップグレードに対応していません**。更新するには、上記のコマンドをコマンドラインで実行してください。
### Docker デプロイ
コンテナ運用に慣れており、より安定した本番向けのデプロイ方法を求めるユーザーには、Docker / Docker Compose での AstrBot デプロイをおすすめします。

View File

@@ -92,6 +92,9 @@ astrbot run
uv tool upgrade astrbot
```
> [!WARNING]
> AstrBot, развёрнутый через `uv`, **не поддерживает обновление через WebUI**. Для обновления выполните указанную выше команду из командной строки.
### Развёртывание Docker
Для пользователей, знакомых с контейнерами и которым нужен более стабильный и подходящий для production способ, мы рекомендуем разворачивать AstrBot через Docker / Docker Compose.

View File

@@ -92,6 +92,9 @@ astrbot run
uv tool upgrade astrbot
```
> [!WARNING]
> 透過 `uv` 部署的 AstrBot **不支援在 WebUI 中進行版本升級**。如需更新,請透過命令列執行上述命令。
### Docker 部署
對於熟悉容器、希望獲得更穩定且更適合正式環境部署方式的使用者,我們推薦使用 Docker / Docker Compose 部署 AstrBot。

View File

@@ -92,6 +92,9 @@ astrbot run
uv tool upgrade astrbot
```
> [!WARNING]
> 通过 `uv` 部署的 AstrBot **不支持在 WebUI 中进行版本升级**。如需更新,请通过命令行执行上述命令。
### Docker 部署
对于熟悉容器、希望获得更稳定且更适合生产环境部署方式的用户,我们推荐使用 Docker / Docker Compose 部署 AstrBot。

View File

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

View File

@@ -51,18 +51,29 @@ def _prepare_stdio_env(config: dict) -> dict:
"""Preserve Windows executable resolution for stdio subprocesses."""
if sys.platform != "win32":
return config
pathext = os.environ.get("PATHEXT")
if not pathext:
return config
prepared = config.copy()
env = dict(prepared.get("env") or {})
env.setdefault("PATHEXT", pathext)
env = _merge_environment_variables(env)
prepared["env"] = env
return prepared
def _merge_environment_variables(env: dict) -> dict:
"""合并环境变量处理Windows不区分大小写的情况"""
merged = env.copy()
# 将用户环境变量转换为统一的大小写形式便于比较
user_keys_lower = {k.lower(): k for k in merged.keys()}
for sys_key, sys_value in os.environ.items():
sys_key_lower = sys_key.lower()
if sys_key_lower not in user_keys_lower:
# 使用系统环境变量中的原始大小写
merged[sys_key] = sys_value
return merged
async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]:
"""Quick test MCP server connectivity"""
import aiohttp

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.2"
VERSION = "4.22.3"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
PERSONAL_WECHAT_CONFIG_METADATA = {
"weixin_oc_base_url": {
@@ -1761,6 +1761,7 @@ CONFIG_METADATA_2 = {
"enable": True,
"rerank_api_key": "",
"rerank_api_base": "http://127.0.0.1:8000",
"rerank_api_suffix": "/v1/rerank",
"rerank_model": "BAAI/bge-reranker-base",
"timeout": 20,
},
@@ -1826,7 +1827,12 @@ CONFIG_METADATA_2 = {
"rerank_api_base": {
"description": "重排序模型 API Base URL",
"type": "string",
"hint": "AstrBot 会在请求时在末尾加上 /v1/rerank。",
"hint": "最终请求路径由 Base URL 和路径后缀拼接而成(默认为 /v1/rerank",
},
"rerank_api_suffix": {
"description": "API URL 路径后缀",
"type": "string",
"hint": "追加到 base_url 后的路径,如 /v1/rerank。留空则不追加。",
},
"rerank_api_key": {
"description": "API Key",

View File

@@ -35,11 +35,11 @@ class DiscordBotClient(discord.Bot):
async def on_ready(self) -> None:
"""当机器人成功连接并准备就绪时触发"""
if self.user is None:
logger.error("[Discord] 客户端未正确加载用户信息 (self.user is None)")
logger.error("[Discord] Bot user not loaded correctly (self.user is None)")
return
logger.info(f"[Discord] 已作为 {self.user} (ID: {self.user.id}) 登录")
logger.info("[Discord] 客户端已准备就绪。")
logger.info(f"[Discord] Logged in as {self.user} (ID: {self.user.id})")
logger.info("[Discord] Client is ready.")
if self.on_ready_once_callback and not self._ready_once_fired:
self._ready_once_fired = True
@@ -47,7 +47,7 @@ class DiscordBotClient(discord.Bot):
await self.on_ready_once_callback()
except Exception as e:
logger.error(
f"[Discord] on_ready_once_callback 执行失败: {e}",
f"[Discord] Failed to execute on_ready_once_callback: {e}",
exc_info=True,
)
@@ -99,7 +99,7 @@ class DiscordBotClient(discord.Bot):
return
logger.debug(
f"[Discord] 收到原始消息 from {message.author.name}: {message.content}",
f"[Discord] Received raw message from {message.author.name}: {message.content}",
)
if self.on_message_received:

View File

@@ -46,7 +46,7 @@ class DiscordPlatformAdapter(Platform):
) -> None:
super().__init__(platform_config, event_queue)
self.settings = platform_settings
self.client_self_id: str | None = None
self.bot_self_id: str | None = None
self.registered_handlers = []
# 指令注册相关
self.enable_command_register = self.config.get("discord_command_register", True)
@@ -64,7 +64,7 @@ class DiscordPlatformAdapter(Platform):
"""通过会话发送消息"""
if self.client.user is None:
logger.error(
"[Discord] 客户端未就绪 (self.client.user is None),无法发送消息"
"[Discord] Client is not ready (self.client.user is None); message send skipped"
)
return
@@ -92,10 +92,10 @@ class DiscordPlatformAdapter(Platform):
message_obj.message_str = message_chain.get_plain_text()
message_obj.sender = MessageMember(
user_id=str(self.client_self_id),
user_id=str(self.bot_self_id),
nickname=self.client.user.display_name,
)
message_obj.self_id = cast(str, self.client_self_id)
message_obj.self_id = cast(str, self.bot_self_id)
message_obj.session_id = session.session_id
message_obj.message = message_chain.chain
@@ -115,7 +115,7 @@ class DiscordPlatformAdapter(Platform):
"""返回平台元数据"""
return PlatformMetadata(
"discord",
"Discord 适配器",
"Discord Adapter",
id=cast(str, self.config.get("id")),
default_config_tmpl=self.config,
support_streaming_message=False,
@@ -127,16 +127,18 @@ class DiscordPlatformAdapter(Platform):
# 初始化回调函数
async def on_received(message_data) -> None:
logger.debug(f"[Discord] 收到消息: {message_data}")
if self.client_self_id is None:
self.client_self_id = message_data.get("bot_id")
logger.debug(f"[Discord] Message received: {message_data}")
if self.bot_self_id is None:
self.bot_self_id = message_data.get("bot_id")
abm = await self.convert_message(data=message_data)
await self.handle_msg(abm)
# 初始化 Discord 客户端
token = str(self.config.get("discord_token"))
if not token:
logger.error("[Discord] Bot Token 未配置。请在配置文件中正确设置 token。")
logger.error(
"[Discord] Bot token is not configured. Please set a valid token in the config file."
)
return
proxy = self.config.get("discord_proxy") or None
@@ -144,12 +146,17 @@ class DiscordPlatformAdapter(Platform):
self.client.on_message_received = on_received
async def callback() -> None:
if self.enable_command_register:
await self._collect_and_register_commands()
if self.activity_name:
await self.client.change_presence(
status=discord.Status.online,
activity=discord.CustomActivity(name=self.activity_name),
try:
if self.enable_command_register:
await self._collect_and_register_commands()
if self.activity_name:
await self.client.change_presence(
status=discord.Status.online,
activity=discord.CustomActivity(name=self.activity_name),
)
except Exception as e:
logger.error(
f"[Discord] on_ready_once_callback err: {e}", exc_info=True
)
self.client.on_ready_once_callback = callback
@@ -158,11 +165,16 @@ class DiscordPlatformAdapter(Platform):
self._polling_task = asyncio.create_task(self.client.start_polling())
await self.shutdown_event.wait()
except discord.errors.LoginFailure:
logger.error("[Discord] 登录失败。请检查你的 Bot Token 是否正确。")
logger.error(
"[Discord] Login failed. Please check whether the bot token is correct."
)
except discord.errors.ConnectionClosed:
logger.warning("[Discord] 与 Discord 的连接已关闭。")
logger.warning("[Discord] Connection with Discord has been closed.")
except Exception as e:
logger.error(f"[Discord] 适配器运行时发生意外错误: {e}", exc_info=True)
logger.error(
f"[Discord] Unexpected error while adapter is running: {e}",
exc_info=True,
)
def _get_message_type(
self,
@@ -241,7 +253,7 @@ class DiscordPlatformAdapter(Platform):
)
abm.message = message_chain
abm.raw_message = message
abm.self_id = cast(str, self.client_self_id)
abm.self_id = cast(str, self.bot_self_id)
abm.session_id = str(message.channel.id)
abm.message_id = str(message.id)
return abm
@@ -264,7 +276,7 @@ class DiscordPlatformAdapter(Platform):
if self.client.user is None:
logger.error(
"[Discord] 客户端未就绪 (self.client.user is None),无法处理消息"
"[Discord] Client is not ready (self.client.user is None); message handling skipped"
)
return
@@ -283,7 +295,7 @@ class DiscordPlatformAdapter(Platform):
raw_message = message.raw_message
if not isinstance(raw_message, discord.Message):
logger.warning(
f"[Discord] 收到非 Message 类型的消息: {type(raw_message)},已忽略。"
f"[Discord] Non-Message type received and ignored: {type(raw_message)}"
)
return
@@ -324,20 +336,9 @@ class DiscordPlatformAdapter(Platform):
@override
async def terminate(self) -> None:
"""终止适配器"""
logger.info("[Discord] 正在终止适配器... (step 1: cancel polling task)")
logger.info("[Discord] Shutting down adapter...")
self.shutdown_event.set()
# 优先 cancel polling_task
if self._polling_task:
self._polling_task.cancel()
try:
await asyncio.wait_for(self._polling_task, timeout=10)
except asyncio.CancelledError:
logger.info("[Discord] polling_task 已取消。")
except Exception as e:
logger.warning(f"[Discord] polling_task 取消异常: {e}")
logger.info("[Discord] 正在清理已注册的斜杠指令... (step 2)")
# 清理指令
logger.info("[Discord] Cleaning up commands...")
if self.enable_command_register and self.client:
try:
await asyncio.wait_for(
@@ -347,16 +348,29 @@ class DiscordPlatformAdapter(Platform):
),
timeout=10,
)
logger.info("[Discord] 指令清理完成。")
logger.info("[Discord] Commands cleaned up successfully.")
except Exception as e:
logger.error(f"[Discord] 清理指令时发生错误: {e}", exc_info=True)
logger.info("[Discord] 正在关闭 Discord 客户端... (step 3)")
logger.warning(
f"[Discord] Error occurred while cleaning up commands: {e}"
)
if self._polling_task:
self._polling_task.cancel()
try:
await asyncio.wait_for(self._polling_task, timeout=10)
except asyncio.CancelledError:
logger.info("[Discord] Polling task cancelled successfully.")
except Exception as e:
logger.warning(
f"[Discord] Error occurred while cancelling polling task: {e}"
)
logger.info("[Discord] Closing client connection...")
if self.client and hasattr(self.client, "close"):
try:
await asyncio.wait_for(self.client.close(), timeout=10)
except Exception as e:
logger.warning(f"[Discord] 客户端关闭异常: {e}")
logger.info("[Discord] 适配器已终止。")
logger.warning(f"[Discord] Error occurred while closing client: {e}")
logger.info("[Discord] Adapter shutdown complete.")
def register_handler(self, handler_info) -> None:
"""注册处理器信息"""
@@ -364,7 +378,7 @@ class DiscordPlatformAdapter(Platform):
async def _collect_and_register_commands(self) -> None:
"""收集所有指令并注册到Discord"""
logger.info("[Discord] 开始收集并注册斜杠指令...")
logger.info("[Discord] Collecting and registering slash commands...")
registered_commands = []
for handler_md in star_handlers_registry:
@@ -405,15 +419,15 @@ class DiscordPlatformAdapter(Platform):
if registered_commands:
logger.info(
f"[Discord] 准备同步 {len(registered_commands)} 个指令: {', '.join(registered_commands)}",
f"[Discord] Ready to sync {len(registered_commands)} commands: {', '.join(registered_commands)}",
)
else:
logger.info("[Discord] 没有发现可注册的指令。")
logger.info("[Discord] No commands found for registration.")
# 使用 Pycord 的方法同步指令
# 注意:这可能需要一些时间,并且有频率限制
await self.client.sync_commands()
logger.info("[Discord] 指令同步完成。")
logger.info("[Discord] Command synchronization completed.")
def _create_dynamic_callback(self, cmd_name: str):
"""为每个指令动态创建一个异步回调函数"""
@@ -422,17 +436,17 @@ class DiscordPlatformAdapter(Platform):
ctx: discord.ApplicationContext, params: str | None = None
) -> None:
# 将平台特定的前缀'/'剥离以适配通用的CommandFilter
logger.debug(f"[Discord] 回调函数触发: {cmd_name}")
logger.debug(f"[Discord] 回调函数参数: {ctx}")
logger.debug(f"[Discord] 回调函数参数: {params}")
logger.debug(f"[Discord] Callback triggered: {cmd_name}")
logger.debug(f"[Discord] Callback context: {ctx}")
logger.debug(f"[Discord] Callback params: {params}")
message_str_for_filter = cmd_name
if params:
message_str_for_filter += f" {params}"
logger.debug(
f"[Discord] 斜杠指令 '{cmd_name}' 被触发。 "
f"原始参数: '{params}'. "
f"构建的指令字符串: '{message_str_for_filter}'",
f"[Discord] Slash command '{cmd_name}' triggered. "
f"Raw params: '{params}'. "
f"Built command string: '{message_str_for_filter}'",
)
# 尝试立即响应,防止超时
@@ -441,7 +455,7 @@ class DiscordPlatformAdapter(Platform):
await ctx.defer()
followup_webhook = ctx.followup
except Exception as e:
logger.warning(f"[Discord] 指令 '{cmd_name}' defer 失败: {e}")
logger.warning(f"[Discord] Failed to defer command '{cmd_name}': {e}")
# 2. 构建 AstrBotMessage
channel = ctx.channel
@@ -465,7 +479,7 @@ class DiscordPlatformAdapter(Platform):
)
abm.message = [Plain(text=message_str_for_filter)]
abm.raw_message = ctx.interaction
abm.self_id = cast(str, self.client_self_id)
abm.self_id = cast(str, self.bot_self_id)
abm.session_id = str(ctx.channel_id)
abm.message_id = str(ctx.interaction.id)
@@ -503,10 +517,10 @@ class DiscordPlatformAdapter(Platform):
# Discord 斜杠指令名称规范
if not re.match(r"^[a-z0-9_-]{1,32}$", cmd_name):
logger.debug(f"[Discord] 跳过不符合规范的指令: {cmd_name}")
logger.debug(f"[Discord] Skipping invalid slash command format: {cmd_name}")
return None
description = handler_metadata.desc or f"指令: {cmd_name}"
description = handler_metadata.desc or f"Command: {cmd_name}"
if len(description) > 100:
description = f"{description[:97]}..."

View File

@@ -93,7 +93,7 @@ class MisskeyPlatformAdapter(Platform):
self.api: MisskeyAPI | None = None
self._running = False
self.client_self_id = ""
self.bot_self_id = ""
self._bot_username = ""
self._user_cache = {}
@@ -138,10 +138,10 @@ class MisskeyPlatformAdapter(Platform):
try:
user_info = await self.api.get_current_user()
self.client_self_id = str(user_info.get("id", ""))
self.bot_self_id = str(user_info.get("id", ""))
self._bot_username = user_info.get("username", "")
logger.info(
f"[Misskey] 已连接用户: {self._bot_username} (ID: {self.client_self_id})",
f"[Misskey] 已连接用户: {self._bot_username} (ID: {self.bot_self_id})",
)
except Exception as e:
logger.error(f"[Misskey] 获取用户信息失败: {e}")
@@ -312,9 +312,9 @@ class MisskeyPlatformAdapter(Platform):
)
room_id = data.get("toRoomId")
logger.debug(
f"[Misskey] 收到聊天事件: sender_id={sender_id}, room_id={room_id}, is_self={sender_id == self.client_self_id}",
f"[Misskey] 收到聊天事件: sender_id={sender_id}, room_id={room_id}, is_self={sender_id == self.bot_self_id}",
)
if sender_id == self.client_self_id:
if sender_id == self.bot_self_id:
return
if room_id:
@@ -354,13 +354,13 @@ class MisskeyPlatformAdapter(Platform):
mentions = note.get("mentions", [])
if self._bot_username and f"@{self._bot_username}" in text:
return True
if self.client_self_id in [str(uid) for uid in mentions]:
if self.bot_self_id in [str(uid) for uid in mentions]:
return True
reply = note.get("reply")
if reply and isinstance(reply, dict):
reply_user_id = str(reply.get("user", {}).get("id", ""))
if reply_user_id == self.client_self_id:
if reply_user_id == self.bot_self_id:
return bool(self._bot_username and f"@{self._bot_username}" in text)
return False
@@ -598,7 +598,7 @@ class MisskeyPlatformAdapter(Platform):
visibility, visible_user_ids = resolve_message_visibility(
user_id=user_id_for_cache,
user_cache=self._user_cache,
self_id=self.client_self_id,
self_id=self.bot_self_id,
default_visibility=self.default_visibility,
)
logger.debug(
@@ -637,14 +637,14 @@ class MisskeyPlatformAdapter(Platform):
message = create_base_message(
raw_data,
sender_info,
self.client_self_id,
self.bot_self_id,
is_chat=False,
)
cache_user_info(
self._user_cache,
sender_info,
raw_data,
self.client_self_id,
self.bot_self_id,
is_chat=False,
)
@@ -656,7 +656,7 @@ class MisskeyPlatformAdapter(Platform):
message,
raw_text,
self._bot_username,
self.client_self_id,
self.bot_self_id,
)
message_parts.extend(text_parts)
@@ -685,14 +685,14 @@ class MisskeyPlatformAdapter(Platform):
message = create_base_message(
raw_data,
sender_info,
self.client_self_id,
self.bot_self_id,
is_chat=True,
)
cache_user_info(
self._user_cache,
sender_info,
raw_data,
self.client_self_id,
self.bot_self_id,
is_chat=True,
)
@@ -713,7 +713,7 @@ class MisskeyPlatformAdapter(Platform):
message = create_base_message(
raw_data,
sender_info,
self.client_self_id,
self.bot_self_id,
is_chat=False,
room_id=room_id,
)
@@ -722,10 +722,10 @@ class MisskeyPlatformAdapter(Platform):
self._user_cache,
sender_info,
raw_data,
self.client_self_id,
self.bot_self_id,
is_chat=False,
)
cache_room_info(self._user_cache, raw_data, self.client_self_id)
cache_room_info(self._user_cache, raw_data, self.bot_self_id)
raw_text = raw_data.get("text", "")
message_parts = []
@@ -736,7 +736,7 @@ class MisskeyPlatformAdapter(Platform):
message,
raw_text,
self._bot_username,
self.client_self_id,
self.bot_self_id,
)
message_parts.extend(text_parts)
else:

View File

@@ -335,7 +335,7 @@ def extract_sender_info(
def create_base_message(
raw_data: dict[str, Any],
sender_info: dict[str, Any],
client_self_id: str,
bot_self_id: str,
is_chat: bool = False,
room_id: str | None = None,
) -> AstrBotMessage:
@@ -367,7 +367,7 @@ def create_base_message(
session_id if sender_info["sender_id"] else f"{session_prefix}%unknown"
)
message.message_id = str(raw_data.get("id", ""))
message.self_id = client_self_id
message.self_id = bot_self_id
return message
@@ -376,7 +376,7 @@ def process_at_mention(
message: AstrBotMessage,
raw_text: str,
bot_username: str,
client_self_id: str,
bot_self_id: str,
) -> tuple[list[str], str]:
"""处理@提及逻辑,返回消息部分列表和处理后的文本"""
message_parts = []
@@ -386,7 +386,7 @@ def process_at_mention(
if bot_username and raw_text.startswith(f"@{bot_username}"):
at_mention = f"@{bot_username}"
message.message.append(Comp.At(qq=client_self_id))
message.message.append(Comp.At(qq=bot_self_id))
remaining_text = raw_text[len(at_mention) :].strip()
if remaining_text:
message.message.append(Comp.Plain(remaining_text))
@@ -401,7 +401,7 @@ def cache_user_info(
user_cache: dict[str, Any],
sender_info: dict[str, Any],
raw_data: dict[str, Any],
client_self_id: str,
bot_self_id: str,
is_chat: bool = False,
) -> None:
"""缓存用户信息"""
@@ -410,7 +410,7 @@ def cache_user_info(
"username": sender_info["username"],
"nickname": sender_info["nickname"],
"visibility": "specified",
"visible_user_ids": [client_self_id, sender_info["sender_id"]],
"visible_user_ids": [bot_self_id, sender_info["sender_id"]],
}
else:
user_cache_data = {
@@ -428,7 +428,7 @@ def cache_user_info(
def cache_room_info(
user_cache: dict[str, Any],
raw_data: dict[str, Any],
client_self_id: str,
bot_self_id: str,
) -> None:
"""缓存房间信息"""
room_data = raw_data.get("toRoom")
@@ -442,7 +442,7 @@ def cache_room_info(
"room_description": room_data.get("description", ""),
"owner_id": room_data.get("ownerId", ""),
"visibility": "specified",
"visible_user_ids": [client_self_id],
"visible_user_ids": [bot_self_id],
}

View File

@@ -50,7 +50,6 @@ class TelegramPlatformAdapter(Platform):
) -> None:
super().__init__(platform_config, event_queue)
self.settings = platform_settings
self.client_self_id = uuid.uuid4().hex[:8]
base_url = self.config.get(
"telegram_api_base_url",
@@ -336,6 +335,8 @@ class TelegramPlatformAdapter(Platform):
return None
def _apply_caption() -> None:
if not update.message:
return
if update.message.caption:
message.message_str = update.message.caption
message.message.append(Comp.Plain(message.message_str))

View File

@@ -148,7 +148,6 @@ class WecomPlatformAdapter(Platform):
) -> None:
super().__init__(platform_config, event_queue)
self.settingss = platform_settings
self.client_self_id = uuid.uuid4().hex[:8]
self.api_base_url = platform_config.get(
"api_base_url",
"https://qyapi.weixin.qq.com/cgi-bin/",

View File

@@ -2,7 +2,6 @@ import asyncio
import os
import sys
import time
import uuid
from collections.abc import Callable, Coroutine
from typing import Any, cast
@@ -324,7 +323,6 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
) -> None:
super().__init__(platform_config, event_queue)
self.settingss = platform_settings
self.client_self_id = uuid.uuid4().hex[:8]
self.api_base_url = platform_config.get(
"api_base_url",
"https://api.weixin.qq.com/cgi-bin/",

View File

@@ -241,15 +241,10 @@ class ProviderGoogleGenAI(Provider):
thinking_config = types.ThinkingConfig(
thinking_budget=thinking_budget,
)
elif model_name in [
"gemini-3-pro",
"gemini-3-pro-preview",
"gemini-3-flash",
"gemini-3-flash-preview",
"gemini-3-flash-lite",
"gemini-3-flash-lite-preview",
]:
# The thinkingLevel parameter, recommended for Gemini 3 models and onwards
elif any(model_name.startswith(p) for p in ("gemini-3-", "gemini-3.")):
# The thinkingLevel parameter, recommended for Gemini 3 models and onwards.
# Use prefix match so new variants (3.1, 3-flash-lite-preview, etc.) are
# covered without needing to keep an exhaustive list up to date.
# Gemini 2.5 series models don't support thinkingLevel; use thinkingBudget instead.
thinking_level = self.provider_config.get("gm_thinking_config", {}).get(
"level", "HIGH"
@@ -517,7 +512,11 @@ class ProviderGoogleGenAI(Provider):
):
chain.append(Comp.Plain("这是图片"))
for part in result_parts:
if part.text:
# Skip thinking parts — their text is already captured via
# _extract_reasoning_content above. Including them here would
# leak the model's internal reasoning into the user-facing message,
# which also causes duplicate/triple replies on some platforms.
if part.text and not part.thought:
chain.append(Comp.Plain(part.text))
if (

View File

@@ -532,6 +532,7 @@ class ProviderOpenAIOfficial(Provider):
**payloads,
stream=True,
extra_body=extra_body,
stream_options={"include_usage": True},
)
llm_response = LLMResponse("assistant", is_chunk=True)
@@ -539,12 +540,10 @@ class ProviderOpenAIOfficial(Provider):
state = ChatCompletionStreamState()
async for chunk in stream:
if not chunk.choices:
continue
choice = chunk.choices[0]
delta = choice.delta
choice = chunk.choices[0] if chunk.choices else None
delta = choice.delta if choice else None
if dtcs := delta.tool_calls:
if delta and (dtcs := delta.tool_calls):
for idx, tc in enumerate(dtcs):
# siliconflow workaround
if tc.function and tc.function.arguments:
@@ -574,7 +573,7 @@ class ProviderOpenAIOfficial(Provider):
_y = True
if chunk.usage:
llm_response.usage = self._extract_usage(chunk.usage)
elif choice_usage := getattr(choice, "usage", None):
elif choice and (choice_usage := getattr(choice, "usage", None)):
# Workaround for some providers that only return usage in choices[].usage, e.g. MoonshotAI
# See https://github.com/AstrBotDevs/AstrBot/issues/6614
llm_response.usage = self._extract_usage(choice_usage)
@@ -863,6 +862,9 @@ class ProviderOpenAIOfficial(Provider):
def _finally_convert_payload(self, payloads: dict) -> None:
"""Finally convert the payload. Such as think part conversion, tool inject."""
model = payloads.get("model", "").lower()
is_gemini = "gemini" in model
for message in payloads.get("messages", []):
if message.get("role") == "assistant" and isinstance(
message.get("content"), list
@@ -874,11 +876,24 @@ class ProviderOpenAIOfficial(Provider):
reasoning_content += str(part.get("think"))
else:
new_content.append(part)
message["content"] = new_content
# reasoning key is "reasoning_content"
# Some providers (Grok, etc.) reject empty content lists.
# When all parts were think blocks, fall back to None.
message["content"] = new_content or None
if reasoning_content:
message["reasoning_content"] = reasoning_content
# Gemini 的 function_response 要求 google.protobuf.Struct即 JSON 对象),
# 纯文本会触发 400 Invalid argument需要包一层 JSON。
if is_gemini and message.get("role") == "tool":
content = message.get("content", "")
if isinstance(content, str):
try:
json.loads(content)
except (json.JSONDecodeError, ValueError):
message["content"] = json.dumps(
{"result": content}, ensure_ascii=False
)
async def _handle_api_error(
self,
e: Exception,

View File

@@ -20,6 +20,11 @@ class VLLMRerankProvider(RerankProvider):
self.auth_key = provider_config.get("rerank_api_key", "")
self.base_url = provider_config.get("rerank_api_base", "http://127.0.0.1:8000")
self.base_url = self.base_url.rstrip("/")
self.api_suffix = provider_config.get("rerank_api_suffix", "/v1/rerank")
if self.api_suffix is None:
self.api_suffix = "/v1/rerank"
if self.api_suffix and not self.api_suffix.startswith("/"):
self.api_suffix = "/" + self.api_suffix
self.timeout = provider_config.get("timeout", 20)
self.model = provider_config.get("rerank_model", "BAAI/bge-reranker-base")
@@ -45,8 +50,9 @@ class VLLMRerankProvider(RerankProvider):
if top_n is not None:
payload["top_n"] = top_n
assert self.client is not None
rerank_url = f"{self.base_url}{self.api_suffix}"
async with self.client.post(
f"{self.base_url}/v1/rerank",
rerank_url,
json=payload,
) as response:
response_data = await response.json()

68
changelogs/v4.22.3.md Normal file
View File

@@ -0,0 +1,68 @@
- [更新日志(简体中文)](#chinese)
- [Changelog(English)](#english)
<a id="chinese"></a>
## What's Changed
### 新增
- 新增 `vllm_rerank` 提供商支持可配置的 `rerank_api_suffix`,可灵活适配不同 API 路径。([#7278](https://github.com/AstrBotDevs/AstrBot/pull/7278)
- 适配 llama.cpp 的 token 用量提取支持,增强 token 统计与计费分析能力。([#7358](https://github.com/AstrBotDevs/AstrBot/pull/7358)
- WebUI 捆绑 Monaco Editor 的 worker 文件,防止从 CDN 下载失败。([#7249](https://github.com/AstrBotDevs/AstrBot/pull/7249)
- 全新的模型/平台总览统计面板。([#7152](https://github.com/AstrBotDevs/AstrBot/pull/7152)
### 优化
- 优化 OpenAI 消息处理,若 `content` 列表为空时将其置为 `None`,并降低空内容下的异常率。([#6551](https://github.com/AstrBotDevs/AstrBot/pull/6551)
- 优化 provider page 布局与样式,提升整体设置页面排版一致性。([#7248](https://github.com/AstrBotDevs/AstrBot/pull/7248)
- 用户已登录时将直接跳转欢迎页,减少再次进入登录页的步骤。([commit](https://github.com/AstrBotDevs/AstrBot/commit/9896b48c)
### 修复
- 修复 Windows 下 MCP runner 未继承完整系统环境变量导致启动异常的问题。([#7054](https://github.com/AstrBotDevs/AstrBot/pull/7054)
- 修复 Gemini 思考片段泄露到用户可见消息链路的问题。([#7196](https://github.com/AstrBotDevs/AstrBot/pull/7196)
- 修复 Gemini(OpenAI 兼容 API) 下,`function_response` 格式要求导致的 `400 Invalid argument` 错误。([#7216](https://github.com/AstrBotDevs/AstrBot/pull/7216)
- 修复知识库初始化失败时的错误提示与恢复问题。([#7243](https://github.com/AstrBotDevs/AstrBot/pull/7243)
- 修复 Bailian Rerank 对新旧响应格式兼容性,避免解析失败。([#7217](https://github.com/AstrBotDevs/AstrBot/pull/7217)
- Docker 镜像补充 AMR 编码器依赖,修复部分部署场景下的音频转码缺失问题。([#7247](https://github.com/AstrBotDevs/AstrBot/pull/7247)
- 修复 pip 安装缺少 `pysocks` 依赖导致的 SOCKS5 代理不可用问题。([#7221](https://github.com/AstrBotDevs/AstrBot/pull/7221)
- 修复 `cached_tokens``_extract_usage` 中的处理逻辑,避免 token 统计错乱。([#6719](https://github.com/AstrBotDevs/AstrBot/pull/6719)
- 增强 SSE 断开保护,压缩传输下主动发送心跳,减少 WebChat 误断连。([#7003](https://github.com/AstrBotDevs/AstrBot/pull/7003)
- 修复严格 API 下空 assistant 消息导致的 400 错误。([#7202](https://github.com/AstrBotDevs/AstrBot/pull/7202)
- 新增二维码功能缺失的 `qrcode` 依赖修复,恢复二维码生成功能。([commit](https://github.com/AstrBotDevs/AstrBot/commit/5886c437)
- 修复配置器数字输入组件的交互体验问题,避免异常输入场景下的处理偏差。([#7153](https://github.com/AstrBotDevs/AstrBot/pull/7153)
- 修复 `Record.path` 在 Pydantic v2 下被错误视为必填字段的问题。([#7048](https://github.com/AstrBotDevs/AstrBot/pull/7048)
<a id="english"></a>
## What's Changed (EN)
### New Features
- Added configurable `rerank_api_suffix` for the `vllm_rerank` provider to support custom API paths.[#7278](https://github.com/AstrBotDevs/AstrBot/pull/7278)
- Added token usage extraction support for llama.cpp to improve token accounting.[#7358](https://github.com/AstrBotDevs/AstrBot/pull/7358)
- Bundled Monaco Editor workers in WebUI so code editing does not depend on external CDN downloads.[#7249](https://github.com/AstrBotDevs/AstrBot/pull/7249)
- Added a new statistics overview for model and platform usage.[#7152](https://github.com/AstrBotDevs/AstrBot/pull/7152)
- Redirected logged-in users to the welcome page instead of the login page.[commit](https://github.com/AstrBotDevs/AstrBot/commit/9896b48c)
### Improvements
- Improved OpenAI message handling by setting message `content` to `None` when content lists are empty, reducing empty-payload edge cases.[#6551](https://github.com/AstrBotDevs/AstrBot/pull/6551)
- Improved provider page layout and styling to keep settings pages consistent.[#7248](https://github.com/AstrBotDevs/AstrBot/pull/7248)
- Refined connection-success handling and messaging when users are already logged in.[commit](https://github.com/AstrBotDevs/AstrBot/commit/2de23184)
### Bug Fixes
- Inherited all system environment variables in Windows MCP runner to fix environment-dependent launch issues.[#7054](https://github.com/AstrBotDevs/AstrBot/pull/7054)
- Filtered Gemini thinking parts from user-facing message chains.[#7196](https://github.com/AstrBotDevs/AstrBot/pull/7196)
- Fixed Gemini `function_response` compatibility to prevent `400 Invalid argument` responses.[#7216](https://github.com/AstrBotDevs/AstrBot/pull/7216)
- Improved knowledge base initialization error handling.[#7243](https://github.com/AstrBotDevs/AstrBot/pull/7243)
- Fixed Bailian rerank parsing for both old and new response formats.[#7217](https://github.com/AstrBotDevs/AstrBot/pull/7217)
- Added AMR encoder support in Docker builds to fix missing audio codec support.[#7247](https://github.com/AstrBotDevs/AstrBot/pull/7247)
- Added the `pysocks` dependency so pip installs can use SOCKS5 proxy correctly.[#7221](https://github.com/AstrBotDevs/AstrBot/pull/7221)
- Fixed `_extract_usage` cached token handling.[#6719](https://github.com/AstrBotDevs/AstrBot/pull/6719)
- Sent SSE heartbeat packets to prevent WebChat disconnection under compression.[#7003](https://github.com/AstrBotDevs/AstrBot/pull/7003)
- Fixed `400` errors caused by empty assistant messages on strict APIs.[#7202](https://github.com/AstrBotDevs/AstrBot/pull/7202)
- Added missing `qrcode` dependency to restore QR code generation.[commit](https://github.com/AstrBotDevs/AstrBot/commit/5886c437)
- Fixed number input UX in config renderer.[#7153](https://github.com/AstrBotDevs/AstrBot/pull/7153)
- Fixed `Record.path` being incorrectly treated as required under Pydantic v2.[#7048](https://github.com/AstrBotDevs/AstrBot/pull/7048)

View File

@@ -1075,7 +1075,11 @@
},
"rerank_api_base": {
"description": "Rerank Model API Base URL",
"hint": "AstrBot appends /v1/rerank to the request URL."
"hint": "The full request URL is formed by combining the Base URL and a path suffix (defaults to /v1/rerank)."
},
"rerank_api_suffix": {
"description": "API URL path suffix",
"hint": "Path appended to base_url, e.g. /v1/rerank. Leave empty to disable auto-append."
},
"rerank_api_key": {
"description": "API Key",

View File

@@ -1076,7 +1076,11 @@
},
"rerank_api_base": {
"description": "Base URL API модели Rerank",
"hint": "AstrBot добавляет /v1/rerank к URL запроса."
"hint": "Полный URL запроса формируется путём добавления суффикса к Base URL (по умолчанию /v1/rerank)."
},
"rerank_api_suffix": {
"description": "Суффикс пути API",
"hint": "Суффикс пути, добавляемый к base_url, например /v1/rerank. Оставьте пустым, чтобы не добавлять."
},
"rerank_api_key": {
"description": "API Key",

View File

@@ -1077,7 +1077,11 @@
},
"rerank_api_base": {
"description": "重排序模型 API Base URL",
"hint": "AstrBot 会在请求时在末尾加上 /v1/rerank。"
"hint": "最终请求路径由 Base URL 和路径后缀拼接而成(默认为 /v1/rerank。"
},
"rerank_api_suffix": {
"description": "API URL 路径后缀",
"hint": "追加到 base_url 后的路径后缀,如 /v1/rerank。留空则不追加。"
},
"rerank_api_key": {
"description": "API Key",

View File

@@ -9,6 +9,11 @@ If `uv` is not installed, install it first by following the official guide:
`uv` supports Linux, Windows, and macOS.
## Important Notes
> [!WARNING]
> AstrBot deployed via `uv` **does not support upgrading through the WebUI**. To update, run `uv tool upgrade astrbot` from the command line.
## Install and Start
```bash

View File

@@ -8,6 +8,11 @@
`uv` 支持 Linux、Windows、macOS。
## 注意事项
> [!WARNING]
> 通过 `uv` 部署的 AstrBot **不支持在 WebUI 中进行版本升级**。如需更新,请在命令行中执行 `uv tool upgrade astrbot`。
## 安装并启动
```bash

View File

@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.22.2"
version = "4.22.3"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
license = { text = "AGPL-3.0-or-later" }

View File

@@ -56,3 +56,4 @@ tenacity>=9.1.2
shipyard-python-sdk>=0.2.4
shipyard-neo-sdk>=0.2.0
packaging>=24.2
qrcode>=8.2

View File

@@ -2,6 +2,7 @@ from types import SimpleNamespace
import pytest
from openai.types.chat.chat_completion import ChatCompletion
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
from PIL import Image as PILImage
from astrbot.core.exceptions import EmptyModelOutputError
@@ -1175,6 +1176,93 @@ async def test_parse_openai_completion_raises_empty_model_output_error():
await provider.terminate()
@pytest.mark.asyncio
async def test_query_stream_extracts_usage_from_empty_choices_chunk(monkeypatch):
provider = _make_provider()
try:
chunks = [
ChatCompletionChunk.model_validate(
{
"id": "chatcmpl-stream",
"object": "chat.completion.chunk",
"created": 0,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"delta": {
"role": "assistant",
"content": "ok",
},
"finish_reason": None,
}
],
}
),
ChatCompletionChunk.model_validate(
{
"id": "chatcmpl-stream",
"object": "chat.completion.chunk",
"created": 0,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"delta": {},
"finish_reason": "stop",
}
],
}
),
ChatCompletionChunk.model_validate(
{
"id": "chatcmpl-stream",
"object": "chat.completion.chunk",
"created": 0,
"model": "gpt-4o-mini",
"choices": [],
"usage": {
"prompt_tokens": 2550,
"completion_tokens": 125,
"total_tokens": 2675,
"prompt_tokens_details": {
"cached_tokens": 2488,
},
},
}
),
]
async def fake_stream():
for chunk in chunks:
yield chunk
async def fake_create(**kwargs):
return fake_stream()
monkeypatch.setattr(provider.client.chat.completions, "create", fake_create)
responses = [
response
async for response in provider._query_stream(
payloads={
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "hello"}],
},
tools=None,
)
]
final_response = responses[-1]
assert final_response.completion_text == "ok"
assert final_response.usage is not None
assert final_response.usage.input_other == 62
assert final_response.usage.input_cached == 2488
assert final_response.usage.output == 125
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_query_filters_empty_assistant_message_without_tool_calls(monkeypatch):
"""Test that empty assistant messages without tool_calls are filtered out."""