mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-21 10:22:18 +00:00
Adds hermes_cli/provider_catalog.py, deriving one descriptor per provider from the CANONICAL_PROVIDERS universe (what `hermes model` renders, auto-extended from provider plugins), joined with auth/env from PROVIDER_REGISTRY and display metadata from ProviderProfile (with canonical/env fallbacks for the four profile-less providers and the many profiles with blank display/signup fields). Each descriptor is tagged with the desktop tab it belongs on (keys vs accounts) by auth_type. This is the single source of truth the desktop Providers tabs will derive membership from, so they can no longer drift from the CLI picker. Tests assert the parity contract (catalog == hermes model universe) and tab routing as invariants, not snapshots.
127 lines
5.1 KiB
Python
127 lines
5.1 KiB
Python
"""Tests for the unified provider catalog (hermes_cli.provider_catalog).
|
|
|
|
These are invariant tests, not snapshots: they assert the parity *contract*
|
|
between what ``hermes model`` shows (``CANONICAL_PROVIDERS``) and what the
|
|
catalog exposes, plus how each provider's ``auth_type`` maps to a desktop tab —
|
|
never a specific provider count or a frozen vendor list (both change over time).
|
|
"""
|
|
|
|
from hermes_cli.models import CANONICAL_PROVIDERS
|
|
from hermes_cli.provider_catalog import (
|
|
ProviderDescriptor,
|
|
provider_catalog,
|
|
provider_catalog_by_slug,
|
|
tab_for_auth_type,
|
|
)
|
|
|
|
|
|
def test_catalog_covers_every_hermes_model_provider():
|
|
"""PARITY CONTRACT: the catalog == the `hermes model` universe."""
|
|
slugs = {d.slug for d in provider_catalog()}
|
|
for entry in CANONICAL_PROVIDERS:
|
|
assert entry.slug in slugs, (
|
|
f"{entry.slug} is shown in `hermes model` but missing from provider_catalog()"
|
|
)
|
|
|
|
|
|
def test_catalog_has_no_providers_outside_hermes_model():
|
|
"""The catalog must not invent providers `hermes model` doesn't show."""
|
|
canonical = {e.slug for e in CANONICAL_PROVIDERS}
|
|
for d in provider_catalog():
|
|
assert d.slug in canonical, f"{d.slug} in catalog but not in CANONICAL_PROVIDERS"
|
|
|
|
|
|
def test_every_descriptor_lands_on_exactly_one_known_tab():
|
|
for d in provider_catalog():
|
|
assert d.tab in {"keys", "accounts"}, f"{d.slug} has bad tab {d.tab!r}"
|
|
|
|
|
|
def test_descriptor_count_matches_canonical():
|
|
"""One descriptor per canonical entry (no dupes, no drops)."""
|
|
cat = provider_catalog()
|
|
assert len(cat) == len(CANONICAL_PROVIDERS)
|
|
assert len({d.slug for d in cat}) == len(cat)
|
|
|
|
|
|
def test_profileless_providers_still_present():
|
|
"""Providers without a ProviderProfile must still resolve via fallbacks.
|
|
|
|
lmstudio / openai-api / tencent-tokenhub / xai-oauth have no profile on
|
|
main; they exist only as registry + canonical entries. The catalog must
|
|
not require a profile to include a provider.
|
|
"""
|
|
by = provider_catalog_by_slug()
|
|
for slug in ("lmstudio", "openai-api", "tencent-tokenhub", "xai-oauth"):
|
|
assert slug in by, f"{slug} dropped from catalog (profile-less provider)"
|
|
assert by[slug].label, f"{slug} has empty label despite canonical fallback"
|
|
assert by[slug].description, f"{slug} has empty description despite fallback"
|
|
|
|
|
|
def test_api_key_providers_route_to_keys_oauth_to_accounts():
|
|
by = provider_catalog_by_slug()
|
|
# api_key → keys
|
|
assert by["kilocode"].tab == "keys"
|
|
assert by["openai-api"].tab == "keys"
|
|
# account / sign-in flows → accounts
|
|
assert by["google-gemini-cli"].tab == "accounts"
|
|
assert by["copilot-acp"].tab == "accounts"
|
|
|
|
|
|
def test_copilot_surfaces_as_a_provider_with_its_own_token_var():
|
|
"""Regression for the reported bug: a GitHub Copilot login showed up under
|
|
tools, never as a provider, because the shared GITHUB_TOKEN is tool-category.
|
|
|
|
Copilot authenticates via the `copilot`/api_key path, so it belongs on the
|
|
keys tab — but its PRIMARY credential var must be the provider-owned
|
|
COPILOT_GITHUB_TOKEN, not the shared tool-category GITHUB_TOKEN. That is what
|
|
lets the desktop render Copilot as its own provider card.
|
|
"""
|
|
by = provider_catalog_by_slug()
|
|
assert "copilot" in by
|
|
d = by["copilot"]
|
|
assert d.tab == "keys"
|
|
assert d.api_key_env_vars, "Copilot must expose a credential env var"
|
|
assert d.api_key_env_vars[0] == "COPILOT_GITHUB_TOKEN", (
|
|
"Copilot's primary var must be the provider-owned token, not shared GITHUB_TOKEN"
|
|
)
|
|
|
|
|
|
def test_bedrock_routes_to_keys():
|
|
"""Bedrock is aws_sdk (AWS_REGION/AWS_PROFILE), configured on the keys tab."""
|
|
by = provider_catalog_by_slug()
|
|
assert by["bedrock"].tab == "keys"
|
|
|
|
|
|
def test_api_key_providers_expose_a_credential_env_var():
|
|
"""Every keys-tab provider that authenticates via a pasted API key must
|
|
surface at least one env var to write the key into (otherwise the GUI can't
|
|
configure it).
|
|
|
|
Exemptions: ``aws_sdk`` (bedrock — uses AWS_REGION/AWS_PROFILE) and the
|
|
``custom`` bring-your-own-endpoint pseudo-provider, which is configured
|
|
inline via the local-endpoint flow rather than a fixed env var.
|
|
"""
|
|
exempt = {"custom"}
|
|
for d in provider_catalog():
|
|
if d.auth_type == "api_key" and d.slug not in exempt:
|
|
assert d.api_key_env_vars, f"{d.slug} is api_key but exposes no env var"
|
|
|
|
|
|
def test_order_mirrors_canonical_declaration():
|
|
cat = provider_catalog()
|
|
assert [d.order for d in cat] == list(range(len(cat)))
|
|
assert [d.slug for d in cat] == [e.slug for e in CANONICAL_PROVIDERS]
|
|
|
|
|
|
def test_descriptors_are_provider_descriptor_instances():
|
|
for d in provider_catalog():
|
|
assert isinstance(d, ProviderDescriptor)
|
|
|
|
|
|
def test_tab_for_auth_type_helper():
|
|
assert tab_for_auth_type("api_key") == "keys"
|
|
assert tab_for_auth_type("aws_sdk") == "keys"
|
|
assert tab_for_auth_type("oauth_external") == "accounts"
|
|
assert tab_for_auth_type("oauth_device_code") == "accounts"
|
|
assert tab_for_auth_type("copilot") == "accounts"
|
|
assert tab_for_auth_type("external_process") == "accounts"
|