Skip to content
Closed
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
92 changes: 89 additions & 3 deletions hermes_cli/runtime_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,39 @@ def _detect_api_mode_for_url(base_url: str) -> Optional[str]:
return None


_MINIMAX_M3_OPENAI_BASE_URL = "https://api.minimax.io/v1"


def _is_minimax_m3_model(model: Any) -> bool:
normalized = str(model or "").strip().lower()
if not normalized:
return False
return normalized.split("/")[-1] == "minimax-m3"


def _maybe_route_default_minimax_m3_openai(
*,
provider: str,
base_url: str,
api_mode: str,
effective_model: Any,
base_url_from_default: bool,
) -> tuple[str, str]:
"""Route built-in MiniMax-M3 to the OpenAI-compatible split-reasoning path.

MiniMax-M3 leaks provider-specific ``<mm:think>`` markers as visible
content on the default Anthropic-compatible endpoint. The OpenAI-compatible
endpoint supports ``reasoning_split`` (emitted by the MiniMax provider
profile), so only the uncustomized built-in route is moved there; explicit
user base URL overrides keep their chosen transport.
"""
if provider != "minimax" or not base_url_from_default:
return base_url, api_mode
if not _is_minimax_m3_model(effective_model):
return base_url, api_mode
return _MINIMAX_M3_OPENAI_BASE_URL, "chat_completions"


