Skip to content

Commit 9892a42

Browse files
committed
fix(minimax): refresh token-plan model availability
1 parent df7698f commit 9892a42

4 files changed

Lines changed: 474 additions & 33 deletions

File tree

src/pythinker_code/auth/minimax.py

Lines changed: 190 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
import os
4-
from collections.abc import AsyncIterator
4+
from collections.abc import AsyncIterator, Mapping
55
from dataclasses import dataclass
66
from typing import Any, cast
77

@@ -15,9 +15,13 @@
1515

1616
MINIMAX_ANTHROPIC_BASE_URL = "https://api.minimax.io/anthropic"
1717
MINIMAX_OPENAI_BASE_URL = "https://api.minimax.io/v1"
18+
MINIMAX_ANTHROPIC_MODELS_URL = f"{MINIMAX_ANTHROPIC_BASE_URL}/v1/models"
19+
MINIMAX_OPENAI_MODELS_URL = f"{MINIMAX_OPENAI_BASE_URL}/models"
1820
MINIMAX_ANTHROPIC_PROVIDER_KEY = "managed:minimax-anthropic"
1921
MINIMAX_DEFAULT_MODEL_ALIAS = "minimax/m2.7"
22+
MINIMAX_DEFAULT_CONTEXT = 192_000
2023
MINIMAX_TOKEN_PLAN_KEY_PREFIX = "sk-cp-"
24+
MINIMAX_MODEL_DISCOVERY_TIMEOUT = aiohttp.ClientTimeout(total=15, sock_connect=8, sock_read=10)
2125

2226

2327
@dataclass(frozen=True, slots=True)
@@ -26,7 +30,7 @@ class MiniMaxModel:
2630
alias_suffix: str
2731
display_name: str
2832
provider_key: str = MINIMAX_ANTHROPIC_PROVIDER_KEY
29-
max_context_size: int = 192_000
33+
max_context_size: int = MINIMAX_DEFAULT_CONTEXT
3034

3135
@property
3236
def alias(self) -> str:
@@ -87,56 +91,214 @@ def _model_by_id() -> dict[str, MiniMaxModel]:
8791
return {model.model_id: model for model in MINIMAX_MODELS}
8892

8993

94+
def _is_supported_minimax_chat_model(model_id: str) -> bool:
95+
"""Return whether a discovered model ID belongs on the chat provider.
96+
97+
MiniMax's documented `/models` responses are account/key-specific and may
98+
change as plans gain or lose access. Keep that list authoritative while
99+
avoiding non-text modality models that would be invalid for the Anthropic
100+
messages provider configured below.
101+
"""
102+
return model_id.startswith("MiniMax-M")
103+
104+
105+
def _derive_alias_suffix(model_id: str) -> str:
106+
if model_id.startswith("MiniMax-"):
107+
model_id = model_id.removeprefix("MiniMax-")
108+
return model_id.strip().lower().replace(" ", "-")
109+
110+
111+
def _derive_display_name(model_id: str) -> str:
112+
if model_id.startswith("MiniMax-"):
113+
model_id = model_id.replace("MiniMax-", "MiniMax ", 1)
114+
return model_id.replace("-", " ")
115+
116+
117+
def _to_positive_int(value: Any) -> int | None:
118+
if isinstance(value, bool):
119+
return None
120+
try:
121+
parsed = int(value)
122+
except (TypeError, ValueError):
123+
return None
124+
return parsed if parsed > 0 else None
125+
126+
127+
def _context_size_from_item(item: Mapping[str, Any], fallback: int) -> int:
128+
for key in ("context_length", "max_context_length", "max_tokens"):
129+
parsed = _to_positive_int(item.get(key))
130+
if parsed is not None:
131+
return parsed
132+
return fallback
133+
134+
135+
def _display_name_from_item(item: Mapping[str, Any], fallback: str) -> str:
136+
for key in ("display_name", "name"):
137+
value = item.get(key)
138+
if isinstance(value, str) and value.strip():
139+
return value.strip()
140+
return fallback
141+
142+
90143
def _parse_discovered_models(data: object) -> tuple[MiniMaxModel, ...]:
91144
if not isinstance(data, dict):
92145
return ()
93-
data = cast(dict[str, Any], data)
94-
raw_items = data.get("data")
146+
raw_items = cast(dict[str, Any], data).get("data")
95147
if not isinstance(raw_items, list):
96148
return ()
97149

