mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-29 23:41:35 +08:00
Compare commits
5 Commits
fix/plugin
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3812fd8174 | ||
|
|
91eefcd1a3 | ||
|
|
3c6a9dab35 | ||
|
|
3442f48285 | ||
|
|
8d3ef574ea |
@@ -144,6 +144,7 @@ _API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = {
|
||||
"stepfun": "step-3.5-flash",
|
||||
"kimi-coding-cn": "kimi-k2-turbo-preview",
|
||||
"minimax": "MiniMax-M2.7",
|
||||
"minimax-oauth": "MiniMax-M2.7-highspeed",
|
||||
"minimax-cn": "MiniMax-M2.7",
|
||||
"anthropic": "claude-haiku-4-5-20251001",
|
||||
"ai-gateway": "google/gemini-3-flash",
|
||||
@@ -2672,7 +2673,7 @@ def _get_task_extra_body(task: str) -> Dict[str, Any]:
|
||||
|
||||
# Providers that use Anthropic-compatible endpoints (via OpenAI SDK wrapper).
|
||||
# Their image content blocks must use Anthropic format, not OpenAI format.
|
||||
_ANTHROPIC_COMPAT_PROVIDERS = frozenset({"minimax", "minimax-cn"})
|
||||
_ANTHROPIC_COMPAT_PROVIDERS = frozenset({"minimax", "minimax-oauth", "minimax-cn"})
|
||||
|
||||
|
||||
def _is_anthropic_compat_endpoint(provider: str, base_url: str) -> bool:
|
||||
|
||||
@@ -46,7 +46,7 @@ def _resolve_requests_verify() -> bool | str:
|
||||
# are preserved so the full model name reaches cache lookups and server queries.
|
||||
_PROVIDER_PREFIXES: frozenset[str] = frozenset({
|
||||
"openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
|
||||
"gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-cn", "anthropic", "deepseek",
|
||||
"gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek",
|
||||
"opencode-zen", "opencode-go", "ai-gateway", "kilocode", "alibaba",
|
||||
"qwen-oauth",
|
||||
"xiaomi",
|
||||
|
||||
@@ -71,6 +71,14 @@ DEFAULT_AGENT_KEY_MIN_TTL_SECONDS = 30 * 60 # 30 minutes
|
||||
ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 # refresh 2 min before expiry
|
||||
DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS = 1 # poll at most every 1s
|
||||
DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||
MINIMAX_OAUTH_CLIENT_ID = "78257093-7e40-4613-99e0-527b14b39113"
|
||||
MINIMAX_OAUTH_SCOPE = "group_id profile model.completion"
|
||||
MINIMAX_OAUTH_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:user_code"
|
||||
MINIMAX_OAUTH_GLOBAL_BASE = "https://api.minimax.io"
|
||||
MINIMAX_OAUTH_CN_BASE = "https://api.minimaxi.com"
|
||||
MINIMAX_OAUTH_GLOBAL_INFERENCE = "https://api.minimax.io/anthropic"
|
||||
MINIMAX_OAUTH_CN_INFERENCE = "https://api.minimaxi.com/anthropic"
|
||||
MINIMAX_OAUTH_REFRESH_SKEW_SECONDS = 60
|
||||
DEFAULT_QWEN_BASE_URL = "https://portal.qwen.ai/v1"
|
||||
DEFAULT_GITHUB_MODELS_BASE_URL = "https://api.githubcopilot.com"
|
||||
DEFAULT_COPILOT_ACP_BASE_URL = "acp://copilot"
|
||||
@@ -119,7 +127,7 @@ class ProviderConfig:
|
||||
"""Describes a known inference provider."""
|
||||
id: str
|
||||
name: str
|
||||
auth_type: str # "oauth_device_code", "oauth_external", or "api_key"
|
||||
auth_type: str # "oauth_device_code", "oauth_external", "oauth_minimax", or "api_key"
|
||||
portal_base_url: str = ""
|
||||
inference_base_url: str = ""
|
||||
client_id: str = ""
|
||||
@@ -232,6 +240,17 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
||||
api_key_env_vars=("MINIMAX_API_KEY",),
|
||||
base_url_env_var="MINIMAX_BASE_URL",
|
||||
),
|
||||
"minimax-oauth": ProviderConfig(
|
||||
id="minimax-oauth",
|
||||
name="MiniMax (OAuth \u00b7 minimax.io)",
|
||||
auth_type="oauth_minimax",
|
||||
portal_base_url=MINIMAX_OAUTH_GLOBAL_BASE,
|
||||
inference_base_url=MINIMAX_OAUTH_GLOBAL_INFERENCE,
|
||||
client_id=MINIMAX_OAUTH_CLIENT_ID,
|
||||
scope=MINIMAX_OAUTH_SCOPE,
|
||||
extra={"region": "global", "cn_portal_base_url": MINIMAX_OAUTH_CN_BASE,
|
||||
"cn_inference_base_url": MINIMAX_OAUTH_CN_INFERENCE},
|
||||
),
|
||||
"anthropic": ProviderConfig(
|
||||
id="anthropic",
|
||||
name="Anthropic",
|
||||
@@ -1097,6 +1116,7 @@ def resolve_provider(
|
||||
"step": "stepfun", "stepfun-coding-plan": "stepfun",
|
||||
"arcee-ai": "arcee", "arceeai": "arcee",
|
||||
"minimax-china": "minimax-cn", "minimax_cn": "minimax-cn",
|
||||
"minimax-portal": "minimax-oauth", "minimax-global": "minimax-oauth", "minimax_oauth": "minimax-oauth",
|
||||
"alibaba_coding": "alibaba-coding-plan", "alibaba-coding": "alibaba-coding-plan",
|
||||
"alibaba_coding_plan": "alibaba-coding-plan",
|
||||
"claude": "anthropic", "claude-code": "anthropic",
|
||||
@@ -4048,6 +4068,326 @@ def _codex_device_code_login() -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
# ==================== MiniMax Portal OAuth ====================
|
||||
|
||||
def _minimax_pkce_pair() -> tuple:
|
||||
"""Generate (code_verifier, code_challenge_S256, state) for MiniMax OAuth."""
|
||||
import secrets
|
||||
verifier = secrets.token_urlsafe(64)[:96]
|
||||
challenge = base64.urlsafe_b64encode(
|
||||
hashlib.sha256(verifier.encode()).digest()
|
||||
).decode().rstrip("=")
|
||||
state = secrets.token_urlsafe(16)
|
||||
return verifier, challenge, state
|
||||
|
||||
|
||||
def _minimax_request_user_code(
|
||||
client: httpx.Client, *, portal_base_url: str, client_id: str,
|
||||
code_challenge: str, state: str,
|
||||
) -> Dict[str, Any]:
|
||||
response = client.post(
|
||||
f"{portal_base_url}/oauth/code",
|
||||
data={
|
||||
"response_type": "code",
|
||||
"client_id": client_id,
|
||||
"scope": MINIMAX_OAUTH_SCOPE,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": state,
|
||||
},
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
"x-request-id": str(uuid.uuid4()),
|
||||
},
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise AuthError(
|
||||
f"MiniMax OAuth authorization failed: {response.text or response.reason_phrase}",
|
||||
provider="minimax-oauth", code="authorization_failed",
|
||||
)
|
||||
payload = response.json()
|
||||
for field in ("user_code", "verification_uri", "expired_in"):
|
||||
if field not in payload:
|
||||
raise AuthError(
|
||||
f"MiniMax OAuth response missing field: {field}",
|
||||
provider="minimax-oauth", code="authorization_incomplete",
|
||||
)
|
||||
if payload.get("state") != state:
|
||||
raise AuthError(
|
||||
"MiniMax OAuth state mismatch (possible CSRF).",
|
||||
provider="minimax-oauth", code="state_mismatch",
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
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],
|
||||
) -> Dict[str, Any]:
|
||||
# OpenClaw treats expired_in as a unix-ms timestamp (Date.now() < expireTimeMs).
|
||||
# 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
|
||||
else:
|
||||
# Treat as duration in seconds from now.
|
||||
deadline = _time.time() + max(1, expired_in)
|
||||
interval = max(2.0, (interval_ms or 2000) / 1000.0)
|
||||
|
||||
while _time.time() < deadline:
|
||||
response = client.post(
|
||||
f"{portal_base_url}/oauth/token",
|
||||
data={
|
||||
"grant_type": MINIMAX_OAUTH_GRANT_TYPE,
|
||||
"client_id": client_id,
|
||||
"user_code": user_code,
|
||||
"code_verifier": code_verifier,
|
||||
},
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
payload = response.json() if response.text else {}
|
||||
except Exception:
|
||||
payload = {}
|
||||
|
||||
if response.status_code != 200:
|
||||
msg = (payload.get("base_resp", {}) or {}).get("status_msg") or response.text
|
||||
raise AuthError(
|
||||
f"MiniMax OAuth error: {msg or 'unknown'}",
|
||||
provider="minimax-oauth", code="token_exchange_failed",
|
||||
)
|
||||
|
||||
status = payload.get("status")
|
||||
if status == "error":
|
||||
raise AuthError(
|
||||
"MiniMax OAuth reported an error. Please try again later.",
|
||||
provider="minimax-oauth", code="authorization_denied",
|
||||
)
|
||||
if status == "success":
|
||||
if not all(payload.get(k) for k in ("access_token", "refresh_token", "expired_in")):
|
||||
raise AuthError(
|
||||
"MiniMax OAuth success payload missing required token fields.",
|
||||
provider="minimax-oauth", code="token_incomplete",
|
||||
)
|
||||
return payload
|
||||
# "pending" or any other status -> keep polling
|
||||
_time.sleep(interval)
|
||||
|
||||
raise AuthError(
|
||||
"MiniMax OAuth timed out before authorization completed.",
|
||||
provider="minimax-oauth", code="timeout",
|
||||
)
|
||||
|
||||
|
||||
def _minimax_save_auth_state(auth_state: Dict[str, Any]) -> None:
|
||||
"""Persist MiniMax OAuth state to Hermes auth store (~/.hermes/auth.json)."""
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
_save_provider_state(auth_store, "minimax-oauth", auth_state)
|
||||
_save_auth_store(auth_store)
|
||||
|
||||
|
||||
def _minimax_oauth_login(
|
||||
*, region: str = "global", open_browser: bool = True,
|
||||
timeout_seconds: float = 15.0,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run MiniMax OAuth flow, persist tokens, return auth state dict."""
|
||||
pconfig = PROVIDER_REGISTRY["minimax-oauth"]
|
||||
if region == "cn":
|
||||
portal_base_url = pconfig.extra["cn_portal_base_url"]
|
||||
inference_base_url = pconfig.extra["cn_inference_base_url"]
|
||||
else:
|
||||
portal_base_url = pconfig.portal_base_url
|
||||
inference_base_url = pconfig.inference_base_url
|
||||
|
||||
verifier, challenge, state = _minimax_pkce_pair()
|
||||
|
||||
if _is_remote_session():
|
||||
open_browser = False
|
||||
|
||||
print(f"Starting Hermes login via MiniMax ({region}) OAuth...")
|
||||
print(f"Portal: {portal_base_url}")
|
||||
|
||||
with httpx.Client(timeout=httpx.Timeout(timeout_seconds),
|
||||
headers={"Accept": "application/json"}) as client:
|
||||
code_data = _minimax_request_user_code(
|
||||
client, portal_base_url=portal_base_url,
|
||||
client_id=pconfig.client_id,
|
||||
code_challenge=challenge, state=state,
|
||||
)
|
||||
verification_url = str(code_data["verification_uri"])
|
||||
user_code = str(code_data["user_code"])
|
||||
|
||||
print()
|
||||
print("To continue:")
|
||||
print(f" 1. Open: {verification_url}")
|
||||
print(f" 2. If prompted, enter code: {user_code}")
|
||||
if open_browser:
|
||||
if webbrowser.open(verification_url):
|
||||
print(" (Opened browser for verification)")
|
||||
else:
|
||||
print(" Could not open browser automatically -- use the URL above.")
|
||||
|
||||
interval_raw = code_data.get("interval")
|
||||
interval_ms = int(interval_raw) if interval_raw is not None else None
|
||||
print("Waiting for approval...")
|
||||
|
||||
token_data = _minimax_poll_token(
|
||||
client, portal_base_url=portal_base_url,
|
||||
client_id=pconfig.client_id,
|
||||
user_code=user_code, code_verifier=verifier,
|
||||
expired_in=int(code_data["expired_in"]),
|
||||
interval_ms=interval_ms,
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_in_s = int(token_data["expired_in"])
|
||||
expires_at = now.timestamp() + expires_in_s
|
||||
|
||||
auth_state = {
|
||||
"provider": "minimax-oauth",
|
||||
"region": region,
|
||||
"portal_base_url": portal_base_url,
|
||||
"inference_base_url": inference_base_url,
|
||||
"client_id": pconfig.client_id,
|
||||
"scope": MINIMAX_OAUTH_SCOPE,
|
||||
"token_type": token_data.get("token_type", "Bearer"),
|
||||
"access_token": token_data["access_token"],
|
||||
"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_in": expires_in_s,
|
||||
}
|
||||
|
||||
_minimax_save_auth_state(auth_state)
|
||||
print("\u2713 MiniMax OAuth login successful.")
|
||||
if msg := token_data.get("notification_message"):
|
||||
print(f"Note from MiniMax: {msg}")
|
||||
return auth_state
|
||||
|
||||
|
||||
def _refresh_minimax_oauth_state(
|
||||
state: Dict[str, Any], *, timeout_seconds: float = 15.0,
|
||||
force: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Refresh MiniMax OAuth access token if close to expiry (or forced)."""
|
||||
if not state.get("refresh_token"):
|
||||
raise AuthError(
|
||||
"MiniMax OAuth state has no refresh_token; please re-login.",
|
||||
provider="minimax-oauth", code="no_refresh_token", relogin_required=True,
|
||||
)
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(state.get("expires_at", "")).timestamp()
|
||||
except Exception:
|
||||
expires_at = 0.0
|
||||
now = time.time()
|
||||
if not force and (expires_at - now) > MINIMAX_OAUTH_REFRESH_SKEW_SECONDS:
|
||||
return state
|
||||
|
||||
portal_base_url = state["portal_base_url"]
|
||||
with httpx.Client(timeout=httpx.Timeout(timeout_seconds)) as client:
|
||||
response = client.post(
|
||||
f"{portal_base_url}/oauth/token",
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": state["client_id"],
|
||||
"refresh_token": state["refresh_token"],
|
||||
},
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
if response.status_code != 200:
|
||||
body = response.text.lower()
|
||||
relogin = any(m in body for m in
|
||||
("invalid_grant", "refresh_token_reused", "invalid_refresh_token"))
|
||||
raise AuthError(
|
||||
f"MiniMax OAuth refresh failed: {response.text or response.reason_phrase}",
|
||||
provider="minimax-oauth", code="refresh_failed",
|
||||
relogin_required=relogin,
|
||||
)
|
||||
payload = response.json()
|
||||
if payload.get("status") != "success":
|
||||
raise AuthError(
|
||||
"MiniMax OAuth refresh did not return success.",
|
||||
provider="minimax-oauth", code="refresh_failed",
|
||||
relogin_required=True,
|
||||
)
|
||||
now_dt = datetime.now(timezone.utc)
|
||||
expires_in_s = int(payload["expired_in"])
|
||||
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_in": expires_in_s,
|
||||
})
|
||||
_minimax_save_auth_state(new_state)
|
||||
return new_state
|
||||
|
||||
|
||||
def resolve_minimax_oauth_runtime_credentials(
|
||||
*, min_token_ttl_seconds: int = MINIMAX_OAUTH_REFRESH_SKEW_SECONDS,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return {provider, api_key, base_url, source} for minimax-oauth."""
|
||||
state = get_provider_auth_state("minimax-oauth")
|
||||
if not state or not state.get("access_token"):
|
||||
raise AuthError(
|
||||
"Not logged into MiniMax OAuth. Run `hermes model` and select "
|
||||
"MiniMax (OAuth).",
|
||||
provider="minimax-oauth", code="not_logged_in", relogin_required=True,
|
||||
)
|
||||
state = _refresh_minimax_oauth_state(state)
|
||||
return {
|
||||
"provider": "minimax-oauth",
|
||||
"api_key": state["access_token"],
|
||||
"base_url": state["inference_base_url"].rstrip("/"),
|
||||
"source": "oauth",
|
||||
}
|
||||
|
||||
|
||||
def get_minimax_oauth_auth_status() -> Dict[str, Any]:
|
||||
"""Return auth status dict for MiniMax OAuth provider."""
|
||||
state = get_provider_auth_state("minimax-oauth")
|
||||
if not state or not state.get("access_token"):
|
||||
return {"logged_in": False, "provider": "minimax-oauth"}
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(state.get("expires_at", "")).timestamp()
|
||||
token_valid = (expires_at - time.time()) > 0
|
||||
except Exception:
|
||||
token_valid = bool(state.get("access_token"))
|
||||
return {
|
||||
"logged_in": token_valid,
|
||||
"provider": "minimax-oauth",
|
||||
"region": state.get("region", "global"),
|
||||
"expires_at": state.get("expires_at"),
|
||||
}
|
||||
|
||||
|
||||
def _login_minimax_oauth(args, pconfig: ProviderConfig) -> None:
|
||||
"""CLI entry for MiniMax OAuth login."""
|
||||
region = getattr(args, "region", None) or "global"
|
||||
open_browser = not getattr(args, "no_browser", False)
|
||||
timeout = getattr(args, "timeout", None) or 15.0
|
||||
try:
|
||||
_minimax_oauth_login(
|
||||
region=region, open_browser=open_browser, timeout_seconds=timeout,
|
||||
)
|
||||
except AuthError as exc:
|
||||
print(format_auth_error(exc))
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def _nous_device_code_login(
|
||||
*,
|
||||
portal_base_url: Optional[str] = None,
|
||||
|
||||
@@ -33,7 +33,7 @@ from hermes_constants import OPENROUTER_BASE_URL
|
||||
|
||||
|
||||
# Providers that support OAuth login in addition to API keys.
|
||||
_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli"}
|
||||
_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli", "minimax-oauth"}
|
||||
|
||||
|
||||
def _get_custom_provider_names() -> list:
|
||||
@@ -170,7 +170,7 @@ def auth_add_command(args) -> None:
|
||||
if provider.startswith(CUSTOM_POOL_PREFIX):
|
||||
requested_type = AUTH_TYPE_API_KEY
|
||||
else:
|
||||
requested_type = AUTH_TYPE_OAUTH if provider in {"anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli"} else AUTH_TYPE_API_KEY
|
||||
requested_type = AUTH_TYPE_OAUTH if provider in {"anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli", "minimax-oauth"} else AUTH_TYPE_API_KEY
|
||||
|
||||
pool = load_pool(provider)
|
||||
|
||||
@@ -333,6 +333,27 @@ def auth_add_command(args) -> None:
|
||||
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
|
||||
return
|
||||
|
||||
if provider == "minimax-oauth":
|
||||
from hermes_cli.auth import resolve_minimax_oauth_runtime_credentials
|
||||
creds = resolve_minimax_oauth_runtime_credentials()
|
||||
label = (getattr(args, "label", None) or "").strip() or label_from_token(
|
||||
creds["api_key"],
|
||||
_oauth_default_label(provider, len(pool.entries()) + 1),
|
||||
)
|
||||
entry = PooledCredential(
|
||||
provider=provider,
|
||||
id=uuid.uuid4().hex[:6],
|
||||
label=label,
|
||||
auth_type=AUTH_TYPE_OAUTH,
|
||||
priority=0,
|
||||
source=f"{SOURCE_MANUAL}:minimax_oauth",
|
||||
access_token=creds["api_key"],
|
||||
base_url=creds.get("base_url"),
|
||||
)
|
||||
pool.add_entry(entry)
|
||||
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
|
||||
return
|
||||
|
||||
raise SystemExit(f"`hermes auth add {provider}` is not implemented for auth type {requested_type} yet.")
|
||||
|
||||
|
||||
|
||||
@@ -476,6 +476,7 @@ def run_doctor(args):
|
||||
get_nous_auth_status,
|
||||
get_codex_auth_status,
|
||||
get_gemini_oauth_auth_status,
|
||||
get_minimax_oauth_auth_status,
|
||||
)
|
||||
|
||||
nous_status = get_nous_auth_status()
|
||||
@@ -505,6 +506,13 @@ def run_doctor(args):
|
||||
check_ok("Google Gemini OAuth", f"(logged in{suffix})")
|
||||
else:
|
||||
check_warn("Google Gemini OAuth", "(not logged in)")
|
||||
|
||||
minimax_status = get_minimax_oauth_auth_status()
|
||||
if minimax_status.get("logged_in"):
|
||||
region = minimax_status.get("region", "global")
|
||||
check_ok("MiniMax OAuth", f"(logged in, region={region})")
|
||||
else:
|
||||
check_warn("MiniMax OAuth", "(not logged in)")
|
||||
except Exception as e:
|
||||
check_warn("Auth provider status", f"(could not check: {e})")
|
||||
|
||||
|
||||
@@ -1594,6 +1594,8 @@ def select_provider_and_model(args=None):
|
||||
_model_flow_openai_codex(config, current_model)
|
||||
elif selected_provider == "qwen-oauth":
|
||||
_model_flow_qwen_oauth(config, current_model)
|
||||
elif selected_provider == "minimax-oauth":
|
||||
_model_flow_minimax_oauth(config, current_model, args=args)
|
||||
elif selected_provider == "google-gemini-cli":
|
||||
_model_flow_google_gemini_cli(config, current_model)
|
||||
elif selected_provider == "copilot-acp":
|
||||
@@ -2474,6 +2476,53 @@ def _model_flow_qwen_oauth(_config, current_model=""):
|
||||
print("No change.")
|
||||
|
||||
|
||||
def _model_flow_minimax_oauth(config, current_model="", args=None):
|
||||
"""MiniMax OAuth provider: ensure logged in, then pick model."""
|
||||
from hermes_cli.auth import (
|
||||
get_provider_auth_state,
|
||||
_prompt_model_selection,
|
||||
_save_model_choice,
|
||||
_update_config_for_provider,
|
||||
resolve_minimax_oauth_runtime_credentials,
|
||||
AuthError,
|
||||
format_auth_error,
|
||||
_login_minimax_oauth,
|
||||
PROVIDER_REGISTRY,
|
||||
)
|
||||
state = get_provider_auth_state("minimax-oauth")
|
||||
if not state or not state.get("access_token"):
|
||||
print("Not logged into MiniMax. Starting OAuth login...")
|
||||
print()
|
||||
try:
|
||||
mock_args = argparse.Namespace(
|
||||
region=getattr(args, "region", None) or "global",
|
||||
no_browser=bool(getattr(args, "no_browser", False)),
|
||||
timeout=getattr(args, "timeout", None) or 15.0,
|
||||
)
|
||||
_login_minimax_oauth(mock_args, PROVIDER_REGISTRY["minimax-oauth"])
|
||||
except SystemExit:
|
||||
print("Login cancelled or failed.")
|
||||
return
|
||||
except Exception as exc:
|
||||
print(f"Login failed: {exc}")
|
||||
return
|
||||
|
||||
try:
|
||||
creds = resolve_minimax_oauth_runtime_credentials()
|
||||
except AuthError as exc:
|
||||
print(format_auth_error(exc))
|
||||
return
|
||||
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
model_ids = _PROVIDER_MODELS.get("minimax-oauth", [])
|
||||
selected = _prompt_model_selection(model_ids, current_model)
|
||||
if not selected:
|
||||
return
|
||||
_save_model_choice(selected)
|
||||
_update_config_for_provider("minimax-oauth", creds["base_url"])
|
||||
print(f"\u2713 Using MiniMax model: {selected}")
|
||||
|
||||
|
||||
def _model_flow_google_gemini_cli(_config, current_model=""):
|
||||
"""Google Gemini OAuth (PKCE) via Cloud Code Assist — supports free AND paid tiers.
|
||||
|
||||
@@ -6949,6 +6998,7 @@ For more help on a command:
|
||||
"kimi-coding-cn",
|
||||
"stepfun",
|
||||
"minimax",
|
||||
"minimax-oauth",
|
||||
"minimax-cn",
|
||||
"kilocode",
|
||||
"xiaomi",
|
||||
|
||||
@@ -248,6 +248,10 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"MiniMax-M2.1",
|
||||
"MiniMax-M2",
|
||||
],
|
||||
"minimax-oauth": [
|
||||
"MiniMax-M2.7",
|
||||
"MiniMax-M2.7-highspeed",
|
||||
],
|
||||
"minimax-cn": [
|
||||
"MiniMax-M2.7",
|
||||
"MiniMax-M2.5",
|
||||
@@ -732,6 +736,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
|
||||
ProviderEntry("kimi-coding-cn", "Kimi / Moonshot (China)", "Kimi / Moonshot China (Moonshot CN direct API)"),
|
||||
ProviderEntry("stepfun", "StepFun Step Plan", "StepFun Step Plan (agent/coding models via Step Plan API)"),
|
||||
ProviderEntry("minimax", "MiniMax", "MiniMax (global direct API)"),
|
||||
ProviderEntry("minimax-oauth", "MiniMax (OAuth)", "MiniMax via OAuth browser login (Coding Plan, minimax.io)"),
|
||||
ProviderEntry("minimax-cn", "MiniMax (China)", "MiniMax China (domestic direct API)"),
|
||||
ProviderEntry("alibaba", "Alibaba Cloud (DashScope)","Alibaba Cloud / DashScope Coding (Qwen + multi-provider)"),
|
||||
ProviderEntry("ollama-cloud", "Ollama Cloud", "Ollama Cloud (cloud-hosted open models — ollama.com)"),
|
||||
@@ -771,6 +776,9 @@ _PROVIDER_ALIASES = {
|
||||
"arceeai": "arcee",
|
||||
"minimax-china": "minimax-cn",
|
||||
"minimax_cn": "minimax-cn",
|
||||
"minimax-portal": "minimax-oauth",
|
||||
"minimax-global": "minimax-oauth",
|
||||
"minimax_oauth": "minimax-oauth",
|
||||
"claude": "anthropic",
|
||||
"claude-code": "anthropic",
|
||||
"deep-seek": "deepseek",
|
||||
|
||||
@@ -889,6 +889,20 @@ def resolve_runtime_provider(
|
||||
logger.info("Qwen OAuth credentials failed; "
|
||||
"falling through to next provider.")
|
||||
|
||||
if provider == "minimax-oauth":
|
||||
pconfig = PROVIDER_REGISTRY.get(provider)
|
||||
if pconfig and pconfig.auth_type == "oauth_minimax":
|
||||
from hermes_cli.auth import resolve_minimax_oauth_runtime_credentials
|
||||
creds = resolve_minimax_oauth_runtime_credentials()
|
||||
return {
|
||||
"provider": provider,
|
||||
"api_mode": "anthropic_messages",
|
||||
"base_url": creds["base_url"],
|
||||
"api_key": creds["api_key"],
|
||||
"source": creds.get("source", "oauth"),
|
||||
"requested_provider": requested_provider,
|
||||
}
|
||||
|
||||
if provider == "google-gemini-cli":
|
||||
try:
|
||||
creds = resolve_gemini_oauth_runtime_credentials()
|
||||
|
||||
@@ -78,6 +78,7 @@ AUTHOR_MAP = {
|
||||
"omni@comelse.com": "omnissiah-comelse",
|
||||
"oussama.redcode@gmail.com": "mavrickdeveloper",
|
||||
"126368201+vilkasdev@users.noreply.github.com": "vilkasdev",
|
||||
"adam.manning@pro-serveinc.com": "amanning3390",
|
||||
"137614867+cutepawss@users.noreply.github.com": "cutepawss",
|
||||
"96793918+memosr@users.noreply.github.com": "memosr",
|
||||
"milkoor@users.noreply.github.com": "milkoor",
|
||||
|
||||
@@ -1033,3 +1033,63 @@ class TestHuggingFaceModels:
|
||||
from hermes_cli.models import _PROVIDER_LABELS
|
||||
assert "huggingface" in _PROVIDER_LABELS
|
||||
assert _PROVIDER_LABELS["huggingface"] == "Hugging Face"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MiniMax OAuth provider tests (added by feat/minimax-oauth-provider)
|
||||
# =============================================================================
|
||||
|
||||
class TestMinimaxOAuthProvider:
|
||||
"""Tests for the minimax-oauth OAuth provider."""
|
||||
|
||||
def test_minimax_oauth_in_provider_registry(self):
|
||||
assert "minimax-oauth" in PROVIDER_REGISTRY
|
||||
pconfig = PROVIDER_REGISTRY["minimax-oauth"]
|
||||
assert pconfig.auth_type == "oauth_minimax"
|
||||
assert pconfig.id == "minimax-oauth"
|
||||
|
||||
def test_minimax_oauth_has_correct_endpoints(self):
|
||||
from hermes_cli.auth import (
|
||||
MINIMAX_OAUTH_GLOBAL_BASE,
|
||||
MINIMAX_OAUTH_GLOBAL_INFERENCE,
|
||||
MINIMAX_OAUTH_CN_BASE,
|
||||
MINIMAX_OAUTH_CN_INFERENCE,
|
||||
)
|
||||
pconfig = PROVIDER_REGISTRY["minimax-oauth"]
|
||||
assert pconfig.portal_base_url == MINIMAX_OAUTH_GLOBAL_BASE
|
||||
assert pconfig.inference_base_url == MINIMAX_OAUTH_GLOBAL_INFERENCE
|
||||
assert pconfig.extra["cn_portal_base_url"] == MINIMAX_OAUTH_CN_BASE
|
||||
assert pconfig.extra["cn_inference_base_url"] == MINIMAX_OAUTH_CN_INFERENCE
|
||||
|
||||
def test_minimax_oauth_alias_resolves_portal(self):
|
||||
result = resolve_provider("minimax-portal")
|
||||
assert result == "minimax-oauth"
|
||||
|
||||
def test_minimax_oauth_alias_resolves_global(self):
|
||||
result = resolve_provider("minimax-global")
|
||||
assert result == "minimax-oauth"
|
||||
|
||||
def test_minimax_oauth_alias_resolves_underscore(self):
|
||||
result = resolve_provider("minimax_oauth")
|
||||
assert result == "minimax-oauth"
|
||||
|
||||
def test_minimax_oauth_listed_in_canonical_providers(self):
|
||||
from hermes_cli.models import CANONICAL_PROVIDERS
|
||||
slugs = [p.slug for p in CANONICAL_PROVIDERS]
|
||||
assert "minimax-oauth" in slugs
|
||||
|
||||
def test_minimax_oauth_models_alias_in_models_py(self):
|
||||
from hermes_cli.models import _PROVIDER_ALIASES
|
||||
assert _PROVIDER_ALIASES.get("minimax-portal") == "minimax-oauth"
|
||||
assert _PROVIDER_ALIASES.get("minimax-global") == "minimax-oauth"
|
||||
assert _PROVIDER_ALIASES.get("minimax_oauth") == "minimax-oauth"
|
||||
|
||||
def test_minimax_oauth_has_models(self):
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
models = _PROVIDER_MODELS.get("minimax-oauth", [])
|
||||
assert len(models) >= 1
|
||||
|
||||
def test_minimax_oauth_aux_model_registered(self):
|
||||
from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS
|
||||
assert "minimax-oauth" in _API_KEY_PROVIDER_AUX_MODELS
|
||||
assert _API_KEY_PROVIDER_AUX_MODELS["minimax-oauth"] # non-empty
|
||||
|
||||
@@ -1565,3 +1565,69 @@ class TestOllamaUrlSubstringLeak:
|
||||
resolved = rp.resolve_runtime_provider(requested="custom")
|
||||
|
||||
assert resolved["api_key"] == "ol-legit-key"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# minimax-oauth runtime resolution tests (added by feat/minimax-oauth-provider)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_minimax_oauth_runtime_returns_anthropic_messages_mode(monkeypatch):
|
||||
"""resolve_runtime_provider for minimax-oauth must return api_mode='anthropic_messages'."""
|
||||
from hermes_cli.auth import MINIMAX_OAUTH_GLOBAL_INFERENCE
|
||||
|
||||
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax-oauth")
|
||||
monkeypatch.setattr(rp, "_get_model_config", lambda: {"provider": "minimax-oauth"})
|
||||
monkeypatch.setattr(rp, "load_pool", lambda provider: None)
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"_resolve_named_custom_runtime",
|
||||
lambda **k: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"_resolve_explicit_runtime",
|
||||
lambda **k: None,
|
||||
)
|
||||
|
||||
fake_creds = {
|
||||
"provider": "minimax-oauth",
|
||||
"api_key": "mock-access-token",
|
||||
"base_url": MINIMAX_OAUTH_GLOBAL_INFERENCE.rstrip("/"),
|
||||
"source": "oauth",
|
||||
}
|
||||
|
||||
import hermes_cli.auth as auth_mod
|
||||
monkeypatch.setattr(auth_mod, "resolve_minimax_oauth_runtime_credentials",
|
||||
lambda **k: fake_creds)
|
||||
|
||||
resolved = rp.resolve_runtime_provider(requested="minimax-oauth")
|
||||
|
||||
assert resolved["provider"] == "minimax-oauth"
|
||||
assert resolved["api_mode"] == "anthropic_messages"
|
||||
assert resolved["api_key"] == "mock-access-token"
|
||||
|
||||
|
||||
def test_minimax_oauth_runtime_uses_inference_base_url(monkeypatch):
|
||||
"""Base URL returned by resolve_runtime_provider should match the OAuth credentials."""
|
||||
from hermes_cli.auth import MINIMAX_OAUTH_CN_INFERENCE
|
||||
|
||||
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax-oauth")
|
||||
monkeypatch.setattr(rp, "_get_model_config", lambda: {"provider": "minimax-oauth"})
|
||||
monkeypatch.setattr(rp, "load_pool", lambda provider: None)
|
||||
monkeypatch.setattr(rp, "_resolve_named_custom_runtime", lambda **k: None)
|
||||
monkeypatch.setattr(rp, "_resolve_explicit_runtime", lambda **k: None)
|
||||
|
||||
fake_creds = {
|
||||
"provider": "minimax-oauth",
|
||||
"api_key": "cn-token",
|
||||
"base_url": MINIMAX_OAUTH_CN_INFERENCE.rstrip("/"),
|
||||
"source": "oauth",
|
||||
}
|
||||
|
||||
import hermes_cli.auth as auth_mod
|
||||
monkeypatch.setattr(auth_mod, "resolve_minimax_oauth_runtime_credentials",
|
||||
lambda **k: fake_creds)
|
||||
|
||||
resolved = rp.resolve_runtime_provider(requested="minimax-oauth")
|
||||
|
||||
assert MINIMAX_OAUTH_CN_INFERENCE.rstrip("/") in resolved["base_url"]
|
||||
|
||||
466
tests/test_minimax_oauth.py
Normal file
466
tests/test_minimax_oauth.py
Normal file
@@ -0,0 +1,466 @@
|
||||
"""Tests for MiniMax OAuth provider (hermes_cli/auth.py).
|
||||
|
||||
Covers:
|
||||
- PKCE pair generation (S256 challenge)
|
||||
- _minimax_request_user_code happy path and state-mismatch error
|
||||
- _minimax_poll_token: pending→success flow, error status, timeout
|
||||
- _refresh_minimax_oauth_state: skip when not expired, update on success,
|
||||
re-login required on invalid_grant
|
||||
- resolve_minimax_oauth_runtime_credentials: error when not logged in
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.auth import (
|
||||
PROVIDER_REGISTRY,
|
||||
AuthError,
|
||||
MINIMAX_OAUTH_CLIENT_ID,
|
||||
MINIMAX_OAUTH_GLOBAL_BASE,
|
||||
MINIMAX_OAUTH_GLOBAL_INFERENCE,
|
||||
MINIMAX_OAUTH_CN_BASE,
|
||||
MINIMAX_OAUTH_CN_INFERENCE,
|
||||
MINIMAX_OAUTH_REFRESH_SKEW_SECONDS,
|
||||
_minimax_pkce_pair,
|
||||
_minimax_request_user_code,
|
||||
_minimax_poll_token,
|
||||
_refresh_minimax_oauth_state,
|
||||
resolve_minimax_oauth_runtime_credentials,
|
||||
get_minimax_oauth_auth_status,
|
||||
get_provider_auth_state,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_httpx_response(status_code: int, body: dict | None = None, text: str = ""):
|
||||
"""Return a minimal mock that quacks like httpx.Response."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
if body is not None:
|
||||
resp.json.return_value = body
|
||||
resp.text = json.dumps(body)
|
||||
else:
|
||||
resp.json.side_effect = Exception("No body")
|
||||
resp.text = text
|
||||
resp.reason_phrase = "OK" if status_code == 200 else "Error"
|
||||
return resp
|
||||
|
||||
|
||||
def _future_iso(seconds_from_now: int = 3600) -> str:
|
||||
ts = time.time() + seconds_from_now
|
||||
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _past_iso(seconds_ago: int = 3600) -> str:
|
||||
ts = time.time() - seconds_ago
|
||||
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. test_pkce_pair_produces_valid_s256
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_pkce_pair_produces_valid_s256():
|
||||
verifier, challenge, state = _minimax_pkce_pair()
|
||||
|
||||
# Verifier must be non-empty and URL-safe
|
||||
assert isinstance(verifier, str)
|
||||
assert len(verifier) >= 32
|
||||
|
||||
# Challenge must be URL-safe base64 without trailing "="
|
||||
assert isinstance(challenge, str)
|
||||
assert "=" not in challenge
|
||||
|
||||
# Re-compute challenge from verifier and verify it matches
|
||||
expected = base64.urlsafe_b64encode(
|
||||
hashlib.sha256(verifier.encode()).digest()
|
||||
).decode().rstrip("=")
|
||||
assert challenge == expected
|
||||
|
||||
# State must be non-empty
|
||||
assert isinstance(state, str)
|
||||
assert len(state) >= 8
|
||||
|
||||
# Two calls must return different values (randomness)
|
||||
v2, c2, s2 = _minimax_pkce_pair()
|
||||
assert verifier != v2
|
||||
assert state != s2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. test_request_user_code_happy_path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_request_user_code_happy_path():
|
||||
state = "test-state-abc"
|
||||
mock_response = _make_httpx_response(200, {
|
||||
"user_code": "ABC-123",
|
||||
"verification_uri": "https://minimax.io/verify",
|
||||
"expired_in": int(time.time() * 1000) + 300_000,
|
||||
"state": state,
|
||||
})
|
||||
|
||||
client = MagicMock()
|
||||
client.post.return_value = mock_response
|
||||
|
||||
result = _minimax_request_user_code(
|
||||
client,
|
||||
portal_base_url=MINIMAX_OAUTH_GLOBAL_BASE,
|
||||
client_id=MINIMAX_OAUTH_CLIENT_ID,
|
||||
code_challenge="test-challenge",
|
||||
state=state,
|
||||
)
|
||||
|
||||
assert result["user_code"] == "ABC-123"
|
||||
assert result["verification_uri"] == "https://minimax.io/verify"
|
||||
assert result["state"] == state
|
||||
|
||||
# Verify correct endpoint was called
|
||||
call_args = client.post.call_args
|
||||
assert "/oauth/code" in call_args[0][0]
|
||||
headers = call_args[1].get("headers", {})
|
||||
assert "x-request-id" in headers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. test_request_user_code_state_mismatch_raises
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_request_user_code_state_mismatch_raises():
|
||||
mock_response = _make_httpx_response(200, {
|
||||
"user_code": "XYZ",
|
||||
"verification_uri": "https://minimax.io/verify",
|
||||
"expired_in": 300,
|
||||
"state": "wrong-state", # Mismatched!
|
||||
})
|
||||
|
||||
client = MagicMock()
|
||||
client.post.return_value = mock_response
|
||||
|
||||
with pytest.raises(AuthError) as exc_info:
|
||||
_minimax_request_user_code(
|
||||
client,
|
||||
portal_base_url=MINIMAX_OAUTH_GLOBAL_BASE,
|
||||
client_id=MINIMAX_OAUTH_CLIENT_ID,
|
||||
code_challenge="challenge",
|
||||
state="correct-state",
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "state_mismatch"
|
||||
assert "CSRF" in str(exc_info.value) or "mismatch" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. test_request_user_code_non_200_raises
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_request_user_code_non_200_raises():
|
||||
mock_response = _make_httpx_response(400, text="Bad Request")
|
||||
mock_response.json.side_effect = Exception("no json")
|
||||
mock_response.text = "Bad Request"
|
||||
|
||||
client = MagicMock()
|
||||
client.post.return_value = mock_response
|
||||
|
||||
with pytest.raises(AuthError) as exc_info:
|
||||
_minimax_request_user_code(
|
||||
client,
|
||||
portal_base_url=MINIMAX_OAUTH_GLOBAL_BASE,
|
||||
client_id=MINIMAX_OAUTH_CLIENT_ID,
|
||||
code_challenge="challenge",
|
||||
state="state",
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "authorization_failed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. test_poll_token_pending_then_success
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_poll_token_pending_then_success():
|
||||
# Set a deadline far enough in the future for polling
|
||||
deadline_ms = int(time.time() * 1000) + 60_000 # 60 seconds from now
|
||||
|
||||
pending_body = {"status": "pending"}
|
||||
success_body = {
|
||||
"status": "success",
|
||||
"access_token": "access-abc",
|
||||
"refresh_token": "refresh-xyz",
|
||||
"expired_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
|
||||
pending_resp = _make_httpx_response(200, pending_body)
|
||||
success_resp = _make_httpx_response(200, success_body)
|
||||
|
||||
client = MagicMock()
|
||||
client.post.side_effect = [pending_resp, pending_resp, success_resp]
|
||||
|
||||
with patch("time.sleep"): # don't actually sleep
|
||||
result = _minimax_poll_token(
|
||||
client,
|
||||
portal_base_url=MINIMAX_OAUTH_GLOBAL_BASE,
|
||||
client_id=MINIMAX_OAUTH_CLIENT_ID,
|
||||
user_code="USER-CODE",
|
||||
code_verifier="verifier",
|
||||
expired_in=deadline_ms,
|
||||
interval_ms=2000,
|
||||
)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["access_token"] == "access-abc"
|
||||
assert result["refresh_token"] == "refresh-xyz"
|
||||
assert client.post.call_count == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. test_poll_token_error_raises
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_poll_token_error_raises():
|
||||
deadline_ms = int(time.time() * 1000) + 60_000
|
||||
error_body = {"status": "error"}
|
||||
error_resp = _make_httpx_response(200, error_body)
|
||||
|
||||
client = MagicMock()
|
||||
client.post.return_value = error_resp
|
||||
|
||||
with pytest.raises(AuthError) as exc_info:
|
||||
_minimax_poll_token(
|
||||
client,
|
||||
portal_base_url=MINIMAX_OAUTH_GLOBAL_BASE,
|
||||
client_id=MINIMAX_OAUTH_CLIENT_ID,
|
||||
user_code="U",
|
||||
code_verifier="v",
|
||||
expired_in=deadline_ms,
|
||||
interval_ms=2000,
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "authorization_denied"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. test_poll_token_timeout_raises
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_poll_token_timeout_raises():
|
||||
# expired_in is a small duration (treated as seconds from now, already expired)
|
||||
expired_in = 1 # 1 second from now
|
||||
# Make sleep a no-op and time.time advance quickly by using a small deadline
|
||||
# We use a duration-style expired_in (small enough to not be a unix timestamp)
|
||||
# duration mode: deadline = time.time() + max(1, expired_in)
|
||||
# We need time() to exceed deadline immediately.
|
||||
|
||||
fixed_now = time.time()
|
||||
call_count = [0]
|
||||
|
||||
def fake_time():
|
||||
call_count[0] += 1
|
||||
# After 2 calls, return a time past the deadline
|
||||
if call_count[0] > 2:
|
||||
return fixed_now + 10 # past deadline
|
||||
return fixed_now
|
||||
|
||||
client = MagicMock()
|
||||
pending_resp = _make_httpx_response(200, {"status": "pending"})
|
||||
client.post.return_value = pending_resp
|
||||
|
||||
import hermes_cli.auth as auth_module
|
||||
with patch.object(auth_module, "time") as mock_time_mod:
|
||||
# We need to patch the 'time' module used inside _minimax_poll_token
|
||||
# The function imports 'import time as _time' locally.
|
||||
# Patch time.sleep and time.time in the auth module's local scope.
|
||||
pass
|
||||
|
||||
# Use a simpler approach: expired_in as past timestamp (already expired)
|
||||
past_deadline_ms = int((time.time() - 1) * 1000) # 1 second ago
|
||||
|
||||
with pytest.raises(AuthError) as exc_info:
|
||||
_minimax_poll_token(
|
||||
client,
|
||||
portal_base_url=MINIMAX_OAUTH_GLOBAL_BASE,
|
||||
client_id=MINIMAX_OAUTH_CLIENT_ID,
|
||||
user_code="U",
|
||||
code_verifier="v",
|
||||
expired_in=past_deadline_ms,
|
||||
interval_ms=2000,
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "timeout"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. test_refresh_skip_when_not_expired
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_refresh_skip_when_not_expired():
|
||||
"""When token is far from expiry, refresh should return the same state."""
|
||||
state = {
|
||||
"access_token": "old-access",
|
||||
"refresh_token": "refresh-token",
|
||||
"portal_base_url": MINIMAX_OAUTH_GLOBAL_BASE,
|
||||
"client_id": MINIMAX_OAUTH_CLIENT_ID,
|
||||
"inference_base_url": MINIMAX_OAUTH_GLOBAL_INFERENCE,
|
||||
"expires_at": _future_iso(3600), # 1 hour in the future
|
||||
}
|
||||
|
||||
result = _refresh_minimax_oauth_state(state)
|
||||
assert result["access_token"] == "old-access"
|
||||
assert result is state # Same object returned (no refresh)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. test_refresh_updates_access_token
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_refresh_updates_access_token():
|
||||
"""When token is close to expiry, refresh should update the state."""
|
||||
# expires_at just MINIMAX_OAUTH_REFRESH_SKEW_SECONDS - 1 from now (close to expiry)
|
||||
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": 7200,
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
# Patch _minimax_save_auth_state to avoid touching the auth store
|
||||
with patch("hermes_cli.auth._minimax_save_auth_state"):
|
||||
result = _refresh_minimax_oauth_state(state)
|
||||
|
||||
assert result["access_token"] == "new-access"
|
||||
assert result["refresh_token"] == "new-refresh"
|
||||
assert result["expires_in"] == 7200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. test_refresh_reuse_triggers_relogin_required
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_refresh_reuse_triggers_relogin_required():
|
||||
"""On 400 + invalid_grant body, relogin_required should be set."""
|
||||
state = {
|
||||
"access_token": "old-access",
|
||||
"refresh_token": "old-refresh",
|
||||
"portal_base_url": MINIMAX_OAUTH_GLOBAL_BASE,
|
||||
"client_id": MINIMAX_OAUTH_CLIENT_ID,
|
||||
"inference_base_url": MINIMAX_OAUTH_GLOBAL_INFERENCE,
|
||||
"expires_at": _past_iso(100), # already expired
|
||||
}
|
||||
|
||||
bad_resp = _make_httpx_response(400, text="invalid_grant")
|
||||
bad_resp.json.side_effect = Exception("no json")
|
||||
bad_resp.text = "invalid_grant"
|
||||
bad_resp.reason_phrase = "Bad Request"
|
||||
|
||||
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 = bad_resp
|
||||
mock_client_class.return_value = mock_client_instance
|
||||
|
||||
with pytest.raises(AuthError) as exc_info:
|
||||
_refresh_minimax_oauth_state(state)
|
||||
|
||||
assert exc_info.value.code == "refresh_failed"
|
||||
assert exc_info.value.relogin_required is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11. test_resolve_credentials_requires_login
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_resolve_credentials_requires_login():
|
||||
"""When no state is stored, resolve_minimax_oauth_runtime_credentials raises."""
|
||||
with patch("hermes_cli.auth.get_provider_auth_state", return_value=None):
|
||||
with pytest.raises(AuthError) as exc_info:
|
||||
resolve_minimax_oauth_runtime_credentials()
|
||||
|
||||
assert exc_info.value.code == "not_logged_in"
|
||||
assert exc_info.value.relogin_required is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 12. test_provider_registry_contains_minimax_oauth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_provider_registry_contains_minimax_oauth():
|
||||
assert "minimax-oauth" in PROVIDER_REGISTRY
|
||||
pconfig = PROVIDER_REGISTRY["minimax-oauth"]
|
||||
assert pconfig.auth_type == "oauth_minimax"
|
||||
assert pconfig.client_id == MINIMAX_OAUTH_CLIENT_ID
|
||||
assert MINIMAX_OAUTH_GLOBAL_BASE in pconfig.portal_base_url
|
||||
assert MINIMAX_OAUTH_GLOBAL_INFERENCE in pconfig.inference_base_url
|
||||
assert "cn_portal_base_url" in pconfig.extra
|
||||
assert "cn_inference_base_url" in pconfig.extra
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 13. test_minimax_oauth_alias_resolves
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_minimax_oauth_alias_resolves():
|
||||
from hermes_cli.auth import resolve_provider
|
||||
# Only test that minimax-oauth itself resolves (alias resolution is tested in models)
|
||||
result = resolve_provider("minimax-oauth")
|
||||
assert result == "minimax-oauth"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 14. test_get_minimax_oauth_auth_status_not_logged_in
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_minimax_oauth_auth_status_not_logged_in():
|
||||
with patch("hermes_cli.auth.get_provider_auth_state", return_value=None):
|
||||
status = get_minimax_oauth_auth_status()
|
||||
|
||||
assert status["logged_in"] is False
|
||||
assert status["provider"] == "minimax-oauth"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 15. test_get_minimax_oauth_auth_status_logged_in
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_minimax_oauth_auth_status_logged_in():
|
||||
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_minimax_oauth_auth_status()
|
||||
|
||||
assert status["logged_in"] is True
|
||||
assert status["region"] == "global"
|
||||
@@ -70,6 +70,7 @@ Good defaults:
|
||||
|---|---|
|
||||
| Least friction | Nous Portal or OpenRouter |
|
||||
| You already have Claude or Codex auth | Anthropic or OpenAI Codex |
|
||||
| You want MiniMax models without an API key | MiniMax (OAuth) — browser login, no billing setup |
|
||||
| You want local/private inference | Ollama or any custom OpenAI-compatible endpoint |
|
||||
| You want multi-provider routing | OpenRouter |
|
||||
| You have a custom GPU server | vLLM, SGLang, LiteLLM, or any OpenAI-compatible endpoint |
|
||||
|
||||
224
website/docs/guides/minimax-oauth.md
Normal file
224
website/docs/guides/minimax-oauth.md
Normal file
@@ -0,0 +1,224 @@
|
||||
---
|
||||
sidebar_position: 15
|
||||
title: "MiniMax OAuth"
|
||||
description: "Log into MiniMax via browser OAuth and use MiniMax-M2.7 models in Hermes Agent — no API key required"
|
||||
---
|
||||
|
||||
# MiniMax OAuth
|
||||
|
||||
Hermes Agent supports **MiniMax** through a browser-based OAuth login flow, using the same credentials as the [MiniMax portal](https://www.minimax.io). No API key or credit card is required — log in once and Hermes automatically refreshes your session.
|
||||
|
||||
The transport reuses the `anthropic_messages` adapter (MiniMax exposes an Anthropic Messages-compatible endpoint at `/anthropic`), so all existing tool-calling, streaming, and context features work without any adapter changes.
|
||||
|
||||
## Overview
|
||||
|
||||
| Item | Value |
|
||||
|------|-------|
|
||||
| Provider ID | `minimax-oauth` |
|
||||
| Display name | MiniMax (OAuth) |
|
||||
| Auth type | Browser OAuth (PKCE device-code flow) |
|
||||
| Transport | Anthropic Messages-compatible (`anthropic_messages`) |
|
||||
| Models | `MiniMax-M2.7`, `MiniMax-M2.7-highspeed` |
|
||||
| Global endpoint | `https://api.minimax.io/anthropic` |
|
||||
| China endpoint | `https://api.minimaxi.com/anthropic` |
|
||||
| Requires env var | No (`MINIMAX_API_KEY` is **not** used for this provider) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.9+
|
||||
- Hermes Agent installed
|
||||
- A MiniMax account at [minimax.io](https://www.minimax.io) (global) or [minimaxi.com](https://www.minimaxi.com) (China)
|
||||
- A browser available on the local machine (or use `--no-browser` for remote sessions)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Launch the provider and model picker
|
||||
hermes model
|
||||
# → Select "MiniMax (OAuth)" from the provider list
|
||||
# → Hermes opens your browser to the MiniMax authorization page
|
||||
# → Approve access in the browser
|
||||
# → Select a model (MiniMax-M2.7 or MiniMax-M2.7-highspeed)
|
||||
# → Start chatting
|
||||
|
||||
hermes
|
||||
```
|
||||
|
||||
After the first login, credentials are stored under `~/.hermes/auth.json` and are refreshed automatically before each session.
|
||||
|
||||
## Logging In Manually
|
||||
|
||||
You can trigger a login without going through the model picker:
|
||||
|
||||
```bash
|
||||
hermes auth add minimax-oauth
|
||||
```
|
||||
|
||||
### China region
|
||||
|
||||
If your account is on the China platform (`minimaxi.com`), pass `--region cn`:
|
||||
|
||||
```bash
|
||||
hermes auth add minimax-oauth --region cn
|
||||
```
|
||||
|
||||
### Remote / headless sessions
|
||||
|
||||
On servers or containers where no browser is available:
|
||||
|
||||
```bash
|
||||
hermes auth add minimax-oauth --no-browser
|
||||
```
|
||||
|
||||
Hermes will print the verification URL and user code — open the URL on any device and enter the code when prompted.
|
||||
|
||||
## The OAuth Flow
|
||||
|
||||
Hermes implements a PKCE device-code flow against the MiniMax OAuth endpoints:
|
||||
|
||||
1. Hermes generates a PKCE verifier / challenge pair and a random state value.
|
||||
2. It POSTs to `{base_url}/oauth/code` with the challenge and receives a `user_code` and `verification_uri`.
|
||||
3. Your browser opens `verification_uri`. If prompted, enter the `user_code`.
|
||||
4. Hermes polls `{base_url}/oauth/token` until the token arrives (or the deadline passes).
|
||||
5. Tokens (`access_token`, `refresh_token`, expiry) are saved to `~/.hermes/auth.json` under the `minimax-oauth` key.
|
||||
|
||||
Token refresh (standard OAuth `refresh_token` grant) runs automatically at each session start when the access token is within 60 seconds of expiry.
|
||||
|
||||
## Checking Login Status
|
||||
|
||||
```bash
|
||||
hermes doctor
|
||||
```
|
||||
|
||||
The `◆ Auth Providers` section will show:
|
||||
|
||||
```
|
||||
✓ MiniMax OAuth (logged in, region=global)
|
||||
```
|
||||
|
||||
or, if not logged in:
|
||||
|
||||
```
|
||||
⚠ MiniMax OAuth (not logged in)
|
||||
```
|
||||
|
||||
## Switching Models
|
||||
|
||||
```bash
|
||||
hermes model
|
||||
# → Select "MiniMax (OAuth)"
|
||||
# → Pick from the model list
|
||||
```
|
||||
|
||||
Or set the model directly:
|
||||
|
||||
```bash
|
||||
hermes config set model MiniMax-M2.7
|
||||
hermes config set provider minimax-oauth
|
||||
```
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
After login, `~/.hermes/config.yaml` will contain entries similar to:
|
||||
|
||||
```yaml
|
||||
model:
|
||||
default: MiniMax-M2.7
|
||||
provider: minimax-oauth
|
||||
base_url: https://api.minimax.io/anthropic
|
||||
```
|
||||
|
||||
### `--region` flag
|
||||
|
||||
| Value | Portal | Inference endpoint |
|
||||
|-------|--------|-------------------|
|
||||
| `global` (default) | `https://api.minimax.io` | `https://api.minimax.io/anthropic` |
|
||||
| `cn` | `https://api.minimaxi.com` | `https://api.minimaxi.com/anthropic` |
|
||||
|
||||
### Provider aliases
|
||||
|
||||
All of the following resolve to `minimax-oauth`:
|
||||
|
||||
```bash
|
||||
hermes --provider minimax-oauth # canonical
|
||||
hermes --provider minimax-portal # alias
|
||||
hermes --provider minimax-global # alias
|
||||
hermes --provider minimax_oauth # alias (underscore form)
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The `minimax-oauth` provider does **not** use `MINIMAX_API_KEY` or `MINIMAX_BASE_URL`. Those variables are for the API-key-based `minimax` and `minimax-cn` providers only.
|
||||
|
||||
| Variable | Effect |
|
||||
|----------|--------|
|
||||
| `MINIMAX_API_KEY` | Used by `minimax` provider only — ignored for `minimax-oauth` |
|
||||
| `MINIMAX_CN_API_KEY` | Used by `minimax-cn` provider only — ignored for `minimax-oauth` |
|
||||
|
||||
To force the `minimax-oauth` provider at runtime:
|
||||
|
||||
```bash
|
||||
HERMES_INFERENCE_PROVIDER=minimax-oauth hermes
|
||||
```
|
||||
|
||||
## Models
|
||||
|
||||
| Model | Best for |
|
||||
|-------|----------|
|
||||
| `MiniMax-M2.7` | Long-context reasoning, complex tool-calling |
|
||||
| `MiniMax-M2.7-highspeed` | Lower latency, lighter tasks, auxiliary calls |
|
||||
|
||||
Both models support up to 200,000 tokens of context.
|
||||
|
||||
`MiniMax-M2.7-highspeed` is also used automatically as the auxiliary model for vision and delegation tasks when `minimax-oauth` is the primary provider.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Token expired — not re-logging in automatically
|
||||
|
||||
Hermes refreshes the token on every session start if it is within 60 seconds of expiry. If the access token is already expired (for example, after a long offline period), the refresh happens automatically on the next request. If refresh fails with `refresh_token_reused` or `invalid_grant`, Hermes marks the session as requiring re-login.
|
||||
|
||||
**Fix:** run `hermes auth add minimax-oauth` again to start a fresh login.
|
||||
|
||||
### Authorization timed out
|
||||
|
||||
The device-code flow has a finite expiry window. If you don't approve the login in time, Hermes raises a timeout error.
|
||||
|
||||
**Fix:** re-run `hermes auth add minimax-oauth` (or `hermes model`). The flow starts fresh.
|
||||
|
||||
### State mismatch (possible CSRF)
|
||||
|
||||
Hermes detected that the `state` value returned by the authorization server does not match what it sent.
|
||||
|
||||
**Fix:** re-run the login. If it persists, check for a proxy or redirect that is modifying the OAuth response.
|
||||
|
||||
### Logging in from a remote server
|
||||
|
||||
If `hermes` cannot open a browser window, use `--no-browser`:
|
||||
|
||||
```bash
|
||||
hermes auth add minimax-oauth --no-browser
|
||||
```
|
||||
|
||||
Hermes prints the URL and code. Open the URL on any device and complete the flow there.
|
||||
|
||||
### "Not logged into MiniMax OAuth" error at runtime
|
||||
|
||||
The auth store has no credentials for `minimax-oauth`. You have not logged in yet, or the credential file was deleted.
|
||||
|
||||
**Fix:** run `hermes model` and select MiniMax (OAuth), or run `hermes auth add minimax-oauth`.
|
||||
|
||||
## Logging Out
|
||||
|
||||
To remove stored MiniMax OAuth credentials:
|
||||
|
||||
```bash
|
||||
hermes auth remove minimax-oauth
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- [AI Providers reference](../integrations/providers.md)
|
||||
- [Environment Variables](../reference/environment-variables.md)
|
||||
- [Configuration](../user-guide/configuration.md)
|
||||
- [hermes doctor](../reference/cli-commands.md)
|
||||
@@ -36,10 +36,10 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config
|
||||
| `KIMI_CN_API_KEY` | Kimi / Moonshot China API key ([moonshot.cn](https://platform.moonshot.cn)) |
|
||||
| `ARCEEAI_API_KEY` | Arcee AI API key ([chat.arcee.ai](https://chat.arcee.ai/)) |
|
||||
| `ARCEE_BASE_URL` | Override Arcee base URL (default: `https://api.arcee.ai/api/v1`) |
|
||||
| `MINIMAX_API_KEY` | MiniMax API key — global endpoint ([minimax.io](https://www.minimax.io)) |
|
||||
| `MINIMAX_BASE_URL` | Override MiniMax base URL (default: `https://api.minimax.io/anthropic` — Hermes uses MiniMax's Anthropic Messages-compatible endpoint) |
|
||||
| `MINIMAX_CN_API_KEY` | MiniMax API key — China endpoint ([minimaxi.com](https://www.minimaxi.com)) |
|
||||
| `MINIMAX_CN_BASE_URL` | Override MiniMax China base URL (default: `https://api.minimaxi.com/anthropic`) |
|
||||
| `MINIMAX_API_KEY` | MiniMax API key — global endpoint ([minimax.io](https://www.minimax.io)). **Not used by `minimax-oauth`** (OAuth path uses browser login instead). |
|
||||
| `MINIMAX_BASE_URL` | Override MiniMax base URL (default: `https://api.minimax.io/anthropic` — Hermes uses MiniMax's Anthropic Messages-compatible endpoint). **Not used by `minimax-oauth`**. |
|
||||
| `MINIMAX_CN_API_KEY` | MiniMax API key — China endpoint ([minimaxi.com](https://www.minimaxi.com)). **Not used by `minimax-oauth`** (OAuth path uses browser login instead). |
|
||||
| `MINIMAX_CN_BASE_URL` | Override MiniMax China base URL (default: `https://api.minimaxi.com/anthropic`). **Not used by `minimax-oauth`**. |
|
||||
| `KILOCODE_API_KEY` | Kilo Code API key ([kilo.ai](https://kilo.ai)) |
|
||||
| `KILOCODE_BASE_URL` | Override Kilo Code base URL (default: `https://api.kilo.ai/api/gateway`) |
|
||||
| `XIAOMI_API_KEY` | Xiaomi MiMo API key ([platform.xiaomimimo.com](https://platform.xiaomimimo.com)) |
|
||||
@@ -86,7 +86,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `kilocode`, `xiaomi`, `arcee`, `alibaba`, `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `google-gemini-cli`, `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `ai-gateway` (default: `auto`) |
|
||||
| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth` (browser OAuth login — no API key required; see [MiniMax OAuth guide](../guides/minimax-oauth.md)), `kilocode`, `xiaomi`, `arcee`, `alibaba`, `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `google-gemini-cli`, `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `ai-gateway` (default: `auto`) |
|
||||
| `HERMES_PORTAL_BASE_URL` | Override Nous Portal URL (for development/testing) |
|
||||
| `NOUS_INFERENCE_BASE_URL` | Override Nous inference API URL |
|
||||
| `HERMES_NOUS_MIN_KEY_TTL_SECONDS` | Min agent key TTL before re-mint (default: 1800 = 30min) |
|
||||
|
||||
@@ -657,7 +657,11 @@ Every model slot in Hermes — auxiliary tasks, compression, fallback — uses t
|
||||
|
||||
When `base_url` is set, Hermes ignores the provider and calls that endpoint directly (using `api_key` or `OPENAI_API_KEY` for auth). When only `provider` is set, Hermes uses that provider's built-in auth and base URL.
|
||||
|
||||
Available providers for auxiliary tasks: `auto`, `main`, plus any provider in the [provider registry](/docs/reference/environment-variables) — `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `qwen-oauth`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `deepseek`, `nvidia`, `xai`, `ollama-cloud`, `alibaba`, `bedrock`, `huggingface`, `arcee`, `xiaomi`, `kilocode`, `opencode-zen`, `opencode-go`, `ai-gateway` — or any named custom provider from your `custom_providers` list (e.g. `provider: "beans"`).
|
||||
Available providers for auxiliary tasks: `auto`, `main`, plus any provider in the [provider registry](/docs/reference/environment-variables) — `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `qwen-oauth`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `ollama-cloud`, `alibaba`, `bedrock`, `huggingface`, `arcee`, `xiaomi`, `kilocode`, `opencode-zen`, `opencode-go`, `ai-gateway` — or any named custom provider from your `custom_providers` list (e.g. `provider: "beans"`).
|
||||
|
||||
:::tip MiniMax OAuth
|
||||
`minimax-oauth` logs in via browser OAuth (no API key needed). Run `hermes model` and select **MiniMax (OAuth)** to authenticate. Auxiliary tasks use `MiniMax-M2.7-highspeed` automatically. See the [MiniMax OAuth guide](../guides/minimax-oauth.md).
|
||||
:::
|
||||
|
||||
:::warning `"main"` is for auxiliary tasks only
|
||||
The `"main"` provider option means "use whatever provider my main agent uses" — it's only valid inside `auxiliary:`, `compression:`, and `fallback_model:` configs. It is **not** a valid value for your top-level `model.provider` setting. If you use a custom OpenAI-compatible endpoint, set `provider: custom` in your `model:` section. See [AI Providers](/docs/integrations/providers) for all main model provider options.
|
||||
@@ -793,6 +797,7 @@ These options apply to **auxiliary task configs** (`auxiliary:`, `compression:`,
|
||||
| `"openrouter"` | Force OpenRouter — routes to any model (Gemini, GPT-4o, Claude, etc.) | `OPENROUTER_API_KEY` |
|
||||
| `"nous"` | Force Nous Portal | `hermes auth` |
|
||||
| `"codex"` | Force Codex OAuth (ChatGPT account). Supports vision (gpt-5.3-codex). | `hermes model` → Codex |
|
||||
| `"minimax-oauth"` | Force MiniMax OAuth (browser login, no API key). Uses MiniMax-M2.7-highspeed for auxiliary tasks. | `hermes model` → MiniMax (OAuth) |
|
||||
| `"main"` | Use your active custom/main endpoint. This can come from `OPENAI_BASE_URL` + `OPENAI_API_KEY` or from a custom endpoint saved via `hermes model` / `config.yaml`. Works with OpenAI, local models, or any OpenAI-compatible API. **Auxiliary tasks only — not valid for `model.provider`.** | Custom endpoint credentials + base URL |
|
||||
|
||||
### Common Setups
|
||||
@@ -836,6 +841,15 @@ auxiliary:
|
||||
# model defaults to gpt-5.3-codex (supports vision)
|
||||
```
|
||||
|
||||
**Using MiniMax OAuth** (browser login, no API key needed):
|
||||
```yaml
|
||||
model:
|
||||
default: MiniMax-M2.7
|
||||
provider: minimax-oauth
|
||||
base_url: https://api.minimax.io/anthropic
|
||||
```
|
||||
Run `hermes model` and select **MiniMax (OAuth)** to log in and set this automatically. For the China region, the base URL will be `https://api.minimaxi.com/anthropic`. See the [MiniMax OAuth guide](../guides/minimax-oauth.md) for the full walkthrough.
|
||||
|
||||
**Using a local/self-hosted model:**
|
||||
```yaml
|
||||
auxiliary:
|
||||
|
||||
Reference in New Issue
Block a user