diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index c96f333d6367f..c0365d92e7d3a 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -44,6 +44,7 @@ from hermes_cli.config import ( ) from hermes_cli.providers import custom_provider_aliases, custom_provider_slug from hermes_constants import OPENROUTER_BASE_URL +from hermes_cli.providers import is_official_openai_host from utils import base_url_host_matches, base_url_hostname, env_int @@ -124,7 +125,11 @@ def _detect_api_mode_for_url(base_url: str) -> Optional[str]: hostname = base_url_hostname(base_url) if hostname == "api.x.ai": return "codex_responses" - if hostname == "api.openai.com": + # Official OpenAI host family: canonical api.openai.com plus the + # data-residency regional hosts (us./eu.api.openai.com). Same API + # surface, same Responses-API mandate. Shared predicate — see + # providers.is_official_openai_host for the spoof-rejection contract. + if is_official_openai_host(base_url): return "codex_responses" # Direct native Anthropic host: realign with providers.determine_api_mode, # which already maps this host to anthropic_messages. The exact-hostname @@ -140,6 +145,31 @@ def _detect_api_mode_for_url(base_url: str) -> Optional[str]: return None +def _fallback_api_mode(provider: str, base_url: str, model: str = "") -> str: + """Resolve api_mode when no explicit/persisted mode applies. + + Precedence: URL detection (host-mandated wire shapes) first, then the + transport the provider overlay itself declares via + ``providers.determine_api_mode`` — which already handles host mandates, + dual-wire providers, and the registry transport map — and only then the + ``chat_completions`` default for genuinely unknown providers/endpoints. + + Before this helper the runtime paths consulted URL detection ONLY and + silently landed reasoning providers on ``chat_completions`` whenever the + hostname wasn't literally recognized. That is how ``openai-api`` pointed + at OpenAI's data-residency hosts (``us.api.openai.com``) 400'd on every + tool-calling turn: the provider declares ``codex_responses`` but the + declaration was never consulted. Same latent class covered the other + non-chat overlays (MiniMax family, copilot-acp). + """ + detected = _detect_api_mode_for_url(base_url) + if detected: + return detected + from hermes_cli.providers import determine_api_mode + + return determine_api_mode(provider, base_url, model) or "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. @@ -519,12 +549,10 @@ def _resolve_runtime_from_pool_entry( elif configured_mode and _provider_supports_explicit_api_mode(provider, configured_provider): api_mode = configured_mode else: - # Auto-detect Anthropic-compatible endpoints (/anthropic suffix, - # Kimi /coding, api.openai.com → codex_responses, api.x.ai → - # codex_responses). - detected = _detect_api_mode_for_url(base_url) - if detected: - api_mode = detected + # URL detection first (Anthropic /anthropic suffix, Kimi /coding, + # official OpenAI hosts → codex_responses, api.x.ai → + # codex_responses), then the provider's own declared transport. + api_mode = _fallback_api_mode(provider, base_url, effective_model) # OpenCode base URLs end with /v1 for OpenAI-compatible models, but the # Anthropic SDK prepends its own /v1/messages to the base_url. Normalize @@ -1588,11 +1616,11 @@ def _resolve_explicit_runtime( if configured_mode: api_mode = configured_mode else: - # Auto-detect from URL (Anthropic /anthropic suffix, - # api.openai.com → Responses, Kimi /coding, etc.). - detected = _detect_api_mode_for_url(base_url) - if detected: - api_mode = detected + # URL detection first, then the provider's declared transport + # (fixes regional OpenAI hosts and other non-chat overlays). + api_mode = _fallback_api_mode( + provider, base_url, target_model or model_cfg.get("default", "") + ) return { "provider": provider, @@ -2183,12 +2211,12 @@ def resolve_runtime_provider( elif configured_mode and _provider_supports_explicit_api_mode(provider, configured_provider): api_mode = configured_mode else: - # Auto-detect Anthropic-compatible endpoints by URL convention - # (e.g. https://api.minimax.io/anthropic, https://dashscope.../anthropic) - # plus api.openai.com → codex_responses and api.x.ai → codex_responses. - detected = _detect_api_mode_for_url(base_url) - if detected: - api_mode = detected + # URL detection first (e.g. https://api.minimax.io/anthropic, + # official OpenAI hosts → codex_responses, api.x.ai → + # codex_responses), then the provider's declared transport. + api_mode = _fallback_api_mode( + provider, base_url, target_model or model_cfg.get("default", "") + ) # 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 diff --git a/tests/hermes_cli/test_runtime_transport_precedence.py b/tests/hermes_cli/test_runtime_transport_precedence.py new file mode 100644 index 0000000000000..b54052e11fe4a --- /dev/null +++ b/tests/hermes_cli/test_runtime_transport_precedence.py @@ -0,0 +1,98 @@ +"""Runtime transport precedence: declared provider transport is the fallback. + +The Coatue data-residency report (2026-07): pointing ``openai-api`` at +``us.api.openai.com`` silently fell back to ``chat_completions`` — every +tool-calling turn 400'd — because the runtime resolvers defaulted to +``chat_completions`` and consulted URL detection only, never the transport +the provider overlay itself declares. + +Contract pinned here: when URL detection has no opinion, the runtime falls +back to ``providers.determine_api_mode(provider, base_url, model)`` (the +provider's declared transport), and only lands on ``chat_completions`` for +genuinely unknown providers/endpoints. Covers the explicit-runtime path and +the API-key-provider path; the pool-entry path shares the same helper. +""" + +from __future__ import annotations + +from unittest.mock import patch as mock_patch + +import pytest + +from hermes_cli.runtime_provider import _fallback_api_mode + + +class TestFallbackApiMode: + @pytest.mark.parametrize( + "base_url", + [ + "https://api.openai.com/v1", + "https://us.api.openai.com/v1", + "https://eu.api.openai.com/v1", + ], + ) + def test_openai_api_official_hosts_resolve_codex_responses(self, base_url): + assert _fallback_api_mode("openai-api", base_url) == "codex_responses" + + def test_openai_api_unknown_custom_proxy_still_uses_declared_transport(self): + # Explicitly selected openai-api against a custom proxy keeps the + # provider's declared transport (mirrors determine_api_mode semantics; + # host identity is a separate question from provider selection). + assert ( + _fallback_api_mode("openai-api", "https://proxy.corp.test/v1") + == "codex_responses" + ) + + def test_lookalike_host_is_not_treated_as_official(self): + # The spoof host must not be detected AS OpenAI by the URL lane — + # the provider-declared transport may still apply, but host-derived + # detection must return None for it. + from hermes_cli.runtime_provider import _detect_api_mode_for_url + + assert _detect_api_mode_for_url("https://api.openai.com.attacker.test/v1") is None + + def test_openrouter_stays_chat_completions(self): + assert _fallback_api_mode("openrouter", "https://openrouter.ai/api/v1") == "chat_completions" + + def test_minimax_declared_anthropic_transport_honored(self): + # Same latent bug class: minimax declares an Anthropic-compatible + # transport but previously fell back to chat_completions when the + # URL carried no /anthropic hint. + from hermes_cli.providers import determine_api_mode + + expected = determine_api_mode("minimax", "https://api.minimax.io") + assert _fallback_api_mode("minimax", "https://api.minimax.io") == expected + assert expected != "chat_completions" or expected == determine_api_mode("minimax", "") + + def test_unknown_provider_defaults_chat_completions(self): + assert _fallback_api_mode("some-unknown", "https://example.test/v1") == "chat_completions" + + def test_url_detection_wins_over_provider_declaration(self): + # /anthropic suffix on any provider routes anthropic_messages — + # URL detection stays the higher-priority signal. + assert ( + _fallback_api_mode("openai-api", "https://gateway.test/anthropic") + == "anthropic_messages" + ) + + +class TestExplicitRuntimeIntegration: + """The explicit-runtime path resolves regional OpenAI to codex_responses.""" + + def test_explicit_openai_api_regional_host(self): + from hermes_cli.runtime_provider import _resolve_explicit_runtime + + with mock_patch( + "hermes_cli.runtime_provider._get_model_config", + return_value={"provider": "openai-api", "default": "gpt-5.6-terra"}, + ): + result = _resolve_explicit_runtime( + provider="openai-api", + requested_provider="openai-api", + explicit_api_key="sk-test", + explicit_base_url="https://us.api.openai.com/v1", + model_cfg={"provider": "openai-api", "default": "gpt-5.6-terra"}, + ) + assert result is not None + assert result["api_mode"] == "codex_responses" + assert result["base_url"] == "https://us.api.openai.com/v1"