Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
246 changes: 206 additions & 40 deletions src/pythinker_code/auth/minimax.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -72,69 +76,233 @@ 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


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(
Expand All @@ -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.")
Expand Down
28 changes: 24 additions & 4 deletions src/pythinker_code/auth/platforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:<platform>` 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
):
Comment thread
elkaix marked this conversation as resolved.
continue
platform_id = parse_managed_provider_key(provider_key)
if not platform_id:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading