Skip to content

feat(model-switch): add excluded_providers config + fix custom provider grouping for shared-endpoint proxies - #28218

Closed
craigdfrench wants to merge 2 commits into
NousResearch:mainfrom
craigdfrench:feat/excluded-providers-and-proxy-grouping
Closed

feat(model-switch): add excluded_providers config + fix custom provider grouping for shared-endpoint proxies#28218
craigdfrench wants to merge 2 commits into
NousResearch:mainfrom
craigdfrench:feat/excluded-providers-and-proxy-grouping

Conversation

@craigdfrench

Copy link
Copy Markdown
Contributor

Summary

Two independent improvements to list_authenticated_providers and the /model picker.

1. model_catalog.excluded_providers config key

Allows users to hide specific providers from the /model picker even when valid credentials exist. Useful when credentials are present for legacy/testing providers that shouldn't appear in normal use (e.g. an old Copilot or OpenRouter token still cached in auth.json or discovered via the gh CLI).

Config:
```yaml
model_catalog:
excluded_providers:
- copilot
- openrouter
- openai
```

The exclusion is applied across all three credential-detection sections:

  • Section 1 (models.dev-mapped providers): checks hermes_id and mdev_id
  • Section 2 (HERMES_OVERLAYS): checks pid and resolved hermes_slug
  • Section 2b (CANONICAL_PROVIDERS): checks slug

Propagated via a new excluded_providers field on ConfigContext in inventory.py, read from model_catalog.excluded_providers in load_picker_context().

2. Fix custom provider grouping for shared-endpoint proxies

Problem: When multiple custom_providers entries share the same base_url (e.g. an Aperture/LiteLLM proxy fronting cerebras, groq, and perplexity at a single URL), they were collapsed into one picker row under the first provider's name with all models merged together.

Root cause: Section 4 grouped entries by (base_url, api_key) — identical for all entries behind a shared proxy — so only one row appeared.

Fix: Include the display name prefix in the group key: (base_url, api_key, name_prefix). Entries with the same URL but different names now each produce their own picker row. The existing "Provider — ModelName" suffix-stripping behavior for Ollama-style per-model entries is preserved (those share the same name prefix and still collapse correctly).

Additional fixes in section 4:

  • Slug assignment: Only reuse current_provider as the slug when the provider name also matches, preventing all shared-URL entries from inheriting the active provider's slug (which caused groq and perplexity to appear as custom:cerebras-2, custom:cerebras-3, etc.)
  • Builtin endpoint dedup: Skip suppression when the custom entry has an explicit models: dict — the user has intentionally defined per-provider model lists on a proxy and the rows should always surface
  • Live /v1/models discovery: Skip when models: is explicitly defined in config — the proxy's endpoint returns all models across all backends, not just the ones scoped to this provider entry

Test plan

  • Add excluded_providers: [copilot] to model_catalog config; verify Copilot row disappears from /model picker even with gh CLI authenticated
  • Configure two custom_providers entries pointing at the same base_url with different names and models: dicts; verify both appear as separate rows with correct model counts
  • Verify existing Ollama "Provider — ModelName" suffix-stripping grouping still works (entries sharing URL + key + name prefix still collapse into one row)
  • Verify excluded_providers: [] (empty) has no effect on picker output

Generated with Devin

@craigdfrench
craigdfrench force-pushed the feat/excluded-providers-and-proxy-grouping branch from 714ece4 to a66bd2d Compare May 18, 2026 21:25
@alt-glitch alt-glitch added type/feature New feature or request comp/cli CLI entry point, hermes_cli/, setup wizard area/config Config system, migrations, profiles P3 Low — cosmetic, nice to have labels May 18, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Supersedes #21544 for the custom provider grouping fix, and adds the new excluded_providers config feature on top.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the contribution — both use cases are real, but this needs rework against current main before it is safe to salvage.

Problems

  • model_catalog.excluded_providers is loaded only through hermes_cli/inventory.py in this PR, while the gateway /model path still calls list_picker_providers / list_authenticated_providers directly at gateway/slash_commands.py:1019 and gateway/slash_commands.py:1168, so those picker surfaces would ignore the new config.
  • The grouping fix is based on the older (api_url, api_key) shape. Current main groups by credential_identity and api_mode at hermes_cli/model_switch.py:1850 and hermes_cli/model_switch.py:1868; dropping those dimensions would regress same-URL entries with different env-backed credentials or transports, covered by tests/hermes_cli/test_model_switch_custom_providers.py:470.
  • Current main intentionally uses live /models by default for API-key custom providers and uses discover_models: false as the scoped-subset opt-out (hermes_cli/model_switch.py:1985, tests at tests/hermes_cli/test_model_switch_custom_providers.py:614 and :675).

Suggested changes

  • Extend the current grouping key rather than replacing it: endpoint + credential identity + API mode + display prefix.
  • Wire excluded providers through all picker/list call paths and add regression tests/docs for the new config key.

Automated hermes-sweeper review.

Comment thread hermes_cli/inventory.py
@@ -96,12 +97,14 @@ def load_picker_context() -> ConfigContext:
current_provider = ""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only feeds inventory consumers. The gateway /model path still calls list_picker_providers / list_authenticated_providers directly, so the new config would not hide providers on that picker surface unless those call paths also receive the exclusion list.

@@ -1590,7 +1603,14 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:
continue

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On current main this needs to preserve the newer grouping dimensions too: credential_identity and api_mode. Adding the display prefix is useful, but replacing the key with (api_url, api_key, prefix) would merge entries that differ by key_env or transport.

@@ -1706,7 +1745,7 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:
# - Without an api_key AND no explicit models, fall through to

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current main uses discover_models: false as the explicit opt-out from live discovery. Skipping discovery for every API-key custom provider with a models dict would regress the default gateway/Bifrost case where the live catalog should replace a stale configured subset.

… grouping

Reworks NousResearch#28218 against current main per review feedback (hermes-sweeper,
PRR_kwDOPRF1G88AAAABDBIirw). Two independent improvements to the /model
picker substrate.

### 1. `model_catalog.excluded_providers` config key

Hide specific providers from the /model picker even when valid credentials
exist (e.g. a stale Copilot/OpenRouter token cached in auth.json or found
via the `gh` CLI). Matched case-insensitively against every key a provider
can surface under — hermes_id / mdev_id (section 1), pid / hermes_slug
(section 2), canonical slug (section 2b) — so `copilot` hides the provider
regardless of which section emits it.

Wired through ALL picker/list call paths, not just the inventory substrate:
  - hermes_cli/inventory.py: ConfigContext.excluded_providers, read from
    `model_catalog.excluded_providers` in load_picker_context(); forwarded
    in build_models_payload() (TUI gateway picker surface).
  - hermes_cli/model_switch.py: list_authenticated_providers() and
    list_picker_providers() accept excluded_providers; prewarm_picker_cache
    forwards it so the warm cache matches what the picker will show.
  - gateway/slash_commands.py: the gateway /model path (both the
    list_picker_providers interactive picker at ~L1132 and the
    list_authenticated_providers text fallback at ~L1351) now reads
    model_catalog.excluded_providers from config and passes it through.

### 2. Fix custom-provider grouping for shared-endpoint proxies

Problem: multiple custom_providers entries sharing the same base_url (e.g.
an Aperture/LiteLLM proxy fronting cerebras, groq and perplexity at one
URL) collapsed into a single picker row under the first provider's name.

Per the review, the grouping key is EXTENDED rather than replaced. Current
main groups by (api_url, credential_identity, api_mode); this adds the
display-name prefix as a fourth dimension:

    (api_url, credential_identity, api_mode, display_prefix)

so same-URL different-name proxies each get their own row, while:
  - same-host entries with different key_env / api_mode stay separate
    (credential_identity / api_mode preserved — no regression to
    test_..._same_url_different_key_env_and_api_mode_stay_separate);
  - per-model suffix entries sharing a prefix ("Ollama — A", "Ollama — B")
    still collapse into one row.

The display-name prefix is computed once and reused as the row's display
name (the prior inline suffix-stripping is deduplicated against it).