98150
known = _model_by_id()
151+
seen: set[str] = set()
99152
result: list[MiniMaxModel] = []
100-
for item in cast(list[dict[str, Any]], raw_items):
153+
for raw_item in cast(list[Any], raw_items):
154+
if not isinstance(raw_item, Mapping):
155+
continue
156+
item = cast(Mapping[str, Any], raw_item)
101157
model_id = item.get("id")
102-
if not isinstance(model_id, str) or model_id not in known:
158+
if not isinstance(model_id, str) or not model_id.strip():
103159
continue
104-
current = known[model_id]
105-
context_length = item.get("context_length")
106-
max_context_size = current.max_context_size
107-
if isinstance(context_length, int) and context_length > 0:
108-
max_context_size = context_length
109-
display_name_raw = item.get("display_name")
110-
display_name = (
111-
display_name_raw
112-
if isinstance(display_name_raw, str) and display_name_raw
113-
else current.display_name
160+
model_id = model_id.strip()
161+
if model_id in seen or not _is_supported_minimax_chat_model(model_id):
162+
continue
163+
seen.add(model_id)
164+
165+
current = known.get(model_id)
166+
alias_suffix = current.alias_suffix if current else _derive_alias_suffix(model_id)
167+
display_name = _display_name_from_item(
168+
item,
169+
current.display_name if current else _derive_display_name(model_id),
170+
)
171+
max_context_size = _context_size_from_item(
172+
item,
173+
current.max_context_size if current else MINIMAX_DEFAULT_CONTEXT,
114174
)
115175
result.append(
116176
MiniMaxModel(
117-
model_id=current.model_id,
118-
alias_suffix=current.alias_suffix,
177+
model_id=model_id,
178+
alias_suffix=alias_suffix,
119179
display_name=display_name,
120-
provider_key=current.provider_key,
180+
provider_key=current.provider_key if current else MINIMAX_ANTHROPIC_PROVIDER_KEY,
121181
max_context_size=max_context_size,
122182
)
123183
)
124184
return tuple(result)
125185

126186

127-
async def _discover_minimax_models(api_key: str) -> tuple[MiniMaxModel, ...]:
128-
async with (
129-
new_client_session() as session,
130-
session.get(
131-
f"{MINIMAX_OPENAI_BASE_URL}/models",
132-
headers={"Authorization": f"Bearer {api_key}"},
133-
raise_for_status=True,
134-
) as response,
135-
):
187+
async def _fetch_minimax_models(
188+
session: aiohttp.ClientSession,
189+
*,
190+
url: str,
191+
headers: Mapping[str, str],
192+
) -> tuple[MiniMaxModel, ...]:
193+
async with session.get(url, headers=headers, raise_for_status=True) as response:
136194
payload = await response.json(content_type=None)
137195
return _parse_discovered_models(payload)
138196

139197

198+
async def _discover_minimax_models(api_key: str) -> tuple[MiniMaxModel, ...]:
199+
errors: list[Exception] = []
200+
auth_errors: list[aiohttp.ClientResponseError] = []
201+
async with new_client_session(timeout=MINIMAX_MODEL_DISCOVERY_TIMEOUT) as session:
202+
# Prefer the Anthropic-compatible model list because configured chat
203+
# traffic uses that provider shape. Fall back to the OpenAI-compatible
204+
# list, which MiniMax also documents and historically exposed first.
205+
for url, headers in (
206+
(MINIMAX_ANTHROPIC_MODELS_URL, {"X-Api-Key": api_key}),
207+
(MINIMAX_OPENAI_MODELS_URL, {"Authorization": f"Bearer {api_key}"}),
208+
):
209+
try:
210+
models = await _fetch_minimax_models(session, url=url, headers=headers)
211+
except aiohttp.ClientResponseError as exc:
212+
if exc.status in {401, 403}:
213+
auth_errors.append(exc)
214+
else:
215+
errors.append(exc)
216+
continue
217+
except (aiohttp.ClientError, TimeoutError, ValueError) as exc:
218+
errors.append(exc)
219+
continue
220+
if models:
221+
return models
222+
223+
if auth_errors:
224+
raise auth_errors[0]
225+
if errors:
226+
raise errors[-1]
227+
return ()
228+
229+
230+
def _minimax_api_key(config: Config) -> str | None:
231+
provider = config.providers.get(MINIMAX_ANTHROPIC_PROVIDER_KEY)
232+
if provider is None:
233+
return None
234+
value = provider.api_key.get_secret_value().strip()
235+
return value or None
236+
237+
238+
def apply_minimax_models(config: Config, models: tuple[MiniMaxModel, ...]) -> bool:
239+
"""Upsert the live MiniMax catalog and prune models no longer returned.
240+
241+
Preserves user preferences unless the selected MiniMax model disappeared.
242+
The authenticated `/models` response is the authority for which models are
243+
available to the saved key, including Token Plan subscription keys.
244+
"""
245+
changed = False
246+
aliases: list[str] = []
247+
for model in models:
248+
alias = model.alias
249+
aliases.append(alias)
250+
existing = config.models.get(alias)
251+
if existing is None:
252+
config.models[alias] = LLMModel(
253+
provider=model.provider_key,
254+
model=model.model_id,
255+
max_context_size=model.max_context_size,
256+
display_name=model.display_name,
257+
)
258+
changed = True
259+
continue
260+
if existing.provider != model.provider_key:
261+
existing.provider = model.provider_key
262+
changed = True
263+
if existing.model != model.model_id:
264+
existing.model = model.model_id
265+
changed = True
266+
if existing.max_context_size != model.max_context_size:
267+
existing.max_context_size = model.max_context_size
268+
changed = True
269+
if existing.display_name != model.display_name:
270+
existing.display_name = model.display_name
271+
changed = True
272+
273+
alias_set = set(aliases)
274+
removed_default = False
275+
for alias, model_cfg in list(config.models.items()):
276+
if model_cfg.provider != MINIMAX_ANTHROPIC_PROVIDER_KEY:
277+
continue
278+
if alias in alias_set:
279+
continue
280+
del config.models[alias]
281+
if config.default_model == alias:
282+
removed_default = True
283+
changed = True
284+
285+
if removed_default:
286+
config.default_model = aliases[0] if aliases else next(iter(config.models), "")
287+
changed = True
288+
elif config.default_model and config.default_model not in config.models:
289+
config.default_model = next(iter(config.models), "")
290+
changed = True
291+
return changed
292+
293+
294+
async def refresh_minimax_models(config: Config) -> tuple[MiniMaxModel, ...] | None:
295+
api_key = _minimax_api_key(config)
296+
if api_key is None:
297+
return None
298+
discovered = await _discover_minimax_models(api_key)
299+
return discovered or None
300+
301+
140302
async def login_minimax_api_key(
141303
config: Config, api_key: str | None = None
142304
) -> AsyncIterator[OAuthEvent]:

src/pythinker_code/auth/platforms.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,12 @@ async def refresh_managed_models(config: Config) -> bool:
231231
if not config.is_from_default_location:
232232
return False
233233

234+
from pythinker_code.auth.minimax import (
235+
MINIMAX_ANTHROPIC_PROVIDER_KEY,
236+
MiniMaxModel,
237+
apply_minimax_models,
238+
refresh_minimax_models,
239+
)
234240
from pythinker_code.auth.opencode_go import (
235241
OPENCODE_GO_PROVIDER_KEYS,
236242
OpenCodeGoModel,
@@ -248,10 +254,14 @@ async def refresh_managed_models(config: Config) -> bool:
248254
updates: list[tuple[str, str, list[ModelInfo]]] = []
249255
oauth_manager = None
250256
for provider_key, provider in managed_providers.items():
251-
# OpenCode Go uses a two-shape (OpenAI- + Anthropic-compatible) provider
252-
# split that the generic single-provider path below can't express, so it
253-
# is refreshed via its own discovery after this loop.
254-
if provider_key in OPENCODE_GO_PROVIDER_KEYS:
257+
# OpenCode Go and MiniMax own provider-specific model discovery. The
258+
# generic `managed:<platform>` path can't express OpenCode Go's
259+
# two-provider split, and MiniMax's provider key intentionally includes
260+
# the wire-shape suffix (`managed:minimax-anthropic`).
261+
if (
262+
provider_key in OPENCODE_GO_PROVIDER_KEYS
263+
or provider_key == MINIMAX_ANTHROPIC_PROVIDER_KEY
264+
):
255265
continue
256266
platform_id = parse_managed_provider_key(provider_key)
257267
if not platform_id:
@@ -399,6 +409,14 @@ async def refresh_managed_models(config: Config) -> bool:
399409
if opencode_go_models and apply_opencode_go_models(config, opencode_go_models):
400410
changed = True
401411

412+
minimax_models: tuple[MiniMaxModel, ...] | None = None
413+
try:
414+
minimax_models = await refresh_minimax_models(config)
415+
except (aiohttp.ClientError, TimeoutError, ValueError) as exc:
416+
logger.warning("Failed to refresh MiniMax models: {error}", error=exc)
417+
if minimax_models and apply_minimax_models(config, minimax_models):
418+
changed = True
419+
402420
if changed:
403421
config_for_save = load_config()
404422
save_changed = False
@@ -407,6 +425,8 @@ async def refresh_managed_models(config: Config) -> bool:
407425
save_changed = True
408426
if opencode_go_models and apply_opencode_go_models(config_for_save, opencode_go_models):
409427
save_changed = True
428+
if minimax_models and apply_minimax_models(config_for_save, minimax_models):
429+
save_changed = True
410430
if save_changed:
411431
save_config(config_for_save)
412432
return changed

0 commit comments

Comments
 (0)