diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 03d0d6dfdd..fd71575830 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -1736,9 +1736,19 @@ def _fallback_api_mode_hint(fb: dict, fb_provider: str, fb_base_url_hint: Option rewrites a dual-surface /anthropic base to /v1, losing the Anthropic wire signal. An explicit ``api_mode`` always wins (even "chat_completions") and suppresses later re-detection; ``provider: anthropic`` without a base_url still resolves to anthropic_messages.""" - explicit = str(fb.get("api_mode") or "").strip() + from hermes_cli.runtime_provider import _get_named_custom_provider, _parse_api_mode + # Entries accept the same ``api_mode`` / ``transport`` spellings as ``providers.``. + explicit = _parse_api_mode(fb.get("api_mode") or fb.get("transport")) if explicit: return True, explicit + # A named ``providers.`` block declares its wire once (``api_mode``/``transport``); a + # fallback entry naming that provider inherits it instead of being re-detected from the host + # (#33062, #81932: an Anthropic-Messages or Responses-only relay on a plain host was downgraded + # to chat_completions while resolve_provider_client had already built the declared client). + if fb_provider and fb_provider not in {"custom", "moa"}: + declared = (_get_named_custom_provider(fb_provider) or {}).get("api_mode") + if declared: + return True, declared if fb_provider == "anthropic" or (fb_base_url_hint and _is_anthropic_wire_url(fb_base_url_hint)): return False, "anthropic_messages" return False, "chat_completions" diff --git a/tests/agent/test_fallback_api_mode_preservation.py b/tests/agent/test_fallback_api_mode_preservation.py index 6a6a330f4d..5198a353bd 100644 --- a/tests/agent/test_fallback_api_mode_preservation.py +++ b/tests/agent/test_fallback_api_mode_preservation.py @@ -178,6 +178,30 @@ class TestOriginalUrlDetection: assert agent.api_mode == "chat_completions" +class TestNamedProviderDeclaredWire: + """A fallback entry naming a ``providers.`` block inherits the block's declared + ``api_mode``/``transport`` instead of host re-detection (#33062, #81932).""" + + def test_named_block_anthropic_messages_inherited_on_plain_host(self): + fbs = [{"provider": "custom:ai-proxy", "model": "claude-4.7-opus"}] + agent = _make_agent(fallback_model=fbs) + with patch( + "hermes_cli.runtime_provider._get_named_custom_provider", + return_value={"name": "ai-proxy", "base_url": "https://ai-proxy.example.com", + "api_key": "k", "api_mode": "anthropic_messages"}, + ): + mock_rpc = _activate(agent, "https://ai-proxy.example.com", "claude-4.7-opus") + assert agent.api_mode == "anthropic_messages" + assert mock_rpc.call_args.kwargs["api_mode"] == "anthropic_messages" + + def test_entry_transport_alias_is_honored(self): + fbs = [{"provider": "custom", "model": "custom/responses", "base_url": "https://gateway.example.com/v1", + "api_key": "k", "transport": "responses"}] + agent = _make_agent(fallback_model=fbs) + _activate(agent, "https://gateway.example.com/v1", "custom/responses") + assert agent.api_mode == "codex_responses" + + class TestPlainFallbackUnchanged: def test_plain_openrouter_fallback_stays_chat_completions(self): fbs = [{ diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index 785dbb8256..4712534133 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -1676,9 +1676,11 @@ fallback_providers: - provider: anthropic model: claude-sonnet-4 # base_url: http://localhost:8000/v1 # optional, for custom endpoints - # api_mode: chat_completions # optional override + # api_mode: chat_completions # optional override (`transport:` is an accepted alias) ``` +An entry that names a `providers.` block (`provider: my-relay` or `provider: custom:my-relay`) inherits that block's `transport` / `api_mode` when the entry sets none, so a Responses-only or Anthropic-Messages relay keeps its declared wire on fallback. Set `api_mode` on the entry to override it. + The legacy single-pair `fallback_model:` dict is still accepted for back-compat: ```yaml