From 58e2109f10b5ea5e29b6c4011187762f9358c4a8 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Mon, 11 May 2026 21:25:41 -0700 Subject: [PATCH] fix(minimax): harden OAuth dashboard and runtime Handle MiniMax OAuth expiry values consistently across CLI and dashboard flows, fix CLI status/add behavior, and force pooled OAuth runtime requests through Anthropic Messages. - web_server._minimax_poller: parse expired_in via the shared resolver so unix-ms absolute timestamps stop landing as TTL seconds and crashing with 'year 583911 is out of range' when a user connects MiniMax OAuth from the dashboard. - auth._minimax_oauth_login / _refresh_minimax_oauth_state: same fix on the CLI login + refresh paths. - auth.get_auth_status: dispatch minimax-oauth to its dedicated status function instead of falling through. - auth_commands.auth_add_command: 'hermes auth add minimax-oauth' now starts the device-code login flow and persists a pool entry with the access + refresh tokens, instead of requiring credentials to already exist. - runtime_provider._resolve_runtime_from_pool_entry: pin pooled minimax-oauth credentials to anthropic_messages so a stale model.api_mode: chat_completions can't send requests to /anthropic/chat/completions and trigger MiniMax nginx 404s. Co-authored-by: Cursor --- hermes_cli/auth.py | 41 +++++++--- hermes_cli/auth_commands.py | 13 ++-- hermes_cli/runtime_provider.py | 8 ++ hermes_cli/web_server.py | 7 +- tests/hermes_cli/test_auth_commands.py | 44 +++++++++++ .../test_runtime_provider_resolution.py | 36 +++++++++ tests/hermes_cli/test_web_oauth_dispatch.py | 49 ++++++++++++ tests/test_minimax_oauth.py | 74 +++++++++++++++++++ 8 files changed, 254 insertions(+), 18 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 6fda05d8fd3..ac102d0be76 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -4046,6 +4046,8 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]: return get_qwen_auth_status() if target == "google-gemini-cli": return get_gemini_oauth_auth_status() + if target == "minimax-oauth": + return get_minimax_oauth_auth_status() if target == "copilot-acp": return get_external_process_provider_status(target) # API-key providers @@ -4757,6 +4759,20 @@ def _minimax_request_user_code( return payload +def _minimax_expired_in_looks_like_unix_ms(expired_in: int, *, now_ms: int) -> bool: + """True if ``expired_in`` is plausibly a unix-ms absolute time (vs TTL seconds).""" + return int(expired_in) > (now_ms // 2) + + +def _minimax_resolve_token_expiry_unix(expired_in: int, *, now: datetime) -> float: + """Return access-token expiry as unix seconds (MiniMax uses ms epoch or TTL seconds).""" + raw = int(expired_in) + now_ms = int(now.timestamp() * 1000) + if _minimax_expired_in_looks_like_unix_ms(raw, now_ms=now_ms): + return raw / 1000.0 + return now.timestamp() + max(1, raw) + + 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], @@ -4765,12 +4781,11 @@ def _minimax_poll_token( # 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 + raw = int(expired_in) + if _minimax_expired_in_looks_like_unix_ms(raw, now_ms=now_ms): + deadline = raw / 1000.0 else: - # Treat as duration in seconds from now. - deadline = _time.time() + max(1, expired_in) + deadline = _time.time() + max(1, raw) interval = max(2.0, (interval_ms or 2000) / 1000.0) while _time.time() < deadline: @@ -4884,8 +4899,10 @@ def _minimax_oauth_login( ) now = datetime.now(timezone.utc) - expires_in_s = int(token_data["expired_in"]) - expires_at = now.timestamp() + expires_in_s + expires_at_unix = _minimax_resolve_token_expiry_unix( + int(token_data["expired_in"]), now=now, + ) + expires_in_s = max(0, int(expires_at_unix - now.timestamp())) auth_state = { "provider": "minimax-oauth", @@ -4899,7 +4916,7 @@ def _minimax_oauth_login( "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_at": datetime.fromtimestamp(expires_at_unix, tz=timezone.utc).isoformat(), "expires_in": expires_in_s, } @@ -4960,14 +4977,16 @@ def _refresh_minimax_oauth_state( relogin_required=True, ) now_dt = datetime.now(timezone.utc) - expires_in_s = int(payload["expired_in"]) + expires_at_unix = _minimax_resolve_token_expiry_unix( + int(payload["expired_in"]), now=now_dt, + ) + expires_in_s = max(0, int(expires_at_unix - now_dt.timestamp())) 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_at": datetime.fromtimestamp(expires_at_unix, tz=timezone.utc).isoformat(), "expires_in": expires_in_s, }) _minimax_save_auth_state(new_state) diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index b701a54725a..65cb7ed1b85 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -375,10 +375,12 @@ def auth_add_command(args) -> None: return if provider == "minimax-oauth": - from hermes_cli.auth import resolve_minimax_oauth_runtime_credentials - creds = resolve_minimax_oauth_runtime_credentials() + creds = auth_mod._minimax_oauth_login( + open_browser=not getattr(args, "no_browser", False), + timeout_seconds=getattr(args, "timeout", None) or 15.0, + ) label = (getattr(args, "label", None) or "").strip() or label_from_token( - creds["api_key"], + creds["access_token"], _oauth_default_label(provider, len(pool.entries()) + 1), ) entry = PooledCredential( @@ -388,8 +390,9 @@ def auth_add_command(args) -> None: auth_type=AUTH_TYPE_OAUTH, priority=0, source=f"{SOURCE_MANUAL}:minimax_oauth", - access_token=creds["api_key"], - base_url=creds.get("base_url"), + access_token=creds["access_token"], + refresh_token=creds.get("refresh_token"), + base_url=creds.get("inference_base_url"), ) pool.add_entry(entry) print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"') diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 1cc41ceae95..1652b72034c 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -205,6 +205,14 @@ def _resolve_runtime_from_pool_entry( elif provider == "google-gemini-cli": api_mode = "chat_completions" base_url = base_url or "cloudcode-pa://google" + elif provider == "minimax-oauth": + # MiniMax OAuth tokens are valid only against the Anthropic Messages + # compatible endpoint. Do not honor stale model.api_mode values from a + # prior OpenAI-compatible provider, or the client will hit + # /chat/completions under /anthropic and receive a bare nginx 404. + api_mode = "anthropic_messages" + pconfig = PROVIDER_REGISTRY.get(provider) + base_url = base_url or (pconfig.inference_base_url if pconfig else "") elif provider == "anthropic": api_mode = "anthropic_messages" cfg_provider = str(model_cfg.get("provider") or "").strip().lower() diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 4a4b8d4b5ab..0da49682b22 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2053,6 +2053,7 @@ def _minimax_poller(session_id: str) -> None: """ from hermes_cli.auth import ( _minimax_poll_token, + _minimax_resolve_token_expiry_unix, _minimax_save_auth_state, MINIMAX_OAUTH_GLOBAL_INFERENCE, MINIMAX_OAUTH_SCOPE, @@ -2090,8 +2091,10 @@ def _minimax_poller(session_id: str) -> None: # dashboard path; cn-region operators can still use the CLI # flow which supports `--region cn`. now = datetime.now(timezone.utc) - expires_in_s = int(token_data["expired_in"]) - expires_at_ts = now.timestamp() + expires_in_s + expires_at_ts = _minimax_resolve_token_expiry_unix( + int(token_data["expired_in"]), now=now, + ) + expires_in_s = max(0, int(expires_at_ts - now.timestamp())) auth_state = { "provider": "minimax-oauth", "region": sess.get("region", "global"), diff --git a/tests/hermes_cli/test_auth_commands.py b/tests/hermes_cli/test_auth_commands.py index 50f639d08ac..74e2a64d312 100644 --- a/tests/hermes_cli/test_auth_commands.py +++ b/tests/hermes_cli/test_auth_commands.py @@ -170,6 +170,50 @@ def test_auth_add_nous_oauth_persists_pool_entry(tmp_path, monkeypatch): assert singleton["inference_base_url"] == "https://inference.example.com/v1" +def test_auth_add_minimax_oauth_starts_login_and_persists_pool_entry(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + _write_auth_store(tmp_path, {"version": 1, "providers": {}}) + token = _jwt_with_email("minimax@example.com") + monkeypatch.setattr( + "hermes_cli.auth._minimax_oauth_login", + lambda **kwargs: { + "provider": "minimax-oauth", + "region": "global", + "portal_base_url": "https://api.minimax.io", + "inference_base_url": "https://api.minimax.io/anthropic", + "client_id": "client-id", + "scope": "group_id profile model.completion", + "token_type": "Bearer", + "access_token": token, + "refresh_token": "refresh-token", + "resource_url": None, + "obtained_at": "2026-05-11T10:00:00+00:00", + "expires_at": "2026-05-14T10:00:00+00:00", + "expires_in": 259200, + }, + ) + + from hermes_cli.auth_commands import auth_add_command + + class _Args: + provider = "minimax-oauth" + auth_type = "oauth" + api_key = None + label = None + no_browser = True + timeout = None + + auth_add_command(_Args()) + + payload = json.loads((tmp_path / "hermes" / "auth.json").read_text()) + entries = payload["credential_pool"]["minimax-oauth"] + entry = next(item for item in entries if item["source"] == "manual:minimax_oauth") + assert entry["label"] == "minimax@example.com" + assert entry["access_token"] == token + assert entry["refresh_token"] == "refresh-token" + assert entry["base_url"] == "https://api.minimax.io/anthropic" + + def test_auth_add_nous_oauth_honors_custom_label(tmp_path, monkeypatch): """`hermes auth add nous --type oauth --label ` must preserve the custom label end-to-end — it was silently dropped in the first cut of the diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index d17b1a41e3a..22c778dbab2 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -2285,3 +2285,39 @@ def test_minimax_oauth_runtime_uses_inference_base_url(monkeypatch): resolved = rp.resolve_runtime_provider(requested="minimax-oauth") assert MINIMAX_OAUTH_CN_INFERENCE.rstrip("/") in resolved["base_url"] + + +def test_minimax_oauth_pool_forces_anthropic_messages_despite_stale_config(monkeypatch): + """A pooled MiniMax OAuth token must not inherit stale chat_completions config.""" + + class _Entry: + access_token = "oauth-token" + source = "manual:minimax_oauth" + base_url = "https://api.minimax.io/anthropic" + + class _Pool: + def has_credentials(self): + return True + + def select(self): + return _Entry() + + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax-oauth") + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: { + "provider": "minimax-oauth", + "default": "MiniMax-M2.7", + "api_mode": "chat_completions", + }, + ) + monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool()) + monkeypatch.setattr(rp, "_resolve_named_custom_runtime", lambda **k: None) + monkeypatch.setattr(rp, "_resolve_explicit_runtime", lambda **k: None) + + resolved = rp.resolve_runtime_provider(requested="minimax-oauth") + + assert resolved["provider"] == "minimax-oauth" + assert resolved["api_mode"] == "anthropic_messages" + assert resolved["base_url"] == "https://api.minimax.io/anthropic" diff --git a/tests/hermes_cli/test_web_oauth_dispatch.py b/tests/hermes_cli/test_web_oauth_dispatch.py index 6ebd0ad7235..23b72a303cf 100644 --- a/tests/hermes_cli/test_web_oauth_dispatch.py +++ b/tests/hermes_cli/test_web_oauth_dispatch.py @@ -19,6 +19,8 @@ The fix: These tests pin the corrected behavior. """ +import time +from datetime import datetime, timezone from unittest.mock import patch import pytest @@ -67,6 +69,53 @@ def test_minimax_login_does_not_launch_anthropic_flow(): assert body["expires_in"] == 600 +def test_minimax_dashboard_poller_accepts_absolute_ms_expired_in(): + """Dashboard MiniMax completion must accept unix-ms token expiry values.""" + from hermes_cli import web_server as ws + + now = datetime.now(timezone.utc) + abs_ms = int((now.timestamp() + 1800) * 1000) + session_id = "minimax-absolute-ms-test" + ws._oauth_sessions[session_id] = { + "session_id": session_id, + "provider": "minimax-oauth", + "flow": "device_code", + "created_at": time.time(), + "status": "pending", + "error_message": None, + "portal_base_url": "https://api.minimax.io", + "client_id": "client-id", + "user_code": "ABCD-1234", + "code_verifier": "verifier", + "interval_ms": 2000, + "expired_in_raw": abs_ms, + "region": "global", + } + captured_state = {} + + try: + with patch( + "hermes_cli.auth._minimax_poll_token", + return_value={ + "status": "success", + "access_token": "access", + "refresh_token": "refresh", + "expired_in": abs_ms, + "token_type": "Bearer", + }, + ), patch( + "hermes_cli.auth._minimax_save_auth_state", + side_effect=lambda state: captured_state.update(state), + ): + ws._minimax_poller(session_id) + finally: + ws._oauth_sessions.pop(session_id, None) + + assert captured_state["access_token"] == "access" + assert 1790 <= captured_state["expires_in"] <= 1810 + assert datetime.fromisoformat(captured_state["expires_at"]).year < 9999 + + def test_anthropic_pkce_branch_still_works(): """Sanity: the dispatcher tightening doesn't break the legitimate Anthropic PKCE path.""" fake_anthropic_response = { diff --git a/tests/test_minimax_oauth.py b/tests/test_minimax_oauth.py index 0e63800e917..f5ac4e28c62 100644 --- a/tests/test_minimax_oauth.py +++ b/tests/test_minimax_oauth.py @@ -32,9 +32,11 @@ from hermes_cli.auth import ( _minimax_pkce_pair, _minimax_request_user_code, _minimax_poll_token, + _minimax_resolve_token_expiry_unix, _refresh_minimax_oauth_state, resolve_minimax_oauth_runtime_credentials, get_minimax_oauth_auth_status, + get_auth_status, get_provider_auth_state, ) @@ -67,6 +69,23 @@ def _past_iso(seconds_ago: int = 3600) -> str: return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() +# --------------------------------------------------------------------------- +# 0. test_resolve_token_expiry_unix_ttl_vs_absolute_ms +# --------------------------------------------------------------------------- + +def test_resolve_token_expiry_unix_ttl_seconds(): + now = datetime(2025, 6, 1, 12, 0, 0, tzinfo=timezone.utc) + got = _minimax_resolve_token_expiry_unix(3600, now=now) + assert abs(got - (now.timestamp() + 3600)) < 0.01 + + +def test_resolve_token_expiry_unix_absolute_ms(): + now = datetime(2025, 6, 1, 12, 0, 0, tzinfo=timezone.utc) + abs_ms = int((now.timestamp() + 7200) * 1000) + got = _minimax_resolve_token_expiry_unix(abs_ms, now=now) + assert abs(got - (now.timestamp() + 7200)) < 0.01 + + # --------------------------------------------------------------------------- # 1. test_pkce_pair_produces_valid_s256 # --------------------------------------------------------------------------- @@ -362,6 +381,46 @@ def test_refresh_updates_access_token(): assert result["expires_in"] == 7200 +def test_refresh_updates_access_token_absolute_ms_expired_in(): + """Refresh payload may use unix-ms absolute ``expired_in`` (same as device-code).""" + now0 = datetime.now(timezone.utc) + abs_ms = int((now0.timestamp() + 1800) * 1000) + + 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": abs_ms, + } + + 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 + + with patch("hermes_cli.auth._minimax_save_auth_state"): + result = _refresh_minimax_oauth_state(state) + + assert result["access_token"] == "new-access" + assert 1790 <= result["expires_in"] <= 1810 + exp = datetime.fromisoformat(result["expires_at"].replace("Z", "+00:00")) + skew = exp.timestamp() - datetime.now(timezone.utc).timestamp() + assert 1790 <= skew <= 1810 + + # --------------------------------------------------------------------------- # 10. test_refresh_reuse_triggers_relogin_required # --------------------------------------------------------------------------- @@ -464,3 +523,18 @@ def test_get_minimax_oauth_auth_status_logged_in(): assert status["logged_in"] is True assert status["region"] == "global" + + +def test_generic_auth_status_dispatches_minimax_oauth(): + 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_auth_status("minimax-oauth") + + assert status["logged_in"] is True + assert status["provider"] == "minimax-oauth" + assert status["region"] == "global"