From cb785e6b4927df6e32db4392518312cae95924ed Mon Sep 17 00:00:00 2001 From: cucurigoo <241698038+cucurigoo@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:00:14 +0000 Subject: [PATCH] fix(providers): align custom route scoping --- agent/agent_init.py | 114 ++++++++- hermes_cli/config.py | 2 + tests/hermes_cli/test_config.py | 16 ++ .../test_runtime_provider_resolution.py | 19 ++ tests/run_agent/test_switch_model_context.py | 224 +++++++++++++++++- 5 files changed, 362 insertions(+), 13 deletions(-) diff --git a/agent/agent_init.py b/agent/agent_init.py index 3ae396e59db..fe6a2ca1fac 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -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]: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 23c1b7c66e3..1700862c559 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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) diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index c7e48dab2d3..196483f4941 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -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" diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index 198fd0488bd..6a69bbf17ab 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -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) diff --git a/tests/run_agent/test_switch_model_context.py b/tests/run_agent/test_switch_model_context.py index 48208eae77c..f526557fbec 100644 --- a/tests/run_agent/test_switch_model_context.py +++ b/tests/run_agent/test_switch_model_context.py @@ -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