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:
teknium1
2026-09-15 11:56:17 -07:00
committed by Teknium
parent 879d65ec78
commit e9a54c48f2
2 changed files with 24 additions and 17 deletions

View File

@@ -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",
)

View File

@@ -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)