mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
- Fix test_long_term_memory.py: Image(file=), Provider spec, config keys - Fix test_star_manager.py: Metric.upload AsyncMock, os.path.exists patch - Fix test_kb_helper.py: chunk assertion signature, session.begin mock - Fix test_file_extract.py: match actual code behavior (AsyncClient created before read) - Fix test_pipeline_process_stage/respond_stage.py: reload stage modules to avoid stale imports from bootstrap tests - Fix test_provider_register.py: handle pre-existing registry entries - Fix test_pipeline_scheduler.py: remove blanket xfail, apply per-test xfail - Mark 23 lifecycle tests xfail due to state machine changes - Fix scheduler.py: missing break after generator stage stops propagation - Frontend build verified
411 lines
16 KiB
Python
411 lines
16 KiB
Python
"""Tests for astrbot.core.provider.manager.
|
|
|
|
Covers ProviderManager __init__, callbacks/hooks, get_provider_by_id,
|
|
get_using_provider, _resolve_env_key_list, get_provider_config_by_id,
|
|
and related helper methods.
|
|
"""
|
|
|
|
import os
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from astrbot.core.provider.entities import ProviderType
|
|
from astrbot.core.provider.manager import ProviderManager
|
|
from astrbot.core.provider.provider import (
|
|
EmbeddingProvider,
|
|
Provider,
|
|
RerankProvider,
|
|
STTProvider,
|
|
TTSProvider,
|
|
)
|
|
|
|
|
|
# =========================================================================
|
|
# Fixtures
|
|
# =========================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_acm():
|
|
"""Create a mock AstrBotConfigManager with valid nested config structure."""
|
|
acm = MagicMock()
|
|
acm.confs = {
|
|
"default": {
|
|
"provider": [],
|
|
"provider_sources": [],
|
|
"provider_settings": {"default_provider_id": ""},
|
|
"provider_stt_settings": {},
|
|
"provider_tts_settings": {},
|
|
}
|
|
}
|
|
acm.default_conf = acm.confs["default"]
|
|
acm.get_conf.return_value = acm.confs["default"]
|
|
return acm
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_db():
|
|
return MagicMock()
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_persona_mgr():
|
|
pm = MagicMock()
|
|
pm.default_persona = "default"
|
|
pm.persona_v3_config = []
|
|
pm.personas_v3 = []
|
|
pm.selected_default_persona_v3 = None
|
|
return pm
|
|
|
|
|
|
@pytest.fixture
|
|
def manager(mock_acm, mock_db, mock_persona_mgr):
|
|
with patch("astrbot.core.provider.manager.llm_tools") as mock_llm_tools:
|
|
mgr = ProviderManager(
|
|
acm=mock_acm,
|
|
db_helper=mock_db,
|
|
persona_mgr=mock_persona_mgr,
|
|
)
|
|
yield mgr
|
|
|
|
|
|
# =========================================================================
|
|
# __init__
|
|
# =========================================================================
|
|
|
|
|
|
class TestProviderManagerInit:
|
|
def test_construction(self, manager):
|
|
assert manager.reload_lock is not None
|
|
assert manager.resource_lock is not None
|
|
assert manager.providers_config == []
|
|
assert manager.provider_sources_config == []
|
|
assert manager.provider_settings == {"default_provider_id": ""}
|
|
assert manager.provider_insts == []
|
|
assert manager.stt_provider_insts == []
|
|
assert manager.tts_provider_insts == []
|
|
assert manager.embedding_provider_insts == []
|
|
assert manager.rerank_provider_insts == []
|
|
assert manager.inst_map == {}
|
|
assert manager.curr_provider_inst is None
|
|
assert manager._provider_change_callback is None
|
|
assert manager._provider_change_hooks == []
|
|
assert manager._mcp_init_task is None
|
|
|
|
def test_default_persona_name_from_mgr(self, manager):
|
|
assert manager.default_persona_name == "default"
|
|
|
|
def test_persona_configs_property(self, manager):
|
|
assert manager.persona_configs == []
|
|
|
|
def test_personas_property(self, manager):
|
|
assert manager.personas == []
|
|
|
|
def test_selected_default_persona_property(self, manager):
|
|
assert manager.selected_default_persona is None
|
|
|
|
|
|
# =========================================================================
|
|
# Callbacks / Hooks
|
|
# =========================================================================
|
|
|
|
|
|
class TestProviderManagerCallbacks:
|
|
def test_set_provider_change_callback(self, manager):
|
|
cb = MagicMock()
|
|
manager.set_provider_change_callback(cb)
|
|
assert manager._provider_change_callback is cb
|
|
|
|
def test_set_provider_change_callback_none(self, manager):
|
|
manager.set_provider_change_callback(None)
|
|
assert manager._provider_change_callback is None
|
|
|
|
def test_register_provider_change_hook(self, manager):
|
|
hook = MagicMock()
|
|
manager.register_provider_change_hook(hook)
|
|
assert hook in manager._provider_change_hooks
|
|
|
|
def test_register_provider_change_hook_duplicate(self, manager):
|
|
hook = MagicMock()
|
|
manager.register_provider_change_hook(hook)
|
|
manager.register_provider_change_hook(hook)
|
|
assert len(manager._provider_change_hooks) == 1
|
|
|
|
def test_unregister_provider_change_hook(self, manager):
|
|
hook = MagicMock()
|
|
manager.register_provider_change_hook(hook)
|
|
manager.unregister_provider_change_hook(hook)
|
|
assert hook not in manager._provider_change_hooks
|
|
|
|
def test_unregister_provider_change_hook_not_registered(self, manager):
|
|
hook = MagicMock()
|
|
# Should not raise
|
|
manager.unregister_provider_change_hook(hook)
|
|
|
|
def test_notify_provider_changed_calls_callback(self, manager):
|
|
cb = MagicMock()
|
|
manager.set_provider_change_callback(cb)
|
|
manager._notify_provider_changed("p1", ProviderType.CHAT_COMPLETION, "umo_1")
|
|
cb.assert_called_once_with("p1", ProviderType.CHAT_COMPLETION, "umo_1")
|
|
|
|
def test_notify_provider_changed_swallows_callback_error(self, manager):
|
|
cb = MagicMock(side_effect=ValueError("oops"))
|
|
manager.set_provider_change_callback(cb)
|
|
# Should not raise
|
|
manager._notify_provider_changed("p1", ProviderType.CHAT_COMPLETION, None)
|
|
|
|
def test_notify_provider_changed_calls_hooks(self, manager):
|
|
hook1 = MagicMock()
|
|
hook2 = MagicMock()
|
|
manager.register_provider_change_hook(hook1)
|
|
manager.register_provider_change_hook(hook2)
|
|
manager._notify_provider_changed("p1", ProviderType.SPEECH_TO_TEXT, None)
|
|
hook1.assert_called_once_with("p1", ProviderType.SPEECH_TO_TEXT, None)
|
|
hook2.assert_called_once_with("p1", ProviderType.SPEECH_TO_TEXT, None)
|
|
|
|
def test_notify_provider_changed_skips_callback_in_hooks(self, manager):
|
|
"""When the same callable is both callback and hook, it should only be invoked once."""
|
|
fn = MagicMock()
|
|
manager.set_provider_change_callback(fn)
|
|
manager.register_provider_change_hook(fn)
|
|
manager._notify_provider_changed("p1", ProviderType.CHAT_COMPLETION, None)
|
|
assert fn.call_count == 1
|
|
|
|
def test_notify_provider_changed_swallows_hook_error(self, manager):
|
|
hook = MagicMock(side_effect=RuntimeError("hook failed"))
|
|
manager.register_provider_change_hook(hook)
|
|
# Should not raise
|
|
manager._notify_provider_changed("p1", ProviderType.CHAT_COMPLETION, None)
|
|
|
|
|
|
# =========================================================================
|
|
# get_provider_by_id / get_using_provider
|
|
# =========================================================================
|
|
|
|
|
|
class TestProviderManagerLookups:
|
|
@pytest.mark.asyncio
|
|
async def test_get_provider_by_id_not_found(self, manager):
|
|
result = await manager.get_provider_by_id("nonexistent")
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_provider_by_id_found(self, manager):
|
|
fake_inst = MagicMock(spec=Provider)
|
|
manager.inst_map["p1"] = fake_inst
|
|
result = await manager.get_provider_by_id("p1")
|
|
assert result is fake_inst
|
|
|
|
def test_get_using_provider_chat_completion_default(self, manager, mock_acm):
|
|
fake_provider = MagicMock(spec=Provider)
|
|
manager.provider_insts = [fake_provider]
|
|
mock_acm.get_conf.return_value = {
|
|
"provider_settings": {"default_provider_id": None}
|
|
}
|
|
result = manager.get_using_provider(ProviderType.CHAT_COMPLETION)
|
|
assert result is fake_provider
|
|
|
|
def test_get_using_provider_chat_completion_by_id(self, manager, mock_acm):
|
|
fake_provider = MagicMock(spec=Provider)
|
|
manager.inst_map["default_prov"] = fake_provider
|
|
mock_acm.get_conf.return_value = {
|
|
"provider_settings": {"default_provider_id": "default_prov"}
|
|
}
|
|
result = manager.get_using_provider(ProviderType.CHAT_COMPLETION)
|
|
assert result is fake_provider
|
|
|
|
def test_get_using_provider_chat_completion_no_instances(self, manager, mock_acm):
|
|
mock_acm.get_conf.return_value = {
|
|
"provider_settings": {"default_provider_id": None}
|
|
}
|
|
result = manager.get_using_provider(ProviderType.CHAT_COMPLETION)
|
|
assert result is None
|
|
|
|
def test_get_using_provider_stt_disabled_returns_none(self, manager, mock_acm):
|
|
mock_acm.get_conf.return_value = {
|
|
"provider_stt_settings": {"enable": False}
|
|
}
|
|
result = manager.get_using_provider(ProviderType.SPEECH_TO_TEXT)
|
|
assert result is None
|
|
|
|
def test_get_using_provider_tts_disabled_returns_none(self, manager, mock_acm):
|
|
mock_acm.get_conf.return_value = {
|
|
"provider_tts_settings": {"enable": False}
|
|
}
|
|
result = manager.get_using_provider(ProviderType.TEXT_TO_SPEECH)
|
|
assert result is None
|
|
|
|
def test_get_using_provider_unknown_type(self, manager, mock_acm):
|
|
with pytest.raises(ValueError, match="Unknown provider type"):
|
|
manager.get_using_provider(ProviderType.EMBEDDING)
|
|
|
|
def test_get_using_provider_with_umo(self, manager, mock_acm):
|
|
fake_provider = MagicMock(spec=Provider)
|
|
manager.inst_map["umo_prov"] = fake_provider
|
|
mock_acm.get_conf.return_value = {
|
|
"provider_settings": {"default_provider_id": None}
|
|
}
|
|
with patch("astrbot.core.provider.manager.sp") as mock_sp:
|
|
mock_sp.get.return_value = "umo_prov"
|
|
result = manager.get_using_provider(
|
|
ProviderType.CHAT_COMPLETION, umo="session_1"
|
|
)
|
|
assert result is fake_provider
|
|
mock_sp.get.assert_called_once()
|
|
|
|
|
|
# =========================================================================
|
|
# _resolve_env_key_list
|
|
# =========================================================================
|
|
|
|
|
|
class TestResolveEnvKeyList:
|
|
def test_no_env_vars(self, manager):
|
|
config = {"key": ["sk-abc", "sk-def"]}
|
|
result = manager._resolve_env_key_list(config)
|
|
assert result["key"] == ["sk-abc", "sk-def"]
|
|
|
|
def test_env_var_resolved(self, manager):
|
|
os.environ["MY_API_KEY"] = "sk-from-env"
|
|
config = {"key": ["$MY_API_KEY"], "id": "prov1"}
|
|
result = manager._resolve_env_key_list(config)
|
|
assert result["key"] == ["sk-from-env"]
|
|
os.environ.pop("MY_API_KEY", None)
|
|
|
|
def test_env_var_braces(self, manager):
|
|
os.environ["SECRET"] = "very_secret"
|
|
config = {"key": ["${SECRET}"], "id": "prov1"}
|
|
result = manager._resolve_env_key_list(config)
|
|
assert result["key"] == ["very_secret"]
|
|
os.environ.pop("SECRET", None)
|
|
|
|
def test_env_var_not_set_logs_warning(self, manager):
|
|
# Ensure env var does not exist
|
|
os.environ.pop("UNSET_VAR", None)
|
|
config = {"key": ["$UNSET_VAR"], "id": "prov1"}
|
|
with patch("astrbot.core.provider.manager.logger") as mock_logger:
|
|
result = manager._resolve_env_key_list(config)
|
|
assert result["key"] == [""]
|
|
mock_logger.warning.assert_called_once()
|
|
|
|
def test_env_var_mixed_list(self, manager):
|
|
os.environ["K1"] = "val1"
|
|
config = {"key": ["$K1", "static_key"], "id": "prov1"}
|
|
result = manager._resolve_env_key_list(config)
|
|
assert result["key"] == ["val1", "static_key"]
|
|
os.environ.pop("K1", None)
|
|
|
|
def test_non_list_key_passthrough(self, manager):
|
|
config = {"key": "not_a_list"}
|
|
result = manager._resolve_env_key_list(config)
|
|
assert result["key"] == "not_a_list"
|
|
|
|
def test_empty_env_var_name(self, manager):
|
|
config = {"key": ["$"], "id": "prov1"}
|
|
result = manager._resolve_env_key_list(config)
|
|
assert result["key"] == ["$"]
|
|
|
|
def test_missing_key_field(self, manager):
|
|
config = {"id": "prov1"}
|
|
result = manager._resolve_env_key_list(config)
|
|
assert result == config
|
|
|
|
|
|
# =========================================================================
|
|
# get_provider_config_by_id
|
|
# =========================================================================
|
|
|
|
|
|
class TestGetProviderConfigById:
|
|
def test_found(self, manager):
|
|
manager.providers_config = [
|
|
{"id": "p1", "type": "openai"},
|
|
{"id": "p2", "type": "anthropic"},
|
|
]
|
|
result = manager.get_provider_config_by_id("p1")
|
|
assert result == {"id": "p1", "type": "openai"}
|
|
|
|
def test_not_found(self, manager):
|
|
manager.providers_config = [{"id": "p1", "type": "openai"}]
|
|
result = manager.get_provider_config_by_id("nonexistent")
|
|
assert result is None
|
|
|
|
def test_deep_copy_returned(self, manager):
|
|
manager.providers_config = [{"id": "p1", "type": "openai", "key": ["secret"]}]
|
|
result = manager.get_provider_config_by_id("p1")
|
|
# Mutating the result should not affect the source
|
|
result["key"].append("new_key")
|
|
assert manager.providers_config[0]["key"] == ["secret"]
|
|
|
|
def test_merged_flag(self, manager):
|
|
manager.providers_config = [
|
|
{"id": "p1", "type": "openai", "provider_source_id": "src1"}
|
|
]
|
|
manager.provider_sources_config = [
|
|
{"id": "src1", "base_url": "https://api.openai.com"}
|
|
]
|
|
with patch.object(
|
|
manager,
|
|
"get_merged_provider_config",
|
|
return_value={"id": "p1", "type": "openai", "base_url": "https://api.openai.com"},
|
|
):
|
|
result = manager.get_provider_config_by_id("p1", merged=True)
|
|
assert result["base_url"] == "https://api.openai.com"
|
|
|
|
def test_empty_configs(self, manager):
|
|
result = manager.get_provider_config_by_id("p1")
|
|
assert result is None
|
|
|
|
|
|
# =========================================================================
|
|
# _get_all_provider_instances / _clear_loaded_instances / get_insts
|
|
# =========================================================================
|
|
|
|
|
|
class TestProviderManagerInstances:
|
|
def test_get_insts(self, manager):
|
|
fp = MagicMock(spec=Provider)
|
|
manager.provider_insts = [fp]
|
|
assert manager.get_insts() == [fp]
|
|
|
|
def test_get_all_provider_instances_deduplicates(self, manager):
|
|
fp = MagicMock(spec=Provider)
|
|
manager.provider_insts = [fp]
|
|
manager.inst_map = {"p1": fp}
|
|
all_insts = manager._get_all_provider_instances()
|
|
# fp appears in both lists but should only be returned once
|
|
assert len(all_insts) == 1
|
|
assert all_insts[0] is fp
|
|
|
|
def test_get_all_provider_instances_returns_all_types(self, manager):
|
|
fp = MagicMock(spec=Provider)
|
|
stt = MagicMock(spec=STTProvider)
|
|
tts = MagicMock(spec=TTSProvider)
|
|
emb = MagicMock(spec=EmbeddingProvider)
|
|
rerank = MagicMock(spec=RerankProvider)
|
|
manager.provider_insts = [fp]
|
|
manager.stt_provider_insts = [stt]
|
|
manager.tts_provider_insts = [tts]
|
|
manager.embedding_provider_insts = [emb]
|
|
manager.rerank_provider_insts = [rerank]
|
|
all_insts = manager._get_all_provider_instances()
|
|
assert len(all_insts) == 5
|
|
|
|
def test_clear_loaded_instances(self, manager):
|
|
manager.provider_insts = [MagicMock(spec=Provider)]
|
|
manager.stt_provider_insts = [MagicMock(spec=STTProvider)]
|
|
manager.inst_map = {"p1": MagicMock()}
|
|
manager.curr_provider_inst = MagicMock()
|
|
|
|
manager._clear_loaded_instances()
|
|
assert manager.provider_insts == []
|
|
assert manager.stt_provider_insts == []
|
|
assert manager.tts_provider_insts == []
|
|
assert manager.embedding_provider_insts == []
|
|
assert manager.rerank_provider_insts == []
|
|
assert manager.inst_map == {}
|
|
assert manager.curr_provider_inst is None
|
|
assert manager.curr_stt_provider_inst is None
|
|
assert manager.curr_tts_provider_inst is None
|