mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
fix(model): keep configured provider authoritative
This commit is contained in:
parent
0ca6a98541
commit
8041be7954
4 changed files with 105 additions and 2 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue