11from __future__ import annotations
22
33import os
4- from collections .abc import AsyncIterator
4+ from collections .abc import AsyncIterator , Mapping
55from dataclasses import dataclass
66from typing import Any , cast
77
1515
1616MINIMAX_ANTHROPIC_BASE_URL = "https://api.minimax.io/anthropic"
1717MINIMAX_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"
1820MINIMAX_ANTHROPIC_PROVIDER_KEY = "managed:minimax-anthropic"
1921MINIMAX_DEFAULT_MODEL_ALIAS = "minimax/m2.7"
22+ MINIMAX_DEFAULT_CONTEXT = 192_000
2023MINIMAX_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+
90143def _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+
140302async def login_minimax_api_key (
141303 config : Config , api_key : str | None = None
142304) -> AsyncIterator [OAuthEvent ]:
0 commit comments