diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 684f24f5da8..4a40f82b9aa 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -1014,6 +1014,71 @@ class TestDelegationCredentialResolution(unittest.TestCase): self.assertIsNone(creds["model"]) self.assertIsNone(creds["provider"]) + @patch("hermes_cli.runtime_provider.resolve_runtime_provider") + def test_named_custom_provider_preserves_provider_name(self, mock_resolve): + """Named custom provider (e.g. crof.ai) resolves to 'custom' at runtime level + but the subagent must retain the original provider identity so that + resolve_provider_client routes to the correct endpoint on retry/fallback. + Regression test for #26954. + """ + mock_resolve.return_value = { + "provider": "custom", # runtime marks it as "custom" type + "model": "deepseek-v4-pro-CEER", + "base_url": "https://api.crof.ai/v1", + "api_key": "crof-key-abc", + "api_mode": "chat_completions", + } + parent = _make_mock_parent(depth=0) + cfg = {"model": "deepseek-v4-pro-CEER", "provider": "crof.ai"} + creds = _resolve_delegation_credentials(cfg, parent) + # The key assertion: subagent must keep "crof.ai", NOT "custom" + self.assertEqual(creds["provider"], "crof.ai") + self.assertEqual(creds["model"], "deepseek-v4-pro-CEER") + self.assertEqual(creds["base_url"], "https://api.crof.ai/v1") + self.assertEqual(creds["api_key"], "crof-key-abc") + # Verify resolve_runtime_provider was called with the configured name + mock_resolve.assert_called_once_with( + requested="crof.ai", target_model="deepseek-v4-pro-CEER" + ) + + @patch("hermes_cli.runtime_provider.resolve_runtime_provider") + def test_standard_provider_not_overwritten_by_configured_name(self, mock_resolve): + """Standard (non-custom) providers must still return runtime identity, + not the configured name, to preserve existing behaviour for openrouter, + nous, etc. + """ + mock_resolve.return_value = { + "provider": "openrouter", + "model": "anthropic/claude-sonnet-4", + "base_url": "https://openrouter.ai/api/v1", + "api_key": "or-key-xyz", + "api_mode": "chat_completions", + } + parent = _make_mock_parent(depth=0) + cfg = {"model": "anthropic/claude-sonnet-4", "provider": "openrouter"} + creds = _resolve_delegation_credentials(cfg, parent) + # Standard provider returns its own name, not "custom" + self.assertEqual(creds["provider"], "openrouter") + + @patch("hermes_cli.runtime_provider.resolve_runtime_provider") + def test_custom_provider_with_empty_configured_provider_falls_back_to_runtime(self, mock_resolve): + """When configured_provider is empty/None, the early return kicks in and + we return provider=None regardless of what runtime resolved. The runtime + path is only reached when configured_provider is a non-empty string. + """ + mock_resolve.return_value = { + "provider": "custom", + "model": "some-model", + "base_url": "https://fallback.example.com/v1", + "api_key": "key-fallback", + "api_mode": "chat_completions", + } + parent = _make_mock_parent(depth=0) + cfg = {"model": "some-model", "provider": ""} + creds = _resolve_delegation_credentials(cfg, parent) + # Empty provider → early return with None (child inherits parent) + self.assertIsNone(creds["provider"]) + class TestDelegationProviderIntegration(unittest.TestCase): """Integration tests: delegation config → _run_single_child → AIAgent construction.""" diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index e9ad32e0d3a..86dcd0715cc 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -31,6 +31,11 @@ from concurrent.futures import ( from typing import Any, Dict, List, Optional from toolsets import TOOLSETS + +# Sentinel value used by the runtime provider system for providers that are +# not natively known (named custom providers, third-party aggregators, etc.). +# Must match hermes_cli.runtime_provider.RUNTIME_PROVIDER_TYPE_CUSTOM. +_RUNTIME_PROVIDER_CUSTOM = "custom" from tools import file_state from tools.terminal_tool import set_approval_callback as _set_subagent_approval_cb from utils import base_url_hostname, is_truthy_value @@ -2442,7 +2447,7 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: return { "model": configured_model or runtime.get("model") or None, - "provider": runtime.get("provider"), + "provider": configured_provider if runtime.get("provider") == _RUNTIME_PROVIDER_CUSTOM else runtime.get("provider"), "base_url": runtime.get("base_url"), "api_key": api_key, "api_mode": runtime.get("api_mode"),