From c73a0c3ef001c39a5b58acae0b93a548025752b3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:33:02 -0700 Subject: [PATCH] =?UTF-8?q?refactor(hermes=5Fcli):=20route=5Fidentity/prov?= =?UTF-8?q?ider=5Fcatalog=20=E2=80=94=20*args=20async=20wrapper,=20url-var?= =?UTF-8?q?=20predicate,=20compact=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/provider_catalog.py | 99 ++++++++++++---------------------- hermes_cli/route_identity.py | 23 +++----- 2 files changed, 41 insertions(+), 81 deletions(-) diff --git a/hermes_cli/provider_catalog.py b/hermes_cli/provider_catalog.py index d80e3dbe49..eda5631fa3 100644 --- a/hermes_cli/provider_catalog.py +++ b/hermes_cli/provider_catalog.py @@ -1,35 +1,26 @@ """Unified provider catalog — one source of truth for the provider universe. The provider list shown by ``hermes model`` (CLI/TUI) and the desktop Settings → Providers tabs -(Accounts + API keys) **must be the same set**. Every provider added after those lists were written -silently went missing from the GUI — e.g. - -* ``auth_type`` / ``api_key_env_vars`` / ``base_url_env_var`` from -:data:`hermes_cli.auth.PROVIDER_REGISTRY` (credential truth), and * ``display_name`` / -``description`` / ``signup_url`` from the provider's :class:`providers.base.ProviderProfile` when -one exists, falling back to the ``CANONICAL_PROVIDERS`` entry's ``label`` / ``tui_desc`` and the -``OPTIONAL_ENV_VARS`` signup URL otherwise (many profiles leave these blank, and four canonical -providers have no profile at all — lmstudio, openai-api, tencent-tokenhub, xai-oauth — so the -fallbacks are load-bearing). +(Accounts + API keys) **must be the same set**; providers added after those lists were written +silently went missing from the GUI. ``auth_type`` / ``api_key_env_vars`` / ``base_url_env_var`` +come from :data:`hermes_cli.auth.PROVIDER_REGISTRY` (credential truth); ``display_name`` / +``description`` / ``signup_url`` from the provider's :class:`providers.base.ProviderProfile`, falling +back to the ``CANONICAL_PROVIDERS`` entry's ``label`` / ``tui_desc`` and the ``OPTIONAL_ENV_VARS`` +signup URL (many profiles leave these blank, and lmstudio, openai-api, tencent-tokenhub, xai-oauth +have no profile at all — the fallbacks are load-bearing). """ from __future__ import annotations from dataclasses import dataclass -# Auth types that authenticate via an account / sign-in flow rather than a -# pasted API key. These route to the desktop "Accounts" tab; everything else -# (api_key, and aws_sdk which is configured via AWS_REGION/AWS_PROFILE) routes -# to the "API keys" tab. Mirrors the auth_type strings used in -# hermes_cli.auth.PROVIDER_REGISTRY and providers.base.ProviderProfile. +# Auth types that authenticate via an account / sign-in flow rather than a pasted API key; these +# route to the desktop "Accounts" tab, everything else (api_key, and aws_sdk configured via +# AWS_REGION/AWS_PROFILE) to "API keys". Mirrors the auth_type strings in PROVIDER_REGISTRY and +# ProviderProfile: external_process = copilot-acp (spawns `copilot --acp --stdio`), copilot = GitHub +# Copilot token / gh auth. _ACCOUNTS_AUTH_TYPES: frozenset[str] = frozenset( - { - "oauth_device_code", - "oauth_external", - "oauth_minimax", - "external_process", # copilot-acp: spawns `copilot --acp --stdio` - "copilot", # GitHub Copilot token / gh auth - } + {"oauth_device_code", "oauth_external", "oauth_minimax", "external_process", "copilot"} ) @@ -54,19 +45,19 @@ def tab_for_auth_type(auth_type: str) -> str: return "accounts" if auth_type in _ACCOUNTS_AUTH_TYPES else "keys" +def _is_url_var(name: str) -> bool: + return name.endswith("_BASE_URL") or name.endswith("_URL") + + def _split_env_vars(env_vars: tuple[str, ...]) -> tuple[tuple[str, ...], str]: """Split a profile's ``env_vars`` into (api_key_vars, base_url_var).""" - keys = tuple(v for v in env_vars if not (v.endswith("_BASE_URL") or v.endswith("_URL"))) - base = next((v for v in env_vars if v.endswith("_BASE_URL") or v.endswith("_URL")), "") - return keys, base + return tuple(v for v in env_vars if not _is_url_var(v)), next((v for v in env_vars if _is_url_var(v)), "") def _safe_import(module: str, attr: str, default): - """Import ``attr`` from ``module``; return ``default`` on ANY failure. - - This module is on the import path of the web server and the CLI, and a - provider-plugin import error must never blank the whole catalog. - """ + """Import ``attr`` from ``module``; ``default`` on ANY failure — this module is on the import + path of the web server and the CLI, and a provider-plugin import error must never blank the + whole catalog.""" try: return getattr(__import__(module, fromlist=[attr]), attr) except Exception: @@ -74,19 +65,15 @@ def _safe_import(module: str, attr: str, default): def provider_catalog() -> list[ProviderDescriptor]: - """Return one descriptor per provider in the ``hermes model`` universe. - - Membership is :data:`CANONICAL_PROVIDERS` (auto-extended by provider plugins). Auth/env come - from ``PROVIDER_REGISTRY``; display metadata from ``ProviderProfile`` with canonical/env - fallbacks so providers without a profile still resolve sensibly. - """ + """One descriptor per provider in the ``hermes model`` universe (:data:`CANONICAL_PROVIDERS`, + auto-extended by provider plugins). Auth/env from ``PROVIDER_REGISTRY``; display metadata from + ``ProviderProfile`` with canonical/env fallbacks so profile-less providers still resolve.""" from hermes_cli.models import CANONICAL_PROVIDERS PROVIDER_REGISTRY = _safe_import("hermes_cli.auth", "PROVIDER_REGISTRY", {}) OPTIONAL_ENV_VARS = _safe_import("hermes_cli.config", "OPTIONAL_ENV_VARS", {}) - # Hermes overlays carry auth_type for providers that have no registry/profile - # entry of their own — notably the ``moa`` virtual provider (auth_type - # "virtual"), which has no real credential and no network endpoint. + # Overlays carry auth_type for providers with no registry/profile entry — notably the ``moa`` + # virtual provider (auth_type "virtual"), which has no credential and no network endpoint. HERMES_OVERLAYS = _safe_import("hermes_cli.providers", "HERMES_OVERLAYS", {}) try: from providers import list_providers @@ -101,47 +88,31 @@ def provider_catalog() -> list[ProviderDescriptor]: cfg = PROVIDER_REGISTRY.get(slug) prof = profiles.get(slug) overlay = HERMES_OVERLAYS.get(slug) - - # auth_type: registry is authoritative; fall back to profile, then the - # Hermes overlay (e.g. moa → "virtual"), then api_key. + # auth_type: registry is authoritative; then profile, then overlay (moa → "virtual"), then api_key. auth_type = ( (cfg.auth_type if cfg else "") or (prof.auth_type if prof else "") or (overlay.auth_type if overlay else "") or "api_key" ) - - # Credential env vars: registry first (it already normalizes these), - # else derive from the profile's env_vars tuple. + # Credential env vars: registry first (already normalized), else derived from the profile. if cfg and cfg.api_key_env_vars: - api_key_vars = tuple(cfg.api_key_env_vars) - base_url_var = cfg.base_url_env_var or "" + api_key_vars, base_url_var = tuple(cfg.api_key_env_vars), cfg.base_url_env_var or "" elif prof and prof.env_vars: api_key_vars, base_url_var = _split_env_vars(tuple(prof.env_vars)) else: api_key_vars, base_url_var = (), "" - label = (prof.display_name if prof else "") or entry.label or slug - description = (prof.description if prof else "") or entry.tui_desc or label signup_url = (prof.signup_url if prof else "") or "" if not signup_url and api_key_vars: - info = OPTIONAL_ENV_VARS.get(api_key_vars[0]) or {} - signup_url = info.get("url") or "" - + signup_url = (OPTIONAL_ENV_VARS.get(api_key_vars[0]) or {}).get("url") or "" out.append( ProviderDescriptor( - slug=slug, - label=label, - description=description, - auth_type=auth_type, - tab=tab_for_auth_type(auth_type), - api_key_env_vars=api_key_vars, - base_url_env_var=base_url_var, - signup_url=signup_url, - order=order, - # Keyless providers (e.g. opencode-free) are served - # anonymously: there is no credential to configure, so the - # GUI renders no key card and contract tests exempt them. + slug=slug, label=label, description=(prof.description if prof else "") or entry.tui_desc or label, + auth_type=auth_type, tab=tab_for_auth_type(auth_type), api_key_env_vars=api_key_vars, + base_url_env_var=base_url_var, signup_url=signup_url, order=order, + # Keyless providers (opencode-free) are served anonymously: no key card in the GUI, + # and contract tests exempt them. keyless=bool(overlay.keyless) if overlay else False, ) ) diff --git a/hermes_cli/route_identity.py b/hermes_cli/route_identity.py index 90d4e0c9cf..65c5be808b 100644 --- a/hermes_cli/route_identity.py +++ b/hermes_cli/route_identity.py @@ -52,10 +52,8 @@ def should_clear_context_pin( configured_provider: Any, active_provider: Any, ) -> bool: """True when a configured ``model.context_length`` pin no longer matches its runtime route. - Fail-closed: any error during route comparison returns ``True`` (drop the pin) so a stale window - never silently inflates the compression threshold. - """ + never silently inflates the compression threshold.""" configured_model = str(configured_model or "").strip() if configured_model and configured_model != str(active_model or "").strip(): return True @@ -67,19 +65,10 @@ def should_clear_context_pin( return True -async def should_clear_context_pin_async( - configured_model: Any, active_model: Any, configured_base_url: Any, active_base_url: Any, - configured_provider: Any, active_provider: Any, -) -> bool: - """Async wrapper for ``should_clear_context_pin``. - - Offloads the route comparison to a worker thread so async gateway handlers never run it on the - event loop — the resolution chain is cache-only (``allow_network=False``) but can still do cold- - start disk I/O. Shares all logic with the sync version — no code duplication. - """ +async def should_clear_context_pin_async(*args: Any) -> bool: + """``should_clear_context_pin`` on a worker thread so async gateway handlers never run it on the + event loop — the resolution chain is cache-only (``allow_network=False``) but can still do + cold-start disk I/O.""" import asyncio - return await asyncio.to_thread( - should_clear_context_pin, configured_model, active_model, configured_base_url, active_base_url, - configured_provider, active_provider, - ) + return await asyncio.to_thread(should_clear_context_pin, *args)