From 9dc088442ced01d7a65af6d8daaf1fcfaed9ee1f Mon Sep 17 00:00:00 2001 From: Adam Manning Date: Fri, 24 Apr 2026 14:31:47 +0000 Subject: [PATCH 1/5] feat(cli): add minimax-oauth provider with PKCE browser flow Add MiniMax OAuth (minimax-oauth) as a first-class provider using a PKCE device-code flow ported from openclaw/extensions/minimax/oauth.ts. Changes: - hermes_cli/auth.py: - Add 8 MINIMAX_OAUTH_* constants (client ID, scope, grant type, global/CN base URLs, inference URLs, refresh skew) - Add 'minimax-oauth' ProviderConfig to PROVIDER_REGISTRY (auth_type oauth_minimax) with global portal + inference base URLs and CN extras in the extra dict - Add provider aliases: minimax-portal, minimax-global, minimax_oauth - Implement _minimax_pkce_pair(), _minimax_request_user_code(), _minimax_poll_token(), _minimax_save_auth_state(), _minimax_oauth_login(), _refresh_minimax_oauth_state(), resolve_minimax_oauth_runtime_credentials(), get_minimax_oauth_auth_status(), _login_minimax_oauth() - Token refresh uses standard OAuth2 refresh_token grant; triggers relogin_required on invalid_grant / refresh_token_reused - hermes_cli/runtime_provider.py: - Add minimax-oauth branch (after qwen-oauth) that calls resolve_minimax_oauth_runtime_credentials() and returns api_mode='anthropic_messages' with the OAuth Bearer token - hermes_cli/auth_commands.py: - Add 'minimax-oauth' to _OAUTH_CAPABLE_PROVIDERS - Add auth_type auto-detection for oauth_minimax - Add provider == 'minimax-oauth' branch in auth_add_command - hermes_cli/doctor.py: - Import get_minimax_oauth_auth_status - Add MiniMax OAuth status check in the Auth Providers section --- hermes_cli/auth.py | 342 ++++++++++++++++++++++++++++++++- hermes_cli/auth_commands.py | 25 ++- hermes_cli/doctor.py | 8 + hermes_cli/runtime_provider.py | 14 ++ 4 files changed, 386 insertions(+), 3 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index c5ff23e5865d..7885e99d1e6a 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -72,6 +72,14 @@ ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 # refresh 2 min before expiry DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS = 1 # poll at most every 1s DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex" +MINIMAX_OAUTH_CLIENT_ID = "78257093-7e40-4613-99e0-527b14b39113" +MINIMAX_OAUTH_SCOPE = "group_id profile model.completion" +MINIMAX_OAUTH_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:user_code" +MINIMAX_OAUTH_GLOBAL_BASE = "https://api.minimax.io" +MINIMAX_OAUTH_CN_BASE = "https://api.minimaxi.com" +MINIMAX_OAUTH_GLOBAL_INFERENCE = "https://api.minimax.io/anthropic" +MINIMAX_OAUTH_CN_INFERENCE = "https://api.minimaxi.com/anthropic" +MINIMAX_OAUTH_REFRESH_SKEW_SECONDS = 60 DEFAULT_QWEN_BASE_URL = "https://portal.qwen.ai/v1" DEFAULT_GITHUB_MODELS_BASE_URL = "https://api.githubcopilot.com" DEFAULT_COPILOT_ACP_BASE_URL = "acp://copilot" @@ -126,7 +134,7 @@ class ProviderConfig: """Describes a known inference provider.""" id: str name: str - auth_type: str # "oauth_device_code", "oauth_external", or "api_key" + auth_type: str # "oauth_device_code", "oauth_external", "oauth_minimax", or "api_key" portal_base_url: str = "" inference_base_url: str = "" client_id: str = "" @@ -255,6 +263,17 @@ class ProviderConfig: api_key_env_vars=("MINIMAX_API_KEY",), base_url_env_var="MINIMAX_BASE_URL", ), + "minimax-oauth": ProviderConfig( + id="minimax-oauth", + name="MiniMax (OAuth \u00b7 minimax.io)", + auth_type="oauth_minimax", + portal_base_url=MINIMAX_OAUTH_GLOBAL_BASE, + inference_base_url=MINIMAX_OAUTH_GLOBAL_INFERENCE, + client_id=MINIMAX_OAUTH_CLIENT_ID, + scope=MINIMAX_OAUTH_SCOPE, + extra={"region": "global", "cn_portal_base_url": MINIMAX_OAUTH_CN_BASE, + "cn_inference_base_url": MINIMAX_OAUTH_CN_INFERENCE}, + ), "anthropic": ProviderConfig( id="anthropic", name="Anthropic", @@ -1153,6 +1172,7 @@ def resolve_provider( "arcee-ai": "arcee", "arceeai": "arcee", "gmi-cloud": "gmi", "gmicloud": "gmi", "minimax-china": "minimax-cn", "minimax_cn": "minimax-cn", + "minimax-portal": "minimax-oauth", "minimax-global": "minimax-oauth", "minimax_oauth": "minimax-oauth", "alibaba_coding": "alibaba-coding-plan", "alibaba-coding": "alibaba-coding-plan", "alibaba_coding_plan": "alibaba-coding-plan", "claude": "anthropic", "claude-code": "anthropic", @@ -4116,6 +4136,326 @@ def _codex_device_code_login() -> Dict[str, Any]: } +# ==================== MiniMax Portal OAuth ==================== + +def _minimax_pkce_pair() -> tuple: + """Generate (code_verifier, code_challenge_S256, state) for MiniMax OAuth.""" + import secrets + verifier = secrets.token_urlsafe(64)[:96] + challenge = base64.urlsafe_b64encode( + hashlib.sha256(verifier.encode()).digest() + ).decode().rstrip("=") + state = secrets.token_urlsafe(16) + return verifier, challenge, state + + +def _minimax_request_user_code( + client: httpx.Client, *, portal_base_url: str, client_id: str, + code_challenge: str, state: str, +) -> Dict[str, Any]: + response = client.post( + f"{portal_base_url}/oauth/code", + data={ + "response_type": "code", + "client_id": client_id, + "scope": MINIMAX_OAUTH_SCOPE, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "state": state, + }, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + "x-request-id": str(uuid.uuid4()), + }, + ) + if response.status_code != 200: + raise AuthError( + f"MiniMax OAuth authorization failed: {response.text or response.reason_phrase}", + provider="minimax-oauth", code="authorization_failed", + ) + payload = response.json() + for field in ("user_code", "verification_uri", "expired_in"): + if field not in payload: + raise AuthError( + f"MiniMax OAuth response missing field: {field}", + provider="minimax-oauth", code="authorization_incomplete", + ) + if payload.get("state") != state: + raise AuthError( + "MiniMax OAuth state mismatch (possible CSRF).", + provider="minimax-oauth", code="state_mismatch", + ) + return payload + + +def _minimax_poll_token( + client: httpx.Client, *, portal_base_url: str, client_id: str, + user_code: str, code_verifier: str, expired_in: int, interval_ms: Optional[int], +) -> Dict[str, Any]: + # OpenClaw treats expired_in as a unix-ms timestamp (Date.now() < expireTimeMs). + # Defensive parsing: if it's small enough to be a duration, treat as seconds. + import time as _time + now_ms = int(_time.time() * 1000) + if expired_in > now_ms // 2: + # Looks like a unix-ms timestamp. + deadline = expired_in / 1000.0 + else: + # Treat as duration in seconds from now. + deadline = _time.time() + max(1, expired_in) + interval = max(2.0, (interval_ms or 2000) / 1000.0) + + while _time.time() < deadline: + response = client.post( + f"{portal_base_url}/oauth/token", + data={ + "grant_type": MINIMAX_OAUTH_GRANT_TYPE, + "client_id": client_id, + "user_code": user_code, + "code_verifier": code_verifier, + }, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + }, + ) + try: + payload = response.json() if response.text else {} + except Exception: + payload = {} + + if response.status_code != 200: + msg = (payload.get("base_resp", {}) or {}).get("status_msg") or response.text + raise AuthError( + f"MiniMax OAuth error: {msg or 'unknown'}", + provider="minimax-oauth", code="token_exchange_failed", + ) + + status = payload.get("status") + if status == "error": + raise AuthError( + "MiniMax OAuth reported an error. Please try again later.", + provider="minimax-oauth", code="authorization_denied", + ) + if status == "success": + if not all(payload.get(k) for k in ("access_token", "refresh_token", "expired_in")): + raise AuthError( + "MiniMax OAuth success payload missing required token fields.", + provider="minimax-oauth", code="token_incomplete", + ) + return payload + # "pending" or any other status -> keep polling + _time.sleep(interval) + + raise AuthError( + "MiniMax OAuth timed out before authorization completed.", + provider="minimax-oauth", code="timeout", + ) + + +def _minimax_save_auth_state(auth_state: Dict[str, Any]) -> None: + """Persist MiniMax OAuth state to Hermes auth store (~/.hermes/auth.json).""" + with _auth_store_lock(): + auth_store = _load_auth_store() + _save_provider_state(auth_store, "minimax-oauth", auth_state) + _save_auth_store(auth_store) + + +def _minimax_oauth_login( + *, region: str = "global", open_browser: bool = True, + timeout_seconds: float = 15.0, +) -> Dict[str, Any]: + """Run MiniMax OAuth flow, persist tokens, return auth state dict.""" + pconfig = PROVIDER_REGISTRY["minimax-oauth"] + if region == "cn": + portal_base_url = pconfig.extra["cn_portal_base_url"] + inference_base_url = pconfig.extra["cn_inference_base_url"] + else: + portal_base_url = pconfig.portal_base_url + inference_base_url = pconfig.inference_base_url + + verifier, challenge, state = _minimax_pkce_pair() + + if _is_remote_session(): + open_browser = False + + print(f"Starting Hermes login via MiniMax ({region}) OAuth...") + print(f"Portal: {portal_base_url}") + + with httpx.Client(timeout=httpx.Timeout(timeout_seconds), + headers={"Accept": "application/json"}) as client: + code_data = _minimax_request_user_code( + client, portal_base_url=portal_base_url, + client_id=pconfig.client_id, + code_challenge=challenge, state=state, + ) + verification_url = str(code_data["verification_uri"]) + user_code = str(code_data["user_code"]) + + print() + print("To continue:") + print(f" 1. Open: {verification_url}") + print(f" 2. If prompted, enter code: {user_code}") + if open_browser: + if webbrowser.open(verification_url): + print(" (Opened browser for verification)") + else: + print(" Could not open browser automatically -- use the URL above.") + + interval_raw = code_data.get("interval") + interval_ms = int(interval_raw) if interval_raw is not None else None + print("Waiting for approval...") + + token_data = _minimax_poll_token( + client, portal_base_url=portal_base_url, + client_id=pconfig.client_id, + user_code=user_code, code_verifier=verifier, + expired_in=int(code_data["expired_in"]), + interval_ms=interval_ms, + ) + + now = datetime.now(timezone.utc) + expires_in_s = int(token_data["expired_in"]) + expires_at = now.timestamp() + expires_in_s + + auth_state = { + "provider": "minimax-oauth", + "region": region, + "portal_base_url": portal_base_url, + "inference_base_url": inference_base_url, + "client_id": pconfig.client_id, + "scope": MINIMAX_OAUTH_SCOPE, + "token_type": token_data.get("token_type", "Bearer"), + "access_token": token_data["access_token"], + "refresh_token": token_data["refresh_token"], + "resource_url": token_data.get("resource_url"), + "obtained_at": now.isoformat(), + "expires_at": datetime.fromtimestamp(expires_at, tz=timezone.utc).isoformat(), + "expires_in": expires_in_s, + } + + _minimax_save_auth_state(auth_state) + print("\u2713 MiniMax OAuth login successful.") + if msg := token_data.get("notification_message"): + print(f"Note from MiniMax: {msg}") + return auth_state + + +def _refresh_minimax_oauth_state( + state: Dict[str, Any], *, timeout_seconds: float = 15.0, + force: bool = False, +) -> Dict[str, Any]: + """Refresh MiniMax OAuth access token if close to expiry (or forced).""" + if not state.get("refresh_token"): + raise AuthError( + "MiniMax OAuth state has no refresh_token; please re-login.", + provider="minimax-oauth", code="no_refresh_token", relogin_required=True, + ) + try: + expires_at = datetime.fromisoformat(state.get("expires_at", "")).timestamp() + except Exception: + expires_at = 0.0 + now = time.time() + if not force and (expires_at - now) > MINIMAX_OAUTH_REFRESH_SKEW_SECONDS: + return state + + portal_base_url = state["portal_base_url"] + with httpx.Client(timeout=httpx.Timeout(timeout_seconds)) as client: + response = client.post( + f"{portal_base_url}/oauth/token", + data={ + "grant_type": "refresh_token", + "client_id": state["client_id"], + "refresh_token": state["refresh_token"], + }, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + }, + ) + if response.status_code != 200: + body = response.text.lower() + relogin = any(m in body for m in + ("invalid_grant", "refresh_token_reused", "invalid_refresh_token")) + raise AuthError( + f"MiniMax OAuth refresh failed: {response.text or response.reason_phrase}", + provider="minimax-oauth", code="refresh_failed", + relogin_required=relogin, + ) + payload = response.json() + if payload.get("status") != "success": + raise AuthError( + "MiniMax OAuth refresh did not return success.", + provider="minimax-oauth", code="refresh_failed", + relogin_required=True, + ) + now_dt = datetime.now(timezone.utc) + expires_in_s = int(payload["expired_in"]) + new_state = dict(state) + new_state.update({ + "access_token": payload["access_token"], + "refresh_token": payload.get("refresh_token", state["refresh_token"]), + "obtained_at": now_dt.isoformat(), + "expires_at": datetime.fromtimestamp(now_dt.timestamp() + expires_in_s, + tz=timezone.utc).isoformat(), + "expires_in": expires_in_s, + }) + _minimax_save_auth_state(new_state) + return new_state + + +def resolve_minimax_oauth_runtime_credentials( + *, min_token_ttl_seconds: int = MINIMAX_OAUTH_REFRESH_SKEW_SECONDS, +) -> Dict[str, Any]: + """Return {provider, api_key, base_url, source} for minimax-oauth.""" + state = get_provider_auth_state("minimax-oauth") + if not state or not state.get("access_token"): + raise AuthError( + "Not logged into MiniMax OAuth. Run `hermes model` and select " + "MiniMax (OAuth).", + provider="minimax-oauth", code="not_logged_in", relogin_required=True, + ) + state = _refresh_minimax_oauth_state(state) + return { + "provider": "minimax-oauth", + "api_key": state["access_token"], + "base_url": state["inference_base_url"].rstrip("/"), + "source": "oauth", + } + + +def get_minimax_oauth_auth_status() -> Dict[str, Any]: + """Return auth status dict for MiniMax OAuth provider.""" + state = get_provider_auth_state("minimax-oauth") + if not state or not state.get("access_token"): + return {"logged_in": False, "provider": "minimax-oauth"} + try: + expires_at = datetime.fromisoformat(state.get("expires_at", "")).timestamp() + token_valid = (expires_at - time.time()) > 0 + except Exception: + token_valid = bool(state.get("access_token")) + return { + "logged_in": token_valid, + "provider": "minimax-oauth", + "region": state.get("region", "global"), + "expires_at": state.get("expires_at"), + } + + +def _login_minimax_oauth(args, pconfig: ProviderConfig) -> None: + """CLI entry for MiniMax OAuth login.""" + region = getattr(args, "region", None) or "global" + open_browser = not getattr(args, "no_browser", False) + timeout = getattr(args, "timeout", None) or 15.0 + try: + _minimax_oauth_login( + region=region, open_browser=open_browser, timeout_seconds=timeout, + ) + except AuthError as exc: + print(format_auth_error(exc)) + raise SystemExit(1) + + def _nous_device_code_login( *, portal_base_url: Optional[str] = None, diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index 94ea2559c46a..a9eb206647de 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -33,7 +33,7 @@ # Providers that support OAuth login in addition to API keys. -_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli"} +_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli", "minimax-oauth"} def _get_custom_provider_names() -> list: @@ -170,7 +170,7 @@ def auth_add_command(args) -> None: if provider.startswith(CUSTOM_POOL_PREFIX): requested_type = AUTH_TYPE_API_KEY else: - requested_type = AUTH_TYPE_OAUTH if provider in {"anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli"} else AUTH_TYPE_API_KEY + requested_type = AUTH_TYPE_OAUTH if provider in {"anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli", "minimax-oauth"} else AUTH_TYPE_API_KEY pool = load_pool(provider) @@ -333,6 +333,27 @@ def auth_add_command(args) -> None: print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"') return + if provider == "minimax-oauth": + from hermes_cli.auth import resolve_minimax_oauth_runtime_credentials + creds = resolve_minimax_oauth_runtime_credentials() + label = (getattr(args, "label", None) or "").strip() or label_from_token( + creds["api_key"], + _oauth_default_label(provider, len(pool.entries()) + 1), + ) + entry = PooledCredential( + provider=provider, + id=uuid.uuid4().hex[:6], + label=label, + auth_type=AUTH_TYPE_OAUTH, + priority=0, + source=f"{SOURCE_MANUAL}:minimax_oauth", + access_token=creds["api_key"], + base_url=creds.get("base_url"), + ) + pool.add_entry(entry) + print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"') + return + raise SystemExit(f"`hermes auth add {provider}` is not implemented for auth type {requested_type} yet.") diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 8ac8822c739b..e71529877140 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -539,6 +539,7 @@ def run_doctor(args): get_nous_auth_status, get_codex_auth_status, get_gemini_oauth_auth_status, + get_minimax_oauth_auth_status, ) nous_status = get_nous_auth_status() @@ -568,6 +569,13 @@ def run_doctor(args): check_ok("Google Gemini OAuth", f"(logged in{suffix})") else: check_warn("Google Gemini OAuth", "(not logged in)") + + minimax_status = get_minimax_oauth_auth_status() + if minimax_status.get("logged_in"): + region = minimax_status.get("region", "global") + check_ok("MiniMax OAuth", f"(logged in, region={region})") + else: + check_warn("MiniMax OAuth", "(not logged in)") except Exception as e: check_warn("Auth provider status", f"(could not check: {e})") diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index e2883c883f7f..c46ebf399199 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -1070,6 +1070,20 @@ def resolve_runtime_provider( logger.info("Qwen OAuth credentials failed; " "falling through to next provider.") + if provider == "minimax-oauth": + pconfig = PROVIDER_REGISTRY.get(provider) + if pconfig and pconfig.auth_type == "oauth_minimax": + from hermes_cli.auth import resolve_minimax_oauth_runtime_credentials + creds = resolve_minimax_oauth_runtime_credentials() + return { + "provider": provider, + "api_mode": "anthropic_messages", + "base_url": creds["base_url"], + "api_key": creds["api_key"], + "source": creds.get("source", "oauth"), + "requested_provider": requested_provider, + } + if provider == "google-gemini-cli": try: creds = resolve_gemini_oauth_runtime_credentials() From ee96f6610879a3c130ecff81b95351ab7631f0a0 Mon Sep 17 00:00:00 2001 From: Adam Manning Date: Fri, 24 Apr 2026 14:31:58 +0000 Subject: [PATCH 2/5] feat(agent): wire MiniMax-M2.7 for minimax-oauth provider Wire MiniMax-M2.7 and MiniMax-M2.7-highspeed into the model catalog, CLI model picker, and agent auxiliary/metadata subsystems. Changes: - hermes_cli/models.py: - Add 'minimax-oauth' to _PROVIDER_MODELS with MiniMax-M2.7 and MiniMax-M2.7-highspeed - Add ProviderEntry('minimax-oauth', 'MiniMax (OAuth)', ...) to CANONICAL_PROVIDERS near existing minimax entries - Add aliases: minimax-portal, minimax-global, minimax_oauth in _PROVIDER_ALIASES - hermes_cli/main.py: - Add 'minimax-oauth' to provider_labels dict - Insert 'minimax-oauth' into providers list in select_provider_and_model() near the other minimax entries - Add 'minimax-oauth' to --provider argparse choices - Add _model_flow_minimax_oauth() function: ensures login via _login_minimax_oauth(), resolves runtime credentials, prompts for model selection, saves model choice and config - Add dispatch elif branch for selected_provider == 'minimax-oauth' - agent/auxiliary_client.py: - Add 'minimax-oauth': 'MiniMax-M2.7-highspeed' to _API_KEY_PROVIDER_AUX_MODELS - Add 'minimax-oauth' to _ANTHROPIC_COMPAT_PROVIDERS set - agent/model_metadata.py: - Add 'minimax-oauth' to _PROVIDER_PREFIXES frozenset - MiniMax-M2.7 context length (200_000) already covered by the existing 'minimax' substring match in DEFAULT_CONTEXT_LENGTHS --- agent/auxiliary_client.py | 3 ++- agent/model_metadata.py | 2 +- hermes_cli/main.py | 49 +++++++++++++++++++++++++++++++++++++++ hermes_cli/models.py | 8 +++++++ 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 026f11dc437f..25d283469ab4 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -210,6 +210,7 @@ def _fixed_temperature_for_model( "kimi-coding-cn": "kimi-k2-turbo-preview", "gmi": "google/gemini-3.1-flash-lite-preview", "minimax": "MiniMax-M2.7", + "minimax-oauth": "MiniMax-M2.7-highspeed", "minimax-cn": "MiniMax-M2.7", "anthropic": "claude-haiku-4-5-20251001", "ai-gateway": "google/gemini-3-flash", @@ -3043,7 +3044,7 @@ def _get_task_extra_body(task: str) -> Dict[str, Any]: # Providers that use Anthropic-compatible endpoints (via OpenAI SDK wrapper). # Their image content blocks must use Anthropic format, not OpenAI format. -_ANTHROPIC_COMPAT_PROVIDERS = frozenset({"minimax", "minimax-cn"}) +_ANTHROPIC_COMPAT_PROVIDERS = frozenset({"minimax", "minimax-oauth", "minimax-cn"}) def _is_anthropic_compat_endpoint(provider: str, base_url: str) -> bool: diff --git a/agent/model_metadata.py b/agent/model_metadata.py index afd8bee192a8..cca842f6b07f 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -46,7 +46,7 @@ def _resolve_requests_verify() -> bool | str: # are preserved so the full model name reaches cache lookups and server queries. _PROVIDER_PREFIXES: frozenset[str] = frozenset({ "openrouter", "nous", "openai-codex", "copilot", "copilot-acp", - "gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-cn", "anthropic", "deepseek", + "gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek", "opencode-zen", "opencode-go", "ai-gateway", "kilocode", "alibaba", "qwen-oauth", "xiaomi", diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 70a30ab5466f..ada148974370 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1770,6 +1770,8 @@ def _lookup_ref(name: str, provider_key: str, model: str) -> str: _model_flow_openai_codex(config, current_model) elif selected_provider == "qwen-oauth": _model_flow_qwen_oauth(config, current_model) + elif selected_provider == "minimax-oauth": + _model_flow_minimax_oauth(config, current_model, args=args) elif selected_provider == "google-gemini-cli": _model_flow_google_gemini_cli(config, current_model) elif selected_provider == "copilot-acp": @@ -2658,6 +2660,53 @@ def _model_flow_qwen_oauth(_config, current_model=""): print("No change.") +def _model_flow_minimax_oauth(config, current_model="", args=None): + """MiniMax OAuth provider: ensure logged in, then pick model.""" + from hermes_cli.auth import ( + get_provider_auth_state, + _prompt_model_selection, + _save_model_choice, + _update_config_for_provider, + resolve_minimax_oauth_runtime_credentials, + AuthError, + format_auth_error, + _login_minimax_oauth, + PROVIDER_REGISTRY, + ) + state = get_provider_auth_state("minimax-oauth") + if not state or not state.get("access_token"): + print("Not logged into MiniMax. Starting OAuth login...") + print() + try: + mock_args = argparse.Namespace( + region=getattr(args, "region", None) or "global", + no_browser=bool(getattr(args, "no_browser", False)), + timeout=getattr(args, "timeout", None) or 15.0, + ) + _login_minimax_oauth(mock_args, PROVIDER_REGISTRY["minimax-oauth"]) + except SystemExit: + print("Login cancelled or failed.") + return + except Exception as exc: + print(f"Login failed: {exc}") + return + + try: + creds = resolve_minimax_oauth_runtime_credentials() + except AuthError as exc: + print(format_auth_error(exc)) + return + + from hermes_cli.models import _PROVIDER_MODELS + model_ids = _PROVIDER_MODELS.get("minimax-oauth", []) + selected = _prompt_model_selection(model_ids, current_model) + if not selected: + return + _save_model_choice(selected) + _update_config_for_provider("minimax-oauth", creds["base_url"]) + print(f"\u2713 Using MiniMax model: {selected}") + + def _model_flow_google_gemini_cli(_config, current_model=""): """Google Gemini OAuth (PKCE) via Cloud Code Assist — supports free AND paid tiers. diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 852c097536ba..74545254c953 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -288,6 +288,10 @@ def _xai_curated_models() -> list[str]: "MiniMax-M2.1", "MiniMax-M2", ], + "minimax-oauth": [ + "MiniMax-M2.7", + "MiniMax-M2.7-highspeed", + ], "minimax-cn": [ "MiniMax-M2.7", "MiniMax-M2.5", @@ -788,6 +792,7 @@ class ProviderEntry(NamedTuple): ProviderEntry("kimi-coding-cn", "Kimi / Moonshot (China)", "Kimi / Moonshot China (Moonshot CN direct API)"), ProviderEntry("stepfun", "StepFun Step Plan", "StepFun Step Plan (agent/coding models via Step Plan API)"), ProviderEntry("minimax", "MiniMax", "MiniMax (global direct API)"), + ProviderEntry("minimax-oauth", "MiniMax (OAuth)", "MiniMax via OAuth browser login (Coding Plan, minimax.io)"), ProviderEntry("minimax-cn", "MiniMax (China)", "MiniMax China (domestic direct API)"), ProviderEntry("alibaba", "Alibaba Cloud (DashScope)","Alibaba Cloud / DashScope Coding (Qwen + multi-provider)"), ProviderEntry("ollama-cloud", "Ollama Cloud", "Ollama Cloud (cloud-hosted open models — ollama.com)"), @@ -831,6 +836,9 @@ class ProviderEntry(NamedTuple): "gmicloud": "gmi", "minimax-china": "minimax-cn", "minimax_cn": "minimax-cn", + "minimax-portal": "minimax-oauth", + "minimax-global": "minimax-oauth", + "minimax_oauth": "minimax-oauth", "claude": "anthropic", "claude-code": "anthropic", "deep-seek": "deepseek", From 6aabfff865c1838d5cf7a6adcb638c176ecec7cc Mon Sep 17 00:00:00 2001 From: Adam Manning Date: Fri, 24 Apr 2026 14:32:13 +0000 Subject: [PATCH 3/5] test(cli): cover minimax-oauth resolution, refresh, menu wiring Add and extend tests for the minimax-oauth provider across three test modules. New file: tests/test_minimax_oauth.py (15 tests) - test_pkce_pair_produces_valid_s256: verifies PKCE verifier/challenge pair produces a valid S256 hash and correct lengths - test_request_user_code_happy_path: mocks httpx, verifies correct POST parameters and response parsing - test_request_user_code_state_mismatch_raises: verifies CSRF guard - test_request_user_code_non_200_raises: verifies HTTP error handling - test_poll_token_pending_then_success: verifies polling loop retries on 'pending' and returns on 'success' - test_poll_token_error_raises: verifies 'error' status raises AuthError - test_poll_token_timeout_raises: verifies deadline expiry raises - test_refresh_skip_when_not_expired: verifies no HTTP call when token is fresh - test_refresh_updates_access_token: verifies new access/refresh tokens stored on successful refresh - test_refresh_reuse_triggers_relogin_required: verifies relogin_required=True on invalid_grant/refresh_token_reused - test_resolve_credentials_requires_login: verifies AuthError when no stored state - test_provider_registry_contains_minimax_oauth: PROVIDER_REGISTRY key - test_minimax_oauth_alias_resolves: portal/global/underscore aliases - test_get_minimax_oauth_auth_status_not_logged_in - test_get_minimax_oauth_auth_status_logged_in Extended: tests/hermes_cli/test_runtime_provider_resolution.py - test_minimax_oauth_runtime_returns_anthropic_messages_mode - test_minimax_oauth_runtime_uses_inference_base_url Extended: tests/hermes_cli/test_api_key_providers.py - TestMinimaxOAuthProvider class (8 tests) covering registry keys, auth_type, endpoints, client_id, aliases, CANONICAL_PROVIDERS listing, _PROVIDER_MODELS entries, and aux model --- tests/hermes_cli/test_api_key_providers.py | 60 +++ .../test_runtime_provider_resolution.py | 65 +++ tests/test_minimax_oauth.py | 466 ++++++++++++++++++ 3 files changed, 591 insertions(+) create mode 100644 tests/test_minimax_oauth.py diff --git a/tests/hermes_cli/test_api_key_providers.py b/tests/hermes_cli/test_api_key_providers.py index 530075238f38..291b8b70d464 100644 --- a/tests/hermes_cli/test_api_key_providers.py +++ b/tests/hermes_cli/test_api_key_providers.py @@ -1097,3 +1097,63 @@ def test_provider_label(self): from hermes_cli.models import _PROVIDER_LABELS assert "huggingface" in _PROVIDER_LABELS assert _PROVIDER_LABELS["huggingface"] == "Hugging Face" + + +# ============================================================================= +# MiniMax OAuth provider tests (added by feat/minimax-oauth-provider) +# ============================================================================= + +class TestMinimaxOAuthProvider: + """Tests for the minimax-oauth OAuth provider.""" + + def test_minimax_oauth_in_provider_registry(self): + assert "minimax-oauth" in PROVIDER_REGISTRY + pconfig = PROVIDER_REGISTRY["minimax-oauth"] + assert pconfig.auth_type == "oauth_minimax" + assert pconfig.id == "minimax-oauth" + + def test_minimax_oauth_has_correct_endpoints(self): + from hermes_cli.auth import ( + MINIMAX_OAUTH_GLOBAL_BASE, + MINIMAX_OAUTH_GLOBAL_INFERENCE, + MINIMAX_OAUTH_CN_BASE, + MINIMAX_OAUTH_CN_INFERENCE, + ) + pconfig = PROVIDER_REGISTRY["minimax-oauth"] + assert pconfig.portal_base_url == MINIMAX_OAUTH_GLOBAL_BASE + assert pconfig.inference_base_url == MINIMAX_OAUTH_GLOBAL_INFERENCE + assert pconfig.extra["cn_portal_base_url"] == MINIMAX_OAUTH_CN_BASE + assert pconfig.extra["cn_inference_base_url"] == MINIMAX_OAUTH_CN_INFERENCE + + def test_minimax_oauth_alias_resolves_portal(self): + result = resolve_provider("minimax-portal") + assert result == "minimax-oauth" + + def test_minimax_oauth_alias_resolves_global(self): + result = resolve_provider("minimax-global") + assert result == "minimax-oauth" + + def test_minimax_oauth_alias_resolves_underscore(self): + result = resolve_provider("minimax_oauth") + assert result == "minimax-oauth" + + def test_minimax_oauth_listed_in_canonical_providers(self): + from hermes_cli.models import CANONICAL_PROVIDERS + slugs = [p.slug for p in CANONICAL_PROVIDERS] + assert "minimax-oauth" in slugs + + def test_minimax_oauth_models_alias_in_models_py(self): + from hermes_cli.models import _PROVIDER_ALIASES + assert _PROVIDER_ALIASES.get("minimax-portal") == "minimax-oauth" + assert _PROVIDER_ALIASES.get("minimax-global") == "minimax-oauth" + assert _PROVIDER_ALIASES.get("minimax_oauth") == "minimax-oauth" + + def test_minimax_oauth_has_models(self): + from hermes_cli.models import _PROVIDER_MODELS + models = _PROVIDER_MODELS.get("minimax-oauth", []) + assert len(models) >= 1 + + def test_minimax_oauth_aux_model_registered(self): + from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS + assert "minimax-oauth" in _API_KEY_PROVIDER_AUX_MODELS + assert _API_KEY_PROVIDER_AUX_MODELS["minimax-oauth"] # non-empty diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index a30cbaecdc10..c7adfe1482df 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -1998,6 +1998,7 @@ def test_key_env_points_at_unset_var_falls_through(self, monkeypatch): assert resolved["api_key"] == "fallback-works" + def test_no_key_anywhere_raises_helpful_error(self, monkeypatch): """When nothing resolves, the error message mentions key_env as an option.""" monkeypatch.delenv("AZURE_ANTHROPIC_KEY", raising=False) @@ -2168,3 +2169,67 @@ def test_explicit_override_skips_env(self, monkeypatch): assert resolved["base_url"] == "https://explicit-proxy.example.com/v1" assert resolved["source"] == "explicit" +# --------------------------------------------------------------------------- +# minimax-oauth runtime resolution tests (added by feat/minimax-oauth-provider) +# --------------------------------------------------------------------------- + +def test_minimax_oauth_runtime_returns_anthropic_messages_mode(monkeypatch): + """resolve_runtime_provider for minimax-oauth must return api_mode='anthropic_messages'.""" + from hermes_cli.auth import MINIMAX_OAUTH_GLOBAL_INFERENCE + + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax-oauth") + monkeypatch.setattr(rp, "_get_model_config", lambda: {"provider": "minimax-oauth"}) + monkeypatch.setattr(rp, "load_pool", lambda provider: None) + monkeypatch.setattr( + rp, + "_resolve_named_custom_runtime", + lambda **k: None, + ) + monkeypatch.setattr( + rp, + "_resolve_explicit_runtime", + lambda **k: None, + ) + + fake_creds = { + "provider": "minimax-oauth", + "api_key": "mock-access-token", + "base_url": MINIMAX_OAUTH_GLOBAL_INFERENCE.rstrip("/"), + "source": "oauth", + } + + import hermes_cli.auth as auth_mod + monkeypatch.setattr(auth_mod, "resolve_minimax_oauth_runtime_credentials", + lambda **k: fake_creds) + + resolved = rp.resolve_runtime_provider(requested="minimax-oauth") + + assert resolved["provider"] == "minimax-oauth" + assert resolved["api_mode"] == "anthropic_messages" + assert resolved["api_key"] == "mock-access-token" + + +def test_minimax_oauth_runtime_uses_inference_base_url(monkeypatch): + """Base URL returned by resolve_runtime_provider should match the OAuth credentials.""" + from hermes_cli.auth import MINIMAX_OAUTH_CN_INFERENCE + + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax-oauth") + monkeypatch.setattr(rp, "_get_model_config", lambda: {"provider": "minimax-oauth"}) + monkeypatch.setattr(rp, "load_pool", lambda provider: None) + monkeypatch.setattr(rp, "_resolve_named_custom_runtime", lambda **k: None) + monkeypatch.setattr(rp, "_resolve_explicit_runtime", lambda **k: None) + + fake_creds = { + "provider": "minimax-oauth", + "api_key": "cn-token", + "base_url": MINIMAX_OAUTH_CN_INFERENCE.rstrip("/"), + "source": "oauth", + } + + import hermes_cli.auth as auth_mod + monkeypatch.setattr(auth_mod, "resolve_minimax_oauth_runtime_credentials", + lambda **k: fake_creds) + + resolved = rp.resolve_runtime_provider(requested="minimax-oauth") + + assert MINIMAX_OAUTH_CN_INFERENCE.rstrip("/") in resolved["base_url"] diff --git a/tests/test_minimax_oauth.py b/tests/test_minimax_oauth.py new file mode 100644 index 000000000000..0e63800e9179 --- /dev/null +++ b/tests/test_minimax_oauth.py @@ -0,0 +1,466 @@ +"""Tests for MiniMax OAuth provider (hermes_cli/auth.py). + +Covers: +- PKCE pair generation (S256 challenge) +- _minimax_request_user_code happy path and state-mismatch error +- _minimax_poll_token: pending→success flow, error status, timeout +- _refresh_minimax_oauth_state: skip when not expired, update on success, + re-login required on invalid_grant +- resolve_minimax_oauth_runtime_credentials: error when not logged in +""" +from __future__ import annotations + +import base64 +import hashlib +import json +import time +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from hermes_cli.auth import ( + PROVIDER_REGISTRY, + AuthError, + MINIMAX_OAUTH_CLIENT_ID, + MINIMAX_OAUTH_GLOBAL_BASE, + MINIMAX_OAUTH_GLOBAL_INFERENCE, + MINIMAX_OAUTH_CN_BASE, + MINIMAX_OAUTH_CN_INFERENCE, + MINIMAX_OAUTH_REFRESH_SKEW_SECONDS, + _minimax_pkce_pair, + _minimax_request_user_code, + _minimax_poll_token, + _refresh_minimax_oauth_state, + resolve_minimax_oauth_runtime_credentials, + get_minimax_oauth_auth_status, + get_provider_auth_state, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_httpx_response(status_code: int, body: dict | None = None, text: str = ""): + """Return a minimal mock that quacks like httpx.Response.""" + resp = MagicMock() + resp.status_code = status_code + if body is not None: + resp.json.return_value = body + resp.text = json.dumps(body) + else: + resp.json.side_effect = Exception("No body") + resp.text = text + resp.reason_phrase = "OK" if status_code == 200 else "Error" + return resp + + +def _future_iso(seconds_from_now: int = 3600) -> str: + ts = time.time() + seconds_from_now + return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() + + +def _past_iso(seconds_ago: int = 3600) -> str: + ts = time.time() - seconds_ago + return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() + + +# --------------------------------------------------------------------------- +# 1. test_pkce_pair_produces_valid_s256 +# --------------------------------------------------------------------------- + +def test_pkce_pair_produces_valid_s256(): + verifier, challenge, state = _minimax_pkce_pair() + + # Verifier must be non-empty and URL-safe + assert isinstance(verifier, str) + assert len(verifier) >= 32 + + # Challenge must be URL-safe base64 without trailing "=" + assert isinstance(challenge, str) + assert "=" not in challenge + + # Re-compute challenge from verifier and verify it matches + expected = base64.urlsafe_b64encode( + hashlib.sha256(verifier.encode()).digest() + ).decode().rstrip("=") + assert challenge == expected + + # State must be non-empty + assert isinstance(state, str) + assert len(state) >= 8 + + # Two calls must return different values (randomness) + v2, c2, s2 = _minimax_pkce_pair() + assert verifier != v2 + assert state != s2 + + +# --------------------------------------------------------------------------- +# 2. test_request_user_code_happy_path +# --------------------------------------------------------------------------- + +def test_request_user_code_happy_path(): + state = "test-state-abc" + mock_response = _make_httpx_response(200, { + "user_code": "ABC-123", + "verification_uri": "https://minimax.io/verify", + "expired_in": int(time.time() * 1000) + 300_000, + "state": state, + }) + + client = MagicMock() + client.post.return_value = mock_response + + result = _minimax_request_user_code( + client, + portal_base_url=MINIMAX_OAUTH_GLOBAL_BASE, + client_id=MINIMAX_OAUTH_CLIENT_ID, + code_challenge="test-challenge", + state=state, + ) + + assert result["user_code"] == "ABC-123" + assert result["verification_uri"] == "https://minimax.io/verify" + assert result["state"] == state + + # Verify correct endpoint was called + call_args = client.post.call_args + assert "/oauth/code" in call_args[0][0] + headers = call_args[1].get("headers", {}) + assert "x-request-id" in headers + + +# --------------------------------------------------------------------------- +# 3. test_request_user_code_state_mismatch_raises +# --------------------------------------------------------------------------- + +def test_request_user_code_state_mismatch_raises(): + mock_response = _make_httpx_response(200, { + "user_code": "XYZ", + "verification_uri": "https://minimax.io/verify", + "expired_in": 300, + "state": "wrong-state", # Mismatched! + }) + + client = MagicMock() + client.post.return_value = mock_response + + with pytest.raises(AuthError) as exc_info: + _minimax_request_user_code( + client, + portal_base_url=MINIMAX_OAUTH_GLOBAL_BASE, + client_id=MINIMAX_OAUTH_CLIENT_ID, + code_challenge="challenge", + state="correct-state", + ) + + assert exc_info.value.code == "state_mismatch" + assert "CSRF" in str(exc_info.value) or "mismatch" in str(exc_info.value).lower() + + +# --------------------------------------------------------------------------- +# 4. test_request_user_code_non_200_raises +# --------------------------------------------------------------------------- + +def test_request_user_code_non_200_raises(): + mock_response = _make_httpx_response(400, text="Bad Request") + mock_response.json.side_effect = Exception("no json") + mock_response.text = "Bad Request" + + client = MagicMock() + client.post.return_value = mock_response + + with pytest.raises(AuthError) as exc_info: + _minimax_request_user_code( + client, + portal_base_url=MINIMAX_OAUTH_GLOBAL_BASE, + client_id=MINIMAX_OAUTH_CLIENT_ID, + code_challenge="challenge", + state="state", + ) + + assert exc_info.value.code == "authorization_failed" + + +# --------------------------------------------------------------------------- +# 5. test_poll_token_pending_then_success +# --------------------------------------------------------------------------- + +def test_poll_token_pending_then_success(): + # Set a deadline far enough in the future for polling + deadline_ms = int(time.time() * 1000) + 60_000 # 60 seconds from now + + pending_body = {"status": "pending"} + success_body = { + "status": "success", + "access_token": "access-abc", + "refresh_token": "refresh-xyz", + "expired_in": 3600, + "token_type": "Bearer", + } + + pending_resp = _make_httpx_response(200, pending_body) + success_resp = _make_httpx_response(200, success_body) + + client = MagicMock() + client.post.side_effect = [pending_resp, pending_resp, success_resp] + + with patch("time.sleep"): # don't actually sleep + result = _minimax_poll_token( + client, + portal_base_url=MINIMAX_OAUTH_GLOBAL_BASE, + client_id=MINIMAX_OAUTH_CLIENT_ID, + user_code="USER-CODE", + code_verifier="verifier", + expired_in=deadline_ms, + interval_ms=2000, + ) + + assert result["status"] == "success" + assert result["access_token"] == "access-abc" + assert result["refresh_token"] == "refresh-xyz" + assert client.post.call_count == 3 + + +# --------------------------------------------------------------------------- +# 6. test_poll_token_error_raises +# --------------------------------------------------------------------------- + +def test_poll_token_error_raises(): + deadline_ms = int(time.time() * 1000) + 60_000 + error_body = {"status": "error"} + error_resp = _make_httpx_response(200, error_body) + + client = MagicMock() + client.post.return_value = error_resp + + with pytest.raises(AuthError) as exc_info: + _minimax_poll_token( + client, + portal_base_url=MINIMAX_OAUTH_GLOBAL_BASE, + client_id=MINIMAX_OAUTH_CLIENT_ID, + user_code="U", + code_verifier="v", + expired_in=deadline_ms, + interval_ms=2000, + ) + + assert exc_info.value.code == "authorization_denied" + + +# --------------------------------------------------------------------------- +# 7. test_poll_token_timeout_raises +# --------------------------------------------------------------------------- + +def test_poll_token_timeout_raises(): + # expired_in is a small duration (treated as seconds from now, already expired) + expired_in = 1 # 1 second from now + # Make sleep a no-op and time.time advance quickly by using a small deadline + # We use a duration-style expired_in (small enough to not be a unix timestamp) + # duration mode: deadline = time.time() + max(1, expired_in) + # We need time() to exceed deadline immediately. + + fixed_now = time.time() + call_count = [0] + + def fake_time(): + call_count[0] += 1 + # After 2 calls, return a time past the deadline + if call_count[0] > 2: + return fixed_now + 10 # past deadline + return fixed_now + + client = MagicMock() + pending_resp = _make_httpx_response(200, {"status": "pending"}) + client.post.return_value = pending_resp + + import hermes_cli.auth as auth_module + with patch.object(auth_module, "time") as mock_time_mod: + # We need to patch the 'time' module used inside _minimax_poll_token + # The function imports 'import time as _time' locally. + # Patch time.sleep and time.time in the auth module's local scope. + pass + + # Use a simpler approach: expired_in as past timestamp (already expired) + past_deadline_ms = int((time.time() - 1) * 1000) # 1 second ago + + with pytest.raises(AuthError) as exc_info: + _minimax_poll_token( + client, + portal_base_url=MINIMAX_OAUTH_GLOBAL_BASE, + client_id=MINIMAX_OAUTH_CLIENT_ID, + user_code="U", + code_verifier="v", + expired_in=past_deadline_ms, + interval_ms=2000, + ) + + assert exc_info.value.code == "timeout" + + +# --------------------------------------------------------------------------- +# 8. test_refresh_skip_when_not_expired +# --------------------------------------------------------------------------- + +def test_refresh_skip_when_not_expired(): + """When token is far from expiry, refresh should return the same state.""" + state = { + "access_token": "old-access", + "refresh_token": "refresh-token", + "portal_base_url": MINIMAX_OAUTH_GLOBAL_BASE, + "client_id": MINIMAX_OAUTH_CLIENT_ID, + "inference_base_url": MINIMAX_OAUTH_GLOBAL_INFERENCE, + "expires_at": _future_iso(3600), # 1 hour in the future + } + + result = _refresh_minimax_oauth_state(state) + assert result["access_token"] == "old-access" + assert result is state # Same object returned (no refresh) + + +# --------------------------------------------------------------------------- +# 9. test_refresh_updates_access_token +# --------------------------------------------------------------------------- + +def test_refresh_updates_access_token(): + """When token is close to expiry, refresh should update the state.""" + # expires_at just MINIMAX_OAUTH_REFRESH_SKEW_SECONDS - 1 from now (close to expiry) + state = { + "access_token": "old-access", + "refresh_token": "my-refresh", + "portal_base_url": MINIMAX_OAUTH_GLOBAL_BASE, + "client_id": MINIMAX_OAUTH_CLIENT_ID, + "inference_base_url": MINIMAX_OAUTH_GLOBAL_INFERENCE, + "expires_at": _future_iso(MINIMAX_OAUTH_REFRESH_SKEW_SECONDS - 1), + } + + new_token_body = { + "status": "success", + "access_token": "new-access", + "refresh_token": "new-refresh", + "expired_in": 7200, + } + + mock_resp = _make_httpx_response(200, new_token_body) + + with patch("httpx.Client") as mock_client_class: + mock_client_instance = MagicMock() + mock_client_instance.__enter__ = MagicMock(return_value=mock_client_instance) + mock_client_instance.__exit__ = MagicMock(return_value=False) + mock_client_instance.post.return_value = mock_resp + mock_client_class.return_value = mock_client_instance + + # Patch _minimax_save_auth_state to avoid touching the auth store + with patch("hermes_cli.auth._minimax_save_auth_state"): + result = _refresh_minimax_oauth_state(state) + + assert result["access_token"] == "new-access" + assert result["refresh_token"] == "new-refresh" + assert result["expires_in"] == 7200 + + +# --------------------------------------------------------------------------- +# 10. test_refresh_reuse_triggers_relogin_required +# --------------------------------------------------------------------------- + +def test_refresh_reuse_triggers_relogin_required(): + """On 400 + invalid_grant body, relogin_required should be set.""" + state = { + "access_token": "old-access", + "refresh_token": "old-refresh", + "portal_base_url": MINIMAX_OAUTH_GLOBAL_BASE, + "client_id": MINIMAX_OAUTH_CLIENT_ID, + "inference_base_url": MINIMAX_OAUTH_GLOBAL_INFERENCE, + "expires_at": _past_iso(100), # already expired + } + + bad_resp = _make_httpx_response(400, text="invalid_grant") + bad_resp.json.side_effect = Exception("no json") + bad_resp.text = "invalid_grant" + bad_resp.reason_phrase = "Bad Request" + + with patch("httpx.Client") as mock_client_class: + mock_client_instance = MagicMock() + mock_client_instance.__enter__ = MagicMock(return_value=mock_client_instance) + mock_client_instance.__exit__ = MagicMock(return_value=False) + mock_client_instance.post.return_value = bad_resp + mock_client_class.return_value = mock_client_instance + + with pytest.raises(AuthError) as exc_info: + _refresh_minimax_oauth_state(state) + + assert exc_info.value.code == "refresh_failed" + assert exc_info.value.relogin_required is True + + +# --------------------------------------------------------------------------- +# 11. test_resolve_credentials_requires_login +# --------------------------------------------------------------------------- + +def test_resolve_credentials_requires_login(): + """When no state is stored, resolve_minimax_oauth_runtime_credentials raises.""" + with patch("hermes_cli.auth.get_provider_auth_state", return_value=None): + with pytest.raises(AuthError) as exc_info: + resolve_minimax_oauth_runtime_credentials() + + assert exc_info.value.code == "not_logged_in" + assert exc_info.value.relogin_required is True + + +# --------------------------------------------------------------------------- +# 12. test_provider_registry_contains_minimax_oauth +# --------------------------------------------------------------------------- + +def test_provider_registry_contains_minimax_oauth(): + assert "minimax-oauth" in PROVIDER_REGISTRY + pconfig = PROVIDER_REGISTRY["minimax-oauth"] + assert pconfig.auth_type == "oauth_minimax" + assert pconfig.client_id == MINIMAX_OAUTH_CLIENT_ID + assert MINIMAX_OAUTH_GLOBAL_BASE in pconfig.portal_base_url + assert MINIMAX_OAUTH_GLOBAL_INFERENCE in pconfig.inference_base_url + assert "cn_portal_base_url" in pconfig.extra + assert "cn_inference_base_url" in pconfig.extra + + +# --------------------------------------------------------------------------- +# 13. test_minimax_oauth_alias_resolves +# --------------------------------------------------------------------------- + +def test_minimax_oauth_alias_resolves(): + from hermes_cli.auth import resolve_provider + # Only test that minimax-oauth itself resolves (alias resolution is tested in models) + result = resolve_provider("minimax-oauth") + assert result == "minimax-oauth" + + +# --------------------------------------------------------------------------- +# 14. test_get_minimax_oauth_auth_status_not_logged_in +# --------------------------------------------------------------------------- + +def test_get_minimax_oauth_auth_status_not_logged_in(): + with patch("hermes_cli.auth.get_provider_auth_state", return_value=None): + status = get_minimax_oauth_auth_status() + + assert status["logged_in"] is False + assert status["provider"] == "minimax-oauth" + + +# --------------------------------------------------------------------------- +# 15. test_get_minimax_oauth_auth_status_logged_in +# --------------------------------------------------------------------------- + +def test_get_minimax_oauth_auth_status_logged_in(): + state = { + "access_token": "tok", + "expires_at": _future_iso(3600), + "region": "global", + } + + with patch("hermes_cli.auth.get_provider_auth_state", return_value=state): + status = get_minimax_oauth_auth_status() + + assert status["logged_in"] is True + assert status["region"] == "global" From 0c57bd7388cbb8fd601a6be6f296dd1eb24f2dc1 Mon Sep 17 00:00:00 2001 From: Adam Manning Date: Fri, 24 Apr 2026 14:32:26 +0000 Subject: [PATCH 4/5] docs: document MiniMax OAuth login flow Add comprehensive documentation for the minimax-oauth provider. New file: website/docs/guides/minimax-oauth.md - Overview table (provider ID, auth type, models, endpoints) - Quick start via 'hermes model' - Manual login via 'hermes auth add minimax-oauth' - --region global|cn flag reference - The PKCE OAuth flow explained step-by-step - hermes doctor output example - Configuration reference (config.yaml shape, region table, aliases) - Environment variables note: MINIMAX_API_KEY is NOT used by minimax-oauth (OAuth path uses browser login) - Models table with context length note - Troubleshooting section: expired token, timeout, state mismatch, headless/remote sessions, not logged in - Logout command Updated: website/docs/getting-started/quickstart.md - Add MiniMax (OAuth) to provider picker table as the recommended path for users who want MiniMax models without an API key Updated: website/docs/user-guide/configuration.md - Add 'minimax-oauth' to the auxiliary providers list - Add MiniMax OAuth tip callout in the providers section - Add minimax-oauth row to the provider table (auxiliary tasks) - Add MiniMax OAuth config.yaml example in Common Setups Updated: website/docs/reference/environment-variables.md - Annotate MINIMAX_API_KEY, MINIMAX_BASE_URL, MINIMAX_CN_API_KEY, MINIMAX_CN_BASE_URL as NOT used by minimax-oauth - Add minimax-oauth to HERMES_INFERENCE_PROVIDER allowed values --- website/docs/getting-started/quickstart.md | 1 + website/docs/guides/minimax-oauth.md | 224 ++++++++++++++++++ .../docs/reference/environment-variables.md | 10 +- website/docs/user-guide/configuration.md | 16 +- 4 files changed, 245 insertions(+), 6 deletions(-) create mode 100644 website/docs/guides/minimax-oauth.md diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index 5042cfbb8de2..c9af17042008 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -77,6 +77,7 @@ Good defaults: | **Kimi / Moonshot China** | China-region Moonshot endpoint | Set `KIMI_CN_API_KEY` | | **Arcee AI** | Trinity models | Set `ARCEEAI_API_KEY` | | **GMI Cloud** | Multi-model direct API | Set `GMI_API_KEY` | +| **MiniMax (OAuth)** | MiniMax-M2.7 via browser OAuth — no API key needed | `hermes model` → MiniMax (OAuth) | | **MiniMax** | International MiniMax endpoint | Set `MINIMAX_API_KEY` | | **MiniMax China** | China-region MiniMax endpoint | Set `MINIMAX_CN_API_KEY` | | **Alibaba Cloud** | Qwen models via DashScope | Set `DASHSCOPE_API_KEY` | diff --git a/website/docs/guides/minimax-oauth.md b/website/docs/guides/minimax-oauth.md new file mode 100644 index 000000000000..2bc1ef3683c2 --- /dev/null +++ b/website/docs/guides/minimax-oauth.md @@ -0,0 +1,224 @@ +--- +sidebar_position: 15 +title: "MiniMax OAuth" +description: "Log into MiniMax via browser OAuth and use MiniMax-M2.7 models in Hermes Agent — no API key required" +--- + +# MiniMax OAuth + +Hermes Agent supports **MiniMax** through a browser-based OAuth login flow, using the same credentials as the [MiniMax portal](https://www.minimax.io). No API key or credit card is required — log in once and Hermes automatically refreshes your session. + +The transport reuses the `anthropic_messages` adapter (MiniMax exposes an Anthropic Messages-compatible endpoint at `/anthropic`), so all existing tool-calling, streaming, and context features work without any adapter changes. + +## Overview + +| Item | Value | +|------|-------| +| Provider ID | `minimax-oauth` | +| Display name | MiniMax (OAuth) | +| Auth type | Browser OAuth (PKCE device-code flow) | +| Transport | Anthropic Messages-compatible (`anthropic_messages`) | +| Models | `MiniMax-M2.7`, `MiniMax-M2.7-highspeed` | +| Global endpoint | `https://api.minimax.io/anthropic` | +| China endpoint | `https://api.minimaxi.com/anthropic` | +| Requires env var | No (`MINIMAX_API_KEY` is **not** used for this provider) | + +## Prerequisites + +- Python 3.9+ +- Hermes Agent installed +- A MiniMax account at [minimax.io](https://www.minimax.io) (global) or [minimaxi.com](https://www.minimaxi.com) (China) +- A browser available on the local machine (or use `--no-browser` for remote sessions) + +## Quick Start + +```bash +# Launch the provider and model picker +hermes model +# → Select "MiniMax (OAuth)" from the provider list +# → Hermes opens your browser to the MiniMax authorization page +# → Approve access in the browser +# → Select a model (MiniMax-M2.7 or MiniMax-M2.7-highspeed) +# → Start chatting + +hermes +``` + +After the first login, credentials are stored under `~/.hermes/auth.json` and are refreshed automatically before each session. + +## Logging In Manually + +You can trigger a login without going through the model picker: + +```bash +hermes auth add minimax-oauth +``` + +### China region + +If your account is on the China platform (`minimaxi.com`), pass `--region cn`: + +```bash +hermes auth add minimax-oauth --region cn +``` + +### Remote / headless sessions + +On servers or containers where no browser is available: + +```bash +hermes auth add minimax-oauth --no-browser +``` + +Hermes will print the verification URL and user code — open the URL on any device and enter the code when prompted. + +## The OAuth Flow + +Hermes implements a PKCE device-code flow against the MiniMax OAuth endpoints: + +1. Hermes generates a PKCE verifier / challenge pair and a random state value. +2. It POSTs to `{base_url}/oauth/code` with the challenge and receives a `user_code` and `verification_uri`. +3. Your browser opens `verification_uri`. If prompted, enter the `user_code`. +4. Hermes polls `{base_url}/oauth/token` until the token arrives (or the deadline passes). +5. Tokens (`access_token`, `refresh_token`, expiry) are saved to `~/.hermes/auth.json` under the `minimax-oauth` key. + +Token refresh (standard OAuth `refresh_token` grant) runs automatically at each session start when the access token is within 60 seconds of expiry. + +## Checking Login Status + +```bash +hermes doctor +``` + +The `◆ Auth Providers` section will show: + +``` +✓ MiniMax OAuth (logged in, region=global) +``` + +or, if not logged in: + +``` +⚠ MiniMax OAuth (not logged in) +``` + +## Switching Models + +```bash +hermes model +# → Select "MiniMax (OAuth)" +# → Pick from the model list +``` + +Or set the model directly: + +```bash +hermes config set model MiniMax-M2.7 +hermes config set provider minimax-oauth +``` + +## Configuration Reference + +After login, `~/.hermes/config.yaml` will contain entries similar to: + +```yaml +model: + default: MiniMax-M2.7 + provider: minimax-oauth + base_url: https://api.minimax.io/anthropic +``` + +### `--region` flag + +| Value | Portal | Inference endpoint | +|-------|--------|-------------------| +| `global` (default) | `https://api.minimax.io` | `https://api.minimax.io/anthropic` | +| `cn` | `https://api.minimaxi.com` | `https://api.minimaxi.com/anthropic` | + +### Provider aliases + +All of the following resolve to `minimax-oauth`: + +```bash +hermes --provider minimax-oauth # canonical +hermes --provider minimax-portal # alias +hermes --provider minimax-global # alias +hermes --provider minimax_oauth # alias (underscore form) +``` + +## Environment Variables + +The `minimax-oauth` provider does **not** use `MINIMAX_API_KEY` or `MINIMAX_BASE_URL`. Those variables are for the API-key-based `minimax` and `minimax-cn` providers only. + +| Variable | Effect | +|----------|--------| +| `MINIMAX_API_KEY` | Used by `minimax` provider only — ignored for `minimax-oauth` | +| `MINIMAX_CN_API_KEY` | Used by `minimax-cn` provider only — ignored for `minimax-oauth` | + +To force the `minimax-oauth` provider at runtime: + +```bash +HERMES_INFERENCE_PROVIDER=minimax-oauth hermes +``` + +## Models + +| Model | Best for | +|-------|----------| +| `MiniMax-M2.7` | Long-context reasoning, complex tool-calling | +| `MiniMax-M2.7-highspeed` | Lower latency, lighter tasks, auxiliary calls | + +Both models support up to 200,000 tokens of context. + +`MiniMax-M2.7-highspeed` is also used automatically as the auxiliary model for vision and delegation tasks when `minimax-oauth` is the primary provider. + +## Troubleshooting + +### Token expired — not re-logging in automatically + +Hermes refreshes the token on every session start if it is within 60 seconds of expiry. If the access token is already expired (for example, after a long offline period), the refresh happens automatically on the next request. If refresh fails with `refresh_token_reused` or `invalid_grant`, Hermes marks the session as requiring re-login. + +**Fix:** run `hermes auth add minimax-oauth` again to start a fresh login. + +### Authorization timed out + +The device-code flow has a finite expiry window. If you don't approve the login in time, Hermes raises a timeout error. + +**Fix:** re-run `hermes auth add minimax-oauth` (or `hermes model`). The flow starts fresh. + +### State mismatch (possible CSRF) + +Hermes detected that the `state` value returned by the authorization server does not match what it sent. + +**Fix:** re-run the login. If it persists, check for a proxy or redirect that is modifying the OAuth response. + +### Logging in from a remote server + +If `hermes` cannot open a browser window, use `--no-browser`: + +```bash +hermes auth add minimax-oauth --no-browser +``` + +Hermes prints the URL and code. Open the URL on any device and complete the flow there. + +### "Not logged into MiniMax OAuth" error at runtime + +The auth store has no credentials for `minimax-oauth`. You have not logged in yet, or the credential file was deleted. + +**Fix:** run `hermes model` and select MiniMax (OAuth), or run `hermes auth add minimax-oauth`. + +## Logging Out + +To remove stored MiniMax OAuth credentials: + +```bash +hermes auth remove minimax-oauth +``` + +## See Also + +- [AI Providers reference](../integrations/providers.md) +- [Environment Variables](../reference/environment-variables.md) +- [Configuration](../user-guide/configuration.md) +- [hermes doctor](../reference/cli-commands.md) diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 8a5678038a31..497c62adcfd8 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -38,10 +38,10 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config | `ARCEE_BASE_URL` | Override Arcee base URL (default: `https://api.arcee.ai/api/v1`) | | `GMI_API_KEY` | GMI Cloud API key ([gmicloud.ai](https://www.gmicloud.ai/)) | | `GMI_BASE_URL` | Override GMI Cloud base URL (default: `https://api.gmi-serving.com/v1`) | -| `MINIMAX_API_KEY` | MiniMax API key — global endpoint ([minimax.io](https://www.minimax.io)) | -| `MINIMAX_BASE_URL` | Override MiniMax base URL (default: `https://api.minimax.io/anthropic` — Hermes uses MiniMax's Anthropic Messages-compatible endpoint) | -| `MINIMAX_CN_API_KEY` | MiniMax API key — China endpoint ([minimaxi.com](https://www.minimaxi.com)) | -| `MINIMAX_CN_BASE_URL` | Override MiniMax China base URL (default: `https://api.minimaxi.com/anthropic`) | +| `MINIMAX_API_KEY` | MiniMax API key — global endpoint ([minimax.io](https://www.minimax.io)). **Not used by `minimax-oauth`** (OAuth path uses browser login instead). | +| `MINIMAX_BASE_URL` | Override MiniMax base URL (default: `https://api.minimax.io/anthropic` — Hermes uses MiniMax's Anthropic Messages-compatible endpoint). **Not used by `minimax-oauth`**. | +| `MINIMAX_CN_API_KEY` | MiniMax API key — China endpoint ([minimaxi.com](https://www.minimaxi.com)). **Not used by `minimax-oauth`** (OAuth path uses browser login instead). | +| `MINIMAX_CN_BASE_URL` | Override MiniMax China base URL (default: `https://api.minimaxi.com/anthropic`). **Not used by `minimax-oauth`**. | | `KILOCODE_API_KEY` | Kilo Code API key ([kilo.ai](https://kilo.ai)) | | `KILOCODE_BASE_URL` | Override Kilo Code base URL (default: `https://api.kilo.ai/api/gateway`) | | `XIAOMI_API_KEY` | Xiaomi MiMo API key ([platform.xiaomimimo.com](https://platform.xiaomimimo.com)) | @@ -93,7 +93,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | Variable | Description | |----------|-------------| -| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `custom`, `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `huggingface`, `gemini`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `alibaba`, `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `google-gemini-cli`, `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `ai-gateway` (default: `auto`) | +| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `custom`, `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `huggingface`, `gemini`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth` (browser OAuth login — no API key required; see [MiniMax OAuth guide](../guides/minimax-oauth.md)), `kilocode`, `xiaomi`, `arcee`, `gmi`, `alibaba`, `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `google-gemini-cli`, `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `ai-gateway` (default: `auto`) | | `HERMES_PORTAL_BASE_URL` | Override Nous Portal URL (for development/testing) | | `NOUS_INFERENCE_BASE_URL` | Override Nous inference API URL | | `HERMES_NOUS_MIN_KEY_TTL_SECONDS` | Min agent key TTL before re-mint (default: 1800 = 30min) | diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index bc65802362c8..7eeec950bd30 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -735,7 +735,11 @@ Every model slot in Hermes — auxiliary tasks, compression, fallback — uses t When `base_url` is set, Hermes ignores the provider and calls that endpoint directly (using `api_key` or `OPENAI_API_KEY` for auth). When only `provider` is set, Hermes uses that provider's built-in auth and base URL. -Available providers for auxiliary tasks: `auto`, `main`, plus any provider in the [provider registry](/docs/reference/environment-variables) — `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `qwen-oauth`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `deepseek`, `nvidia`, `xai`, `ollama-cloud`, `alibaba`, `bedrock`, `huggingface`, `arcee`, `xiaomi`, `kilocode`, `opencode-zen`, `opencode-go`, `ai-gateway`, `azure-foundry` — or any named custom provider from your `custom_providers` list (e.g. `provider: "beans"`). +Available providers for auxiliary tasks: `auto`, `main`, plus any provider in the [provider registry](/docs/reference/environment-variables) — `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `qwen-oauth`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `ollama-cloud`, `alibaba`, `bedrock`, `huggingface`, `arcee`, `xiaomi`, `kilocode`, `opencode-zen`, `opencode-go`, `ai-gateway`, `azure-foundry` — or any named custom provider from your `custom_providers` list (e.g. `provider: "beans"`). + +:::tip MiniMax OAuth +`minimax-oauth` logs in via browser OAuth (no API key needed). Run `hermes model` and select **MiniMax (OAuth)** to authenticate. Auxiliary tasks use `MiniMax-M2.7-highspeed` automatically. See the [MiniMax OAuth guide](../guides/minimax-oauth.md). +::: :::warning `"main"` is for auxiliary tasks only The `"main"` provider option means "use whatever provider my main agent uses" — it's only valid inside `auxiliary:`, `compression:`, and `fallback_model:` configs. It is **not** a valid value for your top-level `model.provider` setting. If you use a custom OpenAI-compatible endpoint, set `provider: custom` in your `model:` section. See [AI Providers](/docs/integrations/providers) for all main model provider options. @@ -863,6 +867,7 @@ These options apply to **auxiliary task configs** (`auxiliary:`, `compression:`, | `"openrouter"` | Force OpenRouter — routes to any model (Gemini, GPT-4o, Claude, etc.) | `OPENROUTER_API_KEY` | | `"nous"` | Force Nous Portal | `hermes auth` | | `"codex"` | Force Codex OAuth (ChatGPT account). Supports vision (gpt-5.3-codex). | `hermes model` → Codex | +| `"minimax-oauth"` | Force MiniMax OAuth (browser login, no API key). Uses MiniMax-M2.7-highspeed for auxiliary tasks. | `hermes model` → MiniMax (OAuth) | | `"main"` | Use your active custom/main endpoint. This can come from `OPENAI_BASE_URL` + `OPENAI_API_KEY` or from a custom endpoint saved via `hermes model` / `config.yaml`. Works with OpenAI, local models, or any OpenAI-compatible API. **Auxiliary tasks only — not valid for `model.provider`.** | Custom endpoint credentials + base URL | Direct API-key providers from the main provider catalog also work here when you want side tasks to bypass your default router. `gmi` is valid once `GMI_API_KEY` is configured: @@ -917,6 +922,15 @@ auxiliary: # model defaults to gpt-5.3-codex (supports vision) ``` +**Using MiniMax OAuth** (browser login, no API key needed): +```yaml +model: + default: MiniMax-M2.7 + provider: minimax-oauth + base_url: https://api.minimax.io/anthropic +``` +Run `hermes model` and select **MiniMax (OAuth)** to log in and set this automatically. For the China region, the base URL will be `https://api.minimaxi.com/anthropic`. See the [MiniMax OAuth guide](../guides/minimax-oauth.md) for the full walkthrough. + **Using a local/self-hosted model:** ```yaml auxiliary: From 34bb8fd9924ad4d5be6ae02057a4d728e755923f Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:25:27 -0700 Subject: [PATCH 5/5] feat(minimax-oauth): full integration with peer OAuth providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close integration gaps discovered by auditing qwen-oauth's file coverage. These are surfaces the original salvage missed — they all existed on main and were added in the 747 commits since PR #15203 was opened. Coverage added: - agent/credential_pool.py: seed pool from auth.json providers.minimax-oauth so `hermes auth list` reflects logged-in state and `hermes auth remove minimax-oauth ` works through the standard flow. - agent/credential_sources.py: register RemovalStep for minimax-oauth with suppression-aware `_clear_auth_store_provider`. - agent/models_dev.py: PROVIDER_TO_MODELS_DEV mapping (-> 'minimax' family). - hermes_cli/providers.py: HermesOverlay entry (anthropic_messages transport, oauth_external auth_type, api.minimax.io/anthropic base). - hermes_cli/model_normalize.py: add to _MATCHING_PREFIX_STRIP_PROVIDERS so `minimax-oauth/MiniMax-M2.7` in config.yaml gets correctly repaired. - hermes_cli/status.py: render MiniMax OAuth block in `hermes doctor` (logged-in / region / expires_at / error). - hermes_cli/web_server.py: register in OAUTH_PROVIDER_REGISTRY + dispatch branch in _resolve_provider_status so the dashboard auth page shows it. - website/docs/integrations/providers.md: full 'MiniMax (OAuth)' section. - website/docs/reference/cli-commands.md: --provider enum. - website/docs/user-guide/features/fallback-providers.md: fallback table row. - scripts/release.py AUTHOR_MAP: amanning3390 mapping (CI gate). --- agent/credential_pool.py | 42 +++++++++++++++++++ agent/credential_sources.py | 18 ++++++++ agent/models_dev.py | 1 + hermes_cli/model_normalize.py | 1 + hermes_cli/providers.py | 5 +++ hermes_cli/status.py | 23 +++++++++- hermes_cli/web_server.py | 18 ++++++++ scripts/release.py | 1 + website/docs/integrations/providers.md | 28 ++++++++++++- website/docs/reference/cli-commands.md | 2 +- .../user-guide/features/fallback-providers.md | 1 + 11 files changed, 137 insertions(+), 3 deletions(-) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index d11b0186e4dc..004b57498890 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -1299,6 +1299,48 @@ def _is_suppressed(_p, _s): # type: ignore[misc] except Exception as exc: logger.debug("Qwen OAuth token seed failed: %s", exc) + elif provider == "minimax-oauth": + # MiniMax OAuth tokens live in ~/.hermes/auth.json providers.minimax-oauth. + # Seed the pool so `/auth list` reflects the logged-in state and the + # standard `hermes auth remove minimax-oauth ` flow works. + # Use refresh_if_expiring=False equivalent: resolve_minimax_oauth_runtime_credentials + # always refreshes on expiry, so instead read raw state here to avoid + # surprise network calls during provider discovery. + try: + from hermes_cli.auth import get_provider_auth_state + state = get_provider_auth_state("minimax-oauth") + if state and state.get("access_token"): + source_name = "oauth" + if not _is_suppressed(provider, source_name): + active_sources.add(source_name) + expires_at_ms = None + try: + from datetime import datetime as _dt + raw = state.get("expires_at", "") + if raw: + expires_at_ms = int(_dt.fromisoformat(raw).timestamp() * 1000) + except Exception: + expires_at_ms = None + base_url = str(state.get("inference_base_url", "") or "").rstrip("/") + changed |= _upsert_entry( + entries, + provider, + source_name, + { + "source": source_name, + "auth_type": AUTH_TYPE_OAUTH, + "access_token": state["access_token"], + "refresh_token": state.get("refresh_token"), + "expires_at_ms": expires_at_ms, + "base_url": base_url, + "label": state.get("label", "") or label_from_token( + state.get("access_token", ""), source_name + ), + }, + ) + except Exception as exc: + logger.debug("MiniMax OAuth token seed failed: %s", exc) + elif provider == "openai-codex": # Respect user suppression — `hermes auth remove openai-codex` marks # the device_code source as suppressed so it won't be re-seeded from diff --git a/agent/credential_sources.py b/agent/credential_sources.py index dce6293526ca..742049192481 100644 --- a/agent/credential_sources.py +++ b/agent/credential_sources.py @@ -252,6 +252,19 @@ def _remove_nous_device_code(provider: str, removed) -> RemovalResult: return result +def _remove_minimax_oauth(provider: str, removed) -> RemovalResult: + """MiniMax OAuth lives in auth.json providers.minimax-oauth — clear it. + + Same pattern as Nous: single-source OAuth state with refresh tokens. + Suppression of the `oauth` source ensures the pool reseed path + (_seed_from_singletons) doesn't instantly undo the removal. + """ + result = RemovalResult() + if _clear_auth_store_provider(provider): + result.cleaned.append(f"Cleared {provider} OAuth tokens from auth store") + return result + + def _remove_codex_device_code(provider: str, removed) -> RemovalResult: """Codex tokens live in TWO places: our auth store AND ~/.codex/auth.json. @@ -389,6 +402,11 @@ def _register_all_sources() -> None: remove_fn=_remove_qwen_cli, description="~/.qwen/oauth_creds.json", )) + register(RemovalStep( + provider="minimax-oauth", source_id="oauth", + remove_fn=_remove_minimax_oauth, + description="auth.json providers.minimax-oauth", + )) register(RemovalStep( provider="*", source_id="config:", match_fn=lambda src: src.startswith("config:") or src == "model_config", diff --git a/agent/models_dev.py b/agent/models_dev.py index 236dd582f923..79cfa90ca952 100644 --- a/agent/models_dev.py +++ b/agent/models_dev.py @@ -149,6 +149,7 @@ class ProviderInfo: "stepfun": "stepfun", "kimi-coding-cn": "kimi-for-coding", "minimax": "minimax", + "minimax-oauth": "minimax", "minimax-cn": "minimax-cn", "deepseek": "deepseek", "alibaba": "alibaba", diff --git a/hermes_cli/model_normalize.py b/hermes_cli/model_normalize.py index 99e6c34e4818..433e34279643 100644 --- a/hermes_cli/model_normalize.py +++ b/hermes_cli/model_normalize.py @@ -96,6 +96,7 @@ "kimi-coding", "kimi-coding-cn", "minimax", + "minimax-oauth", "minimax-cn", "alibaba", "qwen-oauth", diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 60f8dd8eaa74..490987095461 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -111,6 +111,11 @@ class HermesOverlay: transport="anthropic_messages", base_url_env_var="MINIMAX_BASE_URL", ), + "minimax-oauth": HermesOverlay( + transport="anthropic_messages", + auth_type="oauth_external", + base_url_override="https://api.minimax.io/anthropic", + ), "minimax-cn": HermesOverlay( transport="anthropic_messages", base_url_env_var="MINIMAX_CN_BASE_URL", diff --git a/hermes_cli/status.py b/hermes_cli/status.py index b412fb56a452..2f072c0e317e 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -159,14 +159,21 @@ def show_status(args): print(color("◆ Auth Providers", Colors.CYAN, Colors.BOLD)) try: - from hermes_cli.auth import get_nous_auth_status, get_codex_auth_status, get_qwen_auth_status + from hermes_cli.auth import ( + get_nous_auth_status, + get_codex_auth_status, + get_qwen_auth_status, + get_minimax_oauth_auth_status, + ) nous_status = get_nous_auth_status() codex_status = get_codex_auth_status() qwen_status = get_qwen_auth_status() + minimax_status = get_minimax_oauth_auth_status() except Exception: nous_status = {} codex_status = {} qwen_status = {} + minimax_status = {} nous_logged_in = bool(nous_status.get("logged_in")) nous_error = nous_status.get("error") @@ -219,6 +226,20 @@ def show_status(args): if qwen_status.get("error") and not qwen_logged_in: print(f" Error: {qwen_status.get('error')}") + minimax_logged_in = bool(minimax_status.get("logged_in")) + print( + f" {'MiniMax OAuth':<12} {check_mark(minimax_logged_in)} " + f"{'logged in' if minimax_logged_in else 'not logged in (run: hermes auth add minimax-oauth)'}" + ) + minimax_region = minimax_status.get("region") + if minimax_logged_in and minimax_region: + print(f" Region: {minimax_region}") + minimax_exp = minimax_status.get("expires_at") + if minimax_exp: + print(f" Access exp: {minimax_exp}") + if minimax_status.get("error") and not minimax_logged_in: + print(f" Error: {minimax_status.get('error')}") + # ========================================================================= # Nous Subscription Features # ========================================================================= diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index c45375cde2bc..965566bce993 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1221,6 +1221,14 @@ def _claude_code_only_status() -> Dict[str, Any]: "docs_url": "https://github.com/QwenLM/qwen-code", "status_fn": None, # dispatched via auth.get_qwen_auth_status }, + { + "id": "minimax-oauth", + "name": "MiniMax (OAuth)", + "flow": "pkce", + "cli_command": "hermes auth add minimax-oauth", + "docs_url": "https://www.minimax.io", + "status_fn": None, # dispatched via auth.get_minimax_oauth_auth_status + }, ) @@ -1264,6 +1272,16 @@ def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]: "expires_at": raw.get("expires_at"), "has_refresh_token": bool(raw.get("has_refresh_token")), } + if provider_id == "minimax-oauth": + raw = hauth.get_minimax_oauth_auth_status() + return { + "logged_in": bool(raw.get("logged_in")), + "source": "minimax_oauth", + "source_label": f"MiniMax ({raw.get('region', 'global')})", + "token_preview": None, + "expires_at": raw.get("expires_at"), + "has_refresh_token": True, + } except Exception as e: return {"logged_in": False, "error": str(e)} return {"logged_in": False} diff --git a/scripts/release.py b/scripts/release.py index 3c3f7d565932..46d5fcf44cbf 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -101,6 +101,7 @@ "keifergu@tencent.com": "keifergu", "kshitijk4poor@users.noreply.github.com": "kshitijk4poor", "abner.the.foreman@agentmail.to": "Abnertheforeman", + "adam.manning@pro-serveinc.com": "amanning3390", "thomasgeorgevii09@gmail.com": "tochukwuada", "harryykyle1@gmail.com": "hharry11", "kshitijk4poor@gmail.com": "kshitijk4poor", diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index cc2a8dac6d0e..a989d938fedd 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -411,6 +411,32 @@ Set `HERMES_QWEN_BASE_URL` only if the portal endpoint relocates (default: `http `qwen-oauth` uses the consumer-facing Qwen Portal with OAuth login — ideal for individual users. The `alibaba` provider uses DashScope's enterprise API with a `DASHSCOPE_API_KEY` — ideal for programmatic / production workloads. Both route to Qwen-family models but live at different endpoints. ::: +### MiniMax (OAuth) + +MiniMax-M2.7 via browser OAuth login — no API key needed. Pick **MiniMax (OAuth)** in `hermes model`, sign in through the browser, and Hermes persists the access + refresh tokens. Uses the Anthropic Messages-compatible endpoint (`/anthropic`) under the hood. + +```bash +hermes model +# → pick "MiniMax (OAuth)" +# → browser opens; sign in with your MiniMax account (global or CN region) +# → confirm — credentials are saved to ~/.hermes/auth.json + +hermes chat # uses api.minimax.io/anthropic endpoint +``` + +Or configure `config.yaml`: +```yaml +model: + provider: "minimax-oauth" + default: "MiniMax-M2.7" +``` + +Supported models: `MiniMax-M2.7` (main) and `MiniMax-M2.7-highspeed` (wired as the default auxiliary model). The OAuth path ignores `MINIMAX_API_KEY` / `MINIMAX_BASE_URL`. + +:::tip MiniMax OAuth vs API key +`minimax-oauth` uses MiniMax's consumer-facing portal with OAuth login — no billing setup required. The `minimax` and `minimax-cn` providers use `MINIMAX_API_KEY` / `MINIMAX_CN_API_KEY` — for programmatic access. See the [MiniMax OAuth guide](/docs/guides/minimax-oauth) for a full walkthrough. +::: + ### NVIDIA NIM Nemotron and other open source models via [build.nvidia.com](https://build.nvidia.com) (free API key) or a local NIM endpoint. @@ -1194,7 +1220,7 @@ fallback_model: When activated, the fallback swaps the model and provider mid-session without losing your conversation. It fires **at most once** per session. -Supported providers: `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `qwen-oauth`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `deepseek`, `nvidia`, `xai`, `ollama-cloud`, `bedrock`, `ai-gateway`, `opencode-zen`, `opencode-go`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `alibaba`, `tencent-tokenhub`, `custom`. +Supported providers: `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `qwen-oauth`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `ollama-cloud`, `bedrock`, `ai-gateway`, `opencode-zen`, `opencode-go`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `alibaba`, `tencent-tokenhub`, `custom`. :::tip Fallback is configured exclusively through `config.yaml` — there are no environment variables for it. For full details on when it triggers, supported providers, and how it interacts with auxiliary tasks and delegation, see [Fallback Providers](/docs/user-guide/features/fallback-providers). diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index ca1fb0817a98..074f7ee830af 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -85,7 +85,7 @@ Common options: | `-q`, `--query "..."` | One-shot, non-interactive prompt. | | `-m`, `--model ` | Override the model for this run. | | `-t`, `--toolsets ` | Enable a comma-separated set of toolsets. | -| `--provider ` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot-acp`, `copilot`, `anthropic`, `gemini`, `google-gemini-cli`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `alibaba`, `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `ai-gateway`, `azure-foundry`. | +| `--provider ` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot-acp`, `copilot`, `anthropic`, `gemini`, `google-gemini-cli`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `alibaba`, `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `ai-gateway`, `azure-foundry`. | | `-s`, `--skills ` | Preload one or more skills for the session (can be repeated or comma-separated). | | `-v`, `--verbose` | Verbose output. | | `-Q`, `--quiet` | Programmatic mode: suppress banner/spinner/tool previews. | diff --git a/website/docs/user-guide/features/fallback-providers.md b/website/docs/user-guide/features/fallback-providers.md index a0d699dfb2ff..ea8fc3fc8b99 100644 --- a/website/docs/user-guide/features/fallback-providers.md +++ b/website/docs/user-guide/features/fallback-providers.md @@ -54,6 +54,7 @@ Both `provider` and `model` are **required**. If either is missing, the fallback | xAI (Grok) | `xai` (alias `grok`) | `XAI_API_KEY` (optional: `XAI_BASE_URL`) | | AWS Bedrock | `bedrock` | Standard boto3 auth (`AWS_REGION` + `AWS_PROFILE` or `AWS_ACCESS_KEY_ID`) | | Qwen Portal (OAuth) | `qwen-oauth` | `hermes model` (Qwen Portal OAuth; optional: `HERMES_QWEN_BASE_URL`) | +| MiniMax (OAuth) | `minimax-oauth` | `hermes model` (MiniMax portal OAuth) | | OpenCode Zen | `opencode-zen` | `OPENCODE_ZEN_API_KEY` | | OpenCode Go | `opencode-go` | `OPENCODE_GO_API_KEY` | | Kilo Code | `kilocode` | `KILOCODE_API_KEY` |