mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
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:
parent
32abe742fa
commit
58e2109f10
8 changed files with 254 additions and 18 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}"')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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 <name>` must preserve the
|
||||
custom label end-to-end — it was silently dropped in the first cut of the
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue