diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index ba41e5b747..180dec0901 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -340,13 +340,23 @@ def resolve_runtime_provider( if pconfig and pconfig.auth_type == "api_key": creds = resolve_api_key_provider_credentials(provider) model_cfg = _get_model_config() + base_url = creds.get("base_url", "").rstrip("/") api_mode = "chat_completions" if provider == "copilot": api_mode = _copilot_runtime_api_mode(model_cfg, creds.get("api_key", "")) + else: + # Check explicit api_mode from model config first + configured_mode = _parse_api_mode(model_cfg.get("api_mode")) + if configured_mode: + api_mode = configured_mode + # Auto-detect Anthropic-compatible endpoints by URL convention + # (e.g. https://api.minimax.io/anthropic, https://dashscope.../anthropic) + elif base_url.rstrip("/").endswith("/anthropic"): + api_mode = "anthropic_messages" return { "provider": provider, "api_mode": api_mode, - "base_url": creds.get("base_url", "").rstrip("/"), + "base_url": base_url, "api_key": creds.get("api_key", ""), "source": creds.get("source", "env"), "requested_provider": requested_provider, diff --git a/run_agent.py b/run_agent.py index abcc35eea7..58d0cdeeda 100644 --- a/run_agent.py +++ b/run_agent.py @@ -493,6 +493,11 @@ class AIAgent: elif self.provider == "anthropic" or (provider_name is None and "api.anthropic.com" in self._base_url_lower): self.api_mode = "anthropic_messages" self.provider = "anthropic" + elif self._base_url_lower.rstrip("/").endswith("/anthropic"): + # Third-party Anthropic-compatible endpoints (e.g. MiniMax, DashScope) + # use a URL convention ending in /anthropic. Auto-detect these so the + # Anthropic Messages API adapter is used instead of chat completions. + self.api_mode = "anthropic_messages" else: self.api_mode = "chat_completions" @@ -3474,11 +3479,11 @@ class AIAgent: # Determine api_mode from provider fb_api_mode = "chat_completions" + fb_base_url = str(fb_client.base_url) if fb_provider == "openai-codex": fb_api_mode = "codex_responses" - elif fb_provider == "anthropic": + elif fb_provider == "anthropic" or fb_base_url.rstrip("/").lower().endswith("/anthropic"): fb_api_mode = "anthropic_messages" - fb_base_url = str(fb_client.base_url) old_model = self.model self.model = fb_model diff --git a/tests/test_runtime_provider_resolution.py b/tests/test_runtime_provider_resolution.py index bea73715ba..4789287c63 100644 --- a/tests/test_runtime_provider_resolution.py +++ b/tests/test_runtime_provider_resolution.py @@ -438,10 +438,75 @@ def test_named_custom_provider_without_api_mode_defaults(monkeypatch): lambda p: { "name": "my-server", "base_url": "http://localhost:8000/v1", - "api_key": "sk-test", + "api_key": "***", }, ) resolved = rp.resolve_runtime_provider(requested="my-server") assert resolved["api_mode"] == "chat_completions" + + +def test_anthropic_messages_in_valid_api_modes(): + """anthropic_messages should be accepted by _parse_api_mode.""" + assert rp._parse_api_mode("anthropic_messages") == "anthropic_messages" + + +def test_api_key_provider_anthropic_url_auto_detection(monkeypatch): + """API-key providers with /anthropic base URL should auto-detect anthropic_messages mode.""" + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax") + monkeypatch.setattr(rp, "_get_model_config", lambda: {}) + 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_api_key_provider_explicit_api_mode_config(monkeypatch): + """API-key providers should respect api_mode from model config.""" + 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") + monkeypatch.delenv("MINIMAX_BASE_URL", raising=False) + + resolved = rp.resolve_runtime_provider(requested="minimax") + + assert resolved["provider"] == "minimax" + assert resolved["api_mode"] == "anthropic_messages" + + +def test_api_key_provider_default_url_stays_chat_completions(monkeypatch): + """API-key providers with default /v1 URL should stay on chat_completions.""" + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax") + monkeypatch.setattr(rp, "_get_model_config", lambda: {}) + 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_named_custom_provider_anthropic_api_mode(monkeypatch): + """Custom providers should accept api_mode: anthropic_messages.""" + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "my-anthropic-proxy") + monkeypatch.setattr( + rp, "_get_named_custom_provider", + lambda p: { + "name": "my-anthropic-proxy", + "base_url": "https://proxy.example.com/anthropic", + "api_key": "test-key", + "api_mode": "anthropic_messages", + }, + ) + + resolved = rp.resolve_runtime_provider(requested="my-anthropic-proxy") + + assert resolved["api_mode"] == "anthropic_messages" + assert resolved["base_url"] == "https://proxy.example.com/anthropic"