Files
hermes-webui/api
nesquena-hermes 458cf38ac9 fix(picker): collapse duplicate provider groups + guard provider-id-as-model.default (closes #1568)
Reporter (Deor, Discord #report-bugs, May 03 2026 14:19 PT, relayed by
@AvidFuturist) saw the Settings → Default Model dropdown rendering the
OpenCode Go provider as TWO separate optgroups: "OpenCode Go" (the
canonical one with all 14 catalog models) and "Opencode_Go" (a phantom
group containing one self-referential entry).

Three structural causes, all in api/config.py:_build_available_models_uncached:

1. **Detection-path id leakage.** The detection block at line ~1980
   reads cfg["providers"] keys verbatim. If the user's config has
   ``providers.opencode_go.api_key`` (underscore variant) AND another
   path adds the canonical ``opencode-go`` (e.g. via active_provider),
   both end up in detected_providers and the build loop creates two
   distinct provider groups with the second labelled via the
   ``pid.title()`` fallback as ``"Opencode_Go"``.

2. **Injection-block rogue model.** The default-model injection block
   at line ~2598 puts ANY ``model.default`` string into the picker as
   a fake option. A stray ``model.default: opencode_go`` (provider id
   mistakenly used as a model id) surfaces as a phantom model
   labelled ``"Opencode GO"``.

3. **Empty-group bleed.** When a non-canonical provider id makes it
   into detected_providers but has no entry in _PROVIDER_MODELS, the
   build loop creates an optgroup with zero models — pure UI noise.

This PR addresses all three:

- **New `_canonicalise_provider_id()` helper** that folds underscores
  to hyphens, lowercases, and applies alias resolution only when the
  alias target is itself a canonical id in `_PROVIDER_DISPLAY`. The
  last constraint avoids round-tripping ``x-ai`` (canonical) through
  the alias table to ``xai`` (which the WebUI doesn't index by).

- **Detection-path canonicalisation.** The cfg["providers"] scan
  applies the helper before adding to detected_providers. Same
  treatment in the only_show_configured intersection so that mode
  doesn't accidentally exclude the canonical id when configured_providers
  only contains the underscore-variant key.

- **Post-collection dedup pass** that re-canonicalises every entry in
  detected_providers — belt-and-braces against future regressions in
  any of the ~25 ``detected_providers.add(...)`` callsites without
  auditing each one. Idempotent for already-canonical ids.

- **Provider-id guard on the model.default injection block.** When
  the injected value matches a known provider display name or alias
  (after underscore/case normalisation), skip the injection and emit
  a `logger.warning` instead. Real unknown model ids (newly released
  models, custom endpoints) still get injected — only provider-shaped
  values are rejected.

- **Empty-group filter at end of build.** Drop optgroups with zero
  models. Custom: groups (`provider_id` starts with `custom:`) are
  exempt — users may want an empty card visible as a reminder.

Tests
-----

`tests/test_issue1568_duplicate_provider_groups.py` (17 tests):

- TestCanonicaliseProviderId (8): unit tests pinning helper behaviour —
  canonical preserved, underscore folded, case folded, aliases
  resolved, x-ai not round-tripped, empty input, unknown ids
  normalised, idempotence
- TestProviderGroupDedup (4): end-to-end picker behaviour —
  underscored providers-key produces ONE group not two (Deor's case),
  uppercase providers-key collapsed, aliased keys (z-ai → zai)
  collapsed, happy path unchanged
- TestDefaultModelProviderIdGuard (3): provider id as model.default
  doesn't inject phantom + WARNING logged; alias as model.default also
  caught; legitimate unknown model IDs (forward-compat) still injected
- TestEmptyGroupFilter (2): empty optgroups dropped from picker;
  custom: providers exempted from filter

Plus one structural test fix in
`tests/test_issue604_all_providers_model_picker.py:test_cfg_providers_only_adds_known`
— widened the regex window from 500 to 1500 chars so the new
documentation comment block doesn't push `_PROVIDER_MODELS` past the
substring slice. Pre-existing brittle window pattern, not a new issue.

Verification
------------

Live on port 8789 with Deor's exact reproduction config
(`providers.opencode_go.api_key` + `model.provider: opencode-go`):

  /api/models groups: 1 (was 2)
  Browser <select> optgroups: 1 (was 2)
  Total options under "OpenCode Go": 14 (was 14 in real group + 0 in phantom group)

Five-scenario sweep all collapse to ONE provider group:

| Config shape | Pre-fix | Post-fix |
|---|---|---|
| Hyphenated provider + underscored providers-key (Deor's case) | 2 groups | 1 group  |
| Hyphenated provider + UPPERCASE providers-key | 2 groups | 1 group  |
| Aliased providers-key (z-ai resolved to zai) | 2 groups | 1 group  |
| model.default = provider-id (orig #1568 scenario) | 15 models with phantom | 14 models, no phantom  |
| Happy path (canonical-only) | 1 group | 1 group  |

4070 pytest passed (was 4053 → 4070, +17 from this PR).
3 CI runs to follow on push.
QA harness 11/11 passed.
JS unaffected — pure backend fix.

Reporter: Deor (Discord #report-bugs, May 03 2026 14:19 PT)
Relayed by: @AvidFuturist
2026-05-03 22:04:58 +00:00
..
2026-04-29 19:54:07 -07:00