Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 30 additions & 11 deletions hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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],
Expand All @@ -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:
Expand Down Expand Up @@ -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",
Expand All @@ -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,
}

Expand Down Expand Up @@ -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)
Expand Down
13 changes: 8 additions & 5 deletions hermes_cli/auth_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,10 +375,12 @@
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(
Expand All @@ -388,8 +390,9 @@
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}"')
Expand Down Expand Up @@ -475,7 +478,7 @@
if not provider:
raise SystemExit("Provider is required. Example: `hermes auth status spotify`.")
status = auth_mod.get_auth_status(provider)
if not status.get("logged_in"):

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This expression logs
sensitive data (password)
as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (secret) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
reason = status.get("error")
if reason:
print(f"{provider}: logged out ({reason})")
Expand All @@ -484,7 +487,7 @@
return

print(f"{provider}: logged in")
for key in ("auth_type", "client_id", "redirect_uri", "scope", "expires_at", "api_base_url"):

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This expression logs
sensitive data (password)
as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (secret) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
This expression logs sensitive data (password) as clear text.
value = status.get(key)
if value:
print(f" {key}: {value}")
Expand Down
8 changes: 8 additions & 0 deletions hermes_cli/runtime_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
7 changes: 5 additions & 2 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"),
Expand Down
44 changes: 44 additions & 0 deletions tests/hermes_cli/test_auth_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("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
Expand Down
36 changes: 36 additions & 0 deletions tests/hermes_cli/test_runtime_provider_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
49 changes: 49 additions & 0 deletions tests/hermes_cli/test_web_oauth_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

These tests pin the corrected behavior.
"""
import time
from datetime import datetime, timezone
from unittest.mock import patch

import pytest
Expand Down Expand Up @@ -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 = {
Expand Down
Loading
Loading