diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 5ad6e926e789..d2fa23bdaa25 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -2766,6 +2766,31 @@ def _merge_with_models_dev(provider: str, curated: list[str]) -> list[str]: return merged +def _openai_discovery_base_url(provider: str) -> str: + """Effective OpenAI endpoint for model discovery. + + Mirrors the runtime precedence so discovery probes the SAME endpoint + inference uses: ``$OPENAI_BASE_URL`` (explicit env override) → + ``model.base_url`` from config.yaml when the configured provider matches + → the canonical default. Previously this read the env var only, so a + config-set data-residency host (``us.api.openai.com``) was ignored and + the catalog kept coming from ``api.openai.com``. + """ + env_raw = os.getenv("OPENAI_BASE_URL", "").strip().rstrip("/") + if env_raw: + return env_raw + try: + model_cfg = _get_model_config_dict() + cfg_provider = str(model_cfg.get("provider") or "").strip().lower() + if cfg_provider in ("openai", "openai-api") and normalize_provider(provider) == normalize_provider(cfg_provider): + cfg_url = str(model_cfg.get("base_url") or "").strip().rstrip("/") + if cfg_url: + return cfg_url + except Exception: + pass + return "https://api.openai.com/v1" + + def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) -> list[str]: """Return the best known model catalog for a provider. @@ -2881,19 +2906,19 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) if normalized in ("openai", "openai-api"): api_key = os.getenv("OPENAI_API_KEY", "").strip() if api_key: - base_raw = os.getenv("OPENAI_BASE_URL", "").strip().rstrip("/") - base = base_raw or "https://api.openai.com/v1" + base = _openai_discovery_base_url(normalized) # Custom OpenAI-compatible endpoints (proxies, gateways, self-hosted) # may serve a small curated catalog — use the live list verbatim so - # discovery works. But the canonical api.openai.com /v1/models dump - # is 120+ entries of embeddings, whisper, tts, dall-e, moderation and - # legacy chat models — none of which belong in the agent model picker. - # For the default endpoint, intersect the live list with our curated - # agentic catalog so ``/model`` matches what ``hermes model`` shows. - is_default_openai = base.rstrip("/") in ( - "https://api.openai.com/v1", - "https://api.openai.com", - ) + # discovery works. But the official OpenAI hosts (canonical AND the + # data-residency regional hosts, which serve the identical dump) + # return 120+ entries of embeddings, whisper, tts, dall-e, + # moderation and legacy chat models — none of which belong in the + # agent model picker. For official hosts, intersect the live list + # with our curated agentic catalog so ``/model`` matches what + # ``hermes model`` shows. + from hermes_cli.providers import is_official_openai_host + + is_default_openai = is_official_openai_host(base) try: live = fetch_api_models(api_key, base) if live: @@ -3077,6 +3102,17 @@ def _credential_fingerprint(provider: str) -> str: except Exception: pass + # Effective configured endpoint: config.yaml's model.base_url changes the + # endpoint discovery probes (data-residency hosts) without touching any + # env var, so it must change the fingerprint too or `hermes config set + # model.base_url ...` keeps serving the previous endpoint's cached + # catalog until TTL expiry. + if provider in ("openai", "openai-api"): + try: + parts.append(f"effective_base={_openai_discovery_base_url(provider)}") + except Exception: + pass + # OAuth / external-file mtimes that change on re-auth try: from hermes_constants import get_hermes_home @@ -5045,7 +5081,20 @@ def validate_requested_model( # listing that are still valid (stale cache, partial rollout, # gated previews). Use the pure-catalog helper (no extra live # fetch) so we only accept models Hermes actually ships. (#46850) - if _model_in_provider_catalog( + # + # EXCEPTION: official OpenAI hosts (canonical api.openai.com and + # the data-residency regional hosts). Their /v1/models listing is + # access-scoped and authoritative — a model absent from it is one + # this key CANNOT serve, so the curated soft-accept would + # manufacture a selection that 400s at first use. Custom + # OpenAI-compatible proxies keep the fallback (incomplete + # listings are common there). + _openai_listing_is_authoritative = False + if normalized in ("openai", "openai-api"): + from hermes_cli.providers import is_official_openai_host + + _openai_listing_is_authoritative = is_official_openai_host(base_url) + if not _openai_listing_is_authoritative and _model_in_provider_catalog( requested_for_lookup.lower(), _provider_keys(normalized) ): return { diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 17cae5d584fb..9ddf587733a0 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -578,6 +578,27 @@ def is_routing_aggregator(provider: str) -> bool: return is_aggregator(provider_norm) +def is_official_openai_host(base_url: str) -> bool: + """True when *base_url* points at OpenAI's official API host family. + + Matches the canonical host (``api.openai.com``) and OpenAI's documented + data-residency / regional hosts (``us.api.openai.com``, + ``eu.api.openai.com``, and any future ``.api.openai.com``) — + those serve the same API surface with the same transport requirements + and the same access-scoped ``/v1/models`` listing. + + Hostname-parsed matching only — never substring — so lookalike hosts + (``api.openai.com.attacker.test``) and path-segment spoofs + (``proxy.test/api.openai.com/v1``) are rejected. A genuine + ``*.api.openai.com`` subdomain requires control of openai.com DNS, so + the dot-suffix match does not reopen the #32243 spoofing hole. + Delegates to ``utils.base_url_host_matches``, which owns the + exact-or-dot-suffix hostname contract (userinfo/port stripped, + lowercased, trailing dot removed) — one implementation, not two. + """ + return base_url_host_matches(base_url, "api.openai.com") + + def host_mandated_api_mode(base_url: str = "") -> Optional[str]: """Return the wire protocol a specific endpoint *requires*, or None. @@ -605,7 +626,11 @@ def host_mandated_api_mode(base_url: str = "") -> Optional[str]: return "anthropic_messages" if hostname == "api.anthropic.com" or url_lower.endswith("/anthropic"): return "anthropic_messages" - if hostname == "api.openai.com": + # Official OpenAI host family: canonical + data-residency regional hosts + # (us./eu.api.openai.com) all mandate the Responses API for reasoning + # models with tools. Shared predicate keeps this lane in lockstep with + # catalog filtering and listing authority. + if is_official_openai_host(base_url): return "codex_responses" if hostname.startswith("bedrock-runtime.") and base_url_host_matches(base_url, "amazonaws.com"): return "bedrock_converse" diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index c140ee02b4c3..cf33875b165b 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -43,6 +43,7 @@ normalize_extra_headers, ) 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 @@ -123,7 +124,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 @@ -139,6 +144,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. @@ -518,12 +548,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 @@ -1606,11 +1634,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, @@ -2201,12 +2229,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_official_openai_host.py b/tests/hermes_cli/test_official_openai_host.py new file mode 100644 index 000000000000..8d91adfe5f2e --- /dev/null +++ b/tests/hermes_cli/test_official_openai_host.py @@ -0,0 +1,62 @@ +"""Security + parity contract for ``is_official_openai_host``. + +One predicate decides "is this endpoint OpenAI's official API surface?" +for every lane that branches on it: transport mandates +(``host_mandated_api_mode``), URL auto-detection in the runtime resolver, +model-catalog filtering, and live-listing authority. OpenAI's documented +data-residency hosts (``us.api.openai.com``, ``eu.api.openai.com``, and any +future ``.api.openai.com``) are the same API surface as the +canonical host and must match; lookalike/spoof hosts must not (#32243). +""" + +from __future__ import annotations + +import pytest + +from hermes_cli.providers import is_official_openai_host + + +class TestOfficialHosts: + @pytest.mark.parametrize( + "url", + [ + "https://api.openai.com/v1", + "https://api.openai.com", + "https://us.api.openai.com/v1", + "https://eu.api.openai.com/v1", + "https://US.api.OpenAI.com/v1", # case-insensitive hostname + "https://in.api.openai.com/v1", # future regional variants + "https://api.openai.com:443/v1", # port stripped by hostname parse + "https://api.openai.com./v1", # trailing dot normalized + "https://attacker.test@us.api.openai.com/v1", # userinfo stripped; real host wins + ], + ) + def test_official_hosts_match(self, url): + assert is_official_openai_host(url) is True + + +class TestSpoofRejection: + @pytest.mark.parametrize( + "url", + [ + # Lookalike host suffix: registrable domain is attacker.test. + "https://api.openai.com.attacker.test/v1", + "https://us.api.openai.com.attacker.test/v1", + # Path-segment spoofing: host is proxy.test. + "https://proxy.test/api.openai.com/v1", + "https://proxy.test/us.api.openai.com/v1", + # Prefix tricks that are NOT dot-separated subdomains of + # api.openai.com (fooapi.openai.com is an openai.com host but + # not the official API host family this predicate is scoped to). + "https://evilapi.openai.com.attacker.test/v1", + "https://fooapi.openai.com/v1", + # Unrelated hosts. + "https://openrouter.ai/api/v1", + "https://api.anthropic.com/v1", + # IPv6 literal and empty input. + "https://[::1]:8080/v1", + "", + ], + ) + def test_spoof_and_unrelated_hosts_rejected(self, url): + assert is_official_openai_host(url) is False diff --git a/tests/hermes_cli/test_openai_discovery_endpoint.py b/tests/hermes_cli/test_openai_discovery_endpoint.py new file mode 100644 index 000000000000..610ac039681b --- /dev/null +++ b/tests/hermes_cli/test_openai_discovery_endpoint.py @@ -0,0 +1,153 @@ +"""Discovery honors ``model.base_url`` and cache identity tracks the endpoint. + +Coatue data-residency report, issues 3 and 4: with +``model.base_url: https://us.api.openai.com/v1`` in config and +``$OPENAI_BASE_URL`` unset, model discovery probed ``api.openai.com`` (the +wrong endpoint) and the catalog-cache fingerprint did not change when the +configured endpoint changed, so ``hermes config set model.base_url ...`` +kept serving the stale cached catalog. Regional hosts also bypassed the +curated intersection, flooding the picker with whisper/tts/embedding rows. + +Contracts pinned here: + 1. ``_openai_discovery_base_url()`` resolves config ``model.base_url`` + (when the configured provider matches) → ``$OPENAI_BASE_URL`` → default. + 2. ``_credential_fingerprint()`` changes when the effective configured + endpoint changes. + 3. Official OpenAI hosts (canonical + regional) all get the curated∩live + intersection; custom proxies keep the verbatim live list. +""" + +from __future__ import annotations + +from unittest.mock import patch as mock_patch + +import pytest + +from hermes_cli import models as models_mod + + +def _cfg(base_url: str | None, provider: str = "openai-api"): + cfg = {"provider": provider} + if base_url is not None: + cfg["base_url"] = base_url + return cfg + + +class TestDiscoveryBaseUrl: + def test_config_base_url_wins_when_env_unset(self, monkeypatch): + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + with mock_patch.object( + models_mod, + "_get_model_config_dict", + return_value=_cfg("https://us.api.openai.com/v1"), + ): + assert ( + models_mod._openai_discovery_base_url("openai-api") + == "https://us.api.openai.com/v1" + ) + + def test_env_wins_over_config(self, monkeypatch): + monkeypatch.setenv("OPENAI_BASE_URL", "https://eu.api.openai.com/v1") + with mock_patch.object( + models_mod, + "_get_model_config_dict", + return_value=_cfg("https://us.api.openai.com/v1"), + ): + assert ( + models_mod._openai_discovery_base_url("openai-api") + == "https://eu.api.openai.com/v1" + ) + + def test_config_ignored_when_provider_differs(self, monkeypatch): + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + with mock_patch.object( + models_mod, + "_get_model_config_dict", + return_value=_cfg("https://proxy.test/v1", provider="openrouter"), + ): + assert ( + models_mod._openai_discovery_base_url("openai-api") + == "https://api.openai.com/v1" + ) + + def test_default_when_nothing_configured(self, monkeypatch): + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + with mock_patch.object( + models_mod, "_get_model_config_dict", return_value=_cfg(None) + ): + assert ( + models_mod._openai_discovery_base_url("openai-api") + == "https://api.openai.com/v1" + ) + + +class TestFingerprintTracksEndpoint: + def test_config_endpoint_change_changes_fingerprint(self, monkeypatch): + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + with mock_patch.object( + models_mod, + "_get_model_config_dict", + return_value=_cfg("https://us.api.openai.com/v1"), + ): + fp_us = models_mod._credential_fingerprint("openai-api") + with mock_patch.object( + models_mod, + "_get_model_config_dict", + return_value=_cfg("https://eu.api.openai.com/v1"), + ): + fp_eu = models_mod._credential_fingerprint("openai-api") + assert fp_us != fp_eu + + def test_unrelated_provider_fingerprint_stable_across_openai_config(self, monkeypatch): + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + with mock_patch.object( + models_mod, + "_get_model_config_dict", + return_value=_cfg("https://us.api.openai.com/v1"), + ): + fp_a = models_mod._credential_fingerprint("openrouter") + with mock_patch.object( + models_mod, + "_get_model_config_dict", + return_value=_cfg("https://eu.api.openai.com/v1"), + ): + fp_b = models_mod._credential_fingerprint("openrouter") + assert fp_a == fp_b + + +class TestRegionalCatalogFiltering: + _RAW_DUMP = [ + "gpt-5.6-terra", + "whisper-1", + "tts-1", + "text-embedding-ada-002", + ] + + @pytest.mark.parametrize( + "base", + [ + "https://api.openai.com/v1", + "https://us.api.openai.com/v1", + "https://eu.api.openai.com/v1", + ], + ) + def test_official_hosts_intersect_with_curated(self, monkeypatch, base): + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.setenv("OPENAI_BASE_URL", base) + with mock_patch.object( + models_mod, "fetch_api_models", return_value=list(self._RAW_DUMP) + ): + ids = models_mod.provider_model_ids("openai-api", force_refresh=True) + assert "whisper-1" not in ids + assert "tts-1" not in ids + assert "text-embedding-ada-002" not in ids + assert "gpt-5.6-terra" in ids + + def test_custom_proxy_keeps_live_list_verbatim(self, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.setenv("OPENAI_BASE_URL", "https://proxy.corp.test/v1") + with mock_patch.object( + models_mod, "fetch_api_models", return_value=list(self._RAW_DUMP) + ): + ids = models_mod.provider_model_ids("openai-api", force_refresh=True) + assert ids == self._RAW_DUMP diff --git a/tests/hermes_cli/test_openai_listing_authority.py b/tests/hermes_cli/test_openai_listing_authority.py new file mode 100644 index 000000000000..ff34fdc1e7bd --- /dev/null +++ b/tests/hermes_cli/test_openai_listing_authority.py @@ -0,0 +1,60 @@ +"""Live-listing authority on official OpenAI hosts (model switching). + +Coatue data-residency report, issue 2: on an API key whose project only +grants a subset of models, ``/model`` accepted a curated-catalog model that +the live ``/v1/models`` listing (which is access-scoped and authoritative on +official OpenAI hosts) did not contain — manufacturing a selection that is +guaranteed to 400 at first use. + +Contract: for ``openai``/``openai-api`` against an official OpenAI host +(canonical or data-residency regional), a successful live listing is +authoritative — a model absent from it is rejected, not soft-accepted from +the curated catalog. Custom OpenAI-compatible proxies and other providers +keep the #46850 curated-fallback behavior (their listings are often +incomplete). +""" + +from __future__ import annotations + +from unittest.mock import patch as mock_patch + +import pytest + +from hermes_cli import models as models_mod + + +def _validate(requested: str, base_url: str, live: list[str], provider: str = "openai-api"): + with mock_patch.object(models_mod, "fetch_api_models", return_value=live): + return models_mod.validate_requested_model( + requested, + provider=provider, + api_key="sk-test", + base_url=base_url, + ) + + +class TestOfficialHostAuthority: + @pytest.mark.parametrize( + "base_url", + [ + "https://api.openai.com/v1", + "https://us.api.openai.com/v1", + "https://eu.api.openai.com/v1", + ], + ) + def test_absent_model_rejected_on_official_hosts(self, base_url): + result = _validate("gpt-5.5", base_url, live=["gpt-5.6-terra"]) + assert result["accepted"] is False + + def test_present_model_accepted_on_regional_host(self): + result = _validate("gpt-5.6-terra", "https://us.api.openai.com/v1", live=["gpt-5.6-terra"]) + assert result["accepted"] is True + + +class TestCuratedFallbackPreserved: + def test_custom_proxy_keeps_curated_fallback(self): + # gpt-5.5 is in the curated openai catalog; a custom proxy's listing + # may be incomplete, so the #46850 soft-accept stays. + result = _validate("gpt-5.5", "https://proxy.corp.test/v1", live=["some-other-model"]) + assert result["accepted"] is True + assert "curated catalog" in (result.get("message") or "") 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 000000000000..b54052e11fe4 --- /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"