mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
fix(delegation): preserve configured_provider name when runtime returns 'custom'
Named custom providers (e.g. crof.ai) resolve to provider='custom' at the
runtime level, causing subagents to lose their intended provider identity.
On retry/fallback, resolve_provider_client('custom', model=...) searches all
providers advertising that model and picks non-deterministically, routing to
Z.AI or Bailian instead of the configured target.
The fix preserves configured_provider when runtime['provider'] == 'custom',
restoring the original provider name so routing stays correct through retries.
Adds a named constant _RUNTIME_PROVIDER_CUSTOM instead of a magic string.
Adds three regression tests:
- test_named_custom_provider_preserves_provider_name: the #26954 case
- test_standard_provider_not_overwritten_by_configured_name: openrouter/nous
must still return their own identity, not the configured name
- test_custom_provider_with_empty_configured_provider_falls_back_to_runtime:
empty provider triggers the early-return None path as before
This commit is contained in:
parent
08a66b2ae3
commit
84667cbc21
2 changed files with 71 additions and 1 deletions
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue