Skip to content

fix: show same model from different custom providers instead of deduplicating#1947

Merged
1 commit merged into
nesquena:masterfrom
happy5318:fix/custom-provider-duplicate-model-dedup
May 9, 2026
Merged

fix: show same model from different custom providers instead of deduplicating#1947
1 commit merged into
nesquena:masterfrom
happy5318:fix/custom-provider-duplicate-model-dedup

Conversation

@happy5318
Copy link
Copy Markdown
Contributor

Problem

When multiple custom providers expose the same model ID (e.g. baidu, huoshan, and liantong all offering glm-5.1), only the first provider's entry appears in the model dropdown. The others are silently dropped.

Root Cause

Backend (api/config.py): _seen_custom_ids is initialized with bare model IDs from auto_detected_models and used as a global dedup set when iterating custom_providers. Since the dedup key is the bare model ID (e.g. glm-5.1), the second and subsequent providers offering the same model are skipped.

# Before: cross-provider dedup on bare model ID
_seen_custom_ids = {m["id"] for m in auto_detected_models}
...
if _cp_model and _cp_model not in _seen_custom_ids:  # baidu glm-5.1 blocks huoshan glm-5.1

Frontend (static/ui.js): _normId() strips the @provider: prefix and normalizes separators, so @custom:baidu:glm-5.1 and @custom:huoshan:glm-5.1 both normalize to glm.5.1 and are treated as duplicates.

// Before: dedup on normalized ID only
if(existingNorm.has(_normId(mid))) continue; // drops same model from different providers

Fix

Backend: Change the _seen_custom_ids dedup key to {slug}:{model_id} so each provider's models are tracked independently.

# After: per-provider dedup
_seen_custom_ids = set()
...
_dedup_key = f"{_slug}:{_cp_model}" if _slug else _cp_model
if _cp_model and _dedup_key not in _seen_custom_ids:
    _seen_custom_ids.add(_dedup_key)

Frontend: Add _providerOf() helper and deduplicate on the composite (normId, provider) key. Bare model IDs without @provider: prefix still deduplicate on normId alone for backward compatibility.

// After: dedup on (normId, provider) composite key
const _providerOf=id=>{const m=String(id||"").match(/^@([^:]+):/);return m?m[1].toLowerCase():"";};
const existingNormProvider=new Set([...sel.options].map(o=>`${_normId(o.value)}|${_providerOf(o.value)}`));
...
const normKey=`${_normId(mid)}|${_providerOf(mid)}`;
if(existingNormProvider.has(normKey)) continue;        // same provider+model
if(existingNorm.has(_normId(mid)) && !_providerOf(mid)) continue; // bare IDs only

Testing

Verified with a config containing 5 custom providers, where glm-5.1 appears under baidu, huoshan, and liantong. After the fix, all three entries appear in the dropdown with their @custom:provider: prefix.

GET /api/models now returns:

custom:baidu:   ["@custom:baidu:glm-5", "@custom:baidu:glm-5.1"]
custom:huoshan: ["@custom:huoshan:glm-5.1"]
custom:liantong: ["glm-5", "glm-5.1"]   # active provider, no prefix needed

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

Summary

Pulled this branch and read the full diff: api/config.py (+5/-3 around _seen_custom_ids) and static/ui.js (+7/-1 around _addLiveModelsToSelect). Also re-read api/config.py:2949-2989 on origin/master plus the parallel work in PR #1874 to understand the contract this is trying to land into.

