fix(routing): preserve profile and delegation parity

This commit is contained in:
kshitijk4poor 2026-07-10 12:14:36 +05:30 committed by kshitij
parent 3aeaf3755d
commit a0032f5f92
6 changed files with 116 additions and 51 deletions

View file

@ -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

View file

@ -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)

View file

@ -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()

View file

@ -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")

View file

@ -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

View file

@ -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.<task>.extra_body`.
- **CLI mode** — configured in `~/.hermes/config.yaml`, loaded at startup
- **Gateway mode** — same config file, loaded when the gateway starts