NousResearch/hermes-agent

MiniMax OAuth token expiry parsing bug and fix

Cluster 21779. Data as of

Nothing open on GitHub. Fix merged in #24165 on 12 May 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #24165. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

A bug where MiniMax OAuth login fails due to epoch-millisecond timestamps being misinterpreted as seconds, causing out-of-range year errors. The members cover the reported issue and related hardening fixes for the auth and dashboard handling.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #24165 fix(minimax): harden OAuth dashboard and runtime

    merged to main

    files: hermes_cli/auth.py, hermes_cli/auth_commands.py, hermes_cli/runtime_provider.py, hermes_cli/web_server.py, tests/hermes_cli/test_auth_commands.py, tests/hermes_cli/test_runtime_provider_resolution.py, tests/hermes_cli/test_web_oauth_dispatch.py, tests/test_minimax_oauth.py

    Show the diff
    • 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": [redacted:secret-assignment]"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": [redacted:secret-assignment]"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)
    • 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=[redacted:secret-assignment]"api_key"],
      -            base_url=creds.get("base_url"),
      +            access_token=[redacted:secret-assignment]"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}"')
    • 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()
    • 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"),
    • tests/hermes_cli/test_auth_commands.py
      @@ -170,6 +170,50 @@ class _Args:
           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("[redacted:email]")
      +    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": "[redacted:secret-assignment]",
      +            "access_token": [redacted:secret-assignment]
      +            "refresh_token": "[redacted:secret-assignment]",
      +            "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"] == "[redacted:email]"
      +    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 <name>` must preserve the
           custom label end-to-end — it was silently dropped in the first cut of the
    • 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 = "[redacted:secret-assignment]"
      +        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"
    • tests/hermes_cli/test_web_oauth_dispatch.py
      @@ -19,6 +19,8 @@
       
       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": "[redacted:secret-assignment]",
      +                "refresh_token": "[redacted:secret-assignment]",
      +                "expired_in": abs_ms,
      +                "token_type": "[redacted:secret-assignment]",
      +            },
      +        ), 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 = {
    • tests/test_minimax_oauth.py
      @@ -32,9 +32,11 @@
           _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": "[redacted:secret-assignment]",
      +        "refresh_token": "[redacted:secret-assignment]",
      +        "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": "[redacted:secret-assignment]",
      +        "refresh_token": "[redacted:secret-assignment]",
      +        "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"

1 PR closed without merging

From the close comments: superseded by another PR 1

  • #22240 fix(auth): handle MiniMax OAuth epoch-ms expiries · superseded by another PR → #24165
Closed issues · 1
  • Closed#21779MiniMax OAuth login fails with `year 58381 is out of range`
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#24165
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
12 May 2026

For your agent

The same facts, typed.