diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index f389f2c53a..ce7c0056be 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -860,7 +860,31 @@ def resolve_runtime_provider(*, requested: Optional[str] = None, explicit_api_ke OpenCode Zen/Go where different models route through different API surfaces).""" requested_provider = resolve_requested_provider(requested) _raise_if_provider_disabled(requested_provider) - return next(r for r in _ladder_rungs(requested_provider, explicit_api_key, explicit_base_url, target_model) if r) + runtime = next(r for r in _ladder_rungs(requested_provider, explicit_api_key, explicit_base_url, target_model) if r) + _raise_for_credentialless_bare_custom(requested_provider, runtime) + return runtime + + +def _raise_for_credentialless_bare_custom(requested_provider: str, runtime: Dict[str, Any]) -> None: + """Reject a stale bare ``custom`` placeholder before agent construction. + + Named custom providers and local OpenAI-compatible servers retain their existing resolution + paths. A bare placeholder that reaches a remote endpoint without a credential, however, would + otherwise fail later with the unrelated ``No LLM provider configured`` diagnostic. + """ + if requested_provider != "custom": + return + api_key = runtime.get("api_key") + if callable(api_key) or has_usable_secret(api_key): + return + if _loopback_hostname(base_url_hostname(str(runtime.get("base_url") or ""))): + return + raise AuthError( + "provider 'custom' resolved without usable credentials. If this is a named custom provider, " + "use its real name (see providers: in config.yaml).", + provider=requested_provider, + code="missing_api_key", + ) def _ladder_rungs(requested_provider, explicit_api_key, explicit_base_url, target_model): diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index d841a3e6bd..c3dfd8fe95 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -758,6 +758,50 @@ def test_bare_custom_resolves_providers_dict_entry_named_custom(monkeypatch): assert resolved["requested_provider"] == "custom" +def test_bare_custom_without_credentials_for_remote_endpoint_fails_fast(monkeypatch): + """A stale bare placeholder must name the bad request at resolution time.""" + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.setattr( + rp, + "load_config", + lambda: { + "providers": { + "volcano": { + "api": "https://ark.example.com/v1", + "key_env": "ARK_API_KEY", + } + } + }, + ) + monkeypatch.setattr(rp, "_get_model_config", lambda: {"provider": "volcano"}) + + with pytest.raises(rp.AuthError, match="provider 'custom'.*credentials.*real name") as error: + rp.resolve_runtime_provider(requested="custom") + + assert error.value.provider == "custom" + assert error.value.code == "missing_api_key" + + +def test_bare_custom_without_credentials_keeps_loopback_noauth(monkeypatch): + monkeypatch.setattr( + rp, + "load_config", + lambda: { + "providers": { + "custom": { + "api": "http://localhost:11434/v1", + } + } + }, + ) + + resolved = rp.resolve_runtime_provider(requested="custom") + + assert resolved["base_url"] == "http://localhost:11434/v1" + assert resolved["api_key"] == "no-key-required" + + def test_named_custom_provider_same_url_uses_matching_key_env_and_api_mode(monkeypatch):