From 9892a429243bf8f5f8dff06b2c2f072ac3b8541f Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Mon, 1 Jun 2026 12:31:23 -0400 Subject: [PATCH 1/4] fix(minimax): refresh token-plan model availability --- src/pythinker_code/auth/minimax.py | 218 +++++++++++++++++++++++---- src/pythinker_code/auth/platforms.py | 28 +++- tests/auth/test_minimax_auth.py | 124 ++++++++++++++- tests/auth/test_platforms.py | 137 +++++++++++++++++ 4 files changed, 474 insertions(+), 33 deletions(-) diff --git a/src/pythinker_code/auth/minimax.py b/src/pythinker_code/auth/minimax.py index 7c719960..00554f3e 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: @@ -87,56 +91,214 @@ 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 - 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 + model_id = model_id.strip() + if model_id in seen or not _is_supported_minimax_chat_model(model_id): + continue + 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) +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 + if models: + 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 + discovered = await _discover_minimax_models(api_key) + return discovered or None + + async def login_minimax_api_key( config: Config, api_key: str | None = None ) -> AsyncIterator[OAuthEvent]: diff --git a/src/pythinker_code/auth/platforms.py b/src/pythinker_code/auth/platforms.py index 9d0e81bc..815fab44 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 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 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..63ea89bd 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(): @@ -169,6 +169,32 @@ 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 MiniMaxModel, login_minimax_api_key + + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + config = Config(is_from_default_location=True) + + async def fake_discover(api_key): + assert api_key == "sk-cp-token-plan-abc" + return ( + MiniMaxModel( + model_id="MiniMax-M2.7", + alias_suffix="m2.7", + display_name="MiniMax M2.7", + ), + ) + + monkeypatch.setattr("pythinker_code.auth.minimax._discover_minimax_models", fake_discover) + + 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 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 @@ -258,6 +284,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..39747baa 100644 --- a/tests/auth/test_platforms.py +++ b/tests/auth/test_platforms.py @@ -742,3 +742,140 @@ 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._discover_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_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._discover_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 config.models From e1debb416344c9547514a84a4491b5f4fc8becad Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Mon, 1 Jun 2026 12:39:58 -0400 Subject: [PATCH 2/4] docs(changelog): note minimax model refresh --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) 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 From a3fcb22c24ac2815b8fcdf1f642e27f3eeb6d0da Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Mon, 1 Jun 2026 12:51:31 -0400 Subject: [PATCH 3/4] fix(minimax): apply empty live model catalogs --- src/pythinker_code/auth/minimax.py | 17 ++++++------ src/pythinker_code/auth/platforms.py | 4 +-- tests/auth/test_minimax_auth.py | 40 +++++++++++++++++++-------- tests/auth/test_platforms.py | 41 ++++++++++++++++++++++++++-- 4 files changed, 79 insertions(+), 23 deletions(-) diff --git a/src/pythinker_code/auth/minimax.py b/src/pythinker_code/auth/minimax.py index 00554f3e..acd33758 100644 --- a/src/pythinker_code/auth/minimax.py +++ b/src/pythinker_code/auth/minimax.py @@ -192,7 +192,12 @@ async def _fetch_minimax_models( ) -> 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, ...]: @@ -217,8 +222,7 @@ async def _discover_minimax_models(api_key: str) -> tuple[MiniMaxModel, ...]: except (aiohttp.ClientError, TimeoutError, ValueError) as exc: errors.append(exc) continue - if models: - return models + return models if auth_errors: raise auth_errors[0] @@ -295,8 +299,7 @@ async def refresh_minimax_models(config: Config) -> tuple[MiniMaxModel, ...] | N api_key = _minimax_api_key(config) if api_key is None: return None - discovered = await _discover_minimax_models(api_key) - return discovered or None + return await _discover_minimax_models(api_key) async def login_minimax_api_key( @@ -323,9 +326,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 815fab44..77fec38e 100644 --- a/src/pythinker_code/auth/platforms.py +++ b/src/pythinker_code/auth/platforms.py @@ -414,7 +414,7 @@ async def refresh_managed_models(config: Config) -> bool: 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 and apply_minimax_models(config, minimax_models): + if minimax_models is not None and apply_minimax_models(config, minimax_models): changed = True if changed: @@ -425,7 +425,7 @@ 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 and apply_minimax_models(config_for_save, minimax_models): + 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) diff --git a/tests/auth/test_minimax_auth.py b/tests/auth/test_minimax_auth.py index 63ea89bd..f55a4b3c 100644 --- a/tests/auth/test_minimax_auth.py +++ b/tests/auth/test_minimax_auth.py @@ -171,26 +171,42 @@ async def fake_discover(api_key): @pytest.mark.asyncio async def test_login_minimax_token_plan_uses_discovered_available_subset(monkeypatch, tmp_path): - from pythinker_code.auth.minimax import MiniMaxModel, login_minimax_api_key + 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) - async def fake_discover(api_key): - assert api_key == "sk-cp-token-plan-abc" - return ( - MiniMaxModel( - model_id="MiniMax-M2.7", - alias_suffix="m2.7", - display_name="MiniMax M2.7", - ), - ) + class FakeResponse: + async def __aenter__(self): + return self - monkeypatch.setattr("pythinker_code.auth.minimax._discover_minimax_models", fake_discover) + 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" @@ -227,6 +243,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 diff --git a/tests/auth/test_platforms.py b/tests/auth/test_platforms.py index 39747baa..ea0d1d30 100644 --- a/tests/auth/test_platforms.py +++ b/tests/auth/test_platforms.py @@ -800,7 +800,7 @@ async def test_refresh_managed_models_refreshes_minimax_token_plan_without_relog with ( patch( - "pythinker_code.auth.minimax._discover_minimax_models", + "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, @@ -824,6 +824,41 @@ async def test_refresh_managed_models_refreshes_minimax_token_plan_without_relog 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.""" @@ -857,7 +892,7 @@ def _config_with_generic_provider() -> Config: with ( patch( - "pythinker_code.auth.minimax._discover_minimax_models", + "pythinker_code.auth.minimax.refresh_minimax_models", new=AsyncMock(side_effect=aiohttp.ClientConnectionError("offline")), ), patch( @@ -878,4 +913,6 @@ def _config_with_generic_provider() -> 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 From cc303d300be8f4f9a0c4ccdba6267772158c8efb Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Mon, 1 Jun 2026 13:00:36 -0400 Subject: [PATCH 4/4] fix(minimax): preserve defaults on empty login catalog --- src/pythinker_code/auth/minimax.py | 19 +++++++++++-------- tests/auth/test_minimax_auth.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/pythinker_code/auth/minimax.py b/src/pythinker_code/auth/minimax.py index acd33758..30bb97d8 100644 --- a/src/pythinker_code/auth/minimax.py +++ b/src/pythinker_code/auth/minimax.py @@ -76,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 diff --git a/tests/auth/test_minimax_auth.py b/tests/auth/test_minimax_auth.py index f55a4b3c..5bcdac18 100644 --- a/tests/auth/test_minimax_auth.py +++ b/tests/auth/test_minimax_auth.py @@ -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),