fix(model): keep configured provider authoritative

This commit is contained in:
teknium1 2026-07-11 04:11:03 -07:00 committed by Teknium
parent 0ca6a98541
commit 8041be7954
4 changed files with 105 additions and 2 deletions

View file

@ -187,6 +187,14 @@ def build_models_payload(
if explicit_only:
rows = _filter_explicit_provider_rows(rows, ctx)
# Desktop chat pickers request the explicit subset without the full
# unconfigured provider universe. If the configured current provider
# has lost its credential, list_authenticated_providers() omits it;
# keep that one row visible so the UI can show the saved selection and
# a re-auth affordance instead of appearing to jump to another provider.
rows = list(rows) + _append_unconfigured_rows(
rows, ctx, current_only=True
)
# --- Deduplicate: remove models from aggregators that overlap with
# user-defined providers. When a local proxy (e.g. litellm-proxy)
@ -292,7 +300,12 @@ def _apply_capabilities(rows: list[dict]) -> None:
# ─── Internal: row post-processing ──────────────────────────────────────
def _append_unconfigured_rows(rows: list[dict], ctx: ConfigContext) -> list[dict]:
def _append_unconfigured_rows(
rows: list[dict],
ctx: ConfigContext,
*,
current_only: bool = False,
) -> list[dict]:
"""Build fallback rows for canonical providers missing from ``rows``.
Most missing canonical providers become empty setup skeletons. The one
@ -310,6 +323,8 @@ def _append_unconfigured_rows(rows: list[dict], ctx: ConfigContext) -> list[dict
for entry in CANONICAL_PROVIDERS:
if entry.slug.lower() in seen:
continue
if current_only and entry.slug.lower() != cur:
continue
if entry.slug.lower() == cur:
cfg = PROVIDER_REGISTRY.get(entry.slug)
auth_type = cfg.auth_type if cfg else "api_key"

View file

@ -1989,6 +1989,20 @@ def resolve_runtime_provider(
pconfig = PROVIDER_REGISTRY.get(provider)
if pconfig and pconfig.auth_type == "api_key":
creds = resolve_api_key_provider_credentials(provider)
# An explicitly selected API-key provider is authoritative. Returning
# a runtime with an empty key defers failure until the first request and
# can make a later fallback look like a silent provider switch. Fail at
# resolution so callers surface the missing credential (or consult only
# an explicitly configured fallback chain). LM Studio's no-auth path
# supplies a non-empty placeholder in the credential resolver above.
if not has_usable_secret(creds.get("api_key")):
env_names = ", ".join(pconfig.api_key_env_vars)
hint = f" Set {env_names}." if env_names else ""
raise AuthError(
f"No usable credentials found for provider '{provider}'.{hint}",
provider=provider,
code="missing_api_key",
)
# Honour model.base_url from config.yaml when the configured provider
# matches this provider — mirrors the Anthropic path above. Without
# this, users who set model.base_url to e.g. api.minimaxi.com/anthropic

View file

@ -415,6 +415,24 @@ def test_explicit_only_filters_ambient_credentials_but_keeps_current_and_custom_
"gemini",
"custom:lab",
]
def test_explicit_only_keeps_unauthenticated_current_provider_visible():
"""Desktop's configured-only picker must retain its saved provider row."""
ctx = _empty_ctx(provider="deepseek", model="deepseek-v4-pro")
with _list_auth_returning([]):
payload = build_models_payload(
ctx,
explicit_only=True,
picker_hints=True,
)
assert [row["slug"] for row in payload["providers"]] == ["deepseek"]
row = payload["providers"][0]
assert row["source"] == "configured-current"
assert row["authenticated"] is False
assert row["models"] == ["deepseek-v4-pro"]
assert "DEEPSEEK_API_KEY" in row["warning"]
def test_include_unconfigured_keeps_current_provider_visible_without_credentials():
"""If the saved provider is currently unauthenticated, keep a visible row
with the saved model so GUI pickers don't silently jump to another
@ -435,6 +453,19 @@ def test_include_unconfigured_keeps_current_provider_visible_without_credentials
assert "DEEPSEEK_API_KEY" in deepseek["warning"]
assert "saved model only" in deepseek["warning"]
def test_include_unconfigured_does_not_duplicate_configured_current_row():
ctx = _empty_ctx(provider="deepseek", model="deepseek-v4-pro")
with _list_auth_returning([]):
payload = build_models_payload(
ctx,
explicit_only=True,
include_unconfigured=True,
picker_hints=True,
)
assert sum(row["slug"] == "deepseek" for row in payload["providers"]) == 1
def test_explicit_only_keeps_moa_when_raw_config_has_enabled_preset():
rows = [
{"slug": "moa", "name": "MoA", "models": ["review"],
@ -468,8 +499,10 @@ def test_explicit_only_keeps_moa_when_raw_config_has_enabled_preset():
):
payload = build_models_payload(ctx, explicit_only=True)
assert [row["slug"] for row in payload["providers"]] == ["moa"]
assert [row["slug"] for row in payload["providers"]] == ["moa", "openrouter"]
assert payload["providers"][0]["models"] == ["review"]
assert payload["providers"][1]["source"] == "configured-current"
assert payload["providers"][1]["authenticated"] is False
# ─── picker_hints ──────────────────────────────────────────────────────

View file

@ -8,6 +8,47 @@ import pytest
from hermes_cli import runtime_provider as rp
def test_configured_api_key_provider_without_key_fails_closed(monkeypatch):
"""A saved provider must not resolve as another authenticated provider."""
monkeypatch.setattr(
rp,
"_get_model_config",
lambda: {"provider": "deepseek", "default": "deepseek-v4-pro"},
)
monkeypatch.setattr(rp, "load_pool", lambda _provider: SimpleNamespace(has_credentials=lambda: False))
monkeypatch.setattr(
"hermes_cli.auth.resolve_api_key_provider_credentials",
lambda _provider: {
"provider": "deepseek",
"api_key": "",
"base_url": "https://api.deepseek.com/v1",
"source": "default",
},
)
with pytest.raises(rp.AuthError, match="No usable credentials.*deepseek"):
rp.resolve_runtime_provider()
def test_noauth_lmstudio_still_resolves(monkeypatch):
"""The fail-closed key guard preserves LM Studio's no-auth contract."""
monkeypatch.setattr(rp, "load_pool", lambda _provider: SimpleNamespace(has_credentials=lambda: False))
monkeypatch.setattr(
"hermes_cli.auth.resolve_api_key_provider_credentials",
lambda _provider: {
"provider": "lmstudio",
"api_key": "lmstudio-noauth",
"base_url": "http://localhost:1234/v1",
"source": "default",
},
)
resolved = rp.resolve_runtime_provider(requested="lmstudio")
assert resolved["provider"] == "lmstudio"
assert resolved["api_key"]
def _fake_invoke_jwt(ttl_seconds=3600):
header = base64.urlsafe_b64encode(b'{"alg":"none","typ":"JWT"}').decode().rstrip("=")
payload = base64.urlsafe_b64encode(