Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions astrbot/builtin_stars/builtin_commands/commands/conversation.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from sqlalchemy import case, func, select
from sqlmodel import col

from astrbot.api import sp, star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.core import logger
Expand All @@ -7,6 +10,7 @@
DEERFLOW_THREAD_ID_KEY,
)
from astrbot.core.agent.runners.deerflow.deerflow_api_client import DeerFlowAPIClient
from astrbot.core.db.po import ProviderStat
from astrbot.core.utils.active_event_registry import active_event_registry

from .utils.rst_scene import RstScene
Expand Down Expand Up @@ -246,3 +250,62 @@ async def new_conv(self, message: AstrMessageEvent) -> None:
f"✅ Switched to new conversation: {cid[:4]}。"
),
)

async def stats(self, message: AstrMessageEvent) -> None:
"""Show token usage statistics for the current conversation."""
umo = message.unified_msg_origin
cid = await self.context.conversation_manager.get_curr_conversation_id(umo)

if not cid:
message.set_result(
MessageEventResult().message(
"❌ You are not in a conversation. Use /new to create one."
),
)
return

db = self.context.get_db()
async with db.get_db() as session:
result = await session.execute(
select(
func.count(case((col(ProviderStat.id).is_not(None), 1))).label(
"record_count",
),
func.coalesce(func.sum(ProviderStat.token_input_other), 0).label(
"total_input_other",
),
func.coalesce(func.sum(ProviderStat.token_input_cached), 0).label(
"total_input_cached",
),
func.coalesce(func.sum(ProviderStat.token_output), 0).label(
"total_output",
),
).where(
col(ProviderStat.agent_type) == "internal",
col(ProviderStat.conversation_id) == cid,
)
)
stats = result.one()

if stats.record_count == 0:
message.set_result(
MessageEventResult().message(
"📊 No stats available for this conversation yet."
),
)
return

total_input_other = stats.total_input_other
total_input_cached = stats.total_input_cached
total_output = stats.total_output
total_tokens = total_input_other + total_input_cached + total_output

ret = (
f"📊 Conversation Token usage (ID: {cid[:8]}...)\n"
f"Total: {total_tokens:,}\n"
f"Input (cached): {total_input_cached:,}\n"
f"Input (other): {total_input_other:,}\n"
f"Output: {total_output:,}\n"
)

message.set_result(MessageEventResult().message(ret))
5 changes: 5 additions & 0 deletions astrbot/builtin_stars/builtin_commands/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ async def new_conv(self, message: AstrMessageEvent) -> None:
"""Create new conversation"""
await self.conversation_c.new_conv(message)

@filter.command("stats")
async def stats(self, message: AstrMessageEvent) -> None:
"""Show token usage statistics for the current conversation"""
await self.conversation_c.stats(message)