### Not carried over from the original PR (per review)

- The original replaced the grouping key with (api_url, api_key, prefix),
  dropping credential_identity / api_mode — that would have regressed
  same-URL entries with different env-backed credentials or transports.
- The original skipped live /models discovery whenever a `models:` dict was
  present, regressing the default Bifrost/gateway case where the live
  catalog should replace a stale configured subset. Current main already
  uses `discover_models: false` as the explicit scoped-subset opt-out, so
  the proxy-subset use case is handled by telling users to set
  `discover_models: false` on those entries. No change to should_probe.
- The original's slug-assignment rework targeted an older base where
  section 4 reused current_provider as the slug; current main already uses
  custom_provider_slug(display_name) with no current_provider reuse, so
  that change is obsolete.

### Tests (tests/hermes_cli/test_model_switch_custom_providers.py)

- test_excluded_providers_hides_builtin_row: openrouter row appears with
  OPENROUTER_API_KEY set, disappears with excluded_providers=["openrouter"].
- test_excluded_providers_empty_is_noop: [] does not change output.
- test_shared_url_different_display_names_are_separate_rows: three entries
  sharing base_url+api_key+api_mode but different names → three rows.
- test_shared_url_per_model_suffix_still_collapses: "Ollama — A"/"Ollama — B"
  still collapse into one "Ollama" row.

### Docs (website/docs/reference/model-catalog.md)

Document `model_catalog.excluded_providers` under the Config section.

### Known limitation / follow-up

The interactive `hermes model` CLI picker (hermes_cli/main.py::
select_provider_and_model) builds its provider rows through a separate
code path that does not use list_authenticated_providers / build_models_payload,
so excluded_providers does not yet hide rows there. Wiring it in is a
larger, separate change and out of scope for this review rework.
@craigdfrench
craigdfrench force-pushed the feat/excluded-providers-and-proxy-grouping branch from a66bd2d to 93ec97d Compare June 22, 2026 01:45
@craigdfrench

Copy link
Copy Markdown
Contributor Author

Thanks for the review — reworked against current main and force-pushed (now at 93ec97d7b, rebased onto latest main). All three points addressed.

1. Excluded providers wired through all picker/list paths

model_catalog.excluded_providers is now threaded through every surface that builds a picker row, not just the inventory substrate:

  • hermes_cli/inventory.py: ConfigContext.excluded_providers, read in load_picker_context(), forwarded in build_models_payload() (TUI gateway picker).
  • hermes_cli/model_switch.py: list_authenticated_providers() and list_picker_providers() accept excluded_providers; prewarm_picker_cache_async forwards it so the warm cache matches what the picker shows.
  • gateway/slash_commands.py: the gateway /model path now reads model_catalog.excluded_providers from config and passes it to both list_picker_providers (interactive picker, ~L1132) and list_authenticated_providers (text fallback, ~L1351) — the two call sites the review flagged.

Exclusion is matched case-insensitively against every key a provider can surface under: hermes_id/mdev_id (section 1), pid/hermes_slug (section 2), canonical slug (section 2b), so a single copilot entry hides the provider regardless of which section emits it.

2. Grouping key extended, not replaced

Per the suggestion, the current key is preserved and the display prefix added as a fourth dimension:

(api_url, credential_identity, api_mode, display_prefix)

Same-URL different-name proxies (e.g. an Aperture proxy fronting cerebras/groq/perplexity at one URL) now each get their own row, while:

  • same-host entries with different key_env or api_mode stay separate (credential_identity/api_mode preserved — no regression to test_..._same_url_different_key_env_and_api_mode_stay_separate);
  • per-model suffix entries sharing a prefix ("Ollama — A", "Ollama — B") still collapse into one row.

3. No live-discovery regression

The original PR's should_probe change (skip live /models whenever a models: dict is present) was dropped. Current main's discover_models: false opt-out already covers the scoped-subset proxy case — users set discover_models: false on those entries. The builtin-endpoint dedup and slug-assignment changes from the original were also dropped (the slug rework targeted an older base where section 4 reused current_provider; current main already uses custom_provider_slug(display_name)).

