diff --git a/astrbot/core/platform/manager.py b/astrbot/core/platform/manager.py index 0e08c8e4f..118021a93 100644 --- a/astrbot/core/platform/manager.py +++ b/astrbot/core/platform/manager.py @@ -2,6 +2,7 @@ import asyncio import traceback from asyncio import Queue from dataclasses import dataclass +from importlib import import_module from astrbot.core import logger from astrbot.core.config.astrbot_config import AstrBotConfig @@ -12,6 +13,27 @@ from .platform import Platform, PlatformStatus from .register import platform_cls_map from .sources.webchat.webchat_adapter import WebChatAdapter +# Explicit module names keep lazy imports separate from user configuration. +_PLATFORM_MODULES = { + "aiocqhttp": "sources.aiocqhttp.aiocqhttp_platform_adapter", + "qq_official": "sources.qqofficial.qqofficial_platform_adapter", + "qq_official_webhook": "sources.qqofficial_webhook.qo_webhook_adapter", + "lark": "sources.lark.lark_adapter", + "dingtalk": "sources.dingtalk.dingtalk_adapter", + "telegram": "sources.telegram.tg_adapter", + "wecom": "sources.wecom.wecom_adapter", + "wecom_ai_bot": "sources.wecom_ai_bot.wecomai_adapter", + "weixin_official_account": "sources.weixin_official_account.weixin_offacc_adapter", + "discord": "sources.discord.discord_platform_adapter", + "misskey": "sources.misskey.misskey_adapter", + "weixin_oc": "sources.weixin_oc.weixin_oc_adapter", + "slack": "sources.slack.slack_adapter", + "satori": "sources.satori.satori_adapter", + "line": "sources.line.line_adapter", + "kook": "sources.kook.kook_adapter", + "mattermost": "sources.mattermost.mattermost_adapter", +} + @dataclass class PlatformTasks: @@ -131,74 +153,10 @@ class PlatformManager: platform_config["type"], platform_config["id"], ) - match platform_config["type"]: - case "aiocqhttp": - from .sources.aiocqhttp.aiocqhttp_platform_adapter import ( - AiocqhttpAdapter, # noqa: F401 - ) - case "qq_official": - from .sources.qqofficial.qqofficial_platform_adapter import ( - QQOfficialPlatformAdapter, # noqa: F401 - ) - case "qq_official_webhook": - from .sources.qqofficial_webhook.qo_webhook_adapter import ( - QQOfficialWebhookPlatformAdapter, # noqa: F401 - ) - case "lark": - from .sources.lark.lark_adapter import ( - LarkPlatformAdapter, # noqa: F401 - ) - case "dingtalk": - from .sources.dingtalk.dingtalk_adapter import ( - DingtalkPlatformAdapter, # noqa: F401 - ) - case "telegram": - from .sources.telegram.tg_adapter import ( - TelegramPlatformAdapter, # noqa: F401 - ) - case "wecom": - from .sources.wecom.wecom_adapter import ( - WecomPlatformAdapter, # noqa: F401 - ) - case "wecom_ai_bot": - from .sources.wecom_ai_bot.wecomai_adapter import ( - WecomAIBotAdapter, # noqa: F401 - ) - case "weixin_official_account": - from .sources.weixin_official_account.weixin_offacc_adapter import ( - WeixinOfficialAccountPlatformAdapter, # noqa: F401 - ) - case "discord": - from .sources.discord.discord_platform_adapter import ( - DiscordPlatformAdapter, # noqa: F401 - ) - case "misskey": - from .sources.misskey.misskey_adapter import ( - MisskeyPlatformAdapter, # noqa: F401 - ) - case "weixin_oc": - from .sources.weixin_oc.weixin_oc_adapter import ( - WeixinOCAdapter, # noqa: F401 - ) - case "slack": - from .sources.slack.slack_adapter import SlackAdapter # noqa: F401 - case "satori": - from .sources.satori.satori_adapter import ( - SatoriPlatformAdapter, # noqa: F401 - ) - case "line": - from .sources.line.line_adapter import ( - LinePlatformAdapter, # noqa: F401 - ) - case "kook": - from .sources.kook.kook_adapter import ( - KookPlatformAdapter, # noqa: F401 - ) - case "mattermost": - from .sources.mattermost.mattermost_adapter import ( - MattermostPlatformAdapter, # noqa: F401 - ) - except (ImportError, ModuleNotFoundError) as e: + module = _PLATFORM_MODULES.get(platform_config["type"]) + if module is not None: + import_module(f".{module}", package=__package__) + except ImportError as e: logger.error( f"Failed to load platform adapter {platform_config['type']}: {e}. " "Check whether its dependencies are installed. You can install " diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py index 60044fb86..c865e6fb0 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -3,6 +3,7 @@ import copy import os import traceback from collections.abc import Callable +from importlib import import_module from typing import Protocol, runtime_checkable from deprecated import deprecated @@ -24,6 +25,71 @@ from .provider import ( ) from .register import llm_tools, provider_cls_map +# Explicit module names keep lazy imports separate from user configuration. +_PROVIDER_MODULES = { + "openai_chat_completion": "sources.openai_source", + "openai_responses": "sources.openai_responses_source", + "longcat_chat_completion": "sources.longcat_source", + "minimax_token_plan": "sources.minimax_token_plan_source", + "xiaomi_chat_completion": "sources.xiaomi_source", + "xiaomi_token_plan": "sources.xiaomi_token_plan_source", + "zhipu_chat_completion": "sources.zhipu_source", + "groq_chat_completion": "sources.groq_source", + "xai_chat_completion": "sources.xai_source", + "aihubmix_chat_completion": "sources.oai_aihubmix_source", + "mirarouter_chat_completion": "sources.mirarouter_source", + "openrouter_chat_completion": "sources.openrouter_source", + "ssycloud_chat_completion": "sources.ssycloud_source", + "anthropic_chat_completion": "sources.anthropic_source", + "kimi_code_chat_completion": "sources.kimi_code_source", + "googlegenai_chat_completion": "sources.gemini_source", + "sensevoice_stt_selfhost": "sources.sensevoice_selfhosted_source", + "openai_whisper_api": "sources.whisper_api_source", + "mimo_stt_api": "sources.mimo_stt_api_source", + "openai_whisper_selfhost": "sources.whisper_selfhosted_source", + "xinference_stt": "sources.xinference_stt_provider", + "openai_tts_api": "sources.openai_tts_api_source", + "mimo_tts_api": "sources.mimo_tts_api_source", + "genie_tts": "sources.genie_tts", + "edge_tts": "sources.edge_tts_source", + "gsv_tts_selfhost": "sources.gsv_selfhosted_source", + "gsvi_tts_api": "sources.gsvi_tts_source", + "fishaudio_tts_api": "sources.fishaudio_tts_api_source", + "dashscope_tts": "sources.dashscope_tts", + "azure_tts": "sources.azure_tts_source", + "minimax_tts_api": "sources.minimax_tts_api_source", + "volcengine_tts": "sources.volcengine_tts", + "gemini_tts": "sources.gemini_tts_source", + "elevenlabs_tts_api": "sources.elevenlabs_tts_source", + "openai_embedding": "sources.openai_embedding_source", + "gemini_embedding": "sources.gemini_embedding_source", + "nvidia_embedding": "sources.nvidia_embedding_source", + "ollama_embedding": "sources.ollama_embedding_source", + "dashscope_embedding": "sources.dashscope_embedding_source", + "vllm_rerank": "sources.vllm_rerank_source", + "xinference_rerank": "sources.xinference_rerank_source", + "bailian_rerank": "sources.bailian_rerank_source", + "nvidia_rerank": "sources.nvidia_rerank_source", + "tei_rerank": "sources.tei_rerank_source", +} + +# Keep the public instance lists and legacy current-provider fields in sync. +_PROVIDER_TYPES = { + ProviderType.CHAT_COMPLETION: (Provider, "provider_insts", "curr_provider_inst"), + ProviderType.SPEECH_TO_TEXT: ( + STTProvider, + "stt_provider_insts", + "curr_stt_provider_inst", + ), + ProviderType.TEXT_TO_SPEECH: ( + TTSProvider, + "tts_provider_insts", + "curr_tts_provider_inst", + ), + ProviderType.EMBEDDING: (EmbeddingProvider, "embedding_provider_insts", None), + ProviderType.RERANK: (RerankProvider, "rerank_provider_insts", None), +} + @runtime_checkable class HasInitialize(Protocol): @@ -412,204 +478,35 @@ class ProviderManager: ) def dynamic_import_provider(self, type: str) -> None: - """动态导入提供商适配器模块 + """Import a built-in adapter only when requested. Args: - type (str): 提供商请求类型。 + type: Adapter type. Plugin types are registered separately. Raises: - ImportError: 如果提供商类型未知或无法导入对应模块,则抛出异常。 + ImportError: If a built-in adapter or its dependencies cannot load. """ - match type: - case "openai_chat_completion": - from .sources.openai_source import ( - ProviderOpenAIOfficial as ProviderOpenAIOfficial, - ) - case "openai_responses": - from .sources.openai_responses_source import ( - ProviderOpenAIResponses as ProviderOpenAIResponses, - ) - case "longcat_chat_completion": - from .sources.longcat_source import ProviderLongCat as ProviderLongCat - case "minimax_token_plan": - from .sources.minimax_token_plan_source import ( - ProviderMiniMaxTokenPlan as ProviderMiniMaxTokenPlan, - ) - case "xiaomi_chat_completion": - from .sources.xiaomi_source import ProviderXiaomi as ProviderXiaomi - case "xiaomi_token_plan": - from .sources.xiaomi_token_plan_source import ( - ProviderXiaomiTokenPlan as ProviderXiaomiTokenPlan, - ) - case "zhipu_chat_completion": - from .sources.zhipu_source import ProviderZhipu as ProviderZhipu - case "groq_chat_completion": - from .sources.groq_source import ProviderGroq as ProviderGroq - case "xai_chat_completion": - from .sources.xai_source import ProviderXAI as ProviderXAI - case "aihubmix_chat_completion": - from .sources.oai_aihubmix_source import ( - ProviderAIHubMix as ProviderAIHubMix, - ) - case "mirarouter_chat_completion": - from .sources.mirarouter_source import ( - ProviderMiraRouter as ProviderMiraRouter, - ) - case "openrouter_chat_completion": - from .sources.openrouter_source import ( - ProviderOpenRouter as ProviderOpenRouter, - ) - case "ssycloud_chat_completion": - from .sources.ssycloud_source import ( - ProviderSSYCloud as ProviderSSYCloud, - ) - case "anthropic_chat_completion": - from .sources.anthropic_source import ( - ProviderAnthropic as ProviderAnthropic, - ) - case "kimi_code_chat_completion": - from .sources.kimi_code_source import ( - ProviderKimiCode as ProviderKimiCode, - ) - case "googlegenai_chat_completion": - from .sources.gemini_source import ( - ProviderGoogleGenAI as ProviderGoogleGenAI, - ) - case "sensevoice_stt_selfhost": - from .sources.sensevoice_selfhosted_source import ( - ProviderSenseVoiceSTTSelfHost as ProviderSenseVoiceSTTSelfHost, - ) - case "openai_whisper_api": - from .sources.whisper_api_source import ( - ProviderOpenAIWhisperAPI as ProviderOpenAIWhisperAPI, - ) - case "mimo_stt_api": - from .sources.mimo_stt_api_source import ( - ProviderMiMoSTTAPI as ProviderMiMoSTTAPI, - ) - case "openai_whisper_selfhost": - from .sources.whisper_selfhosted_source import ( - ProviderOpenAIWhisperSelfHost as ProviderOpenAIWhisperSelfHost, - ) - case "xinference_stt": - from .sources.xinference_stt_provider import ( - ProviderXinferenceSTT as ProviderXinferenceSTT, - ) - case "openai_tts_api": - from .sources.openai_tts_api_source import ( - ProviderOpenAITTSAPI as ProviderOpenAITTSAPI, - ) - case "mimo_tts_api": - from .sources.mimo_tts_api_source import ( - ProviderMiMoTTSAPI as ProviderMiMoTTSAPI, - ) - case "genie_tts": - from .sources.genie_tts import ( - GenieTTSProvider as GenieTTSProvider, - ) - case "edge_tts": - from .sources.edge_tts_source import ( - ProviderEdgeTTS as ProviderEdgeTTS, - ) - case "gsv_tts_selfhost": - from .sources.gsv_selfhosted_source import ( - ProviderGSVTTS as ProviderGSVTTS, - ) - case "gsvi_tts_api": - from .sources.gsvi_tts_source import ( - ProviderGSVITTS as ProviderGSVITTS, - ) - case "fishaudio_tts_api": - from .sources.fishaudio_tts_api_source import ( - ProviderFishAudioTTSAPI as ProviderFishAudioTTSAPI, - ) - case "dashscope_tts": - from .sources.dashscope_tts import ( - ProviderDashscopeTTSAPI as ProviderDashscopeTTSAPI, - ) - case "azure_tts": - from .sources.azure_tts_source import ( - AzureTTSProvider as AzureTTSProvider, - ) - case "minimax_tts_api": - from .sources.minimax_tts_api_source import ( - ProviderMiniMaxTTSAPI as ProviderMiniMaxTTSAPI, - ) - case "volcengine_tts": - from .sources.volcengine_tts import ( - ProviderVolcengineTTS as ProviderVolcengineTTS, - ) - case "gemini_tts": - from .sources.gemini_tts_source import ( - ProviderGeminiTTSAPI as ProviderGeminiTTSAPI, - ) - case "elevenlabs_tts_api": - from .sources.elevenlabs_tts_source import ( - ProviderElevenLabsTTSAPI as ProviderElevenLabsTTSAPI, - ) - case "openai_embedding": - from .sources.openai_embedding_source import ( - OpenAIEmbeddingProvider as OpenAIEmbeddingProvider, - ) - case "gemini_embedding": - from .sources.gemini_embedding_source import ( - GeminiEmbeddingProvider as GeminiEmbeddingProvider, - ) - case "nvidia_embedding": - from .sources.nvidia_embedding_source import ( - NvidiaEmbeddingProvider as NvidiaEmbeddingProvider, - ) - case "ollama_embedding": - from .sources.ollama_embedding_source import ( - OllamaEmbeddingProvider as OllamaEmbeddingProvider, - ) - case "dashscope_embedding": - from .sources.dashscope_embedding_source import ( - DashScopeEmbeddingProvider as DashScopeEmbeddingProvider, - ) - case "vllm_rerank": - from .sources.vllm_rerank_source import ( - VLLMRerankProvider as VLLMRerankProvider, - ) - case "xinference_rerank": - from .sources.xinference_rerank_source import ( - XinferenceRerankProvider as XinferenceRerankProvider, - ) - case "bailian_rerank": - from .sources.bailian_rerank_source import ( - BailianRerankProvider as BailianRerankProvider, - ) - case "nvidia_rerank": - from .sources.nvidia_rerank_source import ( - NvidiaRerankProvider as NvidiaRerankProvider, - ) - case "tei_rerank": - from .sources.tei_rerank_source import ( - TEIRerankProvider as TEIRerankProvider, - ) + module = _PROVIDER_MODULES.get(type) + if module is not None: + import_module(f".{module}", package=__package__) def get_merged_provider_config(self, provider_config: dict) -> dict: - """获取 provider 配置和 provider_source 配置合并后的结果 + """Merge settings without sharing mutable values between providers. + + Args: + provider_config: Model settings, which override its source settings. Returns: - dict: 合并后的 provider 配置,key 为 provider id,value 为合并后的配置字典 + Independent settings retaining the model's ID. """ - pc = copy.deepcopy(provider_config) - provider_source_id = pc.get("provider_source_id", "") - if provider_source_id: - provider_source = None - for ps in self.provider_sources_config: - if ps.get("id") == provider_source_id: - provider_source = ps + merged = provider_config + source_id = provider_config.get("provider_source_id") + if source_id: + for source in self.provider_sources_config: + if source.get("id") == source_id: + merged = {**source, **provider_config, "id": provider_config["id"]} break - - if provider_source: - # 合并配置,provider 的配置优先级更高 - merged_config = {**provider_source, **pc} - # 保持 id 为 provider 的 id,而不是 source 的 id - merged_config["id"] = pc["id"] - pc = merged_config - return pc + return copy.deepcopy(merged) def get_provider_config_by_id( self, @@ -682,7 +579,7 @@ class ProviderManager: # 动态导入 try: self.dynamic_import_provider(provider_config["type"]) - except (ImportError, ModuleNotFoundError) as e: + except ImportError as e: logger.critical( f"Failed to load provider adapter {provider_config['type']}" f"({provider_config['id']}): {e}. A dependency may be missing.", @@ -714,100 +611,50 @@ class ProviderManager: provider_metadata.id = provider_config["id"] - match provider_metadata.provider_type: - case ProviderType.SPEECH_TO_TEXT: - # STT 任务 - if not issubclass(cls_type, STTProvider): - raise TypeError( - f"Provider class {cls_type} is not a subclass of STTProvider" + provider_type = provider_metadata.provider_type + if provider_type not in _PROVIDER_TYPES: + raise ValueError(f"Unknown provider type: {provider_type}") + base_class, instances_attr, current_attr = _PROVIDER_TYPES[provider_type] + if not issubclass(cls_type, base_class): + raise TypeError( + f"Provider class {cls_type} is not a subclass of {base_class.__name__}" + ) + inst = cls_type(provider_config, self.provider_settings) + try: + if isinstance(inst, HasInitialize): + await inst.initialize() + except BaseException: + # An unpublished instance still owns resources if initialization fails. + if terminate := getattr(inst, "terminate", None): + try: + await terminate() + except Exception as e: + logger.error( + safe_error("Provider initialization cleanup failed: ", e) ) - inst = cls_type(provider_config, self.provider_settings) + raise - if isinstance(inst, HasInitialize): - await inst.initialize() - - self.stt_provider_insts.append(inst) - if ( - self.provider_stt_settings.get("provider_id") - == provider_config["id"] - ): - self.curr_stt_provider_inst = inst - logger.info( - f"Selected {provider_config['type']}({provider_config['id']}) as default STT provider", - ) - if not self.curr_stt_provider_inst: - self.curr_stt_provider_inst = inst - - case ProviderType.TEXT_TO_SPEECH: - # TTS 任务 - if not issubclass(cls_type, TTSProvider): - raise TypeError( - f"Provider class {cls_type} is not a subclass of TTSProvider" - ) - inst = cls_type(provider_config, self.provider_settings) - - if isinstance(inst, HasInitialize): - await inst.initialize() - - self.tts_provider_insts.append(inst) - if ( - self.provider_settings.get("provider_id") - == provider_config["id"] - ): - self.curr_tts_provider_inst = inst - logger.info( - f"Selected {provider_config['type']}({provider_config['id']}) as default TTS provider", - ) - if not self.curr_tts_provider_inst: - self.curr_tts_provider_inst = inst - - case ProviderType.CHAT_COMPLETION: - # 文本生成任务 - if not issubclass(cls_type, Provider): - raise TypeError( - f"Provider class {cls_type} is not a subclass of Provider" - ) - inst = cls_type( - provider_config, - self.provider_settings, - ) - - if isinstance(inst, HasInitialize): - await inst.initialize() - - self.provider_insts.append(inst) - if self.default_chat_provider_id == provider_config["id"]: - self.curr_provider_inst = inst - logger.info( - f"Selected {provider_config['type']}({provider_config['id']}) as default chat model provider", - ) - if not self.curr_provider_inst: - self.curr_provider_inst = inst - - case ProviderType.EMBEDDING: - if not issubclass(cls_type, EmbeddingProvider): - raise TypeError( - f"Provider class {cls_type} is not a subclass of EmbeddingProvider" - ) - inst = cls_type(provider_config, self.provider_settings) - if isinstance(inst, HasInitialize): - await inst.initialize() - self.embedding_provider_insts.append(inst) - case ProviderType.RERANK: - if not issubclass(cls_type, RerankProvider): - raise TypeError( - f"Provider class {cls_type} is not a subclass of RerankProvider" - ) - inst = cls_type(provider_config, self.provider_settings) - if isinstance(inst, HasInitialize): - await inst.initialize() - self.rerank_provider_insts.append(inst) - case _: - # 未知供应商抛出异常,确保inst初始化 - # Should be unreachable - raise Exception( - f"Unknown provider type: {provider_metadata.provider_type}" + getattr(self, instances_attr).append(inst) + if current_attr is not None: + preferred_id = { + ProviderType.CHAT_COMPLETION: self.default_chat_provider_id, + ProviderType.SPEECH_TO_TEXT: self.provider_stt_settings.get( + "provider_id" + ), + ProviderType.TEXT_TO_SPEECH: self.provider_tts_settings.get( + "provider_id" + ), + }[provider_type] + if preferred_id == provider_config["id"]: + setattr(self, current_attr, inst) + logger.info( + "Selected %s(%s) as default %s provider", + provider_config["type"], + provider_config["id"], + provider_type.value, ) + elif not getattr(self, current_attr): + setattr(self, current_attr, inst) self.inst_map[provider_config["id"]] = inst except Exception as e: @@ -835,77 +682,46 @@ class ProviderManager: if key not in config_ids: await self.terminate_provider(key) - if len(self.provider_insts) == 0: - self.curr_provider_inst = None - elif self.curr_provider_inst is None and len(self.provider_insts) > 0: - self.curr_provider_inst = self.provider_insts[0] - logger.info( - f"Automatically selected {self.curr_provider_inst.meta().id} " - "as the current provider adapter.", - ) - - if len(self.stt_provider_insts) == 0: - self.curr_stt_provider_inst = None - elif ( - self.curr_stt_provider_inst is None and len(self.stt_provider_insts) > 0 - ): - self.curr_stt_provider_inst = self.stt_provider_insts[0] - logger.info( - f"Automatically selected {self.curr_stt_provider_inst.meta().id} " - "as the current speech-to-text provider adapter.", - ) - - if len(self.tts_provider_insts) == 0: - self.curr_tts_provider_inst = None - elif ( - self.curr_tts_provider_inst is None and len(self.tts_provider_insts) > 0 - ): - self.curr_tts_provider_inst = self.tts_provider_insts[0] - logger.info( - f"Automatically selected {self.curr_tts_provider_inst.meta().id} " - "as the current text-to-speech provider adapter.", - ) + for _, instances_attr, current_attr in _PROVIDER_TYPES.values(): + if current_attr is None: + continue + instances = getattr(self, instances_attr) + if not instances: + setattr(self, current_attr, None) + elif getattr(self, current_attr) is None: + setattr(self, current_attr, instances[0]) + logger.info( + "Automatically selected %s as %s.", + instances[0].meta().id, + current_attr, + ) def get_insts(self): return self.provider_insts async def terminate_provider(self, provider_id: str) -> None: - if provider_id in self.inst_map: - logger.info( - f"Terminating provider adapter {provider_id} " - f"({len(self.provider_insts)}, {len(self.stt_provider_insts)}, " - f"{len(self.tts_provider_insts)}) ...", - ) + """Detach one instance before closing its resources. - if self.inst_map[provider_id] in self.provider_insts: - prov_inst = self.inst_map[provider_id] - if isinstance(prov_inst, Provider): - self.provider_insts.remove(prov_inst) - if self.inst_map[provider_id] in self.stt_provider_insts: - prov_inst = self.inst_map[provider_id] - if isinstance(prov_inst, STTProvider): - self.stt_provider_insts.remove(prov_inst) - if self.inst_map[provider_id] in self.tts_provider_insts: - prov_inst = self.inst_map[provider_id] - if isinstance(prov_inst, TTSProvider): - self.tts_provider_insts.remove(prov_inst) + Args: + provider_id: Instance to remove. Missing IDs are ignored. - if self.inst_map[provider_id] == self.curr_provider_inst: - self.curr_provider_inst = None - if self.inst_map[provider_id] == self.curr_stt_provider_inst: - self.curr_stt_provider_inst = None - if self.inst_map[provider_id] == self.curr_tts_provider_inst: - self.curr_tts_provider_inst = None - - if getattr(self.inst_map[provider_id], "terminate", None): - await self.inst_map[provider_id].terminate() # type: ignore - - logger.info( - f"Provider adapter {provider_id} terminated " - f"({len(self.provider_insts)}, {len(self.stt_provider_insts)}, " - f"{len(self.tts_provider_insts)})", - ) - del self.inst_map[provider_id] + Raises: + Exception: If the adapter fails to close, after detaching it. + """ + inst = self.inst_map.pop(provider_id, None) + if inst is None: + return + # Detach before awaiting: failure or cancellation must not leave a stale + # instance, and a replacement registered during close must remain intact. + for _, instances_attr, current_attr in _PROVIDER_TYPES.values(): + instances = getattr(self, instances_attr) + instances[:] = [item for item in instances if item is not inst] + if current_attr is not None and getattr(self, current_attr) is inst: + setattr(self, current_attr, None) + logger.info("Terminating provider adapter %s ...", provider_id) + if terminate := getattr(inst, "terminate", None): + await terminate() + logger.info("Provider adapter %s terminated", provider_id) async def delete_provider( self, provider_id: str | None = None, provider_source_id: str | None = None @@ -981,9 +797,13 @@ class ProviderManager: except asyncio.CancelledError: pass - for provider_inst in self.provider_insts: - if hasattr(provider_inst, "terminate"): - await provider_inst.terminate() # type: ignore + for provider_id in list(self.inst_map): + try: + await self.terminate_provider(provider_id) + except Exception as e: + logger.error( + safe_error(f"Failed to terminate provider {provider_id}: ", e) + ) try: await self.llm_tools.disable_mcp_server() except Exception: diff --git a/tests/test_adapter_imports.py b/tests/test_adapter_imports.py new file mode 100644 index 000000000..ac2492515 --- /dev/null +++ b/tests/test_adapter_imports.py @@ -0,0 +1,49 @@ +"""Keep built-in imports lazy and separate from custom adapter registration.""" + +import asyncio +from unittest.mock import Mock + +import pytest + +from astrbot.core.platform import manager as platform_module +from astrbot.core.provider import manager as provider_module + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "scope, adapter, expected", + [ + (provider_module, "openai_chat_completion", "sources.openai_source"), + (provider_module, "googlegenai_chat_completion", "sources.gemini_source"), + (provider_module, "xinference_stt", "sources.xinference_stt_provider"), + (platform_module, "telegram", "sources.telegram.tg_adapter"), + ( + platform_module, + "qq_official_webhook", + "sources.qqofficial_webhook.qo_webhook_adapter", + ), + (platform_module, "slack", "sources.slack.slack_adapter"), + (provider_module, "plugin_custom", None), + (platform_module, "plugin_custom", None), + (provider_module, "../../os", None), + (platform_module, "../../os", None), + ], +) +async def test_import_uses_only_the_explicit_module_map( + monkeypatch, scope, adapter, expected +): + imported = Mock() + monkeypatch.setattr(scope, "import_module", imported) + if scope is provider_module: + manager = provider_module.ProviderManager.__new__(provider_module.ProviderManager) + manager.dynamic_import_provider(adapter) + else: + monkeypatch.setattr(platform_module, "platform_cls_map", {}) + manager = platform_module.PlatformManager( + {"platform": [], "platform_settings": {}}, asyncio.Queue() + ) + await manager.load_platform({"id": "test", "type": adapter, "enable": True}) + if expected is None: + imported.assert_not_called() + else: + imported.assert_called_once_with(f".{expected}", package=scope.__package__) diff --git a/tests/test_provider_manager_lifecycle.py b/tests/test_provider_manager_lifecycle.py new file mode 100644 index 000000000..43ca6aa45 --- /dev/null +++ b/tests/test_provider_manager_lifecycle.py @@ -0,0 +1,198 @@ +"""Provider lifecycle regressions without supplier network requests.""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from astrbot.core.provider import manager as module + +CATEGORIES = [ + ( + module.ProviderType.CHAT_COMPLETION, + module.Provider, + "provider_insts", + "curr_provider_inst", + ), + ( + module.ProviderType.SPEECH_TO_TEXT, + module.STTProvider, + "stt_provider_insts", + "curr_stt_provider_inst", + ), + ( + module.ProviderType.TEXT_TO_SPEECH, + module.TTSProvider, + "tts_provider_insts", + "curr_tts_provider_inst", + ), + ( + module.ProviderType.EMBEDDING, + module.EmbeddingProvider, + "embedding_provider_insts", + None, + ), + (module.ProviderType.RERANK, module.RerankProvider, "rerank_provider_insts", None), +] + + +@pytest.fixture +def manager(monkeypatch): + config = {"provider": [], "provider_settings": {}} + acm = SimpleNamespace(confs={"default": config}) + manager = module.ProviderManager(acm, None, SimpleNamespace(default_persona=None)) + manager.llm_tools = SimpleNamespace(disable_mcp_server=AsyncMock()) + monkeypatch.setattr(module, "provider_cls_map", {}) + monkeypatch.setattr(module, "astrbot_config", config) + return manager + + +@pytest.fixture(params=CATEGORIES, ids=lambda case: case[0].value) +def category(request): + return request.param + + +def register_adapter(category, initialize_error=None): + kind, base, _, _ = category + created = [] + + class Adapter(base): + def __init__(self, config, settings): + self.initialize = AsyncMock(side_effect=initialize_error) + self.terminate = AsyncMock() + self.meta = lambda: SimpleNamespace(id=config["id"]) + created.append(self) + + # Supplier methods are not used by lifecycle tests. + Adapter.__abstractmethods__ = frozenset() + module.provider_cls_map[kind.value] = SimpleNamespace( + cls_type=Adapter, provider_type=kind, type=kind.value + ) + return created + + +def config(category, provider_id="model"): + return {"id": provider_id, "type": category[0].value, "enable": True} + + +@pytest.mark.asyncio +async def test_load_unload_and_reload_all_categories(manager, category): + created = register_adapter(category) + settings = config(category) + module.astrbot_config["provider"] = [settings] + collection = getattr(manager, category[2]) + await manager.load_provider(settings) + created[0].initialize.assert_awaited_once() + assert collection == [created[0]] + await manager.reload(settings) + assert collection == [created[1]] + assert manager.inst_map["model"] is created[1] + created[0].terminate.assert_awaited_once() + await manager.terminate_provider("model") + await manager.terminate_provider("model") + assert not collection and not manager.inst_map + created[1].terminate.assert_awaited_once() + if category[3]: + assert getattr(manager, category[3]) is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure", [RuntimeError, asyncio.CancelledError]) +async def test_failed_close_does_not_leave_registered_instances( + manager, category, failure +): + created = register_adapter(category) + await manager.load_provider(config(category)) + created[0].terminate.side_effect = failure("close failed") + with pytest.raises(failure): + await manager.terminate_provider("model") + assert not manager.inst_map and not getattr(manager, category[2]) + if category[3]: + assert getattr(manager, category[3]) is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure", [RuntimeError, asyncio.CancelledError]) +async def test_failed_initialization_closes_unpublished_instance( + manager, category, failure +): + created = register_adapter(category, failure("initialization failed")) + expected = asyncio.CancelledError if failure is asyncio.CancelledError else Exception + with pytest.raises(expected): + await manager.load_provider(config(category)) + assert not manager.inst_map and not getattr(manager, category[2]) + created[0].terminate.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_configured_tts_is_selected_instead_of_chat_settings(manager): + category = CATEGORIES[2] + created = register_adapter(category) + manager.provider_settings["provider_id"] = "first" + manager.provider_tts_settings["provider_id"] = "preferred" + await manager.load_provider(config(category, "first")) + await manager.load_provider(config(category, "preferred")) + assert manager.curr_tts_provider_inst is created[1] + + +@pytest.mark.asyncio +async def test_shutdown_closes_every_category_after_one_failure(manager): + instances = [] + for category in CATEGORIES: + created = register_adapter(category) + await manager.load_provider(config(category, category[0].value)) + instances.extend(created) + instances[0].terminate.side_effect = RuntimeError("close failed") + await manager.terminate() + for inst in instances: + inst.terminate.assert_awaited_once() + assert not manager.inst_map + assert all(not getattr(manager, case[2]) for case in CATEGORIES) + manager.llm_tools.disable_mcp_server.assert_awaited_once() + await manager.terminate() + for inst in instances: + inst.terminate.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_replacement_during_close_is_not_removed(manager): + category = CATEGORIES[0] + created = register_adapter(category) + await manager.load_provider(config(category)) + started, release = asyncio.Event(), asyncio.Event() + + async def close(): + started.set() + await release.wait() + + created[0].terminate.side_effect = close + task = asyncio.create_task(manager.terminate_provider("model")) + await started.wait() + await manager.load_provider(config(category)) + release.set() + await task + assert manager.inst_map["model"] is created[1] + assert manager.provider_insts == [created[1]] + + +def test_merged_config_does_not_share_nested_source_values(manager): + source = { + "id": "source", "key": ["original"], "custom_headers": {"x-test": "original"} + } + manager.provider_sources_config = [source] + settings = { + "id": "model", "provider_source_id": "source", "options": {"model": True} + } + first = manager.get_merged_provider_config(settings) + second = manager.get_merged_provider_config(settings) + first["key"].append("changed") + first["custom_headers"]["x-test"] = "changed" + first["options"]["model"] = False + assert second["key"] == source["key"] == ["original"] + assert second["custom_headers"] == source["custom_headers"] + assert source["custom_headers"] == {"x-test": "original"} + assert settings["options"] == {"model": True} + assert second["id"] == "model" + override = manager.get_merged_provider_config({**settings, "key": ["override"]}) + assert override["key"] == ["override"]