def _resolve_plain_custom_api_mode(model_cfg: Dict[str, Any], base_url: str) -> str:
"""Resolve api_mode for legacy/plain ``provider: custom`` endpoints.

Expand Down Expand Up @@ -405,7 +438,7 @@ def _resolve_runtime_from_pool_entry(
# longer matches the model actually being used — the bug that caused
# opencode-zen /v1 to be stripped for chat_completions requests when
# config.default was still a Claude model.
effective_model = (target_model or model_cfg.get("default") or "")
effective_model = target_model or model_cfg.get("default") or ""
base_url = (getattr(entry, "runtime_base_url", None) or getattr(entry, "base_url", None) or "").rstrip("/")
api_key = getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "")
api_mode = "chat_completions"
Expand Down Expand Up @@ -479,7 +512,16 @@ def _resolve_runtime_from_pool_entry(
# fell back to the hardcoded default). Env var overrides win (#6039).
pconfig = PROVIDER_REGISTRY.get(provider)
pool_url_is_default = pconfig and base_url.rstrip("/") == pconfig.inference_base_url.rstrip("/")
if configured_provider == provider and pool_url_is_default:
env_url = ""
if pconfig and pconfig.base_url_env_var:
env_url = (
_getenv(pconfig.base_url_env_var, "")
or os.getenv(pconfig.base_url_env_var, "")
).strip().rstrip("/")
cfg_base_url = ""
if provider in {"minimax", "minimax-cn"} and pool_url_is_default and env_url:
base_url = env_url
elif configured_provider == provider and pool_url_is_default:
cfg_base_url = str(model_cfg.get("base_url") or "").strip().rstrip("/")
if cfg_base_url:
base_url = cfg_base_url
Expand All @@ -501,6 +543,13 @@ def _resolve_runtime_from_pool_entry(
detected = _detect_api_mode_for_url(base_url)
if detected:
api_mode = detected
base_url, api_mode = _maybe_route_default_minimax_m3_openai(
provider=provider,
base_url=base_url,
api_mode=api_mode,
effective_model=effective_model,
base_url_from_default=bool(pool_url_is_default and not env_url and not cfg_base_url),
)

# OpenCode base URLs end with /v1 for OpenAI-compatible models, but the
# Anthropic SDK prepends its own /v1/messages to the base_url. Normalize
Expand Down Expand Up @@ -1358,6 +1407,7 @@ def _resolve_explicit_runtime(
model_cfg: Dict[str, Any],
explicit_api_key: Optional[str] = None,
explicit_base_url: Optional[str] = None,
target_model: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
explicit_api_key = str(explicit_api_key or "").strip()
explicit_base_url = str(explicit_base_url or "").strip().rstrip("/")
Expand Down Expand Up @@ -1461,7 +1511,10 @@ def _resolve_explicit_runtime(
if pconfig and pconfig.auth_type == "api_key":
env_url = ""
if pconfig.base_url_env_var:
env_url = _getenv(pconfig.base_url_env_var, "").strip().rstrip("/")
env_url = (
_getenv(pconfig.base_url_env_var, "")
or os.getenv(pconfig.base_url_env_var, "")
).strip().rstrip("/")

base_url = explicit_base_url
if not base_url:
Expand Down Expand Up @@ -1493,6 +1546,18 @@ def _resolve_explicit_runtime(
detected = _detect_api_mode_for_url(base_url)
if detected:
api_mode = detected
effective_model = target_model or model_cfg.get("default") or ""
base_url, api_mode = _maybe_route_default_minimax_m3_openai(
provider=provider,
base_url=base_url,
api_mode=api_mode,
effective_model=effective_model,
base_url_from_default=bool(
not explicit_base_url
and not env_url
and base_url.rstrip("/") == pconfig.inference_base_url.rstrip("/")
),
)

return {
"provider": provider,
Expand Down Expand Up @@ -1661,6 +1726,7 @@ def resolve_runtime_provider(
model_cfg=model_cfg,
explicit_api_key=explicit_api_key,
explicit_base_url=explicit_base_url,
target_model=target_model,
)
if explicit_runtime:
return explicit_runtime
Expand Down Expand Up @@ -1997,7 +2063,15 @@ def resolve_runtime_provider(
cfg_base_url = ""
if cfg_provider == provider:
cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/")
env_url = ""
if pconfig.base_url_env_var:
env_url = (
_getenv(pconfig.base_url_env_var, "")
or os.getenv(pconfig.base_url_env_var, "")
).strip().rstrip("/")
base_url = cfg_base_url or creds.get("base_url", "").rstrip("/")
if provider in {"minimax", "minimax-cn"} and env_url:
base_url = env_url
api_mode = "chat_completions"
if provider == "copilot":
api_mode = _copilot_runtime_api_mode(model_cfg, creds.get("api_key", ""))
Expand Down Expand Up @@ -2028,6 +2102,18 @@ def resolve_runtime_provider(
detected = _detect_api_mode_for_url(base_url)
if detected:
api_mode = detected
effective_model = target_model or model_cfg.get("default") or ""
base_url, api_mode = _maybe_route_default_minimax_m3_openai(
provider=provider,
base_url=base_url,
api_mode=api_mode,
effective_model=effective_model,
base_url_from_default=bool(
not cfg_base_url
and not env_url
and base_url.rstrip("/") == pconfig.inference_base_url.rstrip("/")
),
)
# Normalize the /v1 suffix for OpenCode by API mode (see comment above).
if provider in {"opencode-zen", "opencode-go"}:
from hermes_cli.models import normalize_opencode_base_url
Expand Down
8 changes: 4 additions & 4 deletions plugins/model-providers/minimax/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"""MiniMax provider profiles (international + China).

The default API-key routes use anthropic_messages because their base URLs end
with /anthropic. Users can opt MiniMax-M3 into the OpenAI-compatible endpoint
with base_url=https://api.minimax.io/v1; that route needs MiniMax-specific
reasoning controls in extra_body.
Most API-key routes use anthropic_messages because their base URLs end with
/anthropic. The runtime resolver routes the built-in MiniMax-M3 default to
https://api.minimax.io/v1 so this profile can request split reasoning; explicit
user base_url overrides stay user-owned.
"""

from typing import Any
Expand Down
138 changes: 138 additions & 0 deletions tests/hermes_cli/test_runtime_provider_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ def _fake_invoke_jwt(ttl_seconds=3600):
return f"{header}.{payload}.sig"


class _NoCredentialPool:
def has_credentials(self):
return False


def _disable_credential_pool(monkeypatch):
monkeypatch.setattr(rp, "load_pool", lambda provider: _NoCredentialPool())


def test_resolve_runtime_provider_uses_credential_pool(monkeypatch):
class _Entry:
access_token = "pool-token"
Expand All @@ -45,6 +54,65 @@ def select(self):
assert resolved["source"] == "manual"


def test_minimax_m3_credential_pool_default_route_uses_openai_endpoint(monkeypatch):
class _Entry:
access_token = "pool-token"
source = "manual"
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")
monkeypatch.setattr(
rp,
"_get_model_config",
lambda: {"provider": "minimax", "default": "minimax/minimax-m3"},
)
monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool())

resolved = rp.resolve_runtime_provider(requested="minimax")

assert resolved["provider"] == "minimax"
assert resolved["api_mode"] == "chat_completions"
assert resolved["base_url"] == "https://api.minimax.io/v1"
assert resolved["api_key"] == "pool-token"


def test_minimax_m3_credential_pool_env_base_url_preserves_user_route(monkeypatch):
class _Entry:
access_token = "pool-token"
source = "manual"
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")
monkeypatch.setattr(
rp,
"_get_model_config",
lambda: {"provider": "minimax", "default": "minimax/minimax-m3"},
)
monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool())
monkeypatch.setenv("MINIMAX_BASE_URL", "https://api.minimax.io/anthropic")

resolved = rp.resolve_runtime_provider(requested="minimax")

assert resolved["provider"] == "minimax"
assert resolved["api_mode"] == "anthropic_messages"
assert resolved["base_url"] == "https://api.minimax.io/anthropic"
assert resolved["api_key"] == "pool-token"


def test_resolve_runtime_provider_nous_pool_uses_env_base_url_override(monkeypatch):
entry = SimpleNamespace(
provider="nous",
Expand Down Expand Up @@ -1467,6 +1535,7 @@ def test_anthropic_messages_in_valid_api_modes():

def test_api_key_provider_anthropic_url_auto_detection(monkeypatch):
"""API-key providers with /anthropic base URL should auto-detect anthropic_messages mode."""
_disable_credential_pool(monkeypatch)
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax")
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-key")
Expand All @@ -1481,6 +1550,7 @@ def test_api_key_provider_anthropic_url_auto_detection(monkeypatch):

def test_api_key_provider_explicit_api_mode_config(monkeypatch):
"""API-key providers should respect api_mode from model config."""
_disable_credential_pool(monkeypatch)
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax")
monkeypatch.setattr(rp, "_get_model_config", lambda: {"api_mode": "anthropic_messages"})
monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-key")
Expand All @@ -1494,6 +1564,7 @@ def test_api_key_provider_explicit_api_mode_config(monkeypatch):

def test_minimax_default_url_uses_anthropic_messages(monkeypatch):
"""MiniMax with default /anthropic URL should auto-detect anthropic_messages mode."""
_disable_credential_pool(monkeypatch)
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax")
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-key")
Expand All @@ -1506,8 +1577,70 @@ def test_minimax_default_url_uses_anthropic_messages(monkeypatch):
assert resolved["base_url"] == "https://api.minimax.io/anthropic"


def test_minimax_m3_default_route_uses_openai_reasoning_split_endpoint(monkeypatch):
"""Built-in MiniMax-M3 should use /v1 so the profile can request reasoning_split."""
_disable_credential_pool(monkeypatch)
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax")
monkeypatch.setattr(
rp,
"_get_model_config",
lambda: {"provider": "minimax", "default": "minimax/minimax-m3"},
)
monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-key")
monkeypatch.delenv("MINIMAX_BASE_URL", raising=False)

resolved = rp.resolve_runtime_provider(requested="minimax")

assert resolved["provider"] == "minimax"
assert resolved["api_mode"] == "chat_completions"
assert resolved["base_url"] == "https://api.minimax.io/v1"


def test_minimax_m3_default_route_ignores_stale_anthropic_api_mode(monkeypatch):
"""A stale persisted api_mode must not keep M3 on the inline-reasoning route."""
_disable_credential_pool(monkeypatch)
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax")
monkeypatch.setattr(
rp,
"_get_model_config",
lambda: {
"provider": "minimax",
"default": "MiniMax-M3",
"api_mode": "anthropic_messages",
},
)
monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-key")
monkeypatch.delenv("MINIMAX_BASE_URL", raising=False)

resolved = rp.resolve_runtime_provider(requested="minimax")

assert resolved["provider"] == "minimax"
assert resolved["api_mode"] == "chat_completions"
assert resolved["base_url"] == "https://api.minimax.io/v1"


def test_minimax_m3_env_base_url_preserves_user_route(monkeypatch):
"""An explicit MiniMax base URL remains user-owned, even for M3."""
_disable_credential_pool(monkeypatch)
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax")
monkeypatch.setattr(
rp,
"_get_model_config",
lambda: {"provider": "minimax", "default": "MiniMax-M3"},
)
monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-key")
monkeypatch.setenv("MINIMAX_BASE_URL", "https://api.minimax.io/anthropic")

resolved = rp.resolve_runtime_provider(requested="minimax")

assert resolved["provider"] == "minimax"
assert resolved["api_mode"] == "anthropic_messages"
assert resolved["base_url"] == "https://api.minimax.io/anthropic"


def test_minimax_v1_url_uses_chat_completions(monkeypatch):
"""MiniMax with /v1 base URL should use chat_completions (user override for regions where /anthropic 404s)."""
_disable_credential_pool(monkeypatch)
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax")
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-key")
Expand All @@ -1522,6 +1655,7 @@ def test_minimax_v1_url_uses_chat_completions(monkeypatch):

def test_minimax_cn_v1_url_uses_chat_completions(monkeypatch):
"""MiniMax-CN with /v1 base URL should use chat_completions (user override)."""
_disable_credential_pool(monkeypatch)
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax-cn")
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
monkeypatch.setenv("MINIMAX_CN_API_KEY", "test-minimax-cn-key")
Expand All @@ -1536,6 +1670,7 @@ def test_minimax_cn_v1_url_uses_chat_completions(monkeypatch):

def test_minimax_explicit_api_mode_respected(monkeypatch):
"""Explicit api_mode config should override MiniMax auto-detection."""
_disable_credential_pool(monkeypatch)
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax")
monkeypatch.setattr(rp, "_get_model_config", lambda: {"api_mode": "chat_completions"})
monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-key")
Expand All @@ -1549,6 +1684,7 @@ def test_minimax_explicit_api_mode_respected(monkeypatch):

def test_minimax_config_base_url_overrides_hardcoded_default(monkeypatch):
"""model.base_url in config.yaml should override the hardcoded default (#6039)."""
_disable_credential_pool(monkeypatch)
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax")
monkeypatch.setattr(rp, "_get_model_config", lambda: {
"provider": "minimax",
Expand All @@ -1566,6 +1702,7 @@ def test_minimax_config_base_url_overrides_hardcoded_default(monkeypatch):

def test_minimax_env_base_url_still_wins_over_config(monkeypatch):
"""MINIMAX_BASE_URL env var should take priority over config.yaml model.base_url."""
_disable_credential_pool(monkeypatch)
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax")
monkeypatch.setattr(rp, "_get_model_config", lambda: {
"provider": "minimax",
Expand All @@ -1582,6 +1719,7 @@ def test_minimax_env_base_url_still_wins_over_config(monkeypatch):

def test_minimax_config_base_url_ignored_for_different_provider(monkeypatch):
"""model.base_url should NOT be used when model.provider doesn't match."""
_disable_credential_pool(monkeypatch)
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax")
monkeypatch.setattr(rp, "_get_model_config", lambda: {
"provider": "openrouter",
Expand Down
Loading