fix(runtime): bare-custom fail-fast keys on the dead runtime shape, covers custom aliases
Tighten the post-ladder guard from #111744 to the exact failing shape: a resolved ``custom`` runtime with an EMPTY api_key. Every other custom rung (named entry, direct alias, local bypass, pool, key_cmd) yields a key, a callable or the ``no-key-required`` placeholder, so the loopback heuristic and the has_usable_secret() re-check were dead branches — and keying on the requested name alone missed aliases that resolve to custom (``ollama``, ``vllm`` with nothing configured), which still returned the credential-less OpenRouter fallback and died at agent construction as "No LLM provider configured". The AuthError now names the requested provider, so cron's runner/preflight, the CLI, the gateway and the TUI gateway (all of which format AuthError or walk their fallback chain on it) show the culprit. Tests folded to two invariants: raise-and-name (bare + alias, plus the OpenRouter-key control from #111750) and the loopback no-auth control. Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
This commit is contained in:
@@ -866,22 +866,18 @@ def resolve_runtime_provider(*, requested: Optional[str] = None, explicit_api_ke
|
||||
|
||||
|
||||
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.
|
||||
"""Reject a bare ``custom`` placeholder (or an alias resolving to it: ollama, vllm, …) that fell
|
||||
through the whole ladder to the OpenRouter default endpoint with no credential. Every other
|
||||
custom rung (named entry, direct alias, local bypass, pool, ``key_cmd``) yields a key, a callable
|
||||
or the ``no-key-required`` placeholder, so an EMPTY key on a ``custom`` runtime is exactly the
|
||||
dead shape that otherwise dies at agent construction as ``No LLM provider configured``. Typed
|
||||
``AuthError`` so every caller's fallback chain (CLI, gateway, TUI, cron) still advances (#17929).
|
||||
"""
|
||||
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 ""))):
|
||||
if runtime.get("provider") != "custom" or runtime.get("api_key"):
|
||||
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).",
|
||||
f"provider '{requested_provider}' resolved without credentials (no endpoint or API key configured). "
|
||||
"If this is a named custom provider, use its real name (see providers: in config.yaml).",
|
||||
provider=requested_provider,
|
||||
code="missing_api_key",
|
||||
)
|
||||
|
||||
@@ -759,9 +759,14 @@ def test_bare_custom_resolves_providers_dict_entry_named_custom(monkeypatch):
|
||||
|
||||
|
||||
def test_bare_custom_without_credentials_for_remote_endpoint_fails_fast(monkeypatch):
|
||||
"""A stale bare placeholder must name the bad request at resolution time."""
|
||||
"""#111741: a bare ``custom`` placeholder (or an alias resolving to it) that falls through to the
|
||||
OpenRouter default with no key must raise a typed AuthError naming the request at resolve time,
|
||||
instead of returning a dead runtime that dies later as "No LLM provider configured". With an
|
||||
OpenRouter key present the same request keeps resolving exactly as before."""
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
monkeypatch.delenv("CUSTOM_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"load_config",
|
||||
@@ -778,12 +783,20 @@ def test_bare_custom_without_credentials_for_remote_endpoint_fails_fast(monkeypa
|
||||
|
||||
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"
|
||||
|
||||
with pytest.raises(rp.AuthError, match="provider 'ollama'"):
|
||||
rp.resolve_runtime_provider(requested="ollama")
|
||||
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-v1-usable-key")
|
||||
resolved = rp.resolve_runtime_provider(requested="custom")
|
||||
assert resolved["provider"] == "custom"
|
||||
assert resolved["api_key"] == "sk-or-v1-usable-key"
|
||||
|
||||
|
||||
def test_bare_custom_without_credentials_keeps_loopback_noauth(monkeypatch):
|
||||
"""Control: a configured no-auth local endpoint still resolves with the placeholder key."""
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"load_config",
|
||||
@@ -802,8 +815,6 @@ def test_bare_custom_without_credentials_keeps_loopback_noauth(monkeypatch):
|
||||
assert resolved["api_key"] == "no-key-required"
|
||||
|
||||
|
||||
|
||||
|
||||
def test_named_custom_provider_same_url_uses_matching_key_env_and_api_mode(monkeypatch):
|
||||
"""Named custom providers on one gateway must keep their own credentials and protocol."""
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
|
||||
Reference in New Issue
Block a user