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 <cursoragent@cursor.com>
This commit is contained in:
Austin Pickett 2026-05-11 21:25:41 -07:00 committed by Teknium
parent 32abe742fa
commit 58e2109f10
8 changed files with 254 additions and 18 deletions

View file

@ -4046,6 +4046,8 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
return get_qwen_auth_status() return get_qwen_auth_status()
if target == "google-gemini-cli": if target == "google-gemini-cli":
return get_gemini_oauth_auth_status() return get_gemini_oauth_auth_status()
if target == "minimax-oauth":
return get_minimax_oauth_auth_status()
if target == "copilot-acp": if target == "copilot-acp":
return get_external_process_provider_status(target) return get_external_process_provider_status(target)
# API-key providers # API-key providers
@ -4757,6 +4759,20 @@ def _minimax_request_user_code(
return payload 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( def _minimax_poll_token(
client: httpx.Client, *, portal_base_url: str, client_id: str, client: httpx.Client, *, portal_base_url: str, client_id: str,
user_code: str, code_verifier: str, expired_in: int, interval_ms: Optional[int], 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. # Defensive parsing: if it's small enough to be a duration, treat as seconds.
import time as _time import time as _time
now_ms = int(_time.time() * 1000) now_ms = int(_time.time() * 1000)
if expired_in > now_ms // 2: raw = int(expired_in)
# Looks like a unix-ms timestamp. if _minimax_expired_in_looks_like_unix_ms(raw, now_ms=now_ms):
deadline = expired_in / 1000.0 deadline = raw / 1000.0
else: else:
# Treat as duration in seconds from now. deadline = _time.time() + max(1, raw)
deadline = _time.time() + max(1, expired_in)
interval = max(2.0, (interval_ms or 2000) / 1000.0) interval = max(2.0, (interval_ms or 2000) / 1000.0)
while _time.time() < deadline: while _time.time() < deadline:
@ -4884,8 +4899,10 @@ def _minimax_oauth_login(
) )
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
expires_in_s = int(token_data["expired_in"]) expires_at_unix = _minimax_resolve_token_expiry_unix(
expires_at = now.timestamp() + expires_in_s int(token_data["expired_in"]), now=now,
)
expires_in_s = max(0, int(expires_at_unix - now.timestamp()))
auth_state = { auth_state = {
"provider": "minimax-oauth", "provider": "minimax-oauth",
@ -4899,7 +4916,7 @@ def _minimax_oauth_login(
"refresh_token": token_data["refresh_token"], "refresh_token": token_data["refresh_token"],
"resource_url": token_data.get("resource_url"), "resource_url": token_data.get("resource_url"),
"obtained_at": now.isoformat(), "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, "expires_in": expires_in_s,
} }
@ -4960,14 +4977,16 @@ def _refresh_minimax_oauth_state(
relogin_required=True, relogin_required=True,
) )
now_dt = datetime.now(timezone.utc) 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 = dict(state)
new_state.update({ new_state.update({
"access_token": payload["access_token"], "access_token": payload["access_token"],
"refresh_token": payload.get("refresh_token", state["refresh_token"]), "refresh_token": payload.get("refresh_token", state["refresh_token"]),
"obtained_at": now_dt.isoformat(), "obtained_at": now_dt.isoformat(),
"expires_at": datetime.fromtimestamp(now_dt.timestamp() + expires_in_s, "expires_at": datetime.fromtimestamp(expires_at_unix, tz=timezone.utc).isoformat(),
tz=timezone.utc).isoformat(),
"expires_in": expires_in_s, "expires_in": expires_in_s,
}) })
_minimax_save_auth_state(new_state) _minimax_save_auth_state(new_state)

View file

@ -375,10 +375,12 @@ def auth_add_command(args) -> None:
return return
if provider == "minimax-oauth": if provider == "minimax-oauth":
from hermes_cli.auth import resolve_minimax_oauth_runtime_credentials creds = auth_mod._minimax_oauth_login(
creds = resolve_minimax_oauth_runtime_credentials() 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( 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), _oauth_default_label(provider, len(pool.entries()) + 1),
) )
entry = PooledCredential( entry = PooledCredential(
@ -388,8 +390,9 @@ def auth_add_command(args) -> None:
auth_type=AUTH_TYPE_OAUTH, auth_type=AUTH_TYPE_OAUTH,
priority=0, priority=0,
source=f"{SOURCE_MANUAL}:minimax_oauth", source=f"{SOURCE_MANUAL}:minimax_oauth",
access_token=creds["api_key"], access_token=creds["access_token"],
base_url=creds.get("base_url"), refresh_token=creds.get("refresh_token"),
base_url=creds.get("inference_base_url"),
) )
pool.add_entry(entry) pool.add_entry(entry)
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"') print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')

View file

@ -205,6 +205,14 @@ def _resolve_runtime_from_pool_entry(
elif provider == "google-gemini-cli": elif provider == "google-gemini-cli":
api_mode = "chat_completions" api_mode = "chat_completions"
base_url = base_url or "cloudcode-pa://google" 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": elif provider == "anthropic":
api_mode = "anthropic_messages" api_mode = "anthropic_messages"
cfg_provider = str(model_cfg.get("provider") or "").strip().lower() cfg_provider = str(model_cfg.get("provider") or "").strip().lower()

View file

@ -2053,6 +2053,7 @@ def _minimax_poller(session_id: str) -> None:
""" """
from hermes_cli.auth import ( from hermes_cli.auth import (
_minimax_poll_token, _minimax_poll_token,
_minimax_resolve_token_expiry_unix,
_minimax_save_auth_state, _minimax_save_auth_state,
MINIMAX_OAUTH_GLOBAL_INFERENCE, MINIMAX_OAUTH_GLOBAL_INFERENCE,
MINIMAX_OAUTH_SCOPE, MINIMAX_OAUTH_SCOPE,
@ -2090,8 +2091,10 @@ def _minimax_poller(session_id: str) -> None:
# dashboard path; cn-region operators can still use the CLI # dashboard path; cn-region operators can still use the CLI
# flow which supports `--region cn`. # flow which supports `--region cn`.
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
expires_in_s = int(token_data["expired_in"]) expires_at_ts = _minimax_resolve_token_expiry_unix(
expires_at_ts = now.timestamp() + expires_in_s int(token_data["expired_in"]), now=now,
)
expires_in_s = max(0, int(expires_at_ts - now.timestamp()))
auth_state = { auth_state = {
"provider": "minimax-oauth", "provider": "minimax-oauth",
"region": sess.get("region", "global"), "region": sess.get("region", "global"),

View file

@ -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" 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): def test_auth_add_nous_oauth_honors_custom_label(tmp_path, monkeypatch):
"""`hermes auth add nous --type oauth --label <name>` must preserve the """`hermes auth add nous --type oauth --label <name>` must preserve the
custom label end-to-end it was silently dropped in the first cut of the custom label end-to-end it was silently dropped in the first cut of the

View file

@ -2285,3 +2285,39 @@ def test_minimax_oauth_runtime_uses_inference_base_url(monkeypatch):
resolved = rp.resolve_runtime_provider(requested="minimax-oauth") resolved = rp.resolve_runtime_provider(requested="minimax-oauth")
assert MINIMAX_OAUTH_CN_INFERENCE.rstrip("/") in resolved["base_url"] 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"

View file

@ -19,6 +19,8 @@ The fix:
These tests pin the corrected behavior. These tests pin the corrected behavior.
""" """
import time
from datetime import datetime, timezone
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
@ -67,6 +69,53 @@ def test_minimax_login_does_not_launch_anthropic_flow():
assert body["expires_in"] == 600 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(): def test_anthropic_pkce_branch_still_works():
"""Sanity: the dispatcher tightening doesn't break the legitimate Anthropic PKCE path.""" """Sanity: the dispatcher tightening doesn't break the legitimate Anthropic PKCE path."""
fake_anthropic_response = { fake_anthropic_response = {

View file

@ -32,9 +32,11 @@ from hermes_cli.auth import (
_minimax_pkce_pair, _minimax_pkce_pair,
_minimax_request_user_code, _minimax_request_user_code,
_minimax_poll_token, _minimax_poll_token,
_minimax_resolve_token_expiry_unix,
_refresh_minimax_oauth_state, _refresh_minimax_oauth_state,
resolve_minimax_oauth_runtime_credentials, resolve_minimax_oauth_runtime_credentials,
get_minimax_oauth_auth_status, get_minimax_oauth_auth_status,
get_auth_status,
get_provider_auth_state, get_provider_auth_state,
) )
@ -67,6 +69,23 @@ def _past_iso(seconds_ago: int = 3600) -> str:
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() 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 # 1. test_pkce_pair_produces_valid_s256
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -362,6 +381,46 @@ def test_refresh_updates_access_token():
assert result["expires_in"] == 7200 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 # 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["logged_in"] is True
assert status["region"] == "global" 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"