diff --git a/CHANGELOG.md b/CHANGELOG.md index d23f931a..37c10da6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **MiniMax Token Plan model availability stays current.** MiniMax login and startup refresh now use the authenticated model catalog so Token Plan keys only keep models actually available to that key, while preserving user model preferences and isolating discovery failures from other provider refreshes. + ## 0.28.0 (2026-05-31) ### What changed in this release diff --git a/src/pythinker_code/auth/minimax.py b/src/pythinker_code/auth/minimax.py index 7c719960..30bb97d8 100644 --- a/src/pythinker_code/auth/minimax.py +++ b/src/pythinker_code/auth/minimax.py @@ -1,7 +1,7 @@ from __future__ import annotations import os -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Mapping from dataclasses import dataclass from typing import Any, cast @@ -15,9 +15,13 @@ MINIMAX_ANTHROPIC_BASE_URL = "https://api.minimax.io/anthropic" MINIMAX_OPENAI_BASE_URL = "https://api.minimax.io/v1" +MINIMAX_ANTHROPIC_MODELS_URL = f"{MINIMAX_ANTHROPIC_BASE_URL}/v1/models" +MINIMAX_OPENAI_MODELS_URL = f"{MINIMAX_OPENAI_BASE_URL}/models" MINIMAX_ANTHROPIC_PROVIDER_KEY = "managed:minimax-anthropic" MINIMAX_DEFAULT_MODEL_ALIAS = "minimax/m2.7" +MINIMAX_DEFAULT_CONTEXT = 192_000 MINIMAX_TOKEN_PLAN_KEY_PREFIX = "sk-cp-" +MINIMAX_MODEL_DISCOVERY_TIMEOUT = aiohttp.ClientTimeout(total=15, sock_connect=8, sock_read=10) @dataclass(frozen=True, slots=True) @@ -26,7 +30,7 @@ class MiniMaxModel: alias_suffix: str display_name: str provider_key: str = MINIMAX_ANTHROPIC_PROVIDER_KEY - max_context_size: int = 192_000 + max_context_size: int = MINIMAX_DEFAULT_CONTEXT @property def alias(self) -> str: @@ -72,14 +76,17 @@ def _apply_minimax_config( display_name=model.display_name, ) - fallback = next( - (m.alias for m in models), - next(iter(config.models), ""), - ) - if MINIMAX_DEFAULT_MODEL_ALIAS in config.models: - config.default_model = MINIMAX_DEFAULT_MODEL_ALIAS - else: - config.default_model = fallback + if models: + fallback = next( + (m.alias for m in models), + next(iter(config.models), ""), + ) + if MINIMAX_DEFAULT_MODEL_ALIAS in config.models: + config.default_model = MINIMAX_DEFAULT_MODEL_ALIAS + else: + config.default_model = fallback + elif config.default_model not in config.models: + config.default_model = next(iter(config.models), "") config.default_thinking = False @@ -87,54 +94,215 @@ def _model_by_id() -> dict[str, MiniMaxModel]: return {model.model_id: model for model in MINIMAX_MODELS} +def _is_supported_minimax_chat_model(model_id: str) -> bool: + """Return whether a discovered model ID belongs on the chat provider. + + MiniMax's documented `/models` responses are account/key-specific and may + change as plans gain or lose access. Keep that list authoritative while + avoiding non-text modality models that would be invalid for the Anthropic + messages provider configured below. + """ + return model_id.startswith("MiniMax-M") + + +def _derive_alias_suffix(model_id: str) -> str: + if model_id.startswith("MiniMax-"): + model_id = model_id.removeprefix("MiniMax-") + return model_id.strip().lower().replace(" ", "-") + + +def _derive_display_name(model_id: str) -> str: + if model_id.startswith("MiniMax-"): + model_id = model_id.replace("MiniMax-", "MiniMax ", 1) + return model_id.replace("-", " ") + + +def _to_positive_int(value: Any) -> int | None: + if isinstance(value, bool): + return None + try: + parsed = int(value) + except (TypeError, ValueError): + return None + return parsed if parsed > 0 else None + + +def _context_size_from_item(item: Mapping[str, Any], fallback: int) -> int: + for key in ("context_length", "max_context_length", "max_tokens"): + parsed = _to_positive_int(item.get(key)) + if parsed is not None: + return parsed + return fallback + + +def _display_name_from_item(item: Mapping[str, Any], fallback: str) -> str: + for key in ("display_name", "name"): + value = item.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return fallback + + def _parse_discovered_models(data: object) -> tuple[MiniMaxModel, ...]: if not isinstance(data, dict): return () - data = cast(dict[str, Any], data) - raw_items = data.get("data") + raw_items = cast(dict[str, Any], data).get("data") if not isinstance(raw_items, list): return () known = _model_by_id() + seen: set[str] = set() result: list[MiniMaxModel] = [] - for item in cast(list[dict[str, Any]], raw_items): + for raw_item in cast(list[Any], raw_items): + if not isinstance(raw_item, Mapping): + continue + item = cast(Mapping[str, Any], raw_item) model_id = item.get("id") - if not isinstance(model_id, str) or model_id not in known: + if not isinstance(model_id, str) or not model_id.strip(): + continue + model_id = model_id.strip() + if model_id in seen or not _is_supported_minimax_chat_model(model_id): continue - current = known[model_id] - context_length = item.get("context_length") - max_context_size = current.max_context_size - if isinstance(context_length, int) and context_length > 0: - max_context_size = context_length - display_name_raw = item.get("display_name") - display_name = ( - display_name_raw - if isinstance(display_name_raw, str) and display_name_raw - else current.display_name + seen.add(model_id) + + current = known.get(model_id) + alias_suffix = current.alias_suffix if current else _derive_alias_suffix(model_id) + display_name = _display_name_from_item( + item, + current.display_name if current else _derive_display_name(model_id), + ) + max_context_size = _context_size_from_item( + item, + current.max_context_size if current else MINIMAX_DEFAULT_CONTEXT, ) result.append( MiniMaxModel( - model_id=current.model_id, - alias_suffix=current.alias_suffix, + model_id=model_id, + alias_suffix=alias_suffix, display_name=display_name, - provider_key=current.provider_key, + provider_key=current.provider_key if current else MINIMAX_ANTHROPIC_PROVIDER_KEY, max_context_size=max_context_size, ) ) return tuple(result) -async def _discover_minimax_models(api_key: str) -> tuple[MiniMaxModel, ...]: - async with ( - new_client_session() as session, - session.get( - f"{MINIMAX_OPENAI_BASE_URL}/models", - headers={"Authorization": f"Bearer {api_key}"}, - raise_for_status=True, - ) as response, - ): +async def _fetch_minimax_models( + session: aiohttp.ClientSession, + *, + url: str, + headers: Mapping[str, str], +) -> tuple[MiniMaxModel, ...]: + async with session.get(url, headers=headers, raise_for_status=True) as response: payload = await response.json(content_type=None) - return _parse_discovered_models(payload) + if not isinstance(payload, dict): + raise ValueError(f"Unexpected MiniMax models response for {url}") + payload_map = cast(dict[str, Any], payload) + if not isinstance(payload_map.get("data"), list): + raise ValueError(f"Unexpected MiniMax models response for {url}") + return _parse_discovered_models(payload_map) + + +async def _discover_minimax_models(api_key: str) -> tuple[MiniMaxModel, ...]: + errors: list[Exception] = [] + auth_errors: list[aiohttp.ClientResponseError] = [] + async with new_client_session(timeout=MINIMAX_MODEL_DISCOVERY_TIMEOUT) as session: + # Prefer the Anthropic-compatible model list because configured chat + # traffic uses that provider shape. Fall back to the OpenAI-compatible + # list, which MiniMax also documents and historically exposed first. + for url, headers in ( + (MINIMAX_ANTHROPIC_MODELS_URL, {"X-Api-Key": api_key}), + (MINIMAX_OPENAI_MODELS_URL, {"Authorization": f"Bearer {api_key}"}), + ): + try: + models = await _fetch_minimax_models(session, url=url, headers=headers) + except aiohttp.ClientResponseError as exc: + if exc.status in {401, 403}: + auth_errors.append(exc) + else: + errors.append(exc) + continue + except (aiohttp.ClientError, TimeoutError, ValueError) as exc: + errors.append(exc) + continue + return models + + if auth_errors: + raise auth_errors[0] + if errors: + raise errors[-1] + return () + + +def _minimax_api_key(config: Config) -> str | None: + provider = config.providers.get(MINIMAX_ANTHROPIC_PROVIDER_KEY) + if provider is None: + return None + value = provider.api_key.get_secret_value().strip() + return value or None + + +def apply_minimax_models(config: Config, models: tuple[MiniMaxModel, ...]) -> bool: + """Upsert the live MiniMax catalog and prune models no longer returned. + + Preserves user preferences unless the selected MiniMax model disappeared. + The authenticated `/models` response is the authority for which models are + available to the saved key, including Token Plan subscription keys. + """ + changed = False + aliases: list[str] = [] + for model in models: + alias = model.alias + aliases.append(alias) + existing = config.models.get(alias) + if existing is None: + config.models[alias] = LLMModel( + provider=model.provider_key, + model=model.model_id, + max_context_size=model.max_context_size, + display_name=model.display_name, + ) + changed = True + continue + if existing.provider != model.provider_key: + existing.provider = model.provider_key + changed = True + if existing.model != model.model_id: + existing.model = model.model_id + changed = True + if existing.max_context_size != model.max_context_size: + existing.max_context_size = model.max_context_size + changed = True + if existing.display_name != model.display_name: + existing.display_name = model.display_name + changed = True + + alias_set = set(aliases) + removed_default = False + for alias, model_cfg in list(config.models.items()): + if model_cfg.provider != MINIMAX_ANTHROPIC_PROVIDER_KEY: + continue + if alias in alias_set: + continue + del config.models[alias] + if config.default_model == alias: + removed_default = True + changed = True + + if removed_default: + config.default_model = aliases[0] if aliases else next(iter(config.models), "") + changed = True + elif config.default_model and config.default_model not in config.models: + config.default_model = next(iter(config.models), "") + changed = True + return changed + + +async def refresh_minimax_models(config: Config) -> tuple[MiniMaxModel, ...] | None: + api_key = _minimax_api_key(config) + if api_key is None: + return None + return await _discover_minimax_models(api_key) async def login_minimax_api_key( @@ -161,9 +329,7 @@ async def login_minimax_api_key( models = MINIMAX_MODELS try: - discovered = await _discover_minimax_models(resolved_key) - if discovered: - models = discovered + models = await _discover_minimax_models(resolved_key) except aiohttp.ClientResponseError as exc: if exc.status in {401, 403}: yield OAuthEvent("error", "Invalid MiniMax API key; the key was not saved.") diff --git a/src/pythinker_code/auth/platforms.py b/src/pythinker_code/auth/platforms.py index 9d0e81bc..77fec38e 100644 --- a/src/pythinker_code/auth/platforms.py +++ b/src/pythinker_code/auth/platforms.py @@ -231,6 +231,12 @@ async def refresh_managed_models(config: Config) -> bool: if not config.is_from_default_location: return False + from pythinker_code.auth.minimax import ( + MINIMAX_ANTHROPIC_PROVIDER_KEY, + MiniMaxModel, + apply_minimax_models, + refresh_minimax_models, + ) from pythinker_code.auth.opencode_go import ( OPENCODE_GO_PROVIDER_KEYS, OpenCodeGoModel, @@ -248,10 +254,14 @@ async def refresh_managed_models(config: Config) -> bool: updates: list[tuple[str, str, list[ModelInfo]]] = [] oauth_manager = None for provider_key, provider in managed_providers.items(): - # OpenCode Go uses a two-shape (OpenAI- + Anthropic-compatible) provider - # split that the generic single-provider path below can't express, so it - # is refreshed via its own discovery after this loop. - if provider_key in OPENCODE_GO_PROVIDER_KEYS: + # OpenCode Go and MiniMax own provider-specific model discovery. The + # generic `managed:` path can't express OpenCode Go's + # two-provider split, and MiniMax's provider key intentionally includes + # the wire-shape suffix (`managed:minimax-anthropic`). + if ( + provider_key in OPENCODE_GO_PROVIDER_KEYS + or provider_key == MINIMAX_ANTHROPIC_PROVIDER_KEY + ): continue platform_id = parse_managed_provider_key(provider_key) if not platform_id: @@ -399,6 +409,14 @@ async def refresh_managed_models(config: Config) -> bool: if opencode_go_models and apply_opencode_go_models(config, opencode_go_models): changed = True + minimax_models: tuple[MiniMaxModel, ...] | None = None + try: + minimax_models = await refresh_minimax_models(config) + except (aiohttp.ClientError, TimeoutError, ValueError) as exc: + logger.warning("Failed to refresh MiniMax models: {error}", error=exc) + if minimax_models is not None and apply_minimax_models(config, minimax_models): + changed = True + if changed: config_for_save = load_config() save_changed = False @@ -407,6 +425,8 @@ async def refresh_managed_models(config: Config) -> bool: save_changed = True if opencode_go_models and apply_opencode_go_models(config_for_save, opencode_go_models): save_changed = True + if minimax_models is not None and apply_minimax_models(config_for_save, minimax_models): + save_changed = True if save_changed: save_config(config_for_save) return changed diff --git a/tests/auth/test_minimax_auth.py b/tests/auth/test_minimax_auth.py index 35b1fd79..5bcdac18 100644 --- a/tests/auth/test_minimax_auth.py +++ b/tests/auth/test_minimax_auth.py @@ -6,7 +6,7 @@ from pydantic import SecretStr from yarl import URL -from pythinker_code.config import Config +from pythinker_code.config import Config, LLMModel, LLMProvider def test_minimax_model_catalog_contains_four_current_models(): @@ -65,6 +65,34 @@ def test_apply_minimax_config_writes_provider_and_default(): assert config.default_model == "minimax/m2.7" +def test_apply_minimax_config_empty_catalog_preserves_non_minimax_default(): + from pythinker_code.auth.minimax import _apply_minimax_config + + config = Config(is_from_default_location=True) + config.providers["managed:openai"] = LLMProvider( + type="openai_responses", + base_url="https://api.openai.com/v1", + api_key=SecretStr("sk-test"), + ) + config.models["openai/gpt-5.2"] = LLMModel( + provider="managed:openai", + model="gpt-5.2", + max_context_size=400_000, + ) + config.default_model = "openai/gpt-5.2" + + _apply_minimax_config(config, SecretStr("sk-cp-test"), models=()) + + assert config.default_model == "openai/gpt-5.2" + assert config.models == { + "openai/gpt-5.2": LLMModel( + provider="managed:openai", + model="gpt-5.2", + max_context_size=400_000, + ) + } + + def _request_info(url: str) -> aiohttp.RequestInfo: return aiohttp.RequestInfo( url=URL(url), @@ -169,6 +197,48 @@ async def fake_discover(api_key): assert config.models["minimax/m2.7"].max_context_size == 512_000 +@pytest.mark.asyncio +async def test_login_minimax_token_plan_uses_discovered_available_subset(monkeypatch, tmp_path): + from pythinker_code.auth.minimax import MINIMAX_ANTHROPIC_MODELS_URL, login_minimax_api_key + + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + config = Config(is_from_default_location=True) + + class FakeResponse: + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + async def json(self, *, content_type=None): + return {"data": [{"id": "MiniMax-M2.7"}]} + + class FakeSession: + def __init__(self): + self.calls: list[tuple[str, dict[str, str]]] = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + def get(self, url, *, headers, raise_for_status): + self.calls.append((url, headers)) + return FakeResponse() + + session = FakeSession() + monkeypatch.setattr("pythinker_code.auth.minimax.new_client_session", lambda **_: session) + + events = [event async for event in login_minimax_api_key(config, "sk-cp-token-plan-abc")] + + assert [event.type for event in events] == ["info", "success"] + assert session.calls == [(MINIMAX_ANTHROPIC_MODELS_URL, {"X-Api-Key": "sk-cp-token-plan-abc"})] + assert set(config.models) == {"minimax/m2.7"} + assert config.default_model == "minimax/m2.7" + + @pytest.mark.asyncio async def test_login_minimax_requires_key(monkeypatch, tmp_path): from pythinker_code.auth.minimax import login_minimax_api_key @@ -201,6 +271,8 @@ async def fake_discover(api_key): assert "Token Plan" in events[0].message assert types[-1] == "success" assert "sk-cp-token-plan-abc" not in "\n".join(event.json for event in events) + assert config.models == {} + assert config.default_model == "" @pytest.mark.asyncio @@ -258,6 +330,102 @@ def test_parse_discovered_minimax_models_overrides_context_length_only_for_posit assert by_id["MiniMax-M2.5-highspeed"].max_context_size == 384_000 +def test_parse_discovered_minimax_models_accepts_live_future_models_and_filters_modalities(): + from pythinker_code.auth.minimax import _parse_discovered_models + + payload = { + "data": [ + { + "id": "MiniMax-M2.7", + "display_name": "MiniMax M2.7 Live", + "context_length": 205_000, + }, + { + "id": "MiniMax-M3", + "name": "MiniMax M3", + "max_context_length": 512_000, + }, + {"id": "MiniMax-Audio-01", "context_length": 1_000}, + {"id": "MiniMax-M3", "context_length": 999_000}, + ] + } + + result = _parse_discovered_models(payload) + + assert [model.alias for model in result] == ["minimax/m2.7", "minimax/m3"] + by_id = {m.model_id: m for m in result} + assert by_id["MiniMax-M2.7"].display_name == "MiniMax M2.7 Live" + assert by_id["MiniMax-M2.7"].max_context_size == 205_000 + assert by_id["MiniMax-M3"].display_name == "MiniMax M3" + assert by_id["MiniMax-M3"].max_context_size == 512_000 + + +def test_apply_minimax_models_prunes_stale_models_and_preserves_user_prefs(): + from pythinker_code.auth.minimax import ( + MINIMAX_ANTHROPIC_PROVIDER_KEY, + MiniMaxModel, + _apply_minimax_config, + apply_minimax_models, + ) + + config = Config(is_from_default_location=True) + _apply_minimax_config(config, SecretStr("mx-test")) + config.default_model = "minimax/m2.7" + config.default_thinking = True + + discovered = ( + MiniMaxModel("MiniMax-M2.7", "m2.7", "MiniMax M2.7", max_context_size=205_000), + MiniMaxModel("MiniMax-M3", "m3", "MiniMax M3", max_context_size=512_000), + ) + + changed = apply_minimax_models(config, discovered) + + assert changed is True + minimax_aliases = { + alias + for alias, model in config.models.items() + if model.provider == MINIMAX_ANTHROPIC_PROVIDER_KEY + } + assert minimax_aliases == {"minimax/m2.7", "minimax/m3"} + assert config.models["minimax/m2.7"].max_context_size == 205_000 + assert config.default_model == "minimax/m2.7" + assert config.default_thinking is True + + +def test_apply_minimax_models_reassigns_removed_default_and_returns_false_for_noop(): + from pythinker_code.auth.minimax import ( + MiniMaxModel, + _apply_minimax_config, + apply_minimax_models, + ) + + config = Config(is_from_default_location=True) + current = (MiniMaxModel("MiniMax-M2.7", "m2.7", "MiniMax M2.7"),) + _apply_minimax_config(config, SecretStr("mx-test"), models=current) + config.models["openai/gpt-5.2"] = LLMModel( + provider="managed:openai", + model="gpt-5.2", + max_context_size=400_000, + ) + config.providers["managed:openai"] = LLMProvider( + type="openai_responses", + base_url="https://api.openai.com/v1", + api_key=SecretStr("sk-test"), + ) + + assert apply_minimax_models(config, current) is False + + config.default_model = "minimax/m2.7" + changed = apply_minimax_models( + config, + (MiniMaxModel("MiniMax-M3", "m3", "MiniMax M3", max_context_size=512_000),), + ) + + assert changed is True + assert "minimax/m2.7" not in config.models + assert config.default_model == "minimax/m3" + + @pytest.mark.asyncio async def test_logout_minimax_removes_only_minimax(monkeypatch, tmp_path): from pythinker_code.auth.minimax import ( diff --git a/tests/auth/test_platforms.py b/tests/auth/test_platforms.py index c896f9d5..ea0d1d30 100644 --- a/tests/auth/test_platforms.py +++ b/tests/auth/test_platforms.py @@ -742,3 +742,177 @@ def _config_with_generic_provider() -> Config: # OpenCode Go list is left exactly as-is (not wiped, not half-applied). assert "opencode-go/qwen3.7-max" not in config.models assert config.models["opencode-go/qwen3.5-plus"].provider == OPENCODE_GO_OPENAI_PROVIDER_KEY + + +def _make_minimax_config() -> Config: + from pythinker_code.auth.minimax import ( + MINIMAX_ANTHROPIC_BASE_URL, + MINIMAX_ANTHROPIC_PROVIDER_KEY, + ) + + config = Config( + default_model="minimax/m2.7", + default_thinking=True, + providers={ + MINIMAX_ANTHROPIC_PROVIDER_KEY: LLMProvider( + type="anthropic", + base_url=MINIMAX_ANTHROPIC_BASE_URL, + api_key=SecretStr("sk-cp-test"), + ) + }, + models={ + "minimax/m2.7": LLMModel( + provider=MINIMAX_ANTHROPIC_PROVIDER_KEY, + model="MiniMax-M2.7", + max_context_size=192_000, + ), + "minimax/m2.7-highspeed": LLMModel( + provider=MINIMAX_ANTHROPIC_PROVIDER_KEY, + model="MiniMax-M2.7-highspeed", + max_context_size=192_000, + ), + }, + services=Services(), + ) + config.is_from_default_location = True + return config + + +@pytest.mark.asyncio +async def test_refresh_managed_models_refreshes_minimax_token_plan_without_relogin(): + """MiniMax startup refresh must use the authenticated live catalog. + + Token Plan availability is key-specific, so stale aliases from an older + login must be pruned and newly available models surfaced without routing + through the generic managed-provider path. + """ + from pythinker_code.auth.minimax import ( + MINIMAX_ANTHROPIC_PROVIDER_KEY, + MiniMaxModel, + ) + + config = _make_minimax_config() + discovered = ( + MiniMaxModel("MiniMax-M2.7", "m2.7", "MiniMax M2.7", max_context_size=205_000), + MiniMaxModel("MiniMax-M3", "m3", "MiniMax M3", max_context_size=512_000), + ) + saved: list[Config] = [] + + with ( + patch( + "pythinker_code.auth.minimax.refresh_minimax_models", + new=AsyncMock(return_value=discovered), + ), + patch("pythinker_code.auth.platforms.list_models", new=AsyncMock()) as list_models_mock, + patch("pythinker_code.auth.platforms.load_config", side_effect=_make_minimax_config), + patch( + "pythinker_code.auth.platforms.save_config", + side_effect=lambda cfg, *a, **k: saved.append(cfg), + ), + ): + changed = await refresh_managed_models(config) + + assert changed is True + assert list_models_mock.await_count == 0 + assert "minimax/m2.7-highspeed" not in config.models + assert config.models["minimax/m2.7"].max_context_size == 205_000 + assert config.models["minimax/m3"].provider == MINIMAX_ANTHROPIC_PROVIDER_KEY + assert config.default_model == "minimax/m2.7" + assert config.default_thinking is True + assert len(saved) == 1 + assert "minimax/m3" in saved[0].models + assert "minimax/m2.7-highspeed" not in saved[0].models + + +@pytest.mark.asyncio +async def test_refresh_managed_models_applies_empty_minimax_catalog(): + """An authenticated empty MiniMax catalog is authoritative and prunes stale models.""" + from pythinker_code.auth.minimax import MINIMAX_ANTHROPIC_PROVIDER_KEY + + config = _make_minimax_config() + saved: list[Config] = [] + + with ( + patch( + "pythinker_code.auth.minimax.refresh_minimax_models", + new=AsyncMock(return_value=()), + ) as refresh_mock, + patch("pythinker_code.auth.platforms.list_models", new=AsyncMock()) as list_models_mock, + patch("pythinker_code.auth.platforms.load_config", side_effect=_make_minimax_config), + patch( + "pythinker_code.auth.platforms.save_config", + side_effect=lambda cfg, *a, **k: saved.append(cfg), + ), + ): + changed = await refresh_managed_models(config) + + assert changed is True + assert refresh_mock.await_count == 1 + assert list_models_mock.await_count == 0 + assert not any( + model.provider == MINIMAX_ANTHROPIC_PROVIDER_KEY for model in config.models.values() + ) + assert config.default_model == "" + assert len(saved) == 1 + assert not any( + model.provider == MINIMAX_ANTHROPIC_PROVIDER_KEY for model in saved[0].models.values() + ) + + +@pytest.mark.asyncio +async def test_refresh_managed_models_isolates_minimax_discovery_failure(): + """MiniMax refresh failure must not abort other managed-provider refreshes.""" + + def _config_with_generic_provider() -> Config: + cfg = _make_minimax_config() + cfg.providers["managed:pythinker-code"] = LLMProvider( + type="pythinker", + base_url="https://api.test/v1", + api_key=SecretStr("k"), + ) + cfg.models["pythinker-code/pythinker-for-coding"] = LLMModel( + provider="managed:pythinker-code", + model="pythinker-for-coding", + max_context_size=100_000, + ) + return cfg + + config = _config_with_generic_provider() + generic_models = [ + ModelInfo( + id="pythinker-for-coding", + context_length=200_000, + supports_reasoning=False, + supports_image_in=False, + supports_video_in=False, + display_name=None, + ) + ] + saved: list[Config] = [] + + with ( + patch( + "pythinker_code.auth.minimax.refresh_minimax_models", + new=AsyncMock(side_effect=aiohttp.ClientConnectionError("offline")), + ), + patch( + "pythinker_code.auth.platforms.list_models", + new=AsyncMock(return_value=generic_models), + ), + patch( + "pythinker_code.auth.platforms.load_config", + side_effect=_config_with_generic_provider, + ), + patch( + "pythinker_code.auth.platforms.save_config", + side_effect=lambda cfg, *a, **k: saved.append(cfg), + ), + ): + changed = await refresh_managed_models(config) + + assert changed is True + assert len(saved) == 1 + assert saved[0].models["pythinker-code/pythinker-for-coding"].max_context_size == 200_000 + assert "minimax/m2.7-highspeed" in saved[0].models + assert saved[0].models["minimax/m2.7-highspeed"].provider == "managed:minimax-anthropic" + assert "minimax/m2.7-highspeed" in config.models