From 616e9ac54fb03e09c0b36e126b0f1203444cdc30 Mon Sep 17 00:00:00 2001 From: Taois Date: Mon, 20 Apr 2026 13:44:24 +0800 Subject: [PATCH 01/22] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BA=86?= =?UTF-8?q?=E5=9B=BD=E5=86=85=E9=85=8D=E7=BD=AE=E4=B8=80=E4=BA=9B=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E4=B8=8D=E5=8F=AF=E7=94=A8=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 常见的openai和anthropic协议,如 智谱的codingpan https://open.bigmodel.cn/api/coding/paas/v4 2. 新出的一些没有模型列表的自定义模型提供商,如科大讯飞 https://maas-coding-api.cn-huabei-1.xf-yun.com/v2 --- astrbot/core/provider/sources/anthropic_source.py | 14 ++++++++------ astrbot/core/utils/network_utils.py | 15 +++++++++++---- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index 7644577594..8a8984f832 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -1,5 +1,6 @@ import base64 import json +import ssl from collections.abc import AsyncGenerator from typing import Literal @@ -106,15 +107,16 @@ def _init_api_key(self, provider_config: dict) -> None: http_client=self._create_http_client(provider_config), ) - def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient | None: - """创建带代理的 HTTP 客户端""" + def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient: + """创建带代理的 HTTP 客户端,使用系统 SSL 证书""" + ctx = ssl.create_default_context() proxy = provider_config.get("proxy", "") if proxy: logger.info(f"[Anthropic] 使用代理: {proxy}") - return httpx.AsyncClient(proxy=proxy, headers=self.custom_headers) - if self.custom_headers: - return httpx.AsyncClient(headers=self.custom_headers) - return None + return httpx.AsyncClient( + proxy=proxy, headers=self.custom_headers, verify=ctx + ) + return httpx.AsyncClient(headers=self.custom_headers, verify=ctx) def _apply_thinking_config(self, payloads: dict) -> None: thinking_type = self.thinking_config.get("type", "") diff --git a/astrbot/core/utils/network_utils.py b/astrbot/core/utils/network_utils.py index 727f3762ae..c72a6a9c20 100644 --- a/astrbot/core/utils/network_utils.py +++ b/astrbot/core/utils/network_utils.py @@ -1,5 +1,7 @@ """Network error handling utilities for providers.""" +import ssl + import httpx from astrbot import logger @@ -83,9 +85,13 @@ def log_connection_failure( def create_proxy_client( provider_label: str, proxy: str | None = None, -) -> httpx.AsyncClient | None: +) -> httpx.AsyncClient: """Create an httpx AsyncClient with proxy configuration if provided. + Uses the system SSL certificate store instead of certifi, which avoids + SSL verification failures for endpoints whose CA chain is not in certifi + but is trusted by the operating system. + Note: The caller is responsible for closing the client when done. Consider using the client as a context manager or calling aclose() explicitly. @@ -94,9 +100,10 @@ def create_proxy_client( proxy: The proxy address (e.g., "http://127.0.0.1:7890"), or None/empty Returns: - An httpx.AsyncClient configured with the proxy, or None if no proxy + An httpx.AsyncClient configured with the proxy and system SSL context """ + ctx = ssl.create_default_context() if proxy: logger.info(f"[{provider_label}] 使用代理: {proxy}") - return httpx.AsyncClient(proxy=proxy) - return None + return httpx.AsyncClient(proxy=proxy, verify=ctx) + return httpx.AsyncClient(verify=ctx) From 7fa442158baf1744b03097e8c8b5ab0d26b25bc2 Mon Sep 17 00:00:00 2001 From: Taois Date: Mon, 20 Apr 2026 16:05:41 +0800 Subject: [PATCH 02/22] =?UTF-8?q?feat:=20=E6=8F=90=E9=AB=98=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=A4=8D=E7=94=A8=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/provider/sources/anthropic_source.py | 15 ++++++--------- astrbot/core/utils/network_utils.py | 8 +++++--- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index 8a8984f832..d2fce17ded 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -1,6 +1,5 @@ import base64 import json -import ssl from collections.abc import AsyncGenerator from typing import Literal @@ -19,6 +18,7 @@ from astrbot.core.provider.func_tool_manager import ToolSet from astrbot.core.utils.io import download_image_by_url from astrbot.core.utils.network_utils import ( + create_proxy_client, is_connection_error, log_connection_failure, ) @@ -109,14 +109,11 @@ def _init_api_key(self, provider_config: dict) -> None: def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient: """创建带代理的 HTTP 客户端,使用系统 SSL 证书""" - ctx = ssl.create_default_context() - proxy = provider_config.get("proxy", "") - if proxy: - logger.info(f"[Anthropic] 使用代理: {proxy}") - return httpx.AsyncClient( - proxy=proxy, headers=self.custom_headers, verify=ctx - ) - return httpx.AsyncClient(headers=self.custom_headers, verify=ctx) + return create_proxy_client( + "Anthropic", + provider_config.get("proxy", ""), + headers=self.custom_headers, + ) def _apply_thinking_config(self, payloads: dict) -> None: thinking_type = self.thinking_config.get("type", "") diff --git a/astrbot/core/utils/network_utils.py b/astrbot/core/utils/network_utils.py index c72a6a9c20..120da30a0a 100644 --- a/astrbot/core/utils/network_utils.py +++ b/astrbot/core/utils/network_utils.py @@ -85,6 +85,7 @@ def log_connection_failure( def create_proxy_client( provider_label: str, proxy: str | None = None, + headers: dict[str, str] | None = None, ) -> httpx.AsyncClient: """Create an httpx AsyncClient with proxy configuration if provided. @@ -98,12 +99,13 @@ def create_proxy_client( Args: provider_label: The provider name for log prefix (e.g., "OpenAI", "Gemini") proxy: The proxy address (e.g., "http://127.0.0.1:7890"), or None/empty + headers: Optional custom headers to include in every request Returns: - An httpx.AsyncClient configured with the proxy and system SSL context + An httpx.AsyncClient created with the system SSL context; the proxy is applied only if one is provided. """ ctx = ssl.create_default_context() if proxy: logger.info(f"[{provider_label}] 使用代理: {proxy}") - return httpx.AsyncClient(proxy=proxy, verify=ctx) - return httpx.AsyncClient(verify=ctx) + return httpx.AsyncClient(proxy=proxy, verify=ctx, headers=headers) + return httpx.AsyncClient(verify=ctx, headers=headers) From 269ec309382bfd10a136b786db879c18a9bfd0f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Tue, 21 Apr 2026 11:01:29 +0900 Subject: [PATCH 03/22] fix(network): reuse shared SSL context --- astrbot/core/utils/network_utils.py | 9 +++++---- tests/unit/test_network_utils.py | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_network_utils.py diff --git a/astrbot/core/utils/network_utils.py b/astrbot/core/utils/network_utils.py index 120da30a0a..1942029dd7 100644 --- a/astrbot/core/utils/network_utils.py +++ b/astrbot/core/utils/network_utils.py @@ -6,6 +6,8 @@ from astrbot import logger +_SYSTEM_SSL_CTX = ssl.create_default_context() + def is_connection_error(exc: BaseException) -> bool: """Check if an exception is a connection/network related error. @@ -102,10 +104,9 @@ def create_proxy_client( headers: Optional custom headers to include in every request Returns: - An httpx.AsyncClient created with the system SSL context; the proxy is applied only if one is provided. + An httpx.AsyncClient created with the shared system SSL context; the proxy is applied only if one is provided. """ - ctx = ssl.create_default_context() if proxy: logger.info(f"[{provider_label}] 使用代理: {proxy}") - return httpx.AsyncClient(proxy=proxy, verify=ctx, headers=headers) - return httpx.AsyncClient(verify=ctx, headers=headers) + return httpx.AsyncClient(proxy=proxy, verify=_SYSTEM_SSL_CTX, headers=headers) + return httpx.AsyncClient(verify=_SYSTEM_SSL_CTX, headers=headers) diff --git a/tests/unit/test_network_utils.py b/tests/unit/test_network_utils.py new file mode 100644 index 0000000000..99e9f255c8 --- /dev/null +++ b/tests/unit/test_network_utils.py @@ -0,0 +1,24 @@ +import ssl + +import pytest + +from astrbot.core.utils import network_utils + + +def test_create_proxy_client_reuses_shared_ssl_context( + monkeypatch: pytest.MonkeyPatch, +): + captured_calls: list[dict] = [] + + class _FakeAsyncClient: + def __init__(self, **kwargs): + captured_calls.append(kwargs) + + monkeypatch.setattr(network_utils.httpx, "AsyncClient", _FakeAsyncClient) + + network_utils.create_proxy_client("OpenAI") + network_utils.create_proxy_client("OpenAI", proxy="http://127.0.0.1:7890") + + assert len(captured_calls) == 2 + assert isinstance(captured_calls[0]["verify"], ssl.SSLContext) + assert captured_calls[0]["verify"] is captured_calls[1]["verify"] From c2443a844272ca752e1d73c95e64cd7ded300916 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Tue, 21 Apr 2026 11:07:40 +0900 Subject: [PATCH 04/22] test(network): cover proxy and header forwarding --- tests/unit/test_network_utils.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_network_utils.py b/tests/unit/test_network_utils.py index 99e9f255c8..d9d31085e3 100644 --- a/tests/unit/test_network_utils.py +++ b/tests/unit/test_network_utils.py @@ -9,6 +9,7 @@ def test_create_proxy_client_reuses_shared_ssl_context( monkeypatch: pytest.MonkeyPatch, ): captured_calls: list[dict] = [] + headers = {"X-Test-Header": "value"} class _FakeAsyncClient: def __init__(self, **kwargs): @@ -18,7 +19,12 @@ def __init__(self, **kwargs): network_utils.create_proxy_client("OpenAI") network_utils.create_proxy_client("OpenAI", proxy="http://127.0.0.1:7890") + network_utils.create_proxy_client("OpenAI", headers=headers) - assert len(captured_calls) == 2 + assert len(captured_calls) == 3 + assert "proxy" not in captured_calls[0] + assert captured_calls[1]["proxy"] == "http://127.0.0.1:7890" + assert captured_calls[2]["headers"] is headers assert isinstance(captured_calls[0]["verify"], ssl.SSLContext) assert captured_calls[0]["verify"] is captured_calls[1]["verify"] + assert captured_calls[1]["verify"] is captured_calls[2]["verify"] From e9a4eed9bf064a858995eddb4d9c99887d8f993c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Tue, 21 Apr 2026 11:15:34 +0900 Subject: [PATCH 05/22] fix(network): support verify overrides --- .../core/provider/sources/openai_source.py | 2 +- astrbot/core/utils/network_utils.py | 8 +++++-- tests/unit/test_network_utils.py | 23 ++++++++++++++++++- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index b24bc0885b..67971a2a93 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -438,7 +438,7 @@ async def _fallback_to_text_only_and_retry( image_fallback_used, ) - def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient | None: + def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient: """创建带代理的 HTTP 客户端""" proxy = provider_config.get("proxy", "") return create_proxy_client("OpenAI", proxy) diff --git a/astrbot/core/utils/network_utils.py b/astrbot/core/utils/network_utils.py index 1942029dd7..047529396e 100644 --- a/astrbot/core/utils/network_utils.py +++ b/astrbot/core/utils/network_utils.py @@ -88,6 +88,7 @@ def create_proxy_client( provider_label: str, proxy: str | None = None, headers: dict[str, str] | None = None, + verify: ssl.SSLContext | str | bool | None = None, ) -> httpx.AsyncClient: """Create an httpx AsyncClient with proxy configuration if provided. @@ -102,11 +103,14 @@ def create_proxy_client( provider_label: The provider name for log prefix (e.g., "OpenAI", "Gemini") proxy: The proxy address (e.g., "http://127.0.0.1:7890"), or None/empty headers: Optional custom headers to include in every request + verify: Optional override for TLS verification. Defaults to the shared + system SSL context when not provided. Returns: An httpx.AsyncClient created with the shared system SSL context; the proxy is applied only if one is provided. """ + resolved_verify = _SYSTEM_SSL_CTX if verify is None else verify if proxy: logger.info(f"[{provider_label}] 使用代理: {proxy}") - return httpx.AsyncClient(proxy=proxy, verify=_SYSTEM_SSL_CTX, headers=headers) - return httpx.AsyncClient(verify=_SYSTEM_SSL_CTX, headers=headers) + return httpx.AsyncClient(proxy=proxy, verify=resolved_verify, headers=headers) + return httpx.AsyncClient(verify=resolved_verify, headers=headers) diff --git a/tests/unit/test_network_utils.py b/tests/unit/test_network_utils.py index d9d31085e3..ea3505e387 100644 --- a/tests/unit/test_network_utils.py +++ b/tests/unit/test_network_utils.py @@ -20,11 +20,32 @@ def __init__(self, **kwargs): network_utils.create_proxy_client("OpenAI") network_utils.create_proxy_client("OpenAI", proxy="http://127.0.0.1:7890") network_utils.create_proxy_client("OpenAI", headers=headers) + network_utils.create_proxy_client("OpenAI", proxy="") - assert len(captured_calls) == 3 + assert len(captured_calls) == 4 assert "proxy" not in captured_calls[0] assert captured_calls[1]["proxy"] == "http://127.0.0.1:7890" assert captured_calls[2]["headers"] is headers + assert "proxy" not in captured_calls[3] assert isinstance(captured_calls[0]["verify"], ssl.SSLContext) assert captured_calls[0]["verify"] is captured_calls[1]["verify"] assert captured_calls[1]["verify"] is captured_calls[2]["verify"] + assert captured_calls[2]["verify"] is captured_calls[3]["verify"] + + +def test_create_proxy_client_allows_verify_override( + monkeypatch: pytest.MonkeyPatch, +): + captured_calls: list[dict] = [] + custom_verify = ssl.create_default_context() + + class _FakeAsyncClient: + def __init__(self, **kwargs): + captured_calls.append(kwargs) + + monkeypatch.setattr(network_utils.httpx, "AsyncClient", _FakeAsyncClient) + + network_utils.create_proxy_client("OpenAI", verify=custom_verify) + + assert len(captured_calls) == 1 + assert captured_calls[0]["verify"] is custom_verify From 6ece296deb91129aa946c73709673e2459140a25 Mon Sep 17 00:00:00 2001 From: Taois Date: Wed, 22 Apr 2026 00:35:22 +0800 Subject: [PATCH 06/22] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BA=86MCP?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=AF=B9http/sse=E7=9A=84=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E6=80=A7=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 已知: 根因:系统有代理 127.0.0.1:7890,mcp 库底层用 httpx 会自动走代理,但代理的 SSL 证书不被 Python 信任,导致 SSL 验证失败 → 抛出 CancelledError(BaseException 子类) → 绕过 except Exception → Quart 返回 HTTP 500。 修复: 在 mcp_client.py 中新增了 _create_no_verify_httpx_client 工厂函数(verify=False),通过 httpx_client_factory 参数传给 sse_client 和 streamablehttp_client,跳过 SSL 证书验证。 --- astrbot/core/agent/mcp_client.py | 58 +++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/astrbot/core/agent/mcp_client.py b/astrbot/core/agent/mcp_client.py index b75999ea65..7cf76e102a 100644 --- a/astrbot/core/agent/mcp_client.py +++ b/astrbot/core/agent/mcp_client.py @@ -109,6 +109,31 @@ "Warning: Missing 'mcp' dependency or MCP library version too old, Streamable HTTP connection unavailable.", ) +try: + import httpx as _httpx + + def _create_no_verify_httpx_client( + headers: dict[str, str] | None = None, + timeout: _httpx.Timeout | None = None, + auth: _httpx.Auth | None = None, + ) -> _httpx.AsyncClient: + kwargs: dict[str, Any] = { + "follow_redirects": True, + "verify": False, + } + if timeout is None: + kwargs["timeout"] = _httpx.Timeout(30, read=300) + else: + kwargs["timeout"] = timeout + if headers is not None: + kwargs["headers"] = headers + if auth is not None: + kwargs["auth"] = auth + return _httpx.AsyncClient(**kwargs) + +except (ModuleNotFoundError, ImportError): + _create_no_verify_httpx_client = None + def _prepare_config(config: dict) -> dict: """Prepare configuration, handle nested format""" @@ -439,14 +464,22 @@ def logging_callback( else: raise Exception("MCP connection config missing transport or type field") + _http_client_kwargs: dict[str, Any] = { + "url": cfg["url"], + "headers": cfg.get("headers", {}), + } + if _create_no_verify_httpx_client is not None: + _http_client_kwargs["httpx_client_factory"] = ( + _create_no_verify_httpx_client + ) + if transport_type != "streamable_http": # SSE transport method - self._streams_context = sse_client( - url=cfg["url"], - headers=cfg.get("headers", {}), - timeout=cfg.get("timeout", 5), - sse_read_timeout=cfg.get("sse_read_timeout", 60 * 5), + _http_client_kwargs["timeout"] = cfg.get("timeout", 5) + _http_client_kwargs["sse_read_timeout"] = cfg.get( + "sse_read_timeout", 60 * 5 ) + self._streams_context = sse_client(**_http_client_kwargs) streams = await self.exit_stack.enter_async_context( self._streams_context, ) @@ -461,17 +494,16 @@ def logging_callback( ), ) else: - timeout = timedelta(seconds=cfg.get("timeout", 30)) - sse_read_timeout = timedelta( + _http_client_kwargs["timeout"] = timedelta( + seconds=cfg.get("timeout", 30) + ) + _http_client_kwargs["sse_read_timeout"] = timedelta( seconds=cfg.get("sse_read_timeout", 60 * 5), ) - self._streams_context = streamablehttp_client( - url=cfg["url"], - headers=cfg.get("headers", {}), - timeout=timeout, - sse_read_timeout=sse_read_timeout, - terminate_on_close=cfg.get("terminate_on_close", True), + _http_client_kwargs["terminate_on_close"] = cfg.get( + "terminate_on_close", True ) + self._streams_context = streamablehttp_client(**_http_client_kwargs) read_s, write_s, _ = await self.exit_stack.enter_async_context( self._streams_context, ) From fef98221b9c236e5ef9f7e5883e0237e824aa508 Mon Sep 17 00:00:00 2001 From: Taois Date: Wed, 22 Apr 2026 02:11:06 +0800 Subject: [PATCH 07/22] =?UTF-8?q?fix:=20=E6=89=93=E5=8C=85=E5=90=8Edesktop?= =?UTF-8?q?=E4=B8=8D=E5=8F=AF=E7=94=A8=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/provider/sources/anthropic_source.py | 2 ++ astrbot/core/provider/sources/openai_source.py | 9 +++++++-- astrbot/core/utils/network_utils.py | 13 +++++++++++-- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index d2fce17ded..f81c38221f 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -6,6 +6,7 @@ import anthropic import httpx from anthropic import AsyncAnthropic +from anthropic._base_client import httpx as _anthropic_httpx from anthropic.types import Message from anthropic.types.message_delta_usage import MessageDeltaUsage from anthropic.types.usage import Usage @@ -113,6 +114,7 @@ def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient: "Anthropic", provider_config.get("proxy", ""), headers=self.custom_headers, + httpx_module=_anthropic_httpx, ) def _apply_thinking_config(self, payloads: dict) -> None: diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 67971a2a93..02f062789c 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -14,6 +14,7 @@ import httpx from openai import AsyncAzureOpenAI, AsyncOpenAI +from openai._base_client import httpx as _openai_httpx from openai._exceptions import NotFoundError from openai.lib.streaming.chat._completions import ChatCompletionStreamState from openai.types.chat.chat_completion import ChatCompletion @@ -439,9 +440,13 @@ async def _fallback_to_text_only_and_retry( ) def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient: - """创建带代理的 HTTP 客户端""" + """创建带代理的 HTTP 客户端 + + 使用 openai 库内部引用的 httpx 模块来创建客户端实例, + 避免打包后 httpx 被重复收集导致 isinstance 校验失败。 + """ proxy = provider_config.get("proxy", "") - return create_proxy_client("OpenAI", proxy) + return create_proxy_client("OpenAI", proxy, httpx_module=_openai_httpx) def __init__(self, provider_config, provider_settings) -> None: super().__init__(provider_config, provider_settings) diff --git a/astrbot/core/utils/network_utils.py b/astrbot/core/utils/network_utils.py index 047529396e..e92bb3c02d 100644 --- a/astrbot/core/utils/network_utils.py +++ b/astrbot/core/utils/network_utils.py @@ -1,6 +1,7 @@ """Network error handling utilities for providers.""" import ssl +import types import httpx @@ -89,6 +90,7 @@ def create_proxy_client( proxy: str | None = None, headers: dict[str, str] | None = None, verify: ssl.SSLContext | str | bool | None = None, + httpx_module: types.ModuleType | None = None, ) -> httpx.AsyncClient: """Create an httpx AsyncClient with proxy configuration if provided. @@ -105,12 +107,19 @@ def create_proxy_client( headers: Optional custom headers to include in every request verify: Optional override for TLS verification. Defaults to the shared system SSL context when not provided. + httpx_module: Optional httpx module to use for creating the client. + In packaged environments (PyInstaller, Nuitka, etc.), the packaging + tool may collect httpx as two separate copies. When the caller needs + the created client to pass isinstance checks inside another library + (e.g., openai), pass that library's own ``httpx`` module here so + the client is created from the same class object. Returns: An httpx.AsyncClient created with the shared system SSL context; the proxy is applied only if one is provided. """ + _httpx = httpx_module or httpx resolved_verify = _SYSTEM_SSL_CTX if verify is None else verify if proxy: logger.info(f"[{provider_label}] 使用代理: {proxy}") - return httpx.AsyncClient(proxy=proxy, verify=resolved_verify, headers=headers) - return httpx.AsyncClient(verify=resolved_verify, headers=headers) + return _httpx.AsyncClient(proxy=proxy, verify=resolved_verify, headers=headers) + return _httpx.AsyncClient(verify=resolved_verify, headers=headers) From a34c8500bf2c501f05da79e19334fadb3e90dbb7 Mon Sep 17 00:00:00 2001 From: Taois Date: Wed, 22 Apr 2026 15:13:05 +0800 Subject: [PATCH 08/22] =?UTF-8?q?fix:=20=E5=B5=8C=E5=85=A5=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E8=B0=83=E7=94=A8=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/db/vec_db/faiss_impl/vec_db.py | 6 +++++ .../sources/openai_embedding_source.py | 27 +++++++++++-------- astrbot/dashboard/routes/config.py | 9 ++++--- astrbot/dashboard/routes/knowledge_base.py | 7 +++-- 4 files changed, 33 insertions(+), 16 deletions(-) diff --git a/astrbot/core/db/vec_db/faiss_impl/vec_db.py b/astrbot/core/db/vec_db/faiss_impl/vec_db.py index 0474683754..3022010eb1 100644 --- a/astrbot/core/db/vec_db/faiss_impl/vec_db.py +++ b/astrbot/core/db/vec_db/faiss_impl/vec_db.py @@ -35,6 +35,12 @@ def __init__( async def initialize(self) -> None: await self.document_storage.initialize() + # 如果维度未配置(为 0),通过实际请求自动探测 + if self.embedding_storage.dimension == 0: + vec = await self.embedding_provider.get_embedding("probe") + dim = len(vec) + logger.info(f"自动探测到嵌入模型维度: {dim}") + self.embedding_storage = EmbeddingStorage(dim, self.index_store_path) async def insert( self, diff --git a/astrbot/core/provider/sources/openai_embedding_source.py b/astrbot/core/provider/sources/openai_embedding_source.py index ae531996ae..3eb12cb864 100644 --- a/astrbot/core/provider/sources/openai_embedding_source.py +++ b/astrbot/core/provider/sources/openai_embedding_source.py @@ -1,7 +1,10 @@ -import httpx from openai import AsyncOpenAI +# 使用 openai 库内部引用的 httpx 模块,避免打包后 isinstance 校验失败 +from openai._base_client import httpx as _openai_httpx + from astrbot import logger +from astrbot.core.utils.network_utils import create_proxy_client from ..entities import ProviderType from ..provider import EmbeddingProvider @@ -18,12 +21,12 @@ def __init__(self, provider_config: dict, provider_settings: dict) -> None: super().__init__(provider_config, provider_settings) self.provider_config = provider_config self.provider_settings = provider_settings - proxy = provider_config.get("proxy", "") provider_id = provider_config.get("id", "unknown_id") - http_client = None - if proxy: - logger.info(f"[OpenAI Embedding] {provider_id} Using proxy: {proxy}") - http_client = httpx.AsyncClient(proxy=proxy) + http_client = create_proxy_client( + "OpenAI Embedding", + provider_config.get("proxy", ""), + httpx_module=_openai_httpx, + ) api_base = ( provider_config.get("embedding_api_base", "https://api.openai.com/v1") .strip() @@ -65,9 +68,10 @@ async def get_embeddings(self, text: list[str]) -> list[list[float]]: def _embedding_kwargs(self) -> dict: """构建嵌入请求的可选参数""" kwargs = {} - if "embedding_dimensions" in self.provider_config: + dim_val = self.provider_config.get("embedding_dimensions") + if dim_val not in (None, ""): try: - kwargs["dimensions"] = int(self.provider_config["embedding_dimensions"]) + kwargs["dimensions"] = int(dim_val) except (ValueError, TypeError): logger.warning( f"embedding_dimensions in embedding configs is not a valid integer: '{self.provider_config['embedding_dimensions']}', ignored." @@ -76,12 +80,13 @@ def _embedding_kwargs(self) -> dict: def get_dim(self) -> int: """获取向量的维度""" - if "embedding_dimensions" in self.provider_config: + dim_val = self.provider_config.get("embedding_dimensions") + if dim_val not in (None, ""): try: - return int(self.provider_config["embedding_dimensions"]) + return int(dim_val) except (ValueError, TypeError): logger.warning( - f"embedding_dimensions in embedding configs is not a valid integer: '{self.provider_config['embedding_dimensions']}', ignored." + f"embedding_dimensions in embedding configs is not a valid integer: '{dim_val}', ignored." ) return 0 diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index bcd7e075c7..9a02236ecb 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -895,9 +895,12 @@ async def get_embedding_dim(self): if inspect.iscoroutinefunction(init_fn): await init_fn() - # 通过实际请求验证当前 embedding_dimensions 是否可用 - vec = await inst.get_embedding("echo") - dim = len(vec) + # 通过实际请求检测模型原生维度(不传 dimensions 参数,避免模型不支持时报错) + vec = await inst.client.embeddings.create( + input="echo", + model=inst.model, + ) + dim = len(vec.data[0].embedding) logger.info( f"检测到 {provider_config.get('id', 'unknown')} 的嵌入向量维度为 {dim}", diff --git a/astrbot/dashboard/routes/knowledge_base.py b/astrbot/dashboard/routes/knowledge_base.py index 1b6f7a435d..ebb43ae835 100644 --- a/astrbot/dashboard/routes/knowledge_base.py +++ b/astrbot/dashboard/routes/knowledge_base.py @@ -392,9 +392,12 @@ async def create_kb(self): ) try: vec = await prv.get_embedding("astrbot") - if len(vec) != prv.get_dim(): + actual_dim = len(vec) + configured_dim = prv.get_dim() + # configured_dim == 0 表示未配置维度,使用实际维度 + if configured_dim != 0 and actual_dim != configured_dim: raise ValueError( - f"嵌入向量维度不匹配,实际是 {len(vec)},然而配置是 {prv.get_dim()}", + f"嵌入向量维度不匹配,实际是 {actual_dim},然而配置是 {configured_dim}", ) except Exception as e: return Response().error(f"测试嵌入模型失败: {e!s}").__dict__ From 4d571d564d7ced1b43eb5449480a2b593b0f6a27 Mon Sep 17 00:00:00 2001 From: Taois Date: Wed, 22 Apr 2026 23:23:21 +0800 Subject: [PATCH 09/22] =?UTF-8?q?fix:=20=E5=B5=8C=E5=85=A5=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E8=B0=83=E7=94=A8=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/provider/sources/openai_embedding_source.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/astrbot/core/provider/sources/openai_embedding_source.py b/astrbot/core/provider/sources/openai_embedding_source.py index 3eb12cb864..7f96403098 100644 --- a/astrbot/core/provider/sources/openai_embedding_source.py +++ b/astrbot/core/provider/sources/openai_embedding_source.py @@ -69,9 +69,11 @@ def _embedding_kwargs(self) -> dict: """构建嵌入请求的可选参数""" kwargs = {} dim_val = self.provider_config.get("embedding_dimensions") - if dim_val not in (None, ""): + if dim_val not in (None, "", 0): try: - kwargs["dimensions"] = int(dim_val) + dim_int = int(dim_val) + if dim_int > 0: + kwargs["dimensions"] = dim_int except (ValueError, TypeError): logger.warning( f"embedding_dimensions in embedding configs is not a valid integer: '{self.provider_config['embedding_dimensions']}', ignored." From 72e76da8d31cb3c367a69703621d93c844154ff2 Mon Sep 17 00:00:00 2001 From: Taois Date: Thu, 23 Apr 2026 00:05:34 +0800 Subject: [PATCH 10/22] =?UTF-8?q?fix:=20=E9=9D=9E=E6=A0=87=E5=87=86MCP=20j?= =?UTF-8?q?son=20inputSchema=20=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/agent/mcp_client.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/astrbot/core/agent/mcp_client.py b/astrbot/core/agent/mcp_client.py index 7cf76e102a..e6cd071258 100644 --- a/astrbot/core/agent/mcp_client.py +++ b/astrbot/core/agent/mcp_client.py @@ -351,6 +351,18 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]: return False, f"{e!s}" +_NONSTANDARD_TYPE_MAP: dict[str, str] = { + "int": "integer", + "float": "number", + "double": "number", + "decimal": "number", + "bool": "boolean", + "str": "string", + "dict": "object", + "list": "array", +} + + def _normalize_mcp_input_schema(schema: dict[str, Any]) -> dict[str, Any]: """Normalize common non-standard MCP JSON Schema variants. @@ -359,6 +371,9 @@ def _normalize_mcp_input_schema(schema: dict[str, Any]) -> dict[str, Any]: parent object to declare `required` as an array of property names instead. We lift those booleans to the parent object so the schema remains usable without disabling validation entirely. + + Also normalizes non-standard type names (e.g. ``"int"`` → ``"integer"``, + ``"str"`` → ``"string"``) that some MCP servers emit. """ def _normalize(node: Any) -> Any: @@ -370,6 +385,16 @@ def _normalize(node: Any) -> Any: normalized = {key: _normalize(value) for key, value in node.items()} + # Normalize non-standard type names + type_val = normalized.get("type") + if isinstance(type_val, str) and type_val in _NONSTANDARD_TYPE_MAP: + normalized["type"] = _NONSTANDARD_TYPE_MAP[type_val] + elif isinstance(type_val, list): + normalized["type"] = [ + _NONSTANDARD_TYPE_MAP.get(t, t) if isinstance(t, str) else t + for t in type_val + ] + properties = normalized.get("properties") if isinstance(properties, dict): original_properties = ( From 1b3c979cc5333d7d3e400dec8d204834ab0a40c1 Mon Sep 17 00:00:00 2001 From: Taois Date: Thu, 23 Apr 2026 00:23:11 +0800 Subject: [PATCH 11/22] =?UTF-8?q?fix:=20=E4=BB=8EModelScope=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E5=90=8C=E6=AD=A5mcp=E6=97=B6=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=8E=A2=E6=B5=8B=E7=B1=BB=E5=9E=8B=EF=BC=8C=E9=98=B2=E6=AD=A2?= =?UTF-8?q?=E7=A1=AC=E7=BC=96=E7=A0=81sse=E5=AF=BC=E8=87=B4mcp=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E4=B8=8D=E5=8F=AF=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/provider/func_tool_manager.py | 26 +++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/astrbot/core/provider/func_tool_manager.py b/astrbot/core/provider/func_tool_manager.py index bf16a3ec96..d68dc7aa8e 100644 --- a/astrbot/core/provider/func_tool_manager.py +++ b/astrbot/core/provider/func_tool_manager.py @@ -916,6 +916,26 @@ def save_mcp_config(self, config: dict) -> bool: logger.error(f"保存 MCP 配置失败: {e}") return False + async def _detect_mcp_transport(self, url: str) -> str: + """通过探测 URL 的响应 Content-Type 自动判断 MCP 传输类型。 + + - SSE 端点返回 ``text/event-stream`` + - Streamable HTTP 端点返回 ``application/json`` 或其他非 SSE 类型 + """ + try: + async with aiohttp.ClientSession() as session: + async with session.get( + url, + headers={"Accept": "application/json, text/event-stream"}, + timeout=aiohttp.ClientTimeout(total=10), + ) as resp: + content_type = resp.headers.get("Content-Type", "") + if "text/event-stream" in content_type: + return "sse" + except Exception: + pass + return "streamable_http" + async def sync_modelscope_mcp_servers(self, access_token: str) -> None: """从 ModelScope 平台同步 MCP 服务器配置""" base_url = "https://www.modelscope.cn/openapi/v1" @@ -946,10 +966,14 @@ async def sync_modelscope_mcp_servers(self, access_token: str) -> None: server_url = url_info.get("url") if not server_url: continue + # 自动检测传输类型 + transport = await self._detect_mcp_transport( + server_url, + ) # 添加到配置中(同名会覆盖) local_mcp_config["mcpServers"][server_name] = { "url": server_url, - "transport": "sse", + "transport": transport, "active": True, "provider": "modelscope", } From fb23cb6d941248127c4b56309376ef65d5212228 Mon Sep 17 00:00:00 2001 From: Taois Date: Thu, 23 Apr 2026 02:50:26 +0800 Subject: [PATCH 12/22] =?UTF-8?q?fix:=20uvx=E5=AE=89=E8=A3=85=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E5=AD=98=E5=9C=A8=E7=9A=84=E8=AF=81=E4=B9=A6=E9=97=AE?= =?UTF-8?q?=E9=A2=98=E5=AF=BC=E8=87=B4MCP=E5=AE=89=E8=A3=85=E5=BC=82?= =?UTF-8?q?=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/agent/mcp_client.py | 50 ++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/astrbot/core/agent/mcp_client.py b/astrbot/core/agent/mcp_client.py index e6cd071258..9a765d4721 100644 --- a/astrbot/core/agent/mcp_client.py +++ b/astrbot/core/agent/mcp_client.py @@ -262,13 +262,57 @@ def validate_mcp_stdio_config(config: dict) -> None: raise ValueError("MCP stdio env keys and values must be strings.") +def _get_certifi_ca_bundle() -> str | None: + """Try to locate the certifi CA bundle for SSL_CERT_FILE.""" + try: + import certifi + + return certifi.where() + except ImportError: + pass + # Fallback: look for certifi in common locations + for candidate in ( + os.path.join( + os.path.dirname(sys.executable), + "Lib", + "site-packages", + "certifi", + "cacert.pem", + ), + os.path.join( + os.path.dirname(sys.executable), + "..", + "Lib", + "site-packages", + "certifi", + "cacert.pem", + ), + ): + if os.path.isfile(candidate): + return candidate + return None + + def _prepare_stdio_env(config: dict) -> dict: - """Preserve Windows executable resolution for stdio subprocesses.""" - if sys.platform != "win32": - return config + """Prepare environment variables for stdio subprocesses. + + On Windows: + - Merges system environment variables (case-insensitive handling). + - For uv/uvx commands, sets SSL_CERT_FILE from certifi to avoid + ``invalid peer certificate: UnknownIssuer`` errors caused by + uv's bundled TLS not trusting the system certificate store. + """ prepared = config.copy() env = dict(prepared.get("env") or {}) env = _merge_environment_variables(env) + + if sys.platform == "win32": + command_name = _normalize_stdio_command_name(config.get("command", "")) + if command_name in ("uv", "uvx") and "SSL_CERT_FILE" not in env: + ca_bundle = _get_certifi_ca_bundle() + if ca_bundle: + env["SSL_CERT_FILE"] = ca_bundle + prepared["env"] = env return prepared From 8757494cdc398249a088252f799c874a0501cf00 Mon Sep 17 00:00:00 2001 From: Taois Date: Thu, 23 Apr 2026 15:27:08 +0800 Subject: [PATCH 13/22] =?UTF-8?q?fix:=20=E9=A6=96=E8=BD=AE=E5=AF=B9?= =?UTF-8?q?=E8=AF=9DAI=E4=B8=8D=E8=AF=86=E5=88=AB=E9=99=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/astr_main_agent.py | 219 ++++++++++++++++---------------- 1 file changed, 109 insertions(+), 110 deletions(-) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index a9ddb2e7b9..46142814e1 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -1149,126 +1149,125 @@ async def build_main_agent( req.prompt = event.message_str[len(config.provider_wake_prefix) :] - # media files attachments - for comp in event.message_obj.message: - if isinstance(comp, Image): - path = await comp.convert_to_file_path() - image_path = await _compress_image_for_provider( - path, - config.provider_settings, - ) - if _is_generated_compressed_image_path(path, image_path): - event.track_temporary_local_file(image_path) - req.image_urls.append(image_path) - req.extra_user_content_parts.append( - TextPart(text=f"[Image Attachment: path {image_path}]") + conversation = await _get_session_conv(event, plugin_context) + req.conversation = conversation + req.contexts = json.loads(conversation.history) + event.set_extra("provider_request", req) + + # media files attachments (always process, regardless of req source) + for comp in event.message_obj.message: + if isinstance(comp, Image): + path = await comp.convert_to_file_path() + image_path = await _compress_image_for_provider( + path, + config.provider_settings, + ) + if _is_generated_compressed_image_path(path, image_path): + event.track_temporary_local_file(image_path) + req.image_urls.append(image_path) + req.extra_user_content_parts.append( + TextPart(text=f"[Image Attachment: path {image_path}]") + ) + elif isinstance(comp, Record): + audio_path = await comp.convert_to_file_path() + req.audio_urls.append(audio_path) + _append_audio_attachment(req, audio_path) + elif isinstance(comp, File): + file_path = await comp.get_file() + file_name = comp.name or os.path.basename(file_path) + req.extra_user_content_parts.append( + TextPart( + text=f"[File Attachment: name {file_name}, path {file_path}]" ) - elif isinstance(comp, Record): - audio_path = await comp.convert_to_file_path() - req.audio_urls.append(audio_path) - _append_audio_attachment(req, audio_path) - elif isinstance(comp, File): - file_path = await comp.get_file() - file_name = comp.name or os.path.basename(file_path) - req.extra_user_content_parts.append( - TextPart( - text=f"[File Attachment: name {file_name}, path {file_path}]" + ) + elif isinstance(comp, Video): + await _append_video_attachment(req, comp) + # quoted message attachments + reply_comps = [ + comp for comp in event.message_obj.message if isinstance(comp, Reply) + ] + quoted_message_settings = _get_quoted_message_parser_settings( + config.provider_settings + ) + fallback_quoted_image_count = 0 + for comp in reply_comps: + has_embedded_image = False + if comp.chain: + for reply_comp in comp.chain: + if isinstance(reply_comp, Image): + has_embedded_image = True + path = await reply_comp.convert_to_file_path() + image_path = await _compress_image_for_provider( + path, + config.provider_settings, ) - ) - elif isinstance(comp, Video): - await _append_video_attachment(req, comp) - # quoted message attachments - reply_comps = [ - comp for comp in event.message_obj.message if isinstance(comp, Reply) - ] - quoted_message_settings = _get_quoted_message_parser_settings( - config.provider_settings - ) - fallback_quoted_image_count = 0 - for comp in reply_comps: - has_embedded_image = False - if comp.chain: - for reply_comp in comp.chain: - if isinstance(reply_comp, Image): - has_embedded_image = True - path = await reply_comp.convert_to_file_path() - image_path = await _compress_image_for_provider( - path, - config.provider_settings, - ) - if _is_generated_compressed_image_path(path, image_path): - event.track_temporary_local_file(image_path) - req.image_urls.append(image_path) - _append_quoted_image_attachment(req, image_path) - elif isinstance(reply_comp, Record): - audio_path = await reply_comp.convert_to_file_path() - req.audio_urls.append(audio_path) - _append_quoted_audio_attachment(req, audio_path) - elif isinstance(reply_comp, File): - file_path = await reply_comp.get_file() - file_name = reply_comp.name or os.path.basename(file_path) - req.extra_user_content_parts.append( - TextPart( - text=( - f"[File Attachment in quoted message: " - f"name {file_name}, path {file_path}]" - ) + if _is_generated_compressed_image_path(path, image_path): + event.track_temporary_local_file(image_path) + req.image_urls.append(image_path) + _append_quoted_image_attachment(req, image_path) + elif isinstance(reply_comp, Record): + audio_path = await reply_comp.convert_to_file_path() + req.audio_urls.append(audio_path) + _append_quoted_audio_attachment(req, audio_path) + elif isinstance(reply_comp, File): + file_path = await reply_comp.get_file() + file_name = reply_comp.name or os.path.basename(file_path) + req.extra_user_content_parts.append( + TextPart( + text=( + f"[File Attachment in quoted message: " + f"name {file_name}, path {file_path}]" ) ) - elif isinstance(reply_comp, Video): - await _append_video_attachment(req, reply_comp, quoted=True) - - # Fallback quoted image extraction for reply-id-only payloads, or when - # embedded reply chain only contains placeholders (e.g. [Forward Message], [Image]). - if not has_embedded_image: - try: - fallback_images = normalize_and_dedupe_strings( - await extract_quoted_message_images( - event, - comp, - settings=quoted_message_settings, - ) ) - remaining_limit = max( - config.max_quoted_fallback_images - - fallback_quoted_image_count, - 0, + elif isinstance(reply_comp, Video): + await _append_video_attachment(req, reply_comp, quoted=True) + + # Fallback quoted image extraction for reply-id-only payloads, or when + # embedded reply chain only contains placeholders (e.g. [Forward Message], [Image]). + if not has_embedded_image: + try: + fallback_images = normalize_and_dedupe_strings( + await extract_quoted_message_images( + event, + comp, + settings=quoted_message_settings, ) - if remaining_limit <= 0 and fallback_images: - logger.warning( - "Skip quoted fallback images due to limit=%d for umo=%s", - config.max_quoted_fallback_images, - event.unified_msg_origin, - ) - continue - if len(fallback_images) > remaining_limit: - logger.warning( - "Truncate quoted fallback images for umo=%s, reply_id=%s from %d to %d", - event.unified_msg_origin, - getattr(comp, "id", None), - len(fallback_images), - remaining_limit, - ) - fallback_images = fallback_images[:remaining_limit] - for image_ref in fallback_images: - if image_ref in req.image_urls: - continue - req.image_urls.append(image_ref) - fallback_quoted_image_count += 1 - _append_quoted_image_attachment(req, image_ref) - except Exception as exc: # noqa: BLE001 + ) + remaining_limit = max( + config.max_quoted_fallback_images - fallback_quoted_image_count, + 0, + ) + if remaining_limit <= 0 and fallback_images: + logger.warning( + "Skip quoted fallback images due to limit=%d for umo=%s", + config.max_quoted_fallback_images, + event.unified_msg_origin, + ) + continue + if len(fallback_images) > remaining_limit: logger.warning( - "Failed to resolve fallback quoted images for umo=%s, reply_id=%s: %s", + "Truncate quoted fallback images for umo=%s, reply_id=%s from %d to %d", event.unified_msg_origin, getattr(comp, "id", None), - exc, - exc_info=True, + len(fallback_images), + remaining_limit, ) - - conversation = await _get_session_conv(event, plugin_context) - req.conversation = conversation - req.contexts = json.loads(conversation.history) - event.set_extra("provider_request", req) + fallback_images = fallback_images[:remaining_limit] + for image_ref in fallback_images: + if image_ref in req.image_urls: + continue + req.image_urls.append(image_ref) + fallback_quoted_image_count += 1 + _append_quoted_image_attachment(req, image_ref) + except Exception as exc: # noqa: BLE001 + logger.warning( + "Failed to resolve fallback quoted images for umo=%s, reply_id=%s: %s", + event.unified_msg_origin, + getattr(comp, "id", None), + exc, + exc_info=True, + ) if isinstance(req.contexts, str): req.contexts = json.loads(req.contexts) From 45025eb1c1d904a1576de88e7857ff696d593ac6 Mon Sep 17 00:00:00 2001 From: Taois Date: Thu, 23 Apr 2026 18:01:33 +0800 Subject: [PATCH 14/22] =?UTF-8?q?fix:=20=E5=AE=9A=E6=97=B6=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/cron/manager.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/astrbot/core/cron/manager.py b/astrbot/core/cron/manager.py index aa11bb601f..c604960bfe 100644 --- a/astrbot/core/cron/manager.py +++ b/astrbot/core/cron/manager.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any from zoneinfo import ZoneInfo +from apscheduler.executors.asyncio import AsyncIOExecutor from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.date import DateTrigger @@ -34,6 +35,11 @@ class CronJobManager: def __init__(self, db: BaseDatabase) -> None: self.db = db self.scheduler = AsyncIOScheduler() + # Bypass add_executor isinstance check — directly set the executor + # to avoid TypeError in certain packaged environments where + # _create_default_executor() fails the type check. + self._default_executor = AsyncIOExecutor() + self.scheduler._executors["default"] = self._default_executor self._basic_handlers: dict[str, Callable[..., Any]] = {} self._lock = asyncio.Lock() self._started = False @@ -151,6 +157,10 @@ def _remove_scheduled(self, job_id: str) -> None: def _schedule_job(self, job: CronJob) -> None: if not self._started: + # Ensure default executor exists before starting + if "default" not in self.scheduler._executors: + self._default_executor = AsyncIOExecutor() + self.scheduler._executors["default"] = self._default_executor self.scheduler.start() self._started = True try: From a96b5c6a2fc6b9c757656b3ce950da381bb84a54 Mon Sep 17 00:00:00 2001 From: Taois Date: Fri, 24 Apr 2026 00:17:32 +0800 Subject: [PATCH 15/22] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8Dqq=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E5=90=8E=E9=95=BF=E6=9C=9F=E8=BF=90=E8=A1=8C=E4=B8=80?= =?UTF-8?q?=E6=AE=B5=E6=97=B6=E9=97=B4=E5=90=8E=E5=8F=AF=E8=83=BD=E4=BC=9A?= =?UTF-8?q?=E5=87=BA=E7=8E=B0=E7=94=A8=E6=88=B7=E5=8F=91=E4=B8=80=E6=9D=A1?= =?UTF-8?q?=E6=B6=88=E6=81=AF=EF=BC=8C=E5=90=8E=E5=8F=B0=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E5=A4=84=E7=90=86=E5=9B=9E=E5=A4=8D=E7=BB=99=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=A4=9A=E6=9D=A1=E6=B6=88=E6=81=AF=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../qqofficial/qqofficial_platform_adapter.py | 54 ++++++++++++++++++- .../platform/sources/wecom/wecom_adapter.py | 19 +++++++ .../sources/wecom_ai_bot/wecomai_adapter.py | 22 +++++++- .../sources/weixin_oc/weixin_oc_adapter.py | 19 +++++++ 4 files changed, 111 insertions(+), 3 deletions(-) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index 3037ab2d8d..4ed080028e 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -42,12 +42,33 @@ class ManagedBotWebSocket(BotWebSocket): def __init__(self, session, connection: Any, client: botClient): super().__init__(session, connection) self._client = client + # 防止 on_error + on_closed 双重入队导致连接指数增长 + self._reenqueued = False async def on_closed(self, close_status_code, close_msg): if self._client.is_shutting_down: logger.debug("[QQOfficial] Ignore websocket reconnect during shutdown.") return - await super().on_closed(close_status_code, close_msg) + if self._reenqueued: + logger.debug("[QQOfficial] Session already re-enqueued, skip on_closed.") + return + try: + self._reenqueued = True + await super().on_closed(close_status_code, close_msg) + except Exception: + self._reenqueued = False + raise + + async def on_error(self, exception: BaseException) -> None: + if self._reenqueued: + logger.debug("[QQOfficial] Session already re-enqueued, skip on_error.") + return + try: + self._reenqueued = True + await super().on_error(exception) + except Exception: + self._reenqueued = False + raise async def close(self) -> None: self._can_reconnect = False @@ -57,10 +78,14 @@ async def close(self) -> None: # QQ 机器人官方框架 class botClient(Client): + # 消息去重:message_id -> 收到时间戳 + _DEDUP_TTL = 120 # 去重窗口,秒 + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self._shutting_down = False self._active_websockets: set[ManagedBotWebSocket] = set() + self._seen_message_ids: dict[str, float] = {} def set_platform(self, platform: QQOfficialPlatformAdapter) -> None: self.platform = platform @@ -116,6 +141,22 @@ async def on_c2c_message_create(self, message: botpy.message.C2CMessage) -> None self._commit(abm) def _commit(self, abm: AstrBotMessage) -> None: + msg_id = abm.message_id + if msg_id: + now = time.monotonic() + # 清理过期条目 + expired = [ + k + for k, ts in self._seen_message_ids.items() + if now - ts > self._DEDUP_TTL + ] + for k in expired: + del self._seen_message_ids[k] + if msg_id in self._seen_message_ids: + logger.debug(f"[QQOfficial] Duplicate message {msg_id}, skipping.") + return + self._seen_message_ids[msg_id] = now + self.platform.remember_session_message_id(abm.session_id, abm.message_id) self.platform.commit_event( QQOfficialMessageEvent( @@ -128,7 +169,16 @@ def _commit(self, abm: AstrBotMessage) -> None: ) async def bot_connect(self, session) -> None: - logger.info("[QQOfficial] Websocket session starting.") + active_count = len(self._active_websockets) + if active_count > 0: + logger.warning( + "[QQOfficial] bot_connect called with %d existing active websocket(s). " + "This may indicate a reconnection storm.", + active_count, + ) + logger.info( + "[QQOfficial] Websocket session starting (active: %d).", active_count + 1 + ) websocket = ManagedBotWebSocket(session, self._connection, self) self._active_websockets.add(websocket) diff --git a/astrbot/core/platform/sources/wecom/wecom_adapter.py b/astrbot/core/platform/sources/wecom/wecom_adapter.py index a30afcd8f2..35975d7142 100644 --- a/astrbot/core/platform/sources/wecom/wecom_adapter.py +++ b/astrbot/core/platform/sources/wecom/wecom_adapter.py @@ -1,6 +1,7 @@ import asyncio import os import sys +import time import uuid from collections.abc import Awaitable, Callable from typing import Any, cast @@ -183,6 +184,10 @@ def __init__( self.client.__setattr__("API_BASE_URL", self.api_base_url) + # 消息去重 + self._seen_msg_ids: dict[str, float] = {} + self._DEDUP_TTL = 120 # 去重窗口,秒 + async def callback(msg: BaseMessage) -> None: if msg.type == "unknown" and msg._data["Event"] == "kf_msg_or_event": @@ -432,6 +437,20 @@ async def convert_wechat_kf_message(self, msg: dict) -> AstrBotMessage | None: await self.handle_msg(abm) async def handle_msg(self, message: AstrBotMessage) -> None: + # 消息去重检查 + msg_id = message.message_id + if msg_id: + now = time.monotonic() + expired = [ + k for k, ts in self._seen_msg_ids.items() if now - ts > self._DEDUP_TTL + ] + for k in expired: + del self._seen_msg_ids[k] + if msg_id in self._seen_msg_ids: + logger.debug(f"[WeCom] Duplicate message {msg_id}, skipping.") + return + self._seen_msg_ids[msg_id] = now + message_event = WecomPlatformEvent( message_str=message.message_str, message_obj=message, diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py index 79fe6f8ed2..0b9c4dee48 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py @@ -152,6 +152,10 @@ def __init__( # 事件循环和关闭信号 self.shutdown_event = asyncio.Event() + # 消息去重:msgid -> monotonic 时间戳 + self._seen_msg_ids: dict[str, float] = {} + self._DEDUP_TTL = 120 # 去重窗口,秒 + # 队列管理器 self.queue_mgr = WecomAIQueueMgr() @@ -528,7 +532,9 @@ async def convert_message(self, payload: dict) -> AstrBotMessage: abm = AstrBotMessage() abm.self_id = self.bot_name abm.message_str = content or "[未知消息]" - abm.message_id = str(uuid.uuid4()) + # 使用企业微信平台提供的 msgid 而非随机 UUID,以支持去重 + platform_msgid = message_data.get("msgid") + abm.message_id = str(platform_msgid) if platform_msgid else str(uuid.uuid4()) abm.timestamp = int(time.time()) abm.raw_message = payload @@ -647,6 +653,20 @@ def meta(self) -> PlatformMetadata: async def handle_msg(self, message: AstrBotMessage) -> None: """处理消息,创建消息事件并提交到事件队列""" + # 消息去重检查 + msg_id = message.message_id + if msg_id: + now = time.monotonic() + expired = [ + k for k, ts in self._seen_msg_ids.items() if now - ts > self._DEDUP_TTL + ] + for k in expired: + del self._seen_msg_ids[k] + if msg_id in self._seen_msg_ids: + logger.debug(f"[WecomAI] Duplicate message {msg_id}, skipping.") + return + self._seen_msg_ids[msg_id] = now + try: message_event = WecomAIBotMessageEvent( message_str=message.message_str, diff --git a/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py b/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py index e332474d2a..26ba67190d 100644 --- a/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py +++ b/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py @@ -169,6 +169,9 @@ def __init__( 1, ) self._recent_messages: dict[str, WeixinOCRecentSessionCache] = {} + # 消息去重 + self._seen_msg_ids: dict[str, float] = {} + self._DEDUP_TTL = 120 # 去重窗口,秒 self._typing_keepalive_interval_s = max( 1, int(platform_config.get("weixin_oc_typing_keepalive_interval", 5)), @@ -1531,6 +1534,22 @@ async def _handle_inbound_message(self, msg: dict[str, Any]) -> None: message_str=text, ) + # 消息去重 + now = time.monotonic() + expired = [ + k for k, t in self._seen_msg_ids.items() if now - t > self._DEDUP_TTL + ] + for k in expired: + del self._seen_msg_ids[k] + if message_id in self._seen_msg_ids: + logger.debug( + "weixin_oc(%s): duplicate message %s, skipping.", + self.meta().id, + message_id, + ) + return + self._seen_msg_ids[message_id] = now + self.commit_event( WeixinOCMessageEvent( message_str=text, From f14095f3563dddd6cd79cbfc0b94097eea3da0b8 Mon Sep 17 00:00:00 2001 From: Taois Date: Fri, 24 Apr 2026 14:51:39 +0800 Subject: [PATCH 16/22] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=9B=BD?= =?UTF-8?q?=E5=86=85docker=E9=83=A8=E7=BD=B2=E4=BD=93=E9=AA=8C=EF=BC=8C?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=9B=B8=E5=85=B3=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile.cn | 37 +++++++++++++++++++++++++++++++++++++ README_zh.md | 11 +++++++++++ docker-compose.yml | 17 +++++++++++++++++ 3 files changed, 65 insertions(+) create mode 100644 Dockerfile.cn create mode 100644 docker-compose.yml diff --git a/Dockerfile.cn b/Dockerfile.cn new file mode 100644 index 0000000000..f869ffe1f3 --- /dev/null +++ b/Dockerfile.cn @@ -0,0 +1,37 @@ +FROM python:3.12-slim +WORKDIR /AstrBot + +# 国内镜像源加速 +RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources + +COPY . /AstrBot/ + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + build-essential \ + python3-dev \ + libffi-dev \ + libssl-dev \ + ca-certificates \ + bash \ + ffmpeg \ + libavcodec-extra \ + curl \ + gnupg \ + git \ + ripgrep \ + && curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +RUN python -m pip install uv -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \ + && echo "3.12" > .python-version \ + && uv lock \ + && uv export --format requirements.txt --output-file requirements.txt --frozen \ + && uv pip install -r requirements.txt --no-cache-dir --system --index-url https://mirrors.aliyun.com/pypi/simple/ \ + && uv pip install socksio uv pilk --no-cache-dir --system --index-url https://mirrors.aliyun.com/pypi/simple/ + +EXPOSE 6185 + +CMD ["python", "main.py"] diff --git a/README_zh.md b/README_zh.md index 7ff07e35ac..ef37038547 100644 --- a/README_zh.md +++ b/README_zh.md @@ -102,6 +102,17 @@ uv tool upgrade astrbot --python 3.12 请参考官方文档 [使用 Docker 部署 AstrBot](https://docs.astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot)。 +#### 国内用户 Docker 加速构建 + +项目提供了国内镜像源加速的 `Dockerfile.cn` 和 `docker-compose.yml`,使用阿里云镜像源加速 apt 和 pip 依赖下载: + +```bash +# 克隆项目后,使用国内加速配置构建并启动 +docker compose -f docker-compose.yml up -d --build +``` + +构建完成后,通过 `http://<服务器IP>:6185` 访问 WebUI 进行初始化配置。数据持久化目录为 `./data`。 + ### 在 雨云 上部署 对于希望一键部署 AstrBot 且不想自行管理服务器的用户,我们推荐使用雨云的一键云部署服务 ☁️: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..b375baf023 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + astrbot: + build: + context: . + dockerfile: Dockerfile.cn + container_name: astrbot + restart: always + security_opt: + - no-new-privileges:true + ports: + - "6185:6185" # AstrBot WebUI + - "6199:6199" # Optional. OneBot v11 Napcat Websocket Port + environment: + - TZ=Asia/Shanghai + volumes: + - ./data:/AstrBot/data + - /etc/localtime:/etc/localtime:ro From 43af9d4d351cb3eea926f73d98a5031ce8fe4eed Mon Sep 17 00:00:00 2001 From: Taois Date: Sat, 25 Apr 2026 22:39:58 +0800 Subject: [PATCH 17/22] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8Ddeepseek?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/provider/sources/openai_source.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index dc07c8d5b1..a4ca11f4fb 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -632,6 +632,7 @@ async def _query_stream( ) llm_response = LLMResponse("assistant", is_chunk=True) + reasoning_content_chunks: list[str] = [] state = ChatCompletionStreamState() @@ -660,6 +661,7 @@ async def _query_stream( llm_response.reasoning_content = "" llm_response.completion_text = "" if reasoning: + reasoning_content_chunks.append(reasoning) llm_response.reasoning_content = reasoning _y = True if delta and delta.content: @@ -681,6 +683,8 @@ async def _query_stream( final_completion = state.get_final_completion() llm_response = await self._parse_openai_completion(final_completion, tools) + if not llm_response.reasoning_content and reasoning_content_chunks: + llm_response.reasoning_content = "".join(reasoning_content_chunks) yield llm_response @@ -845,7 +849,8 @@ async def _parse_openai_completion( # parse the reasoning content if any # the priority is higher than the tag extraction - llm_response.reasoning_content = self._extract_reasoning_content(completion) + if reasoning_content := self._extract_reasoning_content(completion): + llm_response.reasoning_content = reasoning_content # parse tool calls if any if choice.message.tool_calls and tools is not None: From 50eb4240d1702e6486998b3903c97da9f06d4cf4 Mon Sep 17 00:00:00 2001 From: Taois Date: Sat, 25 Apr 2026 23:27:46 +0800 Subject: [PATCH 18/22] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8Ddeepseek?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/provider/sources/openai_source.py | 88 ++++++++++++++----- tests/test_openai_source.py | 33 +++++++ 2 files changed, 99 insertions(+), 22 deletions(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index a4ca11f4fb..7c24fc69a3 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -524,6 +524,31 @@ async def get_models(self): except NotFoundError as e: raise Exception(f"获取模型列表失败:{e}") + def _clean_chat_payload_messages(self, payloads: dict) -> None: + if "messages" not in payloads or not isinstance(payloads["messages"], list): + return + + cleaned_messages = [] + for idx, msg in enumerate(payloads["messages"]): + if not isinstance(msg, dict): + cleaned_messages.append(msg) + continue + + if msg.get("role") == "assistant": + content = msg.get("content") + tool_calls = msg.get("tool_calls") + + if not tool_calls and (content == "" or content is None): + logger.warning(f"过滤第 {idx} 条空 assistant 消息 (无工具调用)") + continue + + if content == "" and tool_calls: + msg["content"] = None + + cleaned_messages.append(msg) + + payloads["messages"] = cleaned_messages + async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: if tools: model = payloads.get("model", "").lower() @@ -553,26 +578,7 @@ async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: model = payloads.get("model", "").lower() - if "messages" in payloads and isinstance(payloads["messages"], list): - cleaned_messages = [] - for idx, msg in enumerate(payloads["messages"]): - # 过滤空的 assistant 消息,防止严格 API(如 Moonshot)返回 400 错误 - if msg.get("role") == "assistant": - content = msg.get("content") - tool_calls = msg.get("tool_calls") - - # 情况1: 空/null content 且无 tool_calls -> 过滤掉 - if not tool_calls and (content == "" or content is None): - logger.warning(f"过滤第 {idx} 条空 assistant 消息 (无工具调用)") - continue - - # 情况2: 空 content 但有 tool_calls -> 设为 None (符合 OpenAI 规范) - if content == "" and tool_calls: - msg["content"] = None - - cleaned_messages.append(msg) - - payloads["messages"] = cleaned_messages + self._clean_chat_payload_messages(payloads) completion = await self.client.chat.completions.create( **payloads, @@ -623,6 +629,7 @@ async def _query_stream( for key in to_del: del payloads[key] self._apply_provider_specific_extra_body_overrides(extra_body) + self._clean_chat_payload_messages(payloads) stream = await self.client.chat.completions.create( **payloads, @@ -688,6 +695,15 @@ async def _query_stream( yield llm_response + def _get_openai_extra_attr(self, obj: Any, key: str) -> Any: + value = getattr(obj, key, None) + if value is not None: + return value + model_extra = getattr(obj, "model_extra", None) + if isinstance(model_extra, dict): + return model_extra.get(key) + return None + def _extract_reasoning_content( self, completion: ChatCompletion | ChatCompletionChunk, @@ -698,12 +714,14 @@ def _extract_reasoning_content( return reasoning_text if isinstance(completion, ChatCompletion): choice = completion.choices[0] - reasoning_attr = getattr(choice.message, self.reasoning_key, None) + reasoning_attr = self._get_openai_extra_attr( + choice.message, self.reasoning_key + ) if reasoning_attr: reasoning_text = str(reasoning_attr) elif isinstance(completion, ChatCompletionChunk): delta = completion.choices[0].delta - reasoning_attr = getattr(delta, self.reasoning_key, None) + reasoning_attr = self._get_openai_extra_attr(delta, self.reasoning_key) if reasoning_attr: reasoning_text = str(reasoning_attr) return reasoning_text @@ -969,6 +987,30 @@ async def _prepare_chat_payload( return payloads, context_query + def _is_deepseek_model(self, model: str | None = None) -> bool: + model = (model or self.get_model() or "").lower() + api_base = str(self.provider_config.get("api_base", "")).lower() + return "deepseek" in model or "deepseek" in api_base + + def _ensure_deepseek_tool_reasoning_content(self, payloads: dict) -> None: + if not self._is_deepseek_model(payloads.get("model")): + return + + messages = payloads.get("messages") + if not isinstance(messages, list): + return + + for idx, message in enumerate(messages): + if not isinstance(message, dict): + continue + if message.get("role") != "assistant" or not message.get("tool_calls"): + continue + if self.reasoning_key in message: + continue + next_message = messages[idx + 1] if idx + 1 < len(messages) else None + if isinstance(next_message, dict) and next_message.get("role") == "tool": + message[self.reasoning_key] = "" + def _finally_convert_payload(self, payloads: dict) -> None: """Finally convert the payload. Such as think part conversion, tool inject.""" model = payloads.get("model", "").lower() @@ -1003,6 +1045,8 @@ def _finally_convert_payload(self, payloads: dict) -> None: {"result": content}, ensure_ascii=False ) + self._ensure_deepseek_tool_reasoning_content(payloads) + async def _handle_api_error( self, e: Exception, diff --git a/tests/test_openai_source.py b/tests/test_openai_source.py index 83e18137c4..c6fbdad271 100644 --- a/tests/test_openai_source.py +++ b/tests/test_openai_source.py @@ -1618,3 +1618,36 @@ async def fake_create(**kwargs): assert messages[2] == {"role": "user", "content": "hello"} finally: await provider.terminate() + + +@pytest.mark.asyncio +async def test_query_adds_deepseek_reasoning_content_for_tool_continuation(): + provider = _make_provider({"model": "deepseek/deepseek-v4-flash"}) + try: + payloads = { + "model": "deepseek/deepseek-v4-flash", + "messages": [ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call-123", + "type": "function", + "function": {"name": "test", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "call-123", "content": "result"}, + ], + } + + provider._finally_convert_payload(payloads) + provider._clean_chat_payload_messages(payloads) + + messages = payloads["messages"] + assert messages[1]["content"] is None + assert messages[1]["reasoning_content"] == "" + finally: + await provider.terminate() From e703bc72ff4644f9c5c902752c50bb1a5e812ce5 Mon Sep 17 00:00:00 2001 From: Taois Date: Wed, 29 Apr 2026 11:07:09 +0800 Subject: [PATCH 19/22] =?UTF-8?q?fix=EF=BC=9A=20=E5=B5=8C=E5=85=A5?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E8=8B=B1=E4=BC=9F=E8=BE=BE=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/config/default.py | 12 ++++++++++++ .../core/db/vec_db/faiss_impl/embedding_storage.py | 1 + .../core/provider/sources/openai_embedding_source.py | 11 ++++++++++- astrbot/dashboard/routes/config.py | 3 ++- .../i18n/locales/en-US/features/config-metadata.json | 8 ++++++++ .../i18n/locales/ru-RU/features/config-metadata.json | 8 ++++++++ .../i18n/locales/zh-CN/features/config-metadata.json | 8 ++++++++ 7 files changed, 49 insertions(+), 2 deletions(-) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 64243a82f5..af058e05b1 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -1800,6 +1800,8 @@ class ChatProviderTemplate(TypedDict): "embedding_api_base": "", "embedding_model": "", "embedding_dimensions": 1024, + "embedding_send_dimensions": True, + "embedding_input_type": "", "timeout": 20, "proxy": "", }, @@ -2149,6 +2151,16 @@ class ChatProviderTemplate(TypedDict): "type": "string", "hint": "嵌入模型名称。", }, + "embedding_send_dimensions": { + "description": "发送嵌入维度参数", + "type": "bool", + "hint": "是否在请求中发送 dimensions 参数。部分兼容 OpenAI 的服务(如 NVIDIA)不支持该参数,需要关闭,但 embedding_dimensions 仍会作为本地向量索引维度使用。", + }, + "embedding_input_type": { + "description": "嵌入输入类型", + "type": "string", + "hint": "部分嵌入服务需要 input_type 参数。例如 NVIDIA 的检索嵌入模型可填写 query。留空则不发送。", + }, "embedding_api_key": { "description": "API Key", "type": "string", diff --git a/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py b/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py index dc6977cf8a..1934f7a7e4 100644 --- a/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py +++ b/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py @@ -16,6 +16,7 @@ def __init__(self, dimension: int, path: str | None = None) -> None: self.index = None if path and os.path.exists(path): self.index = faiss.read_index(path) + self.dimension = self.index.d else: base_index = faiss.IndexFlatL2(dimension) self.index = faiss.IndexIDMap(base_index) diff --git a/astrbot/core/provider/sources/openai_embedding_source.py b/astrbot/core/provider/sources/openai_embedding_source.py index 7f96403098..9388cc5bda 100644 --- a/astrbot/core/provider/sources/openai_embedding_source.py +++ b/astrbot/core/provider/sources/openai_embedding_source.py @@ -68,8 +68,10 @@ async def get_embeddings(self, text: list[str]) -> list[list[float]]: def _embedding_kwargs(self) -> dict: """构建嵌入请求的可选参数""" kwargs = {} + extra_body = {} dim_val = self.provider_config.get("embedding_dimensions") - if dim_val not in (None, "", 0): + send_dimensions = self.provider_config.get("embedding_send_dimensions", True) + if dim_val not in (None, "", 0) and send_dimensions: try: dim_int = int(dim_val) if dim_int > 0: @@ -78,6 +80,13 @@ def _embedding_kwargs(self) -> dict: logger.warning( f"embedding_dimensions in embedding configs is not a valid integer: '{self.provider_config['embedding_dimensions']}', ignored." ) + + input_type = self.provider_config.get("embedding_input_type") + if input_type: + extra_body["input_type"] = input_type + + if extra_body: + kwargs["extra_body"] = extra_body return kwargs def get_dim(self) -> int: diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index 9a02236ecb..0203e0ef66 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -895,10 +895,11 @@ async def get_embedding_dim(self): if inspect.iscoroutinefunction(init_fn): await init_fn() - # 通过实际请求检测模型原生维度(不传 dimensions 参数,避免模型不支持时报错) + # 通过实际请求检测模型原生维度 vec = await inst.client.embeddings.create( input="echo", model=inst.model, + **inst._embedding_kwargs(), ) dim = len(vec.data[0].embedding) diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index c0796b7f07..02e76d6d65 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -1305,6 +1305,14 @@ "description": "Embedding model", "hint": "Embedding model name." }, + "embedding_send_dimensions": { + "description": "Send embedding dimensions", + "hint": "Whether to send the dimensions parameter in embedding requests. Some OpenAI-compatible services, such as NVIDIA, do not support it; disable this while keeping embedding_dimensions as the local vector index dimension." + }, + "embedding_input_type": { + "description": "Embedding input type", + "hint": "Some embedding services require an input_type parameter. For NVIDIA retrieval embedding models, use query. Leave empty to omit it." + }, "embedding_api_key": { "description": "API Key" }, diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json index 2f62db65ab..955c130dd9 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json @@ -1302,6 +1302,14 @@ "description": "Модель эмбеддингов", "hint": "Имя модели эмбеддингов." }, + "embedding_send_dimensions": { + "description": "Отправлять параметр dimensions", + "hint": "Отправлять ли параметр dimensions в запросах embeddings. Некоторые OpenAI-совместимые сервисы, например NVIDIA, его не поддерживают; отключите этот параметр, но оставьте embedding_dimensions как локальную размерность векторного индекса." + }, + "embedding_input_type": { + "description": "Тип входа Embedding", + "hint": "Некоторым сервисам embeddings нужен параметр input_type. Для retrieval embedding моделей NVIDIA используйте query. Оставьте пустым, чтобы не отправлять." + }, "embedding_api_key": { "description": "API Base URL" }, diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 407e9f9f45..67bdd93112 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -1307,6 +1307,14 @@ "description": "嵌入模型", "hint": "嵌入模型名称。" }, + "embedding_send_dimensions": { + "description": "发送嵌入维度参数", + "hint": "是否在请求中发送 dimensions 参数。部分兼容 OpenAI 的服务(如 NVIDIA)不支持该参数,需要关闭,但嵌入维度仍会作为本地向量索引维度使用。" + }, + "embedding_input_type": { + "description": "嵌入输入类型", + "hint": "部分嵌入服务需要 input_type 参数。例如 NVIDIA 的检索嵌入模型可填写 query。留空则不发送。" + }, "embedding_api_key": { "description": "API Key" }, From 029982ebb6ae4560686e629b1adb6028291bf7b9 Mon Sep 17 00:00:00 2001 From: Taois Date: Sat, 2 May 2026 16:34:28 +0800 Subject: [PATCH 20/22] =?UTF-8?q?fix=EF=BC=9A=20=E4=BF=AE=E5=A4=8Ddify?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E4=B8=8D=E6=94=AF=E6=8C=81=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/agent/runners/dify/dify_agent_runner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrbot/core/agent/runners/dify/dify_agent_runner.py b/astrbot/core/agent/runners/dify/dify_agent_runner.py index 93f8d3570d..39739dea60 100644 --- a/astrbot/core/agent/runners/dify/dify_agent_runner.py +++ b/astrbot/core/agent/runners/dify/dify_agent_runner.py @@ -298,7 +298,7 @@ async def parse_file(item: dict): case "video": return Comp.Video(file=item["url"]) case _: - return Comp.File(name=item["filename"], file=item["url"]) + return Comp.File(name=item["filename"], url=item["url"]) output = chunk["data"]["outputs"][self.workflow_output_key] chains = [] From 731f98047f369d97a390395cc70e25237098fba0 Mon Sep 17 00:00:00 2001 From: Taois Date: Sat, 2 May 2026 20:38:23 +0800 Subject: [PATCH 21/22] =?UTF-8?q?fix=EF=BC=9A=20=E4=BF=AE=E5=A4=8Ddify?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E4=B8=8D=E6=94=AF=E6=8C=81=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/pipeline/respond/stage.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/astrbot/core/pipeline/respond/stage.py b/astrbot/core/pipeline/respond/stage.py index 604f1ded0e..1fb5ac3278 100644 --- a/astrbot/core/pipeline/respond/stage.py +++ b/astrbot/core/pipeline/respond/stage.py @@ -178,6 +178,18 @@ async def process( return if result.result_content_type == ResultContentType.STREAMING_FINISH: event.set_extra("_streaming_finished", True) + # Send file/video/image attachments from the final result that were + # not included in the streaming text (e.g. Dify workflow file outputs). + media_comps = [ + comp + for comp in result.chain + if isinstance(comp, (Comp.File, Comp.Image, Comp.Video)) + ] + if media_comps: + try: + await event.send(result.derive(media_comps)) + except Exception as e: + logger.error(f"发送流式结果附件失败: {e}", exc_info=True) return logger.info( From 918e7a21e05b9f454a7a8dc9b93afb298810a418 Mon Sep 17 00:00:00 2001 From: Taois Date: Sat, 2 May 2026 23:37:47 +0800 Subject: [PATCH 22/22] =?UTF-8?q?fix=EF=BC=9A=20webchat=E5=8E=9F=E5=A7=8B?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/platform/sources/webchat/webchat_event.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/astrbot/core/platform/sources/webchat/webchat_event.py b/astrbot/core/platform/sources/webchat/webchat_event.py index bc1e1a6bcd..f36e3e0e6e 100644 --- a/astrbot/core/platform/sources/webchat/webchat_event.py +++ b/astrbot/core/platform/sources/webchat/webchat_event.py @@ -112,11 +112,9 @@ async def _send( # save file to local file_path = await comp.get_file() original_name = comp.name or os.path.basename(file_path) - ext = os.path.splitext(original_name)[1] or "" - filename = f"{uuid.uuid4()!s}{ext}" - dest_path = os.path.join(attachments_dir, filename) + dest_path = os.path.join(attachments_dir, original_name) shutil.copy2(file_path, dest_path) - data = f"[FILE]{filename}" + data = f"[FILE]{original_name}" await web_chat_back_queue.put( { "type": "file",