diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index af64541a8285..5aeb02046f31 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1202,6 +1202,7 @@ def restore_primary_runtime(agent) -> bool: # ── Reset fallback chain for the new turn ── agent._fallback_activated = False agent._fallback_index = 0 + agent._fallback_extra_body = None # Undo the fallback's identity rewrite so the prompt is # byte-identical to the stored copy again (prefix cache match). diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index aada15f51ed5..bc050b00dbb2 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -741,6 +741,16 @@ def build_api_kwargs(agent, api_messages: list) -> dict: if agent.provider_data_collection: _prefs["data_collection"] = agent.provider_data_collection + # When a fallback is active, merge its extra_body.provider routing + # directives (e.g. order, allow_fallbacks set via fallback_providers[].extra_body) + # on top of the global _prefs so fallback-local routing is honoured. + # The fallback entry's directives take precedence over global ones because + # they are explicitly scoped to a specific fallback target. See #26460. + _fb_extra_body = getattr(agent, "_fallback_extra_body", None) or {} + _fb_provider_prefs = _fb_extra_body.get("provider") if isinstance(_fb_extra_body, dict) else None + if _fb_provider_prefs and isinstance(_fb_provider_prefs, dict): + _prefs.update(_fb_provider_prefs) + # Claude max-output override on aggregators _ant_max = None if (_is_or or _is_nous) and "claude" in (agent.model or "").lower(): @@ -1264,6 +1274,21 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool if hasattr(agent, "_transport_cache"): agent._transport_cache.clear() agent._fallback_activated = True + # Carry the fallback entry's extra_body (e.g. OpenRouter provider + # routing metadata) into the active request path. Without this, + # fallback-local routing directives such as: + # + # fallback_providers: + # - provider: openrouter + # extra_body: + # provider: + # order: [baidu/fp8, gmicloud/fp8] + # allow_fallbacks: false + # + # are silently dropped because _prefs is assembled from agent-level + # attributes (providers_order, providers_allowed, …) that are never + # updated when the fallback is activated. See issue #26460. + agent._fallback_extra_body = fb.get("extra_body") or None # Rebind the credential pool to the fallback provider when the provider # changes. Keeping the primary pool attached would make downstream diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 7a5919807af1..46b7e56a89f0 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -3627,6 +3627,7 @@ def _perform_api_call(next_api_kwargs): _retry.has_retried_429 = False agent._fallback_index = 0 agent._fallback_activated = False + agent._fallback_extra_body = None continue # Try fallback before giving up entirely if agent._has_pending_fallback(): diff --git a/tests/run_agent/test_26460_fallback_extra_body.py b/tests/run_agent/test_26460_fallback_extra_body.py new file mode 100644 index 000000000000..6afb32fbfdcf --- /dev/null +++ b/tests/run_agent/test_26460_fallback_extra_body.py @@ -0,0 +1,229 @@ +"""Tests that fallback_providers[].extra_body is honoured during fallback. + +Regression tests for issue #26460: OpenRouter-specific routing metadata +(provider.order, allow_fallbacks, etc.) configured under a fallback entry's +extra_body was silently dropped when the fallback was activated, because +_prefs assembly only read from agent-level attributes, not the active +fallback config. +""" + +from unittest.mock import MagicMock, patch + +from run_agent import AIAgent + + +def _make_agent_with_fallback(fallback_providers): + with ( + patch("run_agent.get_tool_definitions", return_value=[]), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + agent = AIAgent( + api_key="primary-key", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + fallback_model=fallback_providers, + ) + agent.client = MagicMock() + agent.client.base_url = "https://openrouter.ai/api/v1" + return agent + + +def _mock_fb_client(base_url="https://openrouter.ai/api/v1", api_key="fb-key"): + m = MagicMock() + m.base_url = base_url + m.api_key = api_key + return m + + +# ── extra_body stored on activation ────────────────────────────────────── + + +class TestFallbackExtraBodyStorage: + def test_extra_body_stored_on_activation(self): + """_fallback_extra_body must be set to the entry's extra_body dict.""" + extra_body = { + "provider": { + "order": ["baidu/fp8", "gmicloud/fp8"], + "allow_fallbacks": False, + } + } + fb_entry = { + "provider": "openrouter", + "model": "z-ai/glm-5.1", + "extra_body": extra_body, + } + agent = _make_agent_with_fallback([fb_entry]) + fb_client = _mock_fb_client() + + with patch( + "agent.chat_completion_helpers.resolve_provider_client", + return_value=(fb_client, "z-ai/glm-5.1"), + ): + activated = agent._try_activate_fallback() + + assert activated + assert agent._fallback_extra_body == extra_body + + def test_no_extra_body_stores_none(self): + """Entry without extra_body must set _fallback_extra_body to None.""" + fb_entry = {"provider": "openrouter", "model": "z-ai/glm-5.1"} + agent = _make_agent_with_fallback([fb_entry]) + fb_client = _mock_fb_client() + + with patch( + "agent.chat_completion_helpers.resolve_provider_client", + return_value=(fb_client, "z-ai/glm-5.1"), + ): + activated = agent._try_activate_fallback() + + assert activated + assert agent._fallback_extra_body is None + + def test_empty_extra_body_stores_none(self): + """An empty dict extra_body should not pollute _prefs — stored as None.""" + fb_entry = { + "provider": "openrouter", + "model": "z-ai/glm-5.1", + "extra_body": {}, + } + agent = _make_agent_with_fallback([fb_entry]) + fb_client = _mock_fb_client() + + with patch( + "agent.chat_completion_helpers.resolve_provider_client", + return_value=(fb_client, "z-ai/glm-5.1"), + ): + agent._try_activate_fallback() + + assert agent._fallback_extra_body is None + + +# ── extra_body forwarded into provider_preferences ────────────────────── + + +class TestFallbackExtraBodyForwarding: + def _activate_with_extra_body(self, extra_body): + fb_entry = { + "provider": "openrouter", + "model": "z-ai/glm-5.1", + "extra_body": extra_body, + } + agent = _make_agent_with_fallback([fb_entry]) + fb_client = _mock_fb_client() + with patch( + "agent.chat_completion_helpers.resolve_provider_client", + return_value=(fb_client, "z-ai/glm-5.1"), + ): + agent._try_activate_fallback() + return agent + + def test_provider_order_forwarded_to_prefs(self): + """provider.order from extra_body must appear in _prefs after activation.""" + from agent.chat_completion_helpers import _build_api_kwargs_for_openai + + extra_body = { + "provider": { + "order": ["baidu/fp8", "gmicloud/fp8"], + "allow_fallbacks": False, + } + } + agent = self._activate_with_extra_body(extra_body) + + # Read _prefs the same way the build path does + _prefs = {} + from agent.chat_completion_helpers import _validated_openrouter_provider_sort + if agent.providers_allowed: + _prefs["only"] = agent.providers_allowed + if agent.providers_ignored: + _prefs["ignore"] = agent.providers_ignored + if agent.providers_order: + _prefs["order"] = agent.providers_order + + _fb_extra_body = getattr(agent, "_fallback_extra_body", None) or {} + _fb_provider_prefs = _fb_extra_body.get("provider") if isinstance(_fb_extra_body, dict) else None + if _fb_provider_prefs and isinstance(_fb_provider_prefs, dict): + _prefs.update(_fb_provider_prefs) + + assert _prefs.get("order") == ["baidu/fp8", "gmicloud/fp8"] + assert _prefs.get("allow_fallbacks") is False + + def test_fallback_prefs_override_global_order(self): + """Fallback-local provider.order takes precedence over global providers_order.""" + fb_entry = { + "provider": "openrouter", + "model": "z-ai/glm-5.1", + "extra_body": { + "provider": {"order": ["fallback-gpu/fp8"]} + }, + } + agent = _make_agent_with_fallback([fb_entry]) + # Simulate a global providers_order set from primary config + agent.providers_order = ["primary-gpu/bf16"] + fb_client = _mock_fb_client() + + with patch( + "agent.chat_completion_helpers.resolve_provider_client", + return_value=(fb_client, "z-ai/glm-5.1"), + ): + agent._try_activate_fallback() + + _prefs = {"order": agent.providers_order} + _fb_extra_body = getattr(agent, "_fallback_extra_body", None) or {} + _fb_pp = _fb_extra_body.get("provider") if isinstance(_fb_extra_body, dict) else None + if _fb_pp and isinstance(_fb_pp, dict): + _prefs.update(_fb_pp) + + # Fallback-local order should win + assert _prefs["order"] == ["fallback-gpu/fp8"] + + +# ── extra_body cleared on restore ──────────────────────────────────────── + + +class TestFallbackExtraBodyClearing: + def test_extra_body_cleared_on_restore(self): + """_fallback_extra_body must be None after restore_primary_runtime.""" + from agent.agent_runtime_helpers import restore_primary_runtime + + fb_entry = { + "provider": "openrouter", + "model": "z-ai/glm-5.1", + "extra_body": {"provider": {"order": ["baidu/fp8"]}}, + } + agent = _make_agent_with_fallback([fb_entry]) + fb_client = _mock_fb_client() + + with patch( + "agent.chat_completion_helpers.resolve_provider_client", + return_value=(fb_client, "z-ai/glm-5.1"), + ): + agent._try_activate_fallback() + + assert agent._fallback_extra_body is not None + + # Simulate restore — set up minimal _primary_runtime + agent._primary_runtime = { + "model": "primary-model", + "provider": "openrouter", + "api_key": "primary-key", + "base_url": "https://openrouter.ai/api/v1", + "api_mode": "chat_completions", + } + + with patch("agent.agent_runtime_helpers.resolve_provider_client", + return_value=(MagicMock(base_url="https://openrouter.ai/api/v1", + api_key="primary-key"), "primary-model")): + try: + restore_primary_runtime(agent) + except Exception: + pass # restore may fail in minimal test env; we only need side-effects + + assert getattr(agent, "_fallback_extra_body", None) is None + + def test_extra_body_none_before_any_activation(self): + """_fallback_extra_body should be absent or None on a fresh agent.""" + agent = _make_agent_with_fallback([]) + assert getattr(agent, "_fallback_extra_body", None) is None