mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(providers): align custom route scoping
This commit is contained in:
parent
97499d702e
commit
cb785e6b49
5 changed files with 362 additions and 13 deletions
|
|
@ -174,6 +174,19 @@ def _provider_default_routes(provider: str) -> set[str]:
|
|||
return routes
|
||||
|
||||
|
||||
def _normalize_custom_provider_name(value: Any) -> str:
|
||||
"""Mirror runtime normalization for a requested custom-provider identity."""
|
||||
return str(value or "").strip().lower().replace(" ", "-")
|
||||
|
||||
|
||||
def _custom_provider_runtime_ids(value: Any) -> set[str]:
|
||||
"""Return raw/menu identities that runtime accepts for a configured name."""
|
||||
normalized = _normalize_custom_provider_name(value)
|
||||
if not normalized:
|
||||
return set()
|
||||
return {normalized, f"custom:{normalized}"}
|
||||
|
||||
|
||||
def _build_codex_gpt5_autoraise_notice(
|
||||
autoraise: Dict[str, Any], context_length: Optional[int] = None
|
||||
) -> str:
|
||||
|
|
@ -1901,17 +1914,98 @@ def init_agent(
|
|||
_configured_base_url = _normalize_route_base_url(
|
||||
_model_cfg.get("base_url")
|
||||
)
|
||||
if not _configured_base_url and _configured_provider.lower().startswith("custom:"):
|
||||
_configured_custom_name = _configured_provider.split(":", 1)[1].lower()
|
||||
for _provider_entry in _custom_providers:
|
||||
if not isinstance(_provider_entry, dict):
|
||||
continue
|
||||
if str(_provider_entry.get("name") or "").strip().lower() != _configured_custom_name:
|
||||
continue
|
||||
_configured_base_url = _normalize_route_base_url(
|
||||
_provider_entry.get("base_url")
|
||||
_configured_provider_norm = _normalize_custom_provider_name(
|
||||
_configured_provider
|
||||
)
|
||||
_custom_provider_candidate = bool(_configured_provider_norm)
|
||||
_runtime_first_provider_ids = {
|
||||
"auto",
|
||||
"moa",
|
||||
"vertex",
|
||||
"google-vertex",
|
||||
"vertex-ai",
|
||||
"gcp-vertex",
|
||||
"vertexai",
|
||||
}
|
||||
if _configured_provider_norm in _runtime_first_provider_ids:
|
||||
_custom_provider_candidate = False
|
||||
elif (
|
||||
_custom_provider_candidate
|
||||
and _configured_provider_norm != "custom"
|
||||
and not _configured_provider_norm.startswith("custom:")
|
||||
):
|
||||
try:
|
||||
from hermes_cli.auth import resolve_provider as resolve_auth_provider
|
||||
|
||||
_resolved_auth_provider = resolve_auth_provider(
|
||||
_configured_provider_norm
|
||||
)
|
||||
break
|
||||
_custom_provider_candidate = (
|
||||
str(_resolved_auth_provider or "").strip().lower()
|
||||
!= _configured_provider_norm
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if not _configured_base_url and _custom_provider_candidate:
|
||||
_configured_custom_provider = _normalize_custom_provider_name(
|
||||
_configured_provider
|
||||
)
|
||||
_user_providers = _agent_cfg.get("providers")
|
||||
_disabled_custom_provider_ids: set[str] = set()
|
||||
if isinstance(_user_providers, dict):
|
||||
from hermes_cli.config import is_provider_enabled
|
||||
|
||||
for _provider_key, _provider_entry in _user_providers.items():
|
||||
if not isinstance(_provider_entry, dict):
|
||||
continue
|
||||
_entry_name = str(
|
||||
_provider_entry.get("name") or ""
|
||||
).strip()
|
||||
_entry_provider_ids = _custom_provider_runtime_ids(
|
||||
_provider_key
|
||||
) | _custom_provider_runtime_ids(_entry_name)
|
||||
if not is_provider_enabled(_provider_entry):
|
||||
_disabled_custom_provider_ids.update(
|
||||
provider_id
|
||||
for provider_id in _entry_provider_ids
|
||||
if provider_id
|
||||
)
|
||||
continue
|
||||
if _configured_custom_provider not in _entry_provider_ids:
|
||||
continue
|
||||
_configured_base_url = _normalize_route_base_url(
|
||||
_provider_entry.get("api")
|
||||
or _provider_entry.get("url")
|
||||
or _provider_entry.get("base_url")
|
||||
)
|
||||
if _configured_base_url:
|
||||
break
|
||||
if not _configured_base_url:
|
||||
for _provider_entry in _custom_providers:
|
||||
if not isinstance(_provider_entry, dict):
|
||||
continue
|
||||
_entry_name = str(
|
||||
_provider_entry.get("name") or ""
|
||||
).strip()
|
||||
_entry_provider_key = str(
|
||||
_provider_entry.get("provider_key") or ""
|
||||
).strip().lower()
|
||||
_entry_provider_ids = _custom_provider_runtime_ids(
|
||||
_entry_name
|
||||
) | _custom_provider_runtime_ids(_entry_provider_key)
|
||||
if (
|
||||
_entry_provider_key
|
||||
and _custom_provider_runtime_ids(_entry_provider_key)
|
||||
& _disabled_custom_provider_ids
|
||||
):
|
||||
continue
|
||||
if _configured_custom_provider not in _entry_provider_ids:
|
||||
continue
|
||||
_configured_base_url = _normalize_route_base_url(
|
||||
_provider_entry.get("base_url")
|
||||
)
|
||||
if _configured_base_url:
|
||||
break
|
||||
_active_route_url = str(agent.base_url or "")
|
||||
_requested_route_url = str(base_url or "")
|
||||
if "?" in _requested_route_url.split("#", 1)[0]:
|
||||
|
|
|
|||
|
|
@ -5228,6 +5228,8 @@ def providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, An
|
|||
|
||||
custom_providers: List[Dict[str, Any]] = []
|
||||
for key, entry in providers_dict.items():
|
||||
if isinstance(entry, dict) and not is_provider_enabled(entry):
|
||||
continue
|
||||
normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))
|
||||
if normalized is not None:
|
||||
custom_providers.append(normalized)
|
||||
|
|
|
|||
|
|
@ -1404,6 +1404,22 @@ class TestCustomProviderCompatibility:
|
|||
assert compatible[0]["provider_key"] == "openai-direct"
|
||||
assert compatible[0]["api_mode"] == "codex_responses"
|
||||
|
||||
def test_disabled_provider_is_excluded_from_compatibility_projection(self):
|
||||
"""Compatibility fallback must not resurrect a disabled modern entry."""
|
||||
compatible = get_compatible_custom_providers(
|
||||
{
|
||||
"providers": {
|
||||
"route-key": {
|
||||
"name": "Route Key",
|
||||
"api": "https://disabled.example/v1",
|
||||
"enabled": False,
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assert compatible == []
|
||||
|
||||
def test_compatible_custom_providers_prefers_base_url_then_url_then_api(self, tmp_path):
|
||||
"""URL field precedence is base_url > url > api (PR #9332)."""
|
||||
config_path = tmp_path / "config.yaml"
|
||||
|
|
|
|||
|
|
@ -1154,6 +1154,25 @@ def test_named_custom_provider_does_not_shadow_builtin_provider(monkeypatch):
|
|||
assert resolved["requested_provider"] == "nous"
|
||||
|
||||
|
||||
def test_disabled_named_custom_provider_is_not_compatibility_fallback(monkeypatch):
|
||||
"""Disabled modern entries stay unavailable through the legacy projection."""
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"load_config",
|
||||
lambda: {
|
||||
"providers": {
|
||||
"route-key": {
|
||||
"name": "Route Key",
|
||||
"api": "https://disabled.example/v1",
|
||||
"enabled": False,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert rp._get_named_custom_provider("custom:route-key") is None
|
||||
|
||||
|
||||
def test_nous_pool_entry_refreshes_expired_agent_key(monkeypatch):
|
||||
stale_token = _fake_invoke_jwt(ttl_seconds=-60)
|
||||
fresh_token = _fake_invoke_jwt(ttl_seconds=3600)
|
||||
|
|
|
|||
|
|
@ -610,10 +610,16 @@ def test_direct_start_named_custom_route_resolves_configured_base_url():
|
|||
},
|
||||
"custom_providers": [
|
||||
{
|
||||
"name": "large-route",
|
||||
"base_url": "https://large.example/v1",
|
||||
"name": "Large Route",
|
||||
"base_url": "https://legacy-large.example/v1",
|
||||
}
|
||||
],
|
||||
"providers": {
|
||||
"large-route": {
|
||||
"name": "Large Route",
|
||||
"api": "https://large.example/v1",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
agent = _make_direct_start_agent(
|
||||
|
|
@ -630,7 +636,219 @@ def test_direct_start_named_custom_route_resolves_configured_base_url():
|
|||
cfg,
|
||||
model="shared-model",
|
||||
provider="custom",
|
||||
base_url="https://large.example/v1",
|
||||
base_url="HTTPS://LARGE.EXAMPLE:443/v1/",
|
||||
)
|
||||
|
||||
assert matching_agent.context_compressor.config_context_length == 1_048_576
|
||||
|
||||
legacy_agent = _make_direct_start_agent(
|
||||
cfg,
|
||||
model="shared-model",
|
||||
provider="custom",
|
||||
base_url="https://legacy-large.example/v1",
|
||||
)
|
||||
|
||||
assert legacy_agent.context_compressor.config_context_length is None
|
||||
|
||||
|
||||
def test_direct_start_named_custom_provider_key_uses_canonical_slug():
|
||||
"""Raw, canonical, and prefixed provider keys/names share runtime identity."""
|
||||
cfg = {
|
||||
"model": {
|
||||
"default": "shared-model",
|
||||
"provider": "custom:Route Key",
|
||||
"context_length": 1_048_576,
|
||||
},
|
||||
"providers": {
|
||||
"Route Key": {
|
||||
"name": "Friendly Label",
|
||||
"api": "https://key.example/v1",
|
||||
},
|
||||
"custom:Prefixed Key": {
|
||||
"name": "custom:Prefixed Label",
|
||||
"api": "https://prefixed.example/v1",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for configured_provider in (
|
||||
"custom:Route Key",
|
||||
"custom:Friendly Label",
|
||||
"Route Key",
|
||||
"route-key",
|
||||
"Friendly Label",
|
||||
"friendly-label",
|
||||
):
|
||||
cfg["model"]["provider"] = configured_provider
|
||||
agent = _make_direct_start_agent(
|
||||
cfg,
|
||||
model="shared-model",
|
||||
provider="custom",
|
||||
base_url="https://key.example/v1",
|
||||
)
|
||||
|
||||
assert agent.context_compressor.config_context_length == 1_048_576
|
||||
|
||||
for configured_provider in (
|
||||
"custom:Prefixed Key",
|
||||
"custom:Prefixed Label",
|
||||
"custom:custom:Prefixed Key",
|
||||
"custom:custom:Prefixed Label",
|
||||
):
|
||||
cfg["model"]["provider"] = configured_provider
|
||||
agent = _make_direct_start_agent(
|
||||
cfg,
|
||||
model="shared-model",
|
||||
provider="custom",
|
||||
base_url="https://prefixed.example/v1",
|
||||
)
|
||||
|
||||
assert agent.context_compressor.config_context_length == 1_048_576
|
||||
|
||||
for configured_provider in (
|
||||
"custom: Prefixed Key",
|
||||
"custom:\tPrefixed Key",
|
||||
):
|
||||
cfg["model"]["provider"] = configured_provider
|
||||
agent = _make_direct_start_agent(
|
||||
cfg,
|
||||
model="shared-model",
|
||||
provider="custom",
|
||||
base_url="https://prefixed.example/v1",
|
||||
)
|
||||
|
||||
assert agent.context_compressor.config_context_length is None
|
||||
|
||||
|
||||
def test_direct_start_named_custom_raw_legacy_display_name_matches():
|
||||
"""Legacy display names accepted by runtime also identify the scoped route."""
|
||||
cfg = {
|
||||
"model": {
|
||||
"default": "shared-model",
|
||||
"provider": "Legacy Route",
|
||||
"context_length": 1_048_576,
|
||||
},
|
||||
"custom_providers": [
|
||||
{
|
||||
"name": "Legacy Route",
|
||||
"base_url": "https://legacy.example/v1",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
agent = _make_direct_start_agent(
|
||||
cfg,
|
||||
model="shared-model",
|
||||
provider="custom",
|
||||
base_url="https://legacy.example/v1",
|
||||
)
|
||||
|
||||
assert agent.context_compressor.config_context_length == 1_048_576
|
||||
|
||||
|
||||
def test_direct_start_literal_bare_custom_entry_matches_runtime():
|
||||
"""A providers.custom entry makes bare custom a complete route identity."""
|
||||
cfg = {
|
||||
"model": {
|
||||
"default": "shared-model",
|
||||
"provider": "custom",
|
||||
"context_length": 1_048_576,
|
||||
},
|
||||
"providers": {
|
||||
"custom": {
|
||||
"api": "https://literal.example/v1",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
agent = _make_direct_start_agent(
|
||||
cfg,
|
||||
model="shared-model",
|
||||
provider="custom",
|
||||
base_url="https://literal.example/v1",
|
||||
)
|
||||
|
||||
assert agent.context_compressor.config_context_length == 1_048_576
|
||||
|
||||
|
||||
def test_direct_start_disabled_modern_custom_falls_back_only_to_legacy():
|
||||
"""Disabled modern entries cannot retain pins, but legacy fallback can."""
|
||||
cfg = {
|
||||
"model": {
|
||||
"default": "shared-model",
|
||||
"provider": "custom:route-key",
|
||||
"context_length": 1_048_576,
|
||||
},
|
||||
"providers": {
|
||||
"route-key": {
|
||||
"name": "Route Key",
|
||||
"api": "https://disabled.example/v1",
|
||||
"enabled": False,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
disabled_agent = _make_direct_start_agent(
|
||||
cfg,
|
||||
model="shared-model",
|
||||
provider="custom",
|
||||
base_url="https://disabled.example/v1",
|
||||
)
|
||||
assert disabled_agent.context_compressor.config_context_length is None
|
||||
|
||||
cfg["custom_providers"] = [
|
||||
{
|
||||
"name": "Route Key",
|
||||
"base_url": "https://legacy.example/v1",
|
||||
}
|
||||
]
|
||||
legacy_agent = _make_direct_start_agent(
|
||||
cfg,
|
||||
model="shared-model",
|
||||
provider="custom",
|
||||
base_url="https://legacy.example/v1",
|
||||
)
|
||||
assert legacy_agent.context_compressor.config_context_length == 1_048_576
|
||||
|
||||
|
||||
def test_direct_start_runtime_first_provider_names_require_explicit_custom_prefix():
|
||||
"""Auto, MoA, and Vertex routes cannot be shadowed by raw custom names."""
|
||||
for provider_name in (
|
||||
"auto",
|
||||
"moa",
|
||||
"vertex",
|
||||
"google-vertex",
|
||||
"vertex-ai",
|
||||
"gcp-vertex",
|
||||
"vertexai",
|
||||
):
|
||||
base_url = f"https://{provider_name}.shadow.example/v1"
|
||||
cfg = {
|
||||
"model": {
|
||||
"default": "shared-model",
|
||||
"provider": provider_name,
|
||||
"context_length": 1_048_576,
|
||||
},
|
||||
"providers": {
|
||||
provider_name: {
|
||||
"api": base_url,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
raw_agent = _make_direct_start_agent(
|
||||
cfg,
|
||||
model="shared-model",
|
||||
provider="custom",
|
||||
base_url=base_url,
|
||||
)
|
||||
assert raw_agent.context_compressor.config_context_length is None
|
||||
|
||||
cfg["model"]["provider"] = f"custom:{provider_name}"
|
||||
custom_agent = _make_direct_start_agent(
|
||||
cfg,
|
||||
model="shared-model",
|
||||
provider="custom",
|
||||
base_url=base_url,
|
||||
)
|
||||
assert custom_agent.context_compressor.config_context_length == 1_048_576
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue