From a0032f5f92b9b738c1397224c9bd07f86cf75577 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:14:36 +0530 Subject: [PATCH] fix(routing): preserve profile and delegation parity --- agent/chat_completion_helpers.py | 94 +++++++++---------- tests/providers/test_provider_profiles.py | 1 + tests/run_agent/test_run_agent.py | 20 +++- tests/tools/test_delegate.py | 40 ++++++++ tools/delegate_tool.py | 10 ++ .../user-guide/features/provider-routing.md | 2 +- 6 files changed, 116 insertions(+), 51 deletions(-) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 331d1f3402b6..4e4aa455a600 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -164,6 +164,25 @@ def _validated_openrouter_provider_sort(raw_sort: Any) -> Optional[str]: return None +def _provider_preferences_for_agent(agent) -> Dict[str, Any]: + """Build the validated provider-routing object shared by request paths.""" + preferences: Dict[str, Any] = {} + if agent.providers_allowed: + preferences["only"] = agent.providers_allowed + if agent.providers_ignored: + preferences["ignore"] = agent.providers_ignored + if agent.providers_order: + preferences["order"] = agent.providers_order + provider_sort = _validated_openrouter_provider_sort(agent.provider_sort) + if provider_sort: + preferences["sort"] = provider_sort + if agent.provider_require_parameters: + preferences["require_parameters"] = True + if agent.provider_data_collection: + preferences["data_collection"] = agent.provider_data_collection + return preferences + + def _env_float(name: str, default: float) -> float: try: return float(os.getenv(name, str(default))) @@ -801,21 +820,8 @@ def build_api_kwargs(agent, api_messages: list) -> dict: _omit_temp = False _fixed_temp = None - # Provider preferences (OpenRouter-style) - _prefs: Dict[str, Any] = {} - 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 - _provider_sort = _validated_openrouter_provider_sort(agent.provider_sort) - if _provider_sort: - _prefs["sort"] = _provider_sort - if agent.provider_require_parameters: - _prefs["require_parameters"] = True - if agent.provider_data_collection: - _prefs["data_collection"] = agent.provider_data_collection + # Provider preferences (aggregator profile decides whether to emit them). + _prefs = _provider_preferences_for_agent(agent) # Anthropic-compatible max-output fallback (last resort only — applied in # build_kwargs *after* ephemeral/user/profile max_tokens, never overriding @@ -1690,42 +1696,32 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str: if _lm_reasoning_effort is not None: summary_kwargs["reasoning_effort"] = _lm_reasoning_effort - # Include provider routing preferences - provider_preferences = {} - if agent.providers_allowed: - provider_preferences["only"] = agent.providers_allowed - if agent.providers_ignored: - provider_preferences["ignore"] = agent.providers_ignored - if agent.providers_order: - provider_preferences["order"] = agent.providers_order - _provider_sort = _validated_openrouter_provider_sort(agent.provider_sort) - if _provider_sort: - provider_preferences["sort"] = _provider_sort - if agent.provider_require_parameters: - provider_preferences["require_parameters"] = True - if agent.provider_data_collection: - provider_preferences["data_collection"] = agent.provider_data_collection - if provider_preferences: - profile_provider_preferences = None - try: - from providers import get_provider_profile + # Merge the profile's canonical body even when routing is unset: + # profiles may always emit required metadata such as Portal tags. + provider_preferences = _provider_preferences_for_agent(agent) + profile_extra_body = {} + try: + from providers import get_provider_profile - provider_profile = get_provider_profile(agent.provider) - if provider_profile is not None: - profile_extra_body = provider_profile.build_extra_body( - provider_preferences=provider_preferences, - ) - profile_provider_preferences = profile_extra_body.get("provider") - except Exception: - pass + provider_profile = get_provider_profile(agent.provider) + if provider_profile is not None: + profile_extra_body = provider_profile.build_extra_body( + session_id=getattr(agent, "session_id", None), + provider_preferences=provider_preferences or None, + model=agent.model, + base_url=agent.base_url, + reasoning_config=agent.reasoning_config, + ) + except Exception: + pass - if profile_provider_preferences: - summary_extra_body["provider"] = profile_provider_preferences - elif ( - (agent.provider or "").strip().lower() == "openrouter" - or agent._is_openrouter_url() - ): - summary_extra_body["provider"] = provider_preferences + if profile_extra_body: + summary_extra_body.update(profile_extra_body) + if provider_preferences and "provider" not in profile_extra_body and ( + (agent.provider or "").strip().lower() == "openrouter" + or agent._is_openrouter_url() + ): + summary_extra_body["provider"] = provider_preferences # Pareto Code router plugin — model-gated. Same shape as # the main-loop emission so summary calls on diff --git a/tests/providers/test_provider_profiles.py b/tests/providers/test_provider_profiles.py index 0c02ba0d78b2..ba8036407282 100644 --- a/tests/providers/test_provider_profiles.py +++ b/tests/providers/test_provider_profiles.py @@ -418,6 +418,7 @@ class TestNousProfile: from agent.portal_tags import nous_portal_tags p = get_provider_profile("nous") + assert p is not None preferences = {"only": ["deepseek"], "ignore": ["deepinfra"]} body = p.build_extra_body(provider_preferences=preferences) diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index a3aa670dd9c7..dfe77d007c1e 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -3789,7 +3789,7 @@ class TestHandleMaxIterations: assert kwargs["extra_body"]["provider"]["only"] == ["Anthropic"] def test_summary_keeps_provider_preferences_for_nous(self, agent): - agent.base_url = "https://inference-api.nousresearch.com/v1" + agent.base_url = "https://proxy.example.com/v1" agent._base_url_lower = agent.base_url.lower() agent.provider = "nous" agent.providers_allowed = ["deepseek"] @@ -3804,6 +3804,9 @@ class TestHandleMaxIterations: assert result == "Summary" kwargs = agent.client.chat.completions.create.call_args.kwargs + from agent.portal_tags import nous_portal_tags + + assert kwargs["extra_body"]["tags"] == nous_portal_tags() assert kwargs["extra_body"]["provider"] == { "only": ["deepseek"], "ignore": ["deepinfra"], @@ -3812,6 +3815,21 @@ class TestHandleMaxIterations: "data_collection": "deny", } + def test_summary_keeps_nous_profile_body_without_routing_preferences(self, agent): + agent.base_url = "https://proxy.example.com/v1" + agent._base_url_lower = agent.base_url.lower() + agent.provider = "nous" + agent.client.chat.completions.create.return_value = _mock_response(content="Summary") + agent._cached_system_prompt = "You are helpful." + + result = agent._handle_max_iterations([{"role": "user", "content": "do stuff"}], 60) + + assert result == "Summary" + kwargs = agent.client.chat.completions.create.call_args.kwargs + from agent.portal_tags import nous_portal_tags + + assert kwargs["extra_body"] == {"tags": nous_portal_tags()} + def test_summary_drops_invalid_provider_sort(self, agent): agent.base_url = "https://openrouter.ai/api/v1" agent._base_url_lower = agent.base_url.lower() diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 43c8089f1127..5ee253d7e6d6 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -1446,6 +1446,8 @@ class TestDelegationProviderIntegration(unittest.TestCase): parent.providers_ignored = ["openai/gpt-4o-mini"] parent.providers_order = ["google/gemini-2.5-pro"] parent.provider_sort = "price" + parent.provider_require_parameters = True + parent.provider_data_collection = "deny" with patch("run_agent.AIAgent") as MockAgent: mock_child = MagicMock() @@ -1464,6 +1466,44 @@ class TestDelegationProviderIntegration(unittest.TestCase): self.assertIsNone(kwargs["providers_ignored"]) self.assertIsNone(kwargs["providers_order"]) self.assertIsNone(kwargs["provider_sort"]) + self.assertIs(kwargs["provider_require_parameters"], False) + self.assertEqual(kwargs["provider_data_collection"], "") + + @patch("tools.delegate_tool._load_config") + @patch("tools.delegate_tool._resolve_delegation_credentials") + def test_same_provider_inherits_all_routing_preferences(self, mock_creds, mock_cfg): + mock_cfg.return_value = {"max_iterations": 45} + mock_creds.return_value = { + "model": None, + "provider": None, + "base_url": None, + "api_key": None, + "api_mode": None, + } + parent = _make_mock_parent(depth=0) + parent.provider = "nous" + parent.providers_allowed = ["deepseek"] + parent.providers_ignored = ["deepinfra"] + parent.providers_order = ["anthropic"] + parent.provider_sort = "throughput" + parent.provider_require_parameters = True + parent.provider_data_collection = "deny" + + with patch("run_agent.AIAgent") as MockAgent: + mock_child = MagicMock() + mock_child.run_conversation.return_value = { + "final_response": "done", "completed": True, "api_calls": 1 + } + MockAgent.return_value = mock_child + delegate_task(goal="Keep routing", parent_agent=parent) + + _, kwargs = MockAgent.call_args + self.assertEqual(kwargs["providers_allowed"], ["deepseek"]) + self.assertEqual(kwargs["providers_ignored"], ["deepinfra"]) + self.assertEqual(kwargs["providers_order"], ["anthropic"]) + self.assertEqual(kwargs["provider_sort"], "throughput") + self.assertIs(kwargs["provider_require_parameters"], True) + self.assertEqual(kwargs["provider_data_collection"], "deny") @patch("tools.delegate_tool._load_config") @patch("tools.delegate_tool._resolve_delegation_credentials") diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 911e25204ab6..7a9854148b97 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -1288,12 +1288,20 @@ def _build_child_agent( child_providers_ignored = getattr(parent_agent, "providers_ignored", None) child_providers_order = getattr(parent_agent, "providers_order", None) child_provider_sort = getattr(parent_agent, "provider_sort", None) + child_provider_require_parameters = getattr( + parent_agent, "provider_require_parameters", False + ) + child_provider_data_collection = getattr( + parent_agent, "provider_data_collection", None + ) or "" child_openrouter_min_coding_score = getattr(parent_agent, "openrouter_min_coding_score", None) if override_provider: child_providers_allowed = None child_providers_ignored = None child_providers_order = None child_provider_sort = None + child_provider_require_parameters = False + child_provider_data_collection = "" # Note: openrouter_min_coding_score is model-gated (only emitted on # openrouter/pareto-code), so we keep it inherited even when the # provider is overridden — it's a no-op on any other model. @@ -1326,6 +1334,8 @@ def _build_child_agent( providers_ignored=child_providers_ignored, providers_order=child_providers_order, provider_sort=child_provider_sort, + provider_require_parameters=child_provider_require_parameters, + provider_data_collection=child_provider_data_collection, openrouter_min_coding_score=child_openrouter_min_coding_score, tool_progress_callback=child_progress_cb, iteration_budget=None, # fresh budget per subagent diff --git a/website/docs/user-guide/features/provider-routing.md b/website/docs/user-guide/features/provider-routing.md index 4c00642efa38..ff8a9ef56ccc 100644 --- a/website/docs/user-guide/features/provider-routing.md +++ b/website/docs/user-guide/features/provider-routing.md @@ -167,7 +167,7 @@ provider_routing: ## How It Works -Provider routing preferences are passed to OpenRouter or Nous Portal via the `extra_body.provider` field on every API call. (`extra_body` is the OpenAI Python SDK argument; it becomes the top-level `provider` object in the JSON request.) This applies to both: +Provider routing preferences are passed to OpenRouter or Nous Portal on agent chat requests and iteration-limit summaries via the `extra_body.provider` field. (`extra_body` is the OpenAI Python SDK argument; it becomes the top-level `provider` object in the JSON request.) Auxiliary tasks such as compression and title generation are configured independently under `auxiliary..extra_body`. - **CLI mode** — configured in `~/.hermes/config.yaml`, loaded at startup - **Gateway mode** — same config file, loaded when the gateway starts