diff --git a/astrbot/core/provider/sources/minimax_token_plan_source.py b/astrbot/core/provider/sources/minimax_token_plan_source.py index 8d86c77b73..db1a19225b 100644 --- a/astrbot/core/provider/sources/minimax_token_plan_source.py +++ b/astrbot/core/provider/sources/minimax_token_plan_source.py @@ -5,6 +5,12 @@ from ..register import register_provider_adapter +# Current MiniMax Token Plan model IDs. Returned as a fallback when the +# dynamic /v1/models fetch is unavailable (no API key configured yet or a +# transient network error) so the model picker can still surface the current +# models. The dynamic list remains authoritative whenever it succeeds. +_MINIMAX_TOKEN_PLAN_FALLBACK_MODELS = ["MiniMax-M3", "MiniMax-M2.7"] + @register_provider_adapter( "minimax_token_plan", @@ -16,6 +22,8 @@ class ProviderMiniMaxTokenPlan(ProviderAnthropic): The model list is fetched dynamically from the MiniMax API's /v1/models endpoint, so newly released models are automatically discovered without a code change. The default model is MiniMax-M3, the current flagship. + When the dynamic fetch is unavailable, a small fallback list of current + model IDs is returned so the dashboard is never empty. """ def __init__( @@ -41,11 +49,16 @@ def __init__( self.set_model(configured_model) async def get_models(self) -> list[str]: - """Dynamically fetch available models from the MiniMax API.""" + """Fetch available models from the MiniMax API. + + Returns the current fallback model IDs when the dynamic fetch is + unavailable (no API key or a network/API error) so the model picker + is never empty. The dynamic list is authoritative when it succeeds. + """ key = self.chosen_api_key if not key: logger.warning("No API key configured for MiniMax Token Plan.") - return [] + return _MINIMAX_TOKEN_PLAN_FALLBACK_MODELS.copy() try: async with httpx.AsyncClient() as client: resp = await client.get( @@ -55,7 +68,11 @@ async def get_models(self) -> list[str]: ) resp.raise_for_status() data = resp.json() - return [m["id"] for m in data.get("data", [])] + models = [m["id"] for m in data.get("data", [])] + if models: + return models + logger.warning("MiniMax /v1/models returned an empty list.") + return _MINIMAX_TOKEN_PLAN_FALLBACK_MODELS.copy() except Exception as e: logger.error(f"Failed to fetch MiniMax model list: {e}") - return [] + return _MINIMAX_TOKEN_PLAN_FALLBACK_MODELS.copy() diff --git a/tests/test_minimax_token_plan_source.py b/tests/test_minimax_token_plan_source.py new file mode 100644 index 0000000000..012e2e9ac0 --- /dev/null +++ b/tests/test_minimax_token_plan_source.py @@ -0,0 +1,126 @@ +from typing import Any +from unittest.mock import patch + +import httpx +import pytest + +import astrbot.core.provider.sources.anthropic_source as anthropic_source +from astrbot.core.provider.sources.minimax_token_plan_source import ( + ProviderMiniMaxTokenPlan, +) + + +class _FakeAsyncAnthropic: + """Minimal AsyncAnthropic stand-in for provider construction in tests.""" + + def __init__(self, **kwargs: Any) -> None: + self.kwargs = kwargs + + async def close(self) -> None: + return None + + +def _make_provider(monkeypatch, key=None): + monkeypatch.setattr(anthropic_source, "AsyncAnthropic", _FakeAsyncAnthropic) + provider_config: dict[str, Any] = { + "id": "minimax-token-plan-test", + "type": "minimax_token_plan", + } + if key is not None: + provider_config["key"] = key + return ProviderMiniMaxTokenPlan( + provider_config=provider_config, provider_settings={} + ) + + +@pytest.mark.asyncio +async def test_get_models_returns_fallback_when_no_api_key(monkeypatch): + provider = _make_provider(monkeypatch, key=[""]) + models = await provider.get_models() + assert models == ["MiniMax-M3", "MiniMax-M2.7"] + + +@pytest.mark.asyncio +async def test_get_models_returns_dynamic_list_when_available(monkeypatch): + provider = _make_provider(monkeypatch, key=["test-key"]) + payload = { + "data": [{"id": "MiniMax-M3"}, {"id": "MiniMax-M2.7"}, {"id": "MiniMax-M2"}] + } + + class _FakeResponse: + def raise_for_status(self) -> None: + return None + + def json(self) -> dict: + return payload + + class _FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def get(self, url, headers=None, timeout=None): + return _FakeResponse() + + with patch( + "astrbot.core.provider.sources.minimax_token_plan_source.httpx.AsyncClient", + return_value=_FakeClient(), + ): + models = await provider.get_models() + + assert models == ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2"] + + +@pytest.mark.asyncio +async def test_get_models_returns_fallback_on_empty_dynamic_list(monkeypatch): + provider = _make_provider(monkeypatch, key=["test-key"]) + + class _FakeResponse: + def raise_for_status(self) -> None: + return None + + def json(self) -> dict: + return {"data": []} + + class _FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def get(self, url, headers=None, timeout=None): + return _FakeResponse() + + with patch( + "astrbot.core.provider.sources.minimax_token_plan_source.httpx.AsyncClient", + return_value=_FakeClient(), + ): + models = await provider.get_models() + + assert models == ["MiniMax-M3", "MiniMax-M2.7"] + + +@pytest.mark.asyncio +async def test_get_models_returns_fallback_on_fetch_error(monkeypatch): + provider = _make_provider(monkeypatch, key=["test-key"]) + + class _FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def get(self, url, headers=None, timeout=None): + raise httpx.ConnectError("connection refused") + + with patch( + "astrbot.core.provider.sources.minimax_token_plan_source.httpx.AsyncClient", + return_value=_FakeClient(), + ): + models = await provider.get_models() + + assert models == ["MiniMax-M3", "MiniMax-M2.7"]