fix: align MiniMax provider with official API docs

Aligns MiniMax provider with official API documentation. Fixes 6 bugs:
transport mismatch (openai_chat -> anthropic_messages), credential leak
in switch_model(), prompt caching sent to non-Anthropic endpoints,
dot-to-hyphen model name corruption, trajectory compressor URL routing,
and stale doctor health check.

Also corrects context window (204,800), thinking support (manual mode),
max output (131,072), and model catalog (M2 family only on /anthropic).

Source: https://platform.minimax.io/docs/api-reference/text-anthropic-api

Co-authored-by: kshitijk4poor <kshitijk4poor@users.noreply.github.com>
This commit is contained in:
kshitijk4poor 2026-04-10 03:53:18 -07:00 committed by Teknium
parent d9f53dba4c
commit d442f25a2f
9 changed files with 237 additions and 74 deletions

View file

@ -60,6 +60,8 @@ _ANTHROPIC_OUTPUT_LIMITS = {
"claude-3-opus": 4_096, "claude-3-opus": 4_096,
"claude-3-sonnet": 4_096, "claude-3-sonnet": 4_096,
"claude-3-haiku": 4_096, "claude-3-haiku": 4_096,
# Third-party Anthropic-compatible providers
"minimax": 131_072,
} }
# For any model not in the table, assume the highest current limit. # For any model not in the table, assume the highest current limit.
@ -1313,9 +1315,10 @@ def build_anthropic_kwargs(
# Map reasoning_config to Anthropic's thinking parameter. # Map reasoning_config to Anthropic's thinking parameter.
# Claude 4.6 models use adaptive thinking + output_config.effort. # Claude 4.6 models use adaptive thinking + output_config.effort.
# Older models use manual thinking with budget_tokens. # Older models use manual thinking with budget_tokens.
# Haiku and MiniMax models do NOT support extended thinking — skip entirely. # MiniMax Anthropic-compat endpoints support thinking (manual mode only,
# not adaptive). Haiku does NOT support extended thinking — skip entirely.
if reasoning_config and isinstance(reasoning_config, dict): if reasoning_config and isinstance(reasoning_config, dict):
if reasoning_config.get("enabled") is not False and "haiku" not in model.lower() and "minimax" not in model.lower(): if reasoning_config.get("enabled") is not False and "haiku" not in model.lower():
effort = str(reasoning_config.get("effort", "medium")).lower() effort = str(reasoning_config.get("effort", "medium")).lower()
budget = THINKING_BUDGET.get(effort, 8000) budget = THINKING_BUDGET.get(effort, 8000)
if _supports_adaptive_thinking(model): if _supports_adaptive_thinking(model):

View file

@ -115,15 +115,9 @@ DEFAULT_CONTEXT_LENGTHS = {
"llama": 131072, "llama": 131072,
# Qwen # Qwen
"qwen": 131072, "qwen": 131072,
# MiniMax (lowercase — lookup lowercases model names at line 973) # MiniMax — official docs: 204,800 context for all models
"minimax-m1-256k": 1000000, # https://platform.minimax.io/docs/api-reference/text-anthropic-api
"minimax-m1-128k": 1000000, "minimax": 204800,
"minimax-m1-80k": 1000000,
"minimax-m1-40k": 1000000,
"minimax-m1": 1000000,
"minimax-m2.5": 1048576,
"minimax-m2.7": 1048576,
"minimax": 1048576,
# GLM # GLM
"glm": 202752, "glm": 202752,
# xAI Grok — xAI /v1/models does not return context_length metadata, # xAI Grok — xAI /v1/models does not return context_length metadata,
@ -151,7 +145,7 @@ DEFAULT_CONTEXT_LENGTHS = {
"deepseek-ai/DeepSeek-V3.2": 65536, "deepseek-ai/DeepSeek-V3.2": 65536,
"moonshotai/Kimi-K2.5": 262144, "moonshotai/Kimi-K2.5": 262144,
"moonshotai/Kimi-K2-Thinking": 262144, "moonshotai/Kimi-K2-Thinking": 262144,
"MiniMaxAI/MiniMax-M2.5": 1048576, "MiniMaxAI/MiniMax-M2.5": 204800,
"XiaomiMiMo/MiMo-V2-Flash": 32768, "XiaomiMiMo/MiMo-V2-Flash": 32768,
"mimo-v2-pro": 1048576, "mimo-v2-pro": 1048576,
"mimo-v2-omni": 1048576, "mimo-v2-omni": 1048576,

View file

@ -722,9 +722,9 @@ def run_doctor(args):
("DeepSeek", ("DEEPSEEK_API_KEY",), "https://api.deepseek.com/v1/models", "DEEPSEEK_BASE_URL", True), ("DeepSeek", ("DEEPSEEK_API_KEY",), "https://api.deepseek.com/v1/models", "DEEPSEEK_BASE_URL", True),
("Hugging Face", ("HF_TOKEN",), "https://router.huggingface.co/v1/models", "HF_BASE_URL", True), ("Hugging Face", ("HF_TOKEN",), "https://router.huggingface.co/v1/models", "HF_BASE_URL", True),
("Alibaba/DashScope", ("DASHSCOPE_API_KEY",), "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models", "DASHSCOPE_BASE_URL", True), ("Alibaba/DashScope", ("DASHSCOPE_API_KEY",), "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models", "DASHSCOPE_BASE_URL", True),
# MiniMax APIs don't support /models endpoint — https://github.com/NousResearch/hermes-agent/issues/811 # MiniMax: the /anthropic endpoint doesn't support /models, but the /v1 endpoint does.
("MiniMax", ("MINIMAX_API_KEY",), None, "MINIMAX_BASE_URL", False), ("MiniMax", ("MINIMAX_API_KEY",), "https://api.minimax.io/v1/models", "MINIMAX_BASE_URL", True),
("MiniMax (China)", ("MINIMAX_CN_API_KEY",), None, "MINIMAX_CN_BASE_URL", False), ("MiniMax (China)", ("MINIMAX_CN_API_KEY",), "https://api.minimaxi.com/v1/models", "MINIMAX_CN_BASE_URL", True),
("AI Gateway", ("AI_GATEWAY_API_KEY",), "https://ai-gateway.vercel.sh/v1/models", "AI_GATEWAY_BASE_URL", True), ("AI Gateway", ("AI_GATEWAY_API_KEY",), "https://ai-gateway.vercel.sh/v1/models", "AI_GATEWAY_BASE_URL", True),
("Kilo Code", ("KILOCODE_API_KEY",), "https://api.kilo.ai/api/gateway/models", "KILOCODE_BASE_URL", True), ("Kilo Code", ("KILOCODE_API_KEY",), "https://api.kilo.ai/api/gateway/models", "KILOCODE_BASE_URL", True),
("OpenCode Zen", ("OPENCODE_ZEN_API_KEY",), "https://opencode.ai/zen/v1/models", "OPENCODE_ZEN_BASE_URL", True), ("OpenCode Zen", ("OPENCODE_ZEN_API_KEY",), "https://opencode.ai/zen/v1/models", "OPENCODE_ZEN_BASE_URL", True),
@ -749,6 +749,11 @@ def run_doctor(args):
# Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com
if not _base and _key.startswith("sk-kimi-"): if not _base and _key.startswith("sk-kimi-"):
_base = "https://api.kimi.com/coding/v1" _base = "https://api.kimi.com/coding/v1"
# Anthropic-compat endpoints (/anthropic) don't support /models.
# Rewrite to the OpenAI-compat /v1 surface for health checks.
if _base and _base.rstrip("/").endswith("/anthropic"):
from agent.auxiliary_client import _to_openai_base_url
_base = _to_openai_base_url(_base)
_url = (_base.rstrip("/") + "/models") if _base else _default_url _url = (_base.rstrip("/") + "/models") if _base else _default_url
_headers = {"Authorization": f"Bearer {_key}"} _headers = {"Authorization": f"Bearer {_key}"}
if "api.kimi.com" in _url.lower(): if "api.kimi.com" in _url.lower():

View file

@ -157,22 +157,16 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"kimi-k2-0905-preview", "kimi-k2-0905-preview",
], ],
"minimax": [ "minimax": [
"MiniMax-M1",
"MiniMax-M1-40k",
"MiniMax-M1-80k",
"MiniMax-M1-128k",
"MiniMax-M1-256k",
"MiniMax-M2.5",
"MiniMax-M2.7", "MiniMax-M2.7",
"MiniMax-M2.5",
"MiniMax-M2.1",
"MiniMax-M2",
], ],
"minimax-cn": [ "minimax-cn": [
"MiniMax-M1",
"MiniMax-M1-40k",
"MiniMax-M1-80k",
"MiniMax-M1-128k",
"MiniMax-M1-256k",
"MiniMax-M2.5",
"MiniMax-M2.7", "MiniMax-M2.7",
"MiniMax-M2.5",
"MiniMax-M2.1",
"MiniMax-M2",
], ],
"anthropic": [ "anthropic": [
"claude-opus-4-6", "claude-opus-4-6",

View file

@ -88,11 +88,11 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
base_url_env_var="KIMI_BASE_URL", base_url_env_var="KIMI_BASE_URL",
), ),
"minimax": HermesOverlay( "minimax": HermesOverlay(
transport="openai_chat", transport="anthropic_messages",
base_url_env_var="MINIMAX_BASE_URL", base_url_env_var="MINIMAX_BASE_URL",
), ),
"minimax-cn": HermesOverlay( "minimax-cn": HermesOverlay(
transport="openai_chat", transport="anthropic_messages",
base_url_env_var="MINIMAX_CN_BASE_URL", base_url_env_var="MINIMAX_CN_BASE_URL",
), ),
"deepseek": HermesOverlay( "deepseek": HermesOverlay(

View file

@ -106,8 +106,8 @@ _DEFAULT_PROVIDER_MODELS = {
], ],
"zai": ["glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"], "zai": ["glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"],
"kimi-coding": ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"], "kimi-coding": ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
"minimax": ["MiniMax-M1", "MiniMax-M1-40k", "MiniMax-M1-80k", "MiniMax-M1-128k", "MiniMax-M1-256k", "MiniMax-M2.5", "MiniMax-M2.7"], "minimax": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"],
"minimax-cn": ["MiniMax-M1", "MiniMax-M1-40k", "MiniMax-M1-80k", "MiniMax-M1-128k", "MiniMax-M1-256k", "MiniMax-M2.5", "MiniMax-M2.7"], "minimax-cn": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"],
"ai-gateway": ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5", "google/gemini-3-flash"], "ai-gateway": ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5", "google/gemini-3-flash"],
"kilocode": ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5.4", "google/gemini-3-pro-preview", "google/gemini-3-flash-preview"], "kilocode": ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5.4", "google/gemini-3-pro-preview", "google/gemini-3-flash-preview"],
"opencode-zen": ["gpt-5.4", "gpt-5.3-codex", "claude-sonnet-4-6", "gemini-3-flash", "glm-5", "kimi-k2.5", "minimax-m2.7"], "opencode-zen": ["gpt-5.4", "gpt-5.3-codex", "claude-sonnet-4-6", "gemini-3-flash", "glm-5", "kimi-k2.5", "minimax-m2.7"],

View file

@ -766,7 +766,7 @@ class AIAgent:
# conversation prefix. Uses system_and_3 strategy (4 breakpoints). # conversation prefix. Uses system_and_3 strategy (4 breakpoints).
is_openrouter = self._is_openrouter_url() is_openrouter = self._is_openrouter_url()
is_claude = "claude" in self.model.lower() is_claude = "claude" in self.model.lower()
is_native_anthropic = self.api_mode == "anthropic_messages" is_native_anthropic = self.api_mode == "anthropic_messages" and self.provider == "anthropic"
self._use_prompt_caching = (is_openrouter and is_claude) or is_native_anthropic self._use_prompt_caching = (is_openrouter and is_claude) or is_native_anthropic
self._cache_ttl = "5m" # Default 5-minute TTL (1.25x write cost) self._cache_ttl = "5m" # Default 5-minute TTL (1.25x write cost)
@ -1510,7 +1510,11 @@ class AIAgent:
resolve_anthropic_token, resolve_anthropic_token,
_is_oauth_token, _is_oauth_token,
) )
effective_key = api_key or self.api_key or resolve_anthropic_token() or "" # Only fall back to ANTHROPIC_TOKEN when the provider is actually Anthropic.
# Other anthropic_messages providers (MiniMax, Alibaba, etc.) must use their own
# API key — falling back would send Anthropic credentials to third-party endpoints.
_is_native_anthropic = new_provider == "anthropic"
effective_key = (api_key or self.api_key or resolve_anthropic_token() or "") if _is_native_anthropic else (api_key or self.api_key or "")
self.api_key = effective_key self.api_key = effective_key
self._anthropic_api_key = effective_key self._anthropic_api_key = effective_key
self._anthropic_base_url = base_url or getattr(self, "_anthropic_base_url", None) self._anthropic_base_url = base_url or getattr(self, "_anthropic_base_url", None)
@ -1534,7 +1538,7 @@ class AIAgent:
) )
# ── Re-evaluate prompt caching ── # ── Re-evaluate prompt caching ──
is_native_anthropic = api_mode == "anthropic_messages" is_native_anthropic = api_mode == "anthropic_messages" and new_provider == "anthropic"
self._use_prompt_caching = ( self._use_prompt_caching = (
("openrouter" in (self.base_url or "").lower() and "claude" in new_model.lower()) ("openrouter" in (self.base_url or "").lower() and "claude" in new_model.lower())
or is_native_anthropic or is_native_anthropic
@ -5297,7 +5301,7 @@ class AIAgent:
} }
# Re-evaluate prompt caching for the new provider/model # Re-evaluate prompt caching for the new provider/model
is_native_anthropic = fb_api_mode == "anthropic_messages" is_native_anthropic = fb_api_mode == "anthropic_messages" and fb_provider == "anthropic"
self._use_prompt_caching = ( self._use_prompt_caching = (
("openrouter" in fb_base_url.lower() and "claude" in fb_model.lower()) ("openrouter" in fb_base_url.lower() and "claude" in fb_model.lower())
or is_native_anthropic or is_native_anthropic
@ -5633,11 +5637,12 @@ class AIAgent:
def _anthropic_preserve_dots(self) -> bool: def _anthropic_preserve_dots(self) -> bool:
"""True when using an anthropic-compatible endpoint that preserves dots in model names. """True when using an anthropic-compatible endpoint that preserves dots in model names.
Alibaba/DashScope keeps dots (e.g. qwen3.5-plus). Alibaba/DashScope keeps dots (e.g. qwen3.5-plus).
MiniMax keeps dots (e.g. MiniMax-M2.7).
OpenCode Go keeps dots (e.g. minimax-m2.7).""" OpenCode Go keeps dots (e.g. minimax-m2.7)."""
if (getattr(self, "provider", "") or "").lower() in {"alibaba", "opencode-go"}: if (getattr(self, "provider", "") or "").lower() in {"alibaba", "minimax", "minimax-cn", "opencode-go"}:
return True return True
base = (getattr(self, "base_url", "") or "").lower() base = (getattr(self, "base_url", "") or "").lower()
return "dashscope" in base or "aliyuncs" in base or "opencode.ai/zen/go" in base return "dashscope" in base or "aliyuncs" in base or "minimax" in base or "opencode.ai/zen/go" in base
def _is_qwen_portal(self) -> bool: def _is_qwen_portal(self) -> bool:
"""Return True when the base URL targets Qwen Portal.""" """Return True when the base URL targets Qwen Portal."""

View file

@ -1,37 +1,37 @@
"""Tests for MiniMax provider hardening — context lengths, thinking guard, catalog, beta headers.""" """Tests for MiniMax provider hardening — context lengths, thinking, catalog, beta headers, transport."""
from unittest.mock import patch from unittest.mock import patch
class TestMinimaxContextLengths: class TestMinimaxContextLengths:
"""Verify per-model context length entries for MiniMax models.""" """Verify context length entries match official docs (204,800 for all models).
def test_m1_variants_have_1m_context(self): Source: https://platform.minimax.io/docs/api-reference/text-anthropic-api
"""
def test_minimax_prefix_has_correct_context(self):
from agent.model_metadata import DEFAULT_CONTEXT_LENGTHS from agent.model_metadata import DEFAULT_CONTEXT_LENGTHS
# Keys are lowercase because the lookup lowercases model names assert DEFAULT_CONTEXT_LENGTHS["minimax"] == 204_800
for model in ("minimax-m1", "minimax-m1-40k", "minimax-m1-80k",
"minimax-m1-128k", "minimax-m1-256k"):
assert model in DEFAULT_CONTEXT_LENGTHS, f"{model} missing from context lengths"
assert DEFAULT_CONTEXT_LENGTHS[model] == 1_000_000, f"{model} expected 1M"
def test_m2_variants_have_1m_context(self): def test_minimax_models_resolve_via_prefix(self):
from agent.model_metadata import DEFAULT_CONTEXT_LENGTHS from agent.model_metadata import get_model_context_length
# Keys are lowercase because the lookup lowercases model names # All MiniMax models should resolve to 204,800 via the "minimax" prefix
for model in ("minimax-m2.5", "minimax-m2.7"): for model in ("MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"):
assert model in DEFAULT_CONTEXT_LENGTHS, f"{model} missing from context lengths" ctx = get_model_context_length(model, "")
assert DEFAULT_CONTEXT_LENGTHS[model] == 1_048_576, f"{model} expected 1048576" assert ctx == 204_800, f"{model} expected 204800, got {ctx}"
def test_minimax_prefix_fallback(self):
from agent.model_metadata import DEFAULT_CONTEXT_LENGTHS
# The generic "minimax" prefix entry should be 1M for unknown models
assert DEFAULT_CONTEXT_LENGTHS["minimax"] == 1_048_576
class TestMinimaxThinkingGuard: class TestMinimaxThinkingSupport:
"""Verify that build_anthropic_kwargs does NOT add thinking params for MiniMax models.""" """Verify that MiniMax gets manual thinking (not adaptive).
def test_no_thinking_for_minimax_m27(self): MiniMax's Anthropic-compat endpoint officially supports the thinking
parameter (https://platform.minimax.io/docs/api-reference/text-anthropic-api).
It should get manual thinking (type=enabled + budget_tokens), NOT adaptive
thinking (which is Claude 4.6-only).
"""
def test_minimax_m27_gets_manual_thinking(self):
from agent.anthropic_adapter import build_anthropic_kwargs from agent.anthropic_adapter import build_anthropic_kwargs
kwargs = build_anthropic_kwargs( kwargs = build_anthropic_kwargs(
model="MiniMax-M2.7", model="MiniMax-M2.7",
@ -40,19 +40,23 @@ class TestMinimaxThinkingGuard:
max_tokens=4096, max_tokens=4096,
reasoning_config={"enabled": True, "effort": "medium"}, reasoning_config={"enabled": True, "effort": "medium"},
) )
assert "thinking" not in kwargs assert "thinking" in kwargs
assert kwargs["thinking"]["type"] == "enabled"
assert "budget_tokens" in kwargs["thinking"]
# MiniMax should NOT get adaptive thinking or output_config
assert "output_config" not in kwargs assert "output_config" not in kwargs
def test_no_thinking_for_minimax_m1(self): def test_minimax_m25_gets_manual_thinking(self):
from agent.anthropic_adapter import build_anthropic_kwargs from agent.anthropic_adapter import build_anthropic_kwargs
kwargs = build_anthropic_kwargs( kwargs = build_anthropic_kwargs(
model="MiniMax-M1-128k", model="MiniMax-M2.5",
messages=[{"role": "user", "content": "hello"}], messages=[{"role": "user", "content": "hello"}],
tools=None, tools=None,
max_tokens=4096, max_tokens=4096,
reasoning_config={"enabled": True, "effort": "high"}, reasoning_config={"enabled": True, "effort": "high"},
) )
assert "thinking" not in kwargs assert "thinking" in kwargs
assert kwargs["thinking"]["type"] == "enabled"
def test_thinking_still_works_for_claude(self): def test_thinking_still_works_for_claude(self):
from agent.anthropic_adapter import build_anthropic_kwargs from agent.anthropic_adapter import build_anthropic_kwargs
@ -81,25 +85,30 @@ class TestMinimaxAuxModel:
class TestMinimaxModelCatalog: class TestMinimaxModelCatalog:
"""Verify the model catalog includes M1 family and excludes deprecated models.""" """Verify the model catalog matches official Anthropic-compat endpoint models.
def test_catalog_includes_m1_family(self): Source: https://platform.minimax.io/docs/api-reference/text-anthropic-api
"""
def test_catalog_includes_current_models(self):
from hermes_cli.models import _PROVIDER_MODELS from hermes_cli.models import _PROVIDER_MODELS
for provider in ("minimax", "minimax-cn"): for provider in ("minimax", "minimax-cn"):
models = _PROVIDER_MODELS[provider] models = _PROVIDER_MODELS[provider]
assert "MiniMax-M1" in models assert "MiniMax-M2.7" in models
assert "MiniMax-M1-40k" in models assert "MiniMax-M2.5" in models
assert "MiniMax-M1-80k" in models assert "MiniMax-M2.1" in models
assert "MiniMax-M1-128k" in models assert "MiniMax-M2" in models
assert "MiniMax-M1-256k" in models
def test_catalog_excludes_deprecated(self): def test_catalog_excludes_m1_family(self):
"""M1 models are not available on the /anthropic endpoint."""
from hermes_cli.models import _PROVIDER_MODELS from hermes_cli.models import _PROVIDER_MODELS
for provider in ("minimax", "minimax-cn"): for provider in ("minimax", "minimax-cn"):
models = _PROVIDER_MODELS[provider] models = _PROVIDER_MODELS[provider]
assert "MiniMax-M2.1" not in models assert "MiniMax-M1" not in models
def test_catalog_excludes_highspeed(self): def test_catalog_excludes_highspeed(self):
"""Highspeed variants are available but not shown in default catalog
(users can still specify them manually)."""
from hermes_cli.models import _PROVIDER_MODELS from hermes_cli.models import _PROVIDER_MODELS
for provider in ("minimax", "minimax-cn"): for provider in ("minimax", "minimax-cn"):
models = _PROVIDER_MODELS[provider] models = _PROVIDER_MODELS[provider]
@ -202,3 +211,154 @@ class TestMinimaxBetaHeaders:
def test_common_betas_regular_url(self): def test_common_betas_regular_url(self):
from agent.anthropic_adapter import _common_betas_for_base_url, _COMMON_BETAS from agent.anthropic_adapter import _common_betas_for_base_url, _COMMON_BETAS
assert _common_betas_for_base_url("https://api.anthropic.com") == _COMMON_BETAS assert _common_betas_for_base_url("https://api.anthropic.com") == _COMMON_BETAS
class TestMinimaxApiMode:
"""Verify determine_api_mode returns anthropic_messages for MiniMax providers.
The MiniMax /anthropic endpoint speaks Anthropic Messages wire format,
not OpenAI chat completions. The overlay transport must reflect this
so that code paths calling determine_api_mode() without a base_url
(e.g. /model switch) get the correct api_mode.
"""
def test_minimax_returns_anthropic_messages(self):
from hermes_cli.providers import determine_api_mode
assert determine_api_mode("minimax") == "anthropic_messages"
def test_minimax_cn_returns_anthropic_messages(self):
from hermes_cli.providers import determine_api_mode
assert determine_api_mode("minimax-cn") == "anthropic_messages"
def test_minimax_with_url_also_works(self):
from hermes_cli.providers import determine_api_mode
# Even with explicit base_url, provider lookup takes priority
assert determine_api_mode("minimax", "https://api.minimax.io/anthropic") == "anthropic_messages"
def test_anthropic_still_returns_anthropic_messages(self):
from hermes_cli.providers import determine_api_mode
assert determine_api_mode("anthropic") == "anthropic_messages"
def test_openai_returns_chat_completions(self):
from hermes_cli.providers import determine_api_mode
# Sanity check: standard providers are unaffected
result = determine_api_mode("deepseek")
assert result == "chat_completions"
class TestMinimaxMaxOutput:
"""Verify _get_anthropic_max_output returns correct limits for MiniMax models.
MiniMax max output is 131,072 tokens (source: OpenClaw model definitions,
cross-referenced with MiniMax API behavior).
"""
def test_minimax_m27_output_limit(self):
from agent.anthropic_adapter import _get_anthropic_max_output
assert _get_anthropic_max_output("MiniMax-M2.7") == 131_072
def test_minimax_m25_output_limit(self):
from agent.anthropic_adapter import _get_anthropic_max_output
assert _get_anthropic_max_output("MiniMax-M2.5") == 131_072
def test_minimax_m2_output_limit(self):
from agent.anthropic_adapter import _get_anthropic_max_output
assert _get_anthropic_max_output("MiniMax-M2") == 131_072
def test_claude_output_unaffected(self):
from agent.anthropic_adapter import _get_anthropic_max_output
# Sanity: Claude limits are not broken by the MiniMax entry
assert _get_anthropic_max_output("claude-sonnet-4-6") == 64_000
class TestMinimaxPreserveDots:
"""Verify that MiniMax model names preserve dots through the Anthropic adapter.
MiniMax model IDs like 'MiniMax-M2.7' must NOT have dots converted to
hyphens the endpoint expects the exact name with dots.
"""
def test_minimax_provider_preserves_dots(self):
from types import SimpleNamespace
agent = SimpleNamespace(provider="minimax", base_url="")
from run_agent import AIAgent
assert AIAgent._anthropic_preserve_dots(agent) is True
def test_minimax_cn_provider_preserves_dots(self):
from types import SimpleNamespace
agent = SimpleNamespace(provider="minimax-cn", base_url="")
from run_agent import AIAgent
assert AIAgent._anthropic_preserve_dots(agent) is True
def test_minimax_url_preserves_dots(self):
from types import SimpleNamespace
agent = SimpleNamespace(provider="custom", base_url="https://api.minimax.io/anthropic")
from run_agent import AIAgent
assert AIAgent._anthropic_preserve_dots(agent) is True
def test_minimax_cn_url_preserves_dots(self):
from types import SimpleNamespace
agent = SimpleNamespace(provider="custom", base_url="https://api.minimaxi.com/anthropic")
from run_agent import AIAgent
assert AIAgent._anthropic_preserve_dots(agent) is True
def test_anthropic_does_not_preserve_dots(self):
from types import SimpleNamespace
agent = SimpleNamespace(provider="anthropic", base_url="https://api.anthropic.com")
from run_agent import AIAgent
assert AIAgent._anthropic_preserve_dots(agent) is False
def test_normalize_preserves_m27_dot(self):
from agent.anthropic_adapter import normalize_model_name
assert normalize_model_name("MiniMax-M2.7", preserve_dots=True) == "MiniMax-M2.7"
def test_normalize_converts_without_preserve(self):
from agent.anthropic_adapter import normalize_model_name
# Without preserve_dots, dots become hyphens (broken for MiniMax)
assert normalize_model_name("MiniMax-M2.7", preserve_dots=False) == "MiniMax-M2-7"
class TestMinimaxSwitchModelCredentialGuard:
"""Verify switch_model() does not leak Anthropic credentials to MiniMax.
The __init__ path correctly guards against this (line 761), but switch_model()
must mirror that guard. Without it, /model switch to minimax with no explicit
api_key would fall back to resolve_anthropic_token() and send Anthropic creds
to the MiniMax endpoint.
"""
def test_switch_to_minimax_does_not_resolve_anthropic_token(self):
"""switch_model() should NOT call resolve_anthropic_token() for MiniMax."""
from unittest.mock import patch, MagicMock
with patch("run_agent.AIAgent.__init__", return_value=None):
from run_agent import AIAgent
agent = AIAgent.__new__(AIAgent)
agent.provider = "anthropic"
agent.model = "claude-sonnet-4"
agent.api_key = "sk-ant-fake"
agent.base_url = "https://api.anthropic.com"
agent.api_mode = "anthropic_messages"
agent._anthropic_base_url = "https://api.anthropic.com"
agent._anthropic_api_key = "sk-ant-fake"
agent._is_anthropic_oauth = False
agent._client_kwargs = {}
agent.client = None
agent._anthropic_client = MagicMock()
with patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, \
patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-leaked") as mock_resolve, \
patch("agent.anthropic_adapter._is_oauth_token", return_value=False):
agent.switch_model(
new_model="MiniMax-M2.7",
new_provider="minimax",
api_mode="anthropic_messages",
api_key="mm-key-123",
base_url="https://api.minimax.io/anthropic",
)
# resolve_anthropic_token should NOT be called for non-Anthropic providers
mock_resolve.assert_not_called()
# The key passed to build_anthropic_client should be the MiniMax key
build_args = mock_build.call_args
assert build_args[0][0] == "mm-key-123"

View file

@ -375,8 +375,9 @@ class TrajectoryCompressor:
f"Missing API key. Set {self.config.api_key_env} " f"Missing API key. Set {self.config.api_key_env} "
f"environment variable.") f"environment variable.")
from openai import OpenAI from openai import OpenAI
from agent.auxiliary_client import _to_openai_base_url
self.client = OpenAI( self.client = OpenAI(
api_key=api_key, base_url=self.config.base_url) api_key=api_key, base_url=_to_openai_base_url(self.config.base_url))
# AsyncOpenAI is created lazily in _get_async_client() so it # AsyncOpenAI is created lazily in _get_async_client() so it
# binds to the current event loop — avoids "Event loop is closed" # binds to the current event loop — avoids "Event loop is closed"
# when process_directory() is called multiple times (each call # when process_directory() is called multiple times (each call
@ -395,10 +396,11 @@ class TrajectoryCompressor:
avoiding "Event loop is closed" errors on repeated calls. avoiding "Event loop is closed" errors on repeated calls.
""" """
from openai import AsyncOpenAI from openai import AsyncOpenAI
from agent.auxiliary_client import _to_openai_base_url
# Always create a fresh client so it binds to the running loop. # Always create a fresh client so it binds to the running loop.
self.async_client = AsyncOpenAI( self.async_client = AsyncOpenAI(
api_key=self._async_client_api_key, api_key=self._async_client_api_key,
base_url=self.config.base_url, base_url=_to_openai_base_url(self.config.base_url),
) )
return self.async_client return self.async_client