What this fixes (and where it overlaps with #1874)

The backend half is correct and necessary. On master, api/config.py:2952 is _seen_custom_ids = {m["id"] for m in auto_detected_models} and at line 2973 the dedup key is bare _cp_model. So if both edith and super-javis declare glm-5.1, only the first iteration's group keeps it. The _dedup_key = f"{_slug}:{_cp_model}" change at line 2974 is exactly right — per-group dedup, with the unnamed bucket still seeded from auto_detected_models.

_dedup_key = f"{_slug}:{_cp_model}" if _slug else _cp_model
if _cp_model and _dedup_key not in _seen_custom_ids:
    _cp_label = _get_label_for_model(_cp_model, [])
    _seen_custom_ids.add(_dedup_key)

That said — this overlaps significantly with PR #1874 ("Fix duplicate models across named custom providers"), which already includes a per-group seen set (_seen_custom_ids_by_group: dict[str, set[str]]), an active-provider-aware _deduplicate_model_ids(), a hijack guard in resolve_model_provider(), and provider-aware badge assignment in get_available_models(). PR #1874 is much broader and addresses several follow-on layers this PR doesn't touch (the @provider:model resolution path, badge leakage, _apply_provider_prefix interactions).

If maintainers are leaning toward #1874 landing first, this PR's backend change is largely subsumed. If #1874 is stuck on review, the narrower change here is a clean partial fix and reasonable to land standalone — but rebasing #1874 on top would then cost work.

Frontend half — the dedup loosening is risky in isolation

The _providerOf() helper plus the composite (normId, provider) key looks reasonable, but the bare-ID escape hatch:

if(existingNormProvider.has(normKey)) continue;        // dedup same provider+normId only
if(existingNorm.has(_normId(mid)) && !_providerOf(mid)) continue; // dedup non-prefixed cross-prefix only

is the bit I'd ask you to walk through carefully. The original existingNorm cross-prefix dedup at static/ui.js:861 exists for #907 — to stop minimax/minimax-m2.7 and @nous:minimax/minimax-m2.7 from both showing up in the dropdown when Nous is the active provider. Your guard if (...) && !_providerOf(mid) skips dedup whenever the incoming model has a provider prefix. So if a portal-fetched @nous:minimax/... shows up after a static minimax/minimax-m2.7 already exists, you now keep both — which is the #907 regression in reverse.

The right invariant, I think, is: dedup if _normId matches AND either both have the same _providerOf or the incoming model would resolve through the same effective provider as an existing bare entry. The simpler heuristic !_providerOf(mid) doesn't capture that.

Worth pulling up tests/test_issue1228_model_picker_duplicate_ids.py and tests/test_model_picker_badges.py and confirming the #907 dedup case still passes. PR #1874's frontend changes are more conservative here (it doesn't loosen the existing existingNorm check; it relies on the backend-side _apply_provider_prefix to keep IDs distinct before they ever reach the dropdown).

Recommendation

  1. Talk to Fix duplicate models across named custom providers #1874's author about whether to fold this in or close in favor — the backend fix is the same shape; the frontend half is more aggressive here.
  2. If keeping standalone: please add a regression test that explicitly covers the @nous:minimax/... + bare minimax/... case (bug(models): duplicate dropdown entries when CLI default model is also returned by live fetch #907) to confirm the loosened cross-prefix dedup doesn't reintroduce duplicates in the active-provider portal-fetch path.
  3. Consider whether the active-provider dedup invariant in Fix duplicate models across named custom providers #1874's _deduplicate_model_ids(groups, active_provider=...) should be applied here — it's the bit that decides which provider's bare ID "wins" in a collision, and without it the badge layer (configured_model_badges in api/config.py:1538-1597) can mis-attach the PRIMARY badge to a non-active provider's entry.

The backend half is solid and this is a real bug. I'd just want to see #907 coverage before signing off on the frontend change.

…licating

When multiple custom providers expose the same model ID (e.g. baidu,
huoshan, and liantong all offering glm-5.1), only the first provider's
entry was shown in the model dropdown.

Root cause (backend):  used the bare model ID as the
dedup key, so the second and subsequent providers with the same model
were silently skipped.

Root cause (frontend):  stripped the @Provider: prefix before
comparing, so @Custom:baidu:glm-5.1 and @Custom:huoshan:glm-5.1 were
treated as duplicates.

Fix:
- Backend: change _seen_custom_ids key to '{slug}:{model_id}' so each
  provider's models are tracked independently.
- Frontend: add _providerOf() helper and deduplicate on the composite
  (normId, provider) key instead of normId alone. Bare model IDs
  (without @Provider: prefix) still deduplicate on normId for backward
  compatibility.
@happy5318 happy5318 force-pushed the fix/custom-provider-duplicate-model-dedup branch from b358b0e to a6599cd Compare May 9, 2026 08:17
@happy5318
Copy link
Copy Markdown
Contributor Author

Updated Fix

Thanks for the detailed review! We've simplified the fix based on your feedback:

Only the backend change is kept — the frontend static/ui.js modification has been removed.

Why the backend-only approach works:

  1. Custom provider models are loaded entirely from the backend /api/models endpoint on initial page load
  2. The frontend _addLiveModelsToSelect function only handles live fetch for built-in providers (deepseek, openai, etc.), not custom providers
  3. Therefore, no frontend change is needed — the backend fix alone solves the problem without risk of bug(models): duplicate dropdown entries when CLI default model is also returned by live fetch #907 regression

The backend fix:

- _seen_custom_ids = {m["id"] for m in auto_detected_models}
+ _seen_custom_ids = set()

- if _cp_model and _cp_model not in _seen_custom_ids:
+ _dedup_key = f"{_slug}:{_cp_model}" if _slug else _cp_model
+ if _cp_model and _dedup_key not in _seen_custom_ids:

- _seen_custom_ids.add(_cp_model)
+ _seen_custom_ids.add(_dedup_key)

Using {slug}:{model} as the dedup key instead of bare model ID ensures each custom provider can expose the same model independently.

Verified on our deployment:

  • custom:baidu: 4 models (was 1)
  • custom:liantong: 5 models (was 1)
  • All other providers working correctly

This aligns with your recommendation to avoid the risky frontend loosening. Let us know if you'd like any adjustments!

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

Backend-only fix LGTM, but please add a regression test

Re-read the diff at PR #1947:1 (now 4/3 lines, only api/config.py:2952 and :2972-2975) plus the surrounding get_available_models() named-custom group assembly at api/config.py:2949-2987 on the PR HEAD.

The simplified fix is correct. Removing the auto_detected_models seed and switching the dedup key to f"{_slug}:{_cp_model}" is exactly the right shape — two named custom providers exposing the same bare id (glm-5.1 from baidu, huoshan, liantong) now hash to distinct keys, so each ends up in its own _named_custom_groups[<slug>] bucket. The downstream _cp_option_id logic at api/config.py:2978-2982 already handles the disambiguation:

_cp_option_id = _cp_model
if active_provider != _slug and not _cp_option_id.startswith("@"):
    _cp_option_id = f"@{_slug}:{_cp_option_id}"

so the active provider keeps the bare id and the non-active siblings get @custom:<slug>:<id> form. That's exactly what resolve_model_provider at api/config.py:1497-1532 is set up to round-trip.

One blocking ask: add a test

The fix is minimal enough that the absence of a test makes this hard to merge confidently. There's a parallel PR (#1874) that addresses the same issue and does ship a test — tests/test_custom_provider_duplicate_models.py::test_named_custom_providers_keep_duplicate_model_ids — that exercises exactly this scenario with a fake hermes_cli and asserts both super-javis and edith retain their gpt-5.4 entries. It's about 60 lines and pattern-matches tests/test_issue1881_phantom_custom_groups.py.

I'd ask either:

  • copy that test into this PR (with attribution to Fix duplicate models across named custom providers #1874), tightened to the 3-provider sharing-glm-5.1 case described in the bug, OR
  • write a fresh one along the same lines: build a cfg with two named custom providers exposing the same model id, call get_available_models(), assert both groups in result["groups"] contain that id (with the active one bare and the others @-prefixed).

Without it, future refactors of _seen_custom_ids could silently regress this back to the master behavior.

Notes on coexistence with #1874

These two PRs are now nearly disjoint in scope:

Either one resolves the user-reported symptom. If maintainers prefer the surgical path, this PR + the test from #1874 would land cleanly. If they want the broader refactor, #1874 supersedes this. Worth a Closes #1948 line in the description either way once the test lands.

Verification step

The reporter's verified on our deployment numbers (custom:baidu: 4 models, custom:liantong: 5) match the expected post-fix shape — bare model count per provider equals what's in custom_providers[*].models, with no cross-provider deduplication. That confirms the fix at runtime; the test would lock it in CI.

nesquena-hermes added a commit that referenced this pull request May 9, 2026
CHANGELOG, ROADMAP, TESTING refresh for v0.51.31 stage release covering
12 contributor PRs:

Added (2 PRs):
- #1956 JKJameson — persistent composer draft (server-side, cross-client)
- #1957 hermes-gimmethebeans — configurable session TTL via env + settings

Fixed (10 PRs):
- #1939 ai-ag2026 — theme-color + sw cache regression coverage
- #1941 ai-ag2026 — preserve chat scroll across final render
- #1945 franksong2702 — localize session jump controls (#1938)
- #1947 happy5318 — show same model from different custom providers
  (Co-authored-by hacker1e7 for #1874 close)
- #1949 Sanjays2402 — close #1937 endless-scroll vs Start-jump race
  with generation-token + mutex
  (Co-authored-by franksong2702 + Michaelyklam)
- #1950 franksong2702 — mute stale stopped gateway heartbeat (#1944)
- #1951 amlyczz — gate goal hook on goal-related turns (#1932)
  (Co-authored-by franksong2702 for #1946 close)
- #1953 lucky-yonug — skip provider peel for custom host:port slugs
- #1960 Michaelyklam — translate hidden-files workspace label (#1841)
- #1961 sbe27 — respect image_input_mode (#1959)

Closed in favor of canonical: #1942, #1962, #1946, #1874, #1311.

Stage-326 hotfixes (per Opus advisor):
- CRITICAL #1951 PENDING_GOAL_CONTINUATION race fix (removed finally
  discard that race-erased the marker before consumer could read it)
- #1956 composer-draft input validation (50 KB text / 50 file clamp +
  type coercion to prevent unbounded session-JSON bloat)
- #1957 SESSION_TTL constant preserved as named fallback (existing
  regression tests pin it; #1957 originally deleted it)

Tests: 5006 → 5028 (+51 net new) — 0 regressions, 142.61s runtime.
@nesquena-hermes nesquena-hermes closed this pull request by merging all changes into nesquena:master in 8a653ba May 9, 2026
pull Bot pushed a commit to soitun/hermes-webui that referenced this pull request May 9, 2026
…d-custom-providers

Adds tests/test_pr1947_same_model_multiple_custom_providers.py covering:

1. Two named custom providers exposing the same model id — both must
   surface in the rendered groups (one bare, one @Custom:slug:model)
2. Three named providers all exposing the same model — none dropped
3. Distinct-model-per-provider sanity check (still grouped correctly)

Verified the regression-detecting tests (1 + 2) FAIL against master's
api/config.py (where _seen_custom_ids was seeded from auto_detected_models
and used as a global bare-id bucket — the second provider's entry was
silently dropped) and PASS against the contributor fix on this branch.

Test 3 (distinct-models sanity) passes either way as expected.

Co-authored-by: happy5318 <happy5318@users.noreply.github.com>
Co-authored-by: hacker1e7 <hacker1e7@users.noreply.github.com>
pull Bot pushed a commit to soitun/hermes-webui that referenced this pull request May 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants