From df2e6f2d0cf7e7b5eeb44e5a8917593559452881 Mon Sep 17 00:00:00 2001 From: tobenwarrior Date: Thu, 17 Sep 2026 14:49:36 +0800 Subject: [PATCH] fix(models): seat configured external_process providers in the picker payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list_authenticated_providers' canonical pass keyed credentials on env vars/auth.json/pool only, and model_ids on models.dev/curated lists — neither exists for agent-CLI providers, so even picker-admitted external_process plugin providers produced no row. Credential evidence now falls back to auth.get_external_process_provider_status (reachability), and an empty catalog falls back to the self-named model (provider name → session-default model). Matches the in-tree copilot-acp precedent, which is hardcoded into the static list. Tests: payload seats a configured provider with models=[slug] + authenticated=True; an unresolvable binary stays hidden. 10/10. --- hermes_cli/model_switch_providers.py | 12 ++++ .../test_plugin_acp_picker_parity.py | 61 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/hermes_cli/model_switch_providers.py b/hermes_cli/model_switch_providers.py index 9e45c97151..7b3001cfc4 100644 --- a/hermes_cli/model_switch_providers.py +++ b/hermes_cli/model_switch_providers.py @@ -937,6 +937,14 @@ def _lap_canonical_rows(b: _PickerBuild) -> None: continue has_creds = has_creds or _auth_store_has_provider(cp.slug) or _pool_usable(cp.slug) or ( _is_aws_sdk(cp_config) and _has_aws_sdk_creds_for_listing(cp.slug, b.current_provider)) + if not has_creds and cp_config is not None and getattr(cp_config, "auth_type", "") == "external_process": + # Subprocess-backed providers own their auth; structural reachability + # (the binary resolves) is the credential evidence for listing. + try: + from hermes_cli.auth import get_external_process_provider_status + has_creds = bool(get_external_process_provider_status(cp.slug).get("configured")) + except Exception: + has_creds = False if not has_creds: continue if _is_aws_sdk(cp_config): @@ -944,6 +952,10 @@ def _lap_canonical_rows(b: _PickerBuild) -> None: else: model_ids = _live_or_curated_ids(cp.slug, b.curated, merge_models_dev=False, non_blocking=b.non_blocking_catalogs) + if not model_ids and cp_config is not None and getattr(cp_config, "auth_type", "") == "external_process": + # No models.dev / curated entry exists for agent CLIs; the provider + # name is itself the selectable model (maps to the session default). + model_ids = [cp.slug] b.add_builtin_row( cp.slug, cp.label, cp.slug == b.current_provider, model_ids, "canonical", uncapped_ok=False) diff --git a/tests/hermes_cli/test_plugin_acp_picker_parity.py b/tests/hermes_cli/test_plugin_acp_picker_parity.py index 7240792611..33c85057e2 100644 --- a/tests/hermes_cli/test_plugin_acp_picker_parity.py +++ b/tests/hermes_cli/test_plugin_acp_picker_parity.py @@ -40,6 +40,67 @@ def test_api_key_plugin_providers_unchanged(): assert _plugin_provider_enters_picker(_profile("acme-api", "api_key")) is True +def test_external_process_providers_in_model_options_payload(monkeypatch): + """Layer 1 admits it into CANONICAL_PROVIDERS; layer 2 (inventory) seats a + row with a self-named model, reachability as the credential.""" + import hermes_cli.auth as auth + import hermes_cli.inventory as inv + import hermes_cli.models as models + + fake = _make_registry_profile() + monkeypatch.setattr("providers.list_providers", lambda: [fake]) + monkeypatch.setattr(auth, "get_external_process_provider_status", lambda slug: {"configured": True}) + entry = models.ProviderEntry("ext-proc", "Ext Proc", "external process test provider") + monkeypatch.setattr(models, "CANONICAL_PROVIDERS", list(models.CANONICAL_PROVIDERS) + [entry]) + ctx = inv.ConfigContext( + current_provider="zai", current_model="glm-5.3", + current_base_url="https://api.z.ai/api/coding/paas/v4", + user_providers={}, custom_providers=[]) + payload = inv.build_model_options_payload(ctx, explicit_only=False) + rows = [p for p in payload["providers"] if p.get("slug") == "ext-proc"] + assert rows, "external_process plugin provider missing from model.options payload" + assert rows[0]["models"] == ["ext-proc"] + assert rows[0]["authenticated"] is True + + +def test_unresolvable_external_process_binary_not_listed(monkeypatch): + """Reachability is the credential: a binary that doesn't resolve stays hidden.""" + import hermes_cli.auth as auth + import hermes_cli.inventory as inv + import hermes_cli.models as models + + fake = _make_registry_profile() + monkeypatch.setattr("providers.list_providers", lambda: [fake]) + monkeypatch.setattr( + auth, "get_external_process_provider_status", + lambda slug: {"configured": False}) + entry = models.ProviderEntry("ext-proc", "Ext Proc", "external process test provider") + monkeypatch.setattr(models, "CANONICAL_PROVIDERS", list(models.CANONICAL_PROVIDERS) + [entry]) + ctx = inv.ConfigContext( + current_provider="zai", current_model="glm-5.3", current_base_url="", + user_providers={}, custom_providers=[]) + payload = inv.build_model_options_payload(ctx, explicit_only=False) + assert not [p for p in payload["providers"] if p.get("slug") == "ext-proc"] + + +def _make_registry_profile(): + """A profile + matching ProviderConfig, registered in both registries.""" + from types import SimpleNamespace + profile = SimpleNamespace( + name="ext-proc", auth_type="external_process", display_name="Ext Proc", + description="external process test provider", aliases=(), + process_command="/fake/does-not-exist", process_args=("acp",), + ) + import hermes_cli.auth as auth + registry_entry = SimpleNamespace( + name="ext-proc", display_name="Ext Proc", auth_type="external_process", + api_key_env_vars=(), base_url="acp://ext", inference_base_url="acp://ext", + command="ext-proc", args=("acp",), base_url_env_var=None, + ) + auth.PROVIDER_REGISTRY.setdefault("ext-proc", registry_entry) + return profile + + def test_auto_extend_seats_external_process_provider(monkeypatch): """Integration through the real auto-extend loop (reloaded with a stubbed plugin registry, mirroring discovery output)."""