Tests

Added to tests/hermes_cli/test_model_switch_custom_providers.py:

  • test_excluded_providers_hides_builtin_row — openrouter row appears with OPENROUTER_API_KEY set, disappears with excluded_providers=["openrouter"].
  • test_excluded_providers_empty_is_noop[] unchanged.
  • test_shared_url_different_display_names_are_separate_rows — three entries sharing base_url+api_key+api_mode but different names → three rows.
  • test_shared_url_per_model_suffix_still_collapses"Ollama — A"/"Ollama — B" still collapse into one Ollama row.

Docs

website/docs/reference/model-catalog.md documents model_catalog.excluded_providers under the Config section.

Known follow-up (out of scope here)

The interactive hermes model CLI picker (hermes_cli/main.py::select_provider_and_model) builds its rows through a separate code path that doesn't use list_authenticated_providers/build_models_payload, so excluded_providers doesn't yet hide rows there. Wiring it in is a larger, separate change.

…I picker

Follow-up to the excluded_providers rework: wire the config through the
interactive `hermes model` CLI picker (hermes_cli/main.py::
select_provider_and_model), which builds its provider menu from
CANONICAL_PROVIDERS via group_providers — a separate code path from
list_authenticated_providers / build_models_payload that the prior commit
did not cover.

A canonical provider is hidden from the CLI menu if its slug OR any of its
aliases (_PROVIDER_ALIASES) appears in model_catalog.excluded_providers
(case-insensitive), matching list_authenticated_providers' matching
against hermes_id / alias / canonical slug. The filtered slug list is
passed to group_providers, so excluded members also drop out of
multi-member group rows. Custom providers are intentionally not filtered
(parity with list_authenticated_providers, which does not exclude
section-4 custom rows).

Tests (tests/hermes_cli/test_model_picker_excluded_providers.py):
- test_cli_picker_hides_excluded_provider: excluded_providers=["openrouter"]
  removes the OpenRouter row from the provider menu.
- test_cli_picker_hides_excluded_provider_by_alias: excluding by an alias
  (not the canonical slug) also hides the provider.
- test_cli_picker_empty_excluded_is_noop: [] does not change the menu.

Docs: model-catalog.md updated to note every /model picker surface
(gateway, TUI, and `hermes model` CLI) now honors the key.
@craigdfrench

Copy link
Copy Markdown
Contributor Author

Follow-up: the "known limitation" noted in the previous comment is now resolved (commit ab1fad519).

model_catalog.excluded_providers is now also honored by the interactive hermes model CLI picker (hermes_cli/main.py::select_provider_and_model), which builds its provider menu from CANONICAL_PROVIDERS via group_providers — a separate code path from list_authenticated_providers/build_models_payload.

A canonical provider is hidden from the CLI menu if its slug or any of its aliases (_PROVIDER_ALIASES) appears in excluded_providers (case-insensitive), matching list_authenticated_providers' matching against hermes_id / alias / canonical slug. Excluded members also drop out of multi-member group rows. Custom providers are intentionally not filtered (parity with list_authenticated_providers, which doesn't exclude section-4 custom rows).

Tests added in tests/hermes_cli/test_model_picker_excluded_providers.py:

  • test_cli_picker_hides_excluded_providerexcluded_providers=["openrouter"] removes the OpenRouter row.
  • test_cli_picker_hides_excluded_provider_by_alias — excluding by an alias (not the canonical slug) also hides the provider.
  • test_cli_picker_empty_excluded_is_noop[] unchanged.

Every /model picker surface (gateway interactive + text, TUI, and hermes model CLI) now honors the config. Docs updated accordingly.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 13, 2026
@teknium1

Copy link
Copy Markdown
Contributor

Closing as superseded by your own split: the grouping fix landed via #67925 (salvaging #67747, your authorship preserved), and the excluded_providers half lives on in #67751 which is now unblocked for review. Thanks for splitting this into reviewable pieces!

@teknium1 teknium1 closed this Jul 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config Config system, migrations, profiles comp/cli CLI entry point, hermes_cli/, setup wizard P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants