fix(runtime): fall back to the provider's declared transport, not chat_completions
The P1 from the enterprise data-residency report: with model.base_url=https://us.api.openai.com/v1, every tool-calling turn 400'd ('Function tools with reasoning_effort are not supported ... use /v1/responses') because the runtime resolvers hardcoded api_mode=chat_completions and consulted URL detection only. openai-api declares codex_responses in its overlay; the declaration was never consulted, so any OpenAI host that wasn't literally api.openai.com landed on the wrong wire protocol. New _fallback_api_mode(provider, base_url, model): URL detection first (host-mandated wire shapes keep priority), then providers.determine_api_mode() (the provider's declared transport), then chat_completions only for genuinely unknown providers. All three runtime fallback sites route through it: the pool-entry path, the explicit-runtime path, and the API-key-provider path, so the lanes cannot drift apart. Blast radius beyond openai-api: minimax, minimax-cn, and copilot-acp were the other overlays whose declared non-chat transport fell through to chat_completions on the same paths (same latent bug class). openrouter is unaffected (declares openai_chat). _detect_api_mode_for_url also now uses the shared official-host predicate, so regional hosts detect as codex_responses on the direct-URL lane too.
This commit is contained in:
parent
564e9b90af
commit
33a2f29a63
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
Loading…
Reference in New Issue