From 46fd8d83c36755de251324a314906435ba13e476 Mon Sep 17 00:00:00 2001 From: s11IM <877703596@qq.com> Date: Mon, 13 Apr 2026 02:58:44 +0800 Subject: [PATCH 1/5] fix: warn when default chat provider is unset --- astrbot/core/core_lifecycle.py | 63 +++++++++++++++++++++ tests/unit/test_core_lifecycle.py | 94 +++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py index fe6b1c351d..2d37510af6 100644 --- a/astrbot/core/core_lifecycle.py +++ b/astrbot/core/core_lifecycle.py @@ -59,6 +59,7 @@ def __init__(self, log_broker: LogBroker, db: BaseDatabase) -> None: self.subagent_orchestrator: SubAgentOrchestrator | None = None self.cron_manager: CronJobManager | None = None self.temp_dir_cleaner: TempDirCleaner | None = None + self._default_chat_provider_warning_emitted = False # 设置代理 proxy_config = self.astrbot_config.get("http_proxy", "") @@ -97,6 +98,65 @@ async def _init_or_reload_subagent_orchestrator(self) -> None: except Exception as e: logger.error(f"Subagent orchestrator init failed: {e}", exc_info=True) + @staticmethod + def _is_chat_provider_config( + provider_config: dict, provider_sources: dict[str, dict] + ) -> bool: + if provider_config.get("provider_type") == "chat_completion": + return True + + provider_source_id = provider_config.get("provider_source_id") + if not provider_source_id: + return False + + provider_source = provider_sources.get(provider_source_id, {}) + if provider_source.get("provider_type") == "chat_completion": + return True + + provider_source_type = provider_source.get("type", "") + return isinstance(provider_source_type, str) and ( + "chat_completion" in provider_source_type + ) + + def _warn_about_unset_default_chat_provider(self, config: dict) -> None: + if self._default_chat_provider_warning_emitted: + return + + provider_settings = config.get("provider_settings", {}) + default_provider_id = provider_settings.get("default_provider_id", "") + if default_provider_id: + return + + provider_sources = { + source.get("id"): source + for source in config.get("provider_sources", []) + if isinstance(source, dict) and source.get("id") + } + enabled_chat_provider_ids: list[str] = [] + for provider in config.get("provider", []): + if not isinstance(provider, dict): + continue + if not provider.get("enable", True): + continue + if not self._is_chat_provider_config(provider, provider_sources): + continue + + provider_id = provider.get("id") + if isinstance(provider_id, str) and provider_id: + enabled_chat_provider_ids.append(provider_id) + + if len(enabled_chat_provider_ids) <= 1: + return + + self._default_chat_provider_warning_emitted = True + logger.warning( + "Detected %d enabled chat providers but `provider_settings.default_provider_id` is empty. " + "AstrBot will use `%s` as the startup fallback chat provider. " + "Set a default chat model in the WebUI configuration page to avoid unexpected provider switching.", + len(enabled_chat_provider_ids), + enabled_chat_provider_ids[0], + ) + async def initialize(self) -> None: """初始化 AstrBot 核心生命周期管理类. @@ -202,6 +262,9 @@ async def initialize(self) -> None: # 根据配置实例化各个 Provider await self.provider_manager.initialize() + self._warn_about_unset_default_chat_provider( + self.astrbot_config_mgr.default_conf + ) await self.kb_manager.initialize() diff --git a/tests/unit/test_core_lifecycle.py b/tests/unit/test_core_lifecycle.py index fc8300bf96..a544e8576f 100644 --- a/tests/unit/test_core_lifecycle.py +++ b/tests/unit/test_core_lifecycle.py @@ -259,6 +259,100 @@ async def test_subagent_orchestrator_error_is_logged( ) +class TestAstrBotCoreLifecycleDefaultChatProviderWarning: + """Tests for startup warning when default chat provider is unset.""" + + def test_warns_for_multiple_enabled_chat_providers_without_default( + self, mock_log_broker, mock_db + ): + lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db) + config = { + "provider_settings": {"default_provider_id": ""}, + "provider_sources": [ + {"id": "openai_source", "provider_type": "chat_completion"} + ], + "provider": [ + { + "id": "openai_source/model-a", + "provider_source_id": "openai_source", + "enable": True, + }, + { + "id": "agent_runner_provider", + "provider_type": "agent_runner", + "enable": True, + }, + { + "id": "openai_source/model-b", + "provider_source_id": "openai_source", + "enable": True, + }, + ], + } + + with patch("astrbot.core.core_lifecycle.logger") as mock_logger: + lifecycle._warn_about_unset_default_chat_provider(config) + + mock_logger.warning.assert_called_once() + assert mock_logger.warning.call_args[0][1] == 2 + assert mock_logger.warning.call_args[0][2] == "openai_source/model-a" + + def test_warns_only_once_per_lifecycle(self, mock_log_broker, mock_db): + lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db) + config = { + "provider_settings": {"default_provider_id": ""}, + "provider_sources": [ + {"id": "openai_source", "provider_type": "chat_completion"} + ], + "provider": [ + { + "id": "openai_source/model-a", + "provider_source_id": "openai_source", + "enable": True, + }, + { + "id": "openai_source/model-b", + "provider_source_id": "openai_source", + "enable": True, + }, + ], + } + + with patch("astrbot.core.core_lifecycle.logger") as mock_logger: + lifecycle._warn_about_unset_default_chat_provider(config) + lifecycle._warn_about_unset_default_chat_provider(config) + + mock_logger.warning.assert_called_once() + + def test_does_not_warn_when_default_chat_provider_is_set( + self, mock_log_broker, mock_db + ): + lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db) + config = { + "provider_settings": {"default_provider_id": "openai_source/model-a"}, + "provider_sources": [ + {"id": "openai_source", "provider_type": "chat_completion"} + ], + "provider": [ + { + "id": "openai_source/model-a", + "provider_source_id": "openai_source", + "enable": True, + }, + { + "id": "openai_source/model-b", + "provider_source_id": "openai_source", + "enable": True, + }, + ], + } + + with patch("astrbot.core.core_lifecycle.logger") as mock_logger: + lifecycle._warn_about_unset_default_chat_provider(config) + + mock_logger.warning.assert_not_called() + + class TestAstrBotCoreLifecycleInitialize: """Tests for AstrBotCoreLifecycle.initialize method.""" From 2bb58e4ac1e0382b3ad089ce0c7923c83a77c202 Mon Sep 17 00:00:00 2001 From: s11IM <877703596@qq.com> Date: Mon, 13 Apr 2026 03:06:48 +0800 Subject: [PATCH 2/5] fix: align startup warning with provider fallback --- astrbot/core/core_lifecycle.py | 65 +++++------------- tests/unit/test_core_lifecycle.py | 109 +++++++++++++----------------- 2 files changed, 65 insertions(+), 109 deletions(-) diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py index 2d37510af6..721618ceae 100644 --- a/astrbot/core/core_lifecycle.py +++ b/astrbot/core/core_lifecycle.py @@ -98,63 +98,36 @@ async def _init_or_reload_subagent_orchestrator(self) -> None: except Exception as e: logger.error(f"Subagent orchestrator init failed: {e}", exc_info=True) - @staticmethod - def _is_chat_provider_config( - provider_config: dict, provider_sources: dict[str, dict] - ) -> bool: - if provider_config.get("provider_type") == "chat_completion": - return True - - provider_source_id = provider_config.get("provider_source_id") - if not provider_source_id: - return False - - provider_source = provider_sources.get(provider_source_id, {}) - if provider_source.get("provider_type") == "chat_completion": - return True - - provider_source_type = provider_source.get("type", "") - return isinstance(provider_source_type, str) and ( - "chat_completion" in provider_source_type - ) - - def _warn_about_unset_default_chat_provider(self, config: dict) -> None: + def _warn_about_unset_default_chat_provider(self) -> None: if self._default_chat_provider_warning_emitted: return - provider_settings = config.get("provider_settings", {}) - default_provider_id = provider_settings.get("default_provider_id", "") + provider_manager = getattr(self, "provider_manager", None) + if provider_manager is None: + return + + default_provider_id = provider_manager.provider_settings.get( + "default_provider_id", "" + ) if default_provider_id: return - provider_sources = { - source.get("id"): source - for source in config.get("provider_sources", []) - if isinstance(source, dict) and source.get("id") - } - enabled_chat_provider_ids: list[str] = [] - for provider in config.get("provider", []): - if not isinstance(provider, dict): - continue - if not provider.get("enable", True): - continue - if not self._is_chat_provider_config(provider, provider_sources): - continue - - provider_id = provider.get("id") - if isinstance(provider_id, str) and provider_id: - enabled_chat_provider_ids.append(provider_id) - - if len(enabled_chat_provider_ids) <= 1: + enabled_chat_providers = provider_manager.provider_insts + if len(enabled_chat_providers) <= 1: return + fallback_provider = ( + provider_manager.curr_provider_inst or enabled_chat_providers[0] + ) + fallback_provider_id = fallback_provider.provider_config.get("id", "unknown") + self._default_chat_provider_warning_emitted = True logger.warning( "Detected %d enabled chat providers but `provider_settings.default_provider_id` is empty. " "AstrBot will use `%s` as the startup fallback chat provider. " "Set a default chat model in the WebUI configuration page to avoid unexpected provider switching.", - len(enabled_chat_provider_ids), - enabled_chat_provider_ids[0], + len(enabled_chat_providers), + fallback_provider_id, ) async def initialize(self) -> None: @@ -262,9 +235,7 @@ async def initialize(self) -> None: # 根据配置实例化各个 Provider await self.provider_manager.initialize() - self._warn_about_unset_default_chat_provider( - self.astrbot_config_mgr.default_conf - ) + self._warn_about_unset_default_chat_provider() await self.kb_manager.initialize() diff --git a/tests/unit/test_core_lifecycle.py b/tests/unit/test_core_lifecycle.py index a544e8576f..e6990854ba 100644 --- a/tests/unit/test_core_lifecycle.py +++ b/tests/unit/test_core_lifecycle.py @@ -262,93 +262,78 @@ async def test_subagent_orchestrator_error_is_logged( class TestAstrBotCoreLifecycleDefaultChatProviderWarning: """Tests for startup warning when default chat provider is unset.""" + @staticmethod + def _make_provider(provider_id: str): + provider = MagicMock() + provider.provider_config = {"id": provider_id} + return provider + def test_warns_for_multiple_enabled_chat_providers_without_default( self, mock_log_broker, mock_db ): lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db) - config = { - "provider_settings": {"default_provider_id": ""}, - "provider_sources": [ - {"id": "openai_source", "provider_type": "chat_completion"} - ], - "provider": [ - { - "id": "openai_source/model-a", - "provider_source_id": "openai_source", - "enable": True, - }, - { - "id": "agent_runner_provider", - "provider_type": "agent_runner", - "enable": True, - }, - { - "id": "openai_source/model-b", - "provider_source_id": "openai_source", - "enable": True, - }, - ], - } + provider_a = self._make_provider("openai_source/model-a") + provider_b = self._make_provider("openai_source/model-b") + lifecycle.provider_manager = MagicMock( + provider_settings={"default_provider_id": ""}, + provider_insts=[provider_a, provider_b], + curr_provider_inst=provider_b, + ) with patch("astrbot.core.core_lifecycle.logger") as mock_logger: - lifecycle._warn_about_unset_default_chat_provider(config) + lifecycle._warn_about_unset_default_chat_provider() mock_logger.warning.assert_called_once() assert mock_logger.warning.call_args[0][1] == 2 - assert mock_logger.warning.call_args[0][2] == "openai_source/model-a" + assert mock_logger.warning.call_args[0][2] == "openai_source/model-b" def test_warns_only_once_per_lifecycle(self, mock_log_broker, mock_db): lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db) - config = { - "provider_settings": {"default_provider_id": ""}, - "provider_sources": [ - {"id": "openai_source", "provider_type": "chat_completion"} + lifecycle.provider_manager = MagicMock( + provider_settings={"default_provider_id": ""}, + provider_insts=[ + self._make_provider("openai_source/model-a"), + self._make_provider("openai_source/model-b"), ], - "provider": [ - { - "id": "openai_source/model-a", - "provider_source_id": "openai_source", - "enable": True, - }, - { - "id": "openai_source/model-b", - "provider_source_id": "openai_source", - "enable": True, - }, - ], - } + curr_provider_inst=self._make_provider("openai_source/model-a"), + ) with patch("astrbot.core.core_lifecycle.logger") as mock_logger: - lifecycle._warn_about_unset_default_chat_provider(config) - lifecycle._warn_about_unset_default_chat_provider(config) + lifecycle._warn_about_unset_default_chat_provider() + lifecycle._warn_about_unset_default_chat_provider() mock_logger.warning.assert_called_once() + def test_does_not_warn_with_single_enabled_chat_provider_without_default( + self, mock_log_broker, mock_db + ): + lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db) + lifecycle.provider_manager = MagicMock( + provider_settings={"default_provider_id": ""}, + provider_insts=[self._make_provider("openai_source/model-a")], + curr_provider_inst=self._make_provider("openai_source/model-a"), + ) + + with patch("astrbot.core.core_lifecycle.logger") as mock_logger: + lifecycle._warn_about_unset_default_chat_provider() + + mock_logger.warning.assert_not_called() + def test_does_not_warn_when_default_chat_provider_is_set( self, mock_log_broker, mock_db ): lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db) - config = { - "provider_settings": {"default_provider_id": "openai_source/model-a"}, - "provider_sources": [ - {"id": "openai_source", "provider_type": "chat_completion"} - ], - "provider": [ - { - "id": "openai_source/model-a", - "provider_source_id": "openai_source", - "enable": True, - }, - { - "id": "openai_source/model-b", - "provider_source_id": "openai_source", - "enable": True, - }, + lifecycle.provider_manager = MagicMock( + provider_settings={"default_provider_id": "openai_source/model-a"}, + provider_insts=[ + self._make_provider("openai_source/model-a"), + self._make_provider("openai_source/model-b"), ], - } + curr_provider_inst=self._make_provider("openai_source/model-a"), + ) with patch("astrbot.core.core_lifecycle.logger") as mock_logger: - lifecycle._warn_about_unset_default_chat_provider(config) + lifecycle._warn_about_unset_default_chat_provider() mock_logger.warning.assert_not_called() From cd8973490c12a5cd16ec70f059fe794644efdec0 Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Mon, 13 Apr 2026 09:19:35 +0800 Subject: [PATCH 3/5] refactor: simplify default chat provider warning guard checks --- astrbot/core/core_lifecycle.py | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py index 721618ceae..2eb5ad17a6 100644 --- a/astrbot/core/core_lifecycle.py +++ b/astrbot/core/core_lifecycle.py @@ -102,32 +102,24 @@ def _warn_about_unset_default_chat_provider(self) -> None: if self._default_chat_provider_warning_emitted: return - provider_manager = getattr(self, "provider_manager", None) - if provider_manager is None: + pm = getattr(self, "provider_manager", None) + if not pm or pm.provider_settings.get("default_provider_id"): return - default_provider_id = provider_manager.provider_settings.get( - "default_provider_id", "" - ) - if default_provider_id: - return - - enabled_chat_providers = provider_manager.provider_insts - if len(enabled_chat_providers) <= 1: + providers = pm.provider_insts + if len(providers) <= 1: return - fallback_provider = ( - provider_manager.curr_provider_inst or enabled_chat_providers[0] - ) - fallback_provider_id = fallback_provider.provider_config.get("id", "unknown") + fallback = pm.curr_provider_inst or providers[0] + fallback_id = fallback.provider_config.get("id", "unknown") self._default_chat_provider_warning_emitted = True logger.warning( "Detected %d enabled chat providers but `provider_settings.default_provider_id` is empty. " "AstrBot will use `%s` as the startup fallback chat provider. " "Set a default chat model in the WebUI configuration page to avoid unexpected provider switching.", - len(enabled_chat_providers), - fallback_provider_id, + len(providers), + fallback_id, ) async def initialize(self) -> None: From d78a51489c509a9c17622319fea7b896835ec410 Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Thu, 16 Apr 2026 09:07:18 +0800 Subject: [PATCH 4/5] feat: warn when default chat provider id is invalid or missing - Emit a warning when `default_provider_id` points to a non-existent enabled provider, preventing silent fallback to an unexpected model. - Reset the warning guard before each `provider_manager.initialize()` so configuration reloads trigger a fresh re-evaluation. - Harden guard checks to handle `None` `provider_settings` and `None` provider IDs gracefully. --- astrbot/core/core_lifecycle.py | 43 +++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py index 2eb5ad17a6..16696cd4b8 100644 --- a/astrbot/core/core_lifecycle.py +++ b/astrbot/core/core_lifecycle.py @@ -103,24 +103,44 @@ def _warn_about_unset_default_chat_provider(self) -> None: return pm = getattr(self, "provider_manager", None) - if not pm or pm.provider_settings.get("default_provider_id"): + if not pm: return providers = pm.provider_insts - if len(providers) <= 1: + if len(providers) == 0: return + provider_settings = getattr(pm, "provider_settings", None) or {} + default_id = provider_settings.get("default_provider_id") fallback = pm.curr_provider_inst or providers[0] - fallback_id = fallback.provider_config.get("id", "unknown") - - self._default_chat_provider_warning_emitted = True - logger.warning( - "Detected %d enabled chat providers but `provider_settings.default_provider_id` is empty. " - "AstrBot will use `%s` as the startup fallback chat provider. " - "Set a default chat model in the WebUI configuration page to avoid unexpected provider switching.", - len(providers), - fallback_id, + fallback_id = (fallback.provider_config.get("id") or "unknown") + + if not default_id: + if len(providers) <= 1: + return + self._default_chat_provider_warning_emitted = True + logger.warning( + "Detected %d enabled chat providers but `provider_settings.default_provider_id` is empty. " + "AstrBot will use `%s` as the startup fallback chat provider. " + "Set a default chat model in the WebUI configuration page to avoid unexpected provider switching.", + len(providers), + fallback_id, + ) + return + + found = any( + (p.provider_config.get("id") == default_id) + for p in providers ) + if not found: + self._default_chat_provider_warning_emitted = True + logger.warning( + "Configured `default_provider_id` is `%s` but no enabled provider matches that ID. " + "AstrBot will use `%s` as the fallback chat provider. " + "Please check the WebUI configuration page.", + default_id, + fallback_id, + ) async def initialize(self) -> None: """初始化 AstrBot 核心生命周期管理类. @@ -226,6 +246,7 @@ async def initialize(self) -> None: await self.plugin_manager.reload() # 根据配置实例化各个 Provider + self._default_chat_provider_warning_emitted = False await self.provider_manager.initialize() self._warn_about_unset_default_chat_provider() From 04a5a319854105bbdf4190f3adf217b7798b185e Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Thu, 16 Apr 2026 09:19:13 +0800 Subject: [PATCH 5/5] test: cover fallback and invalid default provider id warnings - Add case for `curr_provider_inst=None` to verify fallback to `providers[0]`. - Add case for a `default_provider_id` that does not match any enabled provider. --- tests/unit/test_core_lifecycle.py | 39 +++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/unit/test_core_lifecycle.py b/tests/unit/test_core_lifecycle.py index e6990854ba..1fc8035e48 100644 --- a/tests/unit/test_core_lifecycle.py +++ b/tests/unit/test_core_lifecycle.py @@ -337,6 +337,45 @@ def test_does_not_warn_when_default_chat_provider_is_set( mock_logger.warning.assert_not_called() + def test_warns_and_fallbacks_to_first_provider_when_curr_provider_inst_is_none( + self, mock_log_broker, mock_db + ): + lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db) + provider_a = self._make_provider("openai_source/model-a") + provider_b = self._make_provider("openai_source/model-b") + lifecycle.provider_manager = MagicMock( + provider_settings={"default_provider_id": ""}, + provider_insts=[provider_a, provider_b], + curr_provider_inst=None, + ) + + with patch("astrbot.core.core_lifecycle.logger") as mock_logger: + lifecycle._warn_about_unset_default_chat_provider() + + mock_logger.warning.assert_called_once() + assert mock_logger.warning.call_args[0][1] == 2 + assert mock_logger.warning.call_args[0][2] == "openai_source/model-a" + + def test_warns_when_default_provider_id_does_not_match_any_enabled_provider( + self, mock_log_broker, mock_db + ): + lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db) + lifecycle.provider_manager = MagicMock( + provider_settings={"default_provider_id": "non-existent-id"}, + provider_insts=[ + self._make_provider("openai_source/model-a"), + self._make_provider("openai_source/model-b"), + ], + curr_provider_inst=self._make_provider("openai_source/model-b"), + ) + + with patch("astrbot.core.core_lifecycle.logger") as mock_logger: + lifecycle._warn_about_unset_default_chat_provider() + + mock_logger.warning.assert_called_once() + assert mock_logger.warning.call_args[0][1] == "non-existent-id" + assert mock_logger.warning.call_args[0][2] == "openai_source/model-b" + class TestAstrBotCoreLifecycleInitialize: """Tests for AstrBotCoreLifecycle.initialize method."""