@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("provider")
async def provider(
Expand Down
2 changes: 1 addition & 1 deletion astrbot/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "4.23.5"
__version__ = "4.23.6"
12 changes: 6 additions & 6 deletions astrbot/core/agent/runners/tool_loop_agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,10 @@ async def _complete_with_assistant_response(self, llm_resp: LLMResponse) -> None
self.stats.end_time = time.time()

parts = []
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
if llm_resp.reasoning_content is not None or llm_resp.reasoning_signature:
parts.append(
ThinkPart(
think=llm_resp.reasoning_content,
think=llm_resp.reasoning_content or "",
encrypted=llm_resp.reasoning_signature,
)
)
Expand Down Expand Up @@ -876,10 +876,10 @@ async def step(self):

# 将结果添加到上下文中
parts = []
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
if llm_resp.reasoning_content is not None or llm_resp.reasoning_signature:
parts.append(
ThinkPart(
think=llm_resp.reasoning_content,
think=llm_resp.reasoning_content or "",
encrypted=llm_resp.reasoning_signature,
)
)
Expand Down Expand Up @@ -1361,10 +1361,10 @@ async def _finalize_aborted_step(
self.stats.end_time = time.time()

parts = []
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
if llm_resp.reasoning_content is not None or llm_resp.reasoning_signature:
parts.append(
ThinkPart(
think=llm_resp.reasoning_content,
think=llm_resp.reasoning_content or "",
encrypted=llm_resp.reasoning_signature,
)
)
Expand Down
2 changes: 1 addition & 1 deletion astrbot/core/config/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from astrbot.core.utils.astrbot_path import get_astrbot_data_path

VERSION = "4.23.5"
VERSION = "4.23.6"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
PERSONAL_WECHAT_CONFIG_METADATA = {
"weixin_oc_base_url": {
Expand Down
4 changes: 1 addition & 3 deletions astrbot/core/provider/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ class LLMResponse:
"""Tool call IDs."""
tools_call_extra_content: dict[str, dict[str, Any]] = field(default_factory=dict)
"""Tool call extra content. tool_call_id -> extra_content dict"""
reasoning_content: str = ""
reasoning_content: str | None = None
"""The reasoning content extracted from the LLM, if any."""
reasoning_signature: str | None = None
"""The signature of the reasoning content, if any."""
Expand Down Expand Up @@ -404,8 +404,6 @@ def __init__(
raw_completion (ChatCompletion, optional): 原始响应, OpenAI 格式. Defaults to None.

"""
if reasoning_content is None:
reasoning_content = ""
if tools_call_args is None:
tools_call_args = []
if tools_call_name is None:
Expand Down
2 changes: 1 addition & 1 deletion astrbot/core/provider/sources/anthropic_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def _ensure_usable_response(
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_reasoning_output = bool((llm_response.reasoning_content or "").strip())
has_tool_output = bool(llm_response.tools_call_args)
if has_text_output or has_reasoning_output or has_tool_output:
return
Expand Down
2 changes: 1 addition & 1 deletion astrbot/core/provider/sources/gemini_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,7 @@ def _ensure_usable_response(
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_reasoning_output = bool((llm_response.reasoning_content or "").strip())
has_tool_output = bool(llm_response.tools_call_args)
if has_text_output or has_reasoning_output or has_tool_output:
return
Expand Down
57 changes: 34 additions & 23 deletions astrbot/core/provider/sources/openai_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -671,9 +671,9 @@ async def _query_stream(
reasoning = self._extract_reasoning_content(chunk)
_y = False
llm_response.id = chunk.id
llm_response.reasoning_content = ""
llm_response.reasoning_content = None
llm_response.completion_text = ""
if reasoning:
if reasoning is not None:
llm_response.reasoning_content = reasoning
_y = True
if delta and delta.content:
Expand Down Expand Up @@ -701,22 +701,28 @@ async def _query_stream(
def _extract_reasoning_content(
self,
completion: ChatCompletion | ChatCompletionChunk,
) -> str:
) -> str | None:
"""Extract reasoning content from OpenAI ChatCompletion if available."""
reasoning_text = ""

def _get_reasoning_attr(obj: Any) -> str | None:
fields_set = getattr(obj, "model_fields_set", None)
if isinstance(fields_set, set) and self.reasoning_key in fields_set:
attr = getattr(obj, self.reasoning_key, "")
return "" if attr is None else str(attr)
attr = getattr(obj, self.reasoning_key, None)
return None if attr is None else str(attr)

if not completion.choices:
return reasoning_text
return None
if isinstance(completion, ChatCompletion):
choice = completion.choices[0]
reasoning_attr = getattr(choice.message, self.reasoning_key, None)
if reasoning_attr:
reasoning_text = str(reasoning_attr)
reasoning_attr = _get_reasoning_attr(choice.message)
elif isinstance(completion, ChatCompletionChunk):
delta = completion.choices[0].delta
reasoning_attr = getattr(delta, self.reasoning_key, None)
if reasoning_attr:
reasoning_text = str(reasoning_attr)
return reasoning_text
reasoning_attr = _get_reasoning_attr(delta)
else:
return None
return reasoning_attr

def _extract_usage(self, usage: CompletionUsage | dict) -> TokenUsage:
ptd = getattr(usage, "prompt_tokens_details", None)
Expand Down Expand Up @@ -859,7 +865,9 @@ async def _parse_openai_completion(

# parse the reasoning content if any
# the priority is higher than the <think> tag extraction
llm_response.reasoning_content = self._extract_reasoning_content(completion)
reasoning_content = self._extract_reasoning_content(completion)
if reasoning_content is not None:
llm_response.reasoning_content = reasoning_content

# parse tool calls if any
if choice.message.tool_calls and tools is not None:
Expand Down Expand Up @@ -906,7 +914,7 @@ async def _parse_openai_completion(
"API 返回的 completion 由于内容安全过滤被拒绝(非 AstrBot)。",
)
has_text_output = bool((llm_response.completion_text or "").strip())
has_reasoning_output = bool(llm_response.reasoning_content.strip())
has_reasoning_output = bool((llm_response.reasoning_content or "").strip())
if (
not has_text_output
and not has_reasoning_output
Expand Down Expand Up @@ -987,31 +995,34 @@ def _finally_convert_payload(self, payloads: dict) -> None:
model in deepseek_reasoning_models
or "api.deepseek.com" in self.client.base_url.host
)

for message in payloads.get("messages", []):
if message.get("role") == "assistant" and isinstance(
message.get("content"), list
):
reasoning_content = ""
reasoning_content_present = False
new_content = [] # not including think part
for part in message["content"]:
if part.get("type") == "think":
reasoning_content_present = True
reasoning_content += str(part.get("think"))
else:
new_content.append(part)
# 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 is_deepseek_v4_reasoning and not reasoning_content:
logger.info(
"Deepseek v4 model requires non-empty reasoning content, but got empty. Setting to 'none' to satisfy the requirement."
)
# Deepseek models require the field on assistant
# history messages, even when the reasoning content is empty.
message["reasoning_content"] = "none"
elif reasoning_content:
if reasoning_content_present:
message["reasoning_content"] = reasoning_content

if (
message.get("role") == "assistant"
and is_deepseek_v4_reasoning
and "reasoning_content" not in message
):
# DeepSeek v4 reasoning models require the field on assistant
# history messages, even when the reasoning content is empty.
message["reasoning_content"] = ""

# Gemini 的 function_response 要求 google.protobuf.Struct(即 JSON 对象),
# 纯文本会触发 400 Invalid argument,需要包一层 JSON。
if is_gemini and message.get("role") == "tool":
Expand Down
5 changes: 4 additions & 1 deletion astrbot/dashboard/routes/stat.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ async def get_provider_token_stats(self):
total_by_umo: dict[str, int] = defaultdict(int)
total_by_bucket: dict[int, int] = defaultdict(int)
range_total_tokens = 0
range_total_output_tokens = 0
range_total_calls = 0
range_success_calls = 0
range_ttft_total_ms = 0.0
Expand Down Expand Up @@ -286,6 +287,7 @@ async def get_provider_token_stats(self):
record.end_time - record.start_time
) * 1000
range_duration_samples += 1
range_total_output_tokens += record.token_output

if created_at_local >= today_start_local:
today_total_calls += 1
Expand Down Expand Up @@ -371,7 +373,8 @@ async def get_provider_token_stats(self):
else 0
),
"range_avg_tpm": (
range_total_tokens / (range_duration_total_ms / 1000 / 60)
range_total_output_tokens
/ (range_duration_total_ms / 1000 / 60)
if range_duration_total_ms > 0
else 0
),
Expand Down
56 changes: 56 additions & 0 deletions changelogs/v4.23.6.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
- [更新日志(简体中文)](#chinese)
- [Changelog(English)](#english)

<a id="chinese"></a>

## What's Changed

### 新增

- 新增 `/stats` 命令,可查看当前会话的 Token 使用统计,并按总量、输入(缓存)、输入(其他)与输出拆分展示。([#7831](https://github.com/AstrBotDevs/AstrBot/pull/7831))
- 新增 Firecrawl Web 搜索与网页提取工具,支持搜索结果处理、网页内容提取、会话管理、请求校验与相关测试。([#7764](https://github.com/AstrBotDevs/AstrBot/pull/7764))
- 微信客服文本消息新增 15 秒内去重,减少重复消息处理。([#7788](https://github.com/AstrBotDevs/AstrBot/pull/7788))

### 优化

- 优化 Provider 配置界面性能与响应式显示,改善相关组件的字体和布局体验。([#7772](https://github.com/AstrBotDevs/AstrBot/pull/7772))
- 优化统计页 TPM 计算逻辑,TPM 现在仅统计输出 Token,并更新相关文案。([#7827](https://github.com/AstrBotDevs/AstrBot/pull/7827))
- 优化 OpenAI 兼容 Provider 的空 assistant 消息过滤逻辑,流式与非流式路径统一处理空字符串和空列表内容,避免严格 Provider 拒绝历史消息。([#7758](https://github.com/AstrBotDevs/AstrBot/pull/7758))

### 修复

- 修复 DeepSeek v4 与 reasoning content 相关处理,支持空字符串 reasoning 内容,并在 assistant 消息中保留 reasoning 字段。([#7823](https://github.com/AstrBotDevs/AstrBot/pull/7823), [#7830](https://github.com/AstrBotDevs/AstrBot/pull/7830))
- 修复 OpenRouter reasoning 字段属性名不正确的问题。([#7821](https://github.com/AstrBotDevs/AstrBot/pull/7821))
- 修复超大图片未压缩可能导致后续处理异常的问题,并复用图片最大尺寸检查工具。([#7807](https://github.com/AstrBotDevs/AstrBot/pull/7807))
- 修复 MiniMax TTS 默认输出 MP3 导致 QQ 官方平台语音转换出现 RIFF 错误的问题,默认输出格式改为 WAV。([#7797](https://github.com/AstrBotDevs/AstrBot/pull/7797))
- 修复 Computer 沙盒图片下载未按图片发送的问题。([#7785](https://github.com/AstrBotDevs/AstrBot/pull/7785))
- 修复 Windows 环境下部分 HTTPS 请求证书校验失败的问题,使用 certifi SSL context 提升兼容性。([#7778](https://github.com/AstrBotDevs/AstrBot/pull/7778))
- 修复非安全上下文或部分对话框中复制功能不可用的问题,抽取共享剪贴板工具并增加 fallback。([#7747](https://github.com/AstrBotDevs/AstrBot/pull/7747))
- 修复文件上传可能存在路径穿越的问题,并清理上传文件名中的 NUL 字节。([#7751](https://github.com/AstrBotDevs/AstrBot/pull/7751))

<a id="english"></a>

## What's Changed (EN)

### New Features

- Added a `/stats` command to show token usage for the current conversation, including total tokens, input cached tokens, input other tokens, and output tokens. ([#7831](https://github.com/AstrBotDevs/AstrBot/pull/7831))
- Added Firecrawl web search and web extract tools with result handling, content extraction, session management, payload validation, and tests. ([#7764](https://github.com/AstrBotDevs/AstrBot/pull/7764))
- Added 15-second deduplication for WeChat kefu text messages to reduce duplicate message handling. ([#7788](https://github.com/AstrBotDevs/AstrBot/pull/7788))

### Improvements

- Improved the Provider configuration UI performance and responsive layout, including font and component styling updates. ([#7772](https://github.com/AstrBotDevs/AstrBot/pull/7772))
- Updated stats-page TPM calculation so TPM only counts output tokens, with matching label updates. ([#7827](https://github.com/AstrBotDevs/AstrBot/pull/7827))
- Improved empty assistant message filtering for OpenAI-compatible providers by sharing the logic across streaming and non-streaming paths and handling empty string or empty list content. ([#7758](https://github.com/AstrBotDevs/AstrBot/pull/7758))

### Bug Fixes

- Fixed DeepSeek v4 and reasoning content handling by supporting empty-string reasoning content and preserving the reasoning field in assistant messages. ([#7823](https://github.com/AstrBotDevs/AstrBot/pull/7823), [#7830](https://github.com/AstrBotDevs/AstrBot/pull/7830))
- Fixed the reasoning field attribute used for OpenRouter. ([#7821](https://github.com/AstrBotDevs/AstrBot/pull/7821))
- Fixed oversized image handling by downscaling large images and sharing the image max-size check helper. ([#7807](https://github.com/AstrBotDevs/AstrBot/pull/7807))
- Fixed MiniMax TTS output for QQ Official voice conversion by changing the default output format from MP3 to WAV. ([#7797](https://github.com/AstrBotDevs/AstrBot/pull/7797))
- Fixed Computer sandbox image downloads so they are sent as images. ([#7785](https://github.com/AstrBotDevs/AstrBot/pull/7785))
- Fixed HTTPS certificate verification issues on Windows by using a certifi SSL context. ([#7778](https://github.com/AstrBotDevs/AstrBot/pull/7778))
- Fixed copy actions in insecure contexts and dialogs by extracting a shared clipboard utility with fallback behavior. ([#7747](https://github.com/AstrBotDevs/AstrBot/pull/7747))
- Fixed path traversal risks in file uploads and removed embedded NUL bytes from upload filenames. ([#7751](https://github.com/AstrBotDevs/AstrBot/pull/7751))
Loading
Loading