From 780bddca048891403091003999d89af3f8a4a4c1 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 16 May 2026 23:04:53 +0000 Subject: [PATCH] =?UTF-8?q?Stage=20372:=20PR=20#2411=20=E2=80=94=20fix:=20?= =?UTF-8?q?preserve=20named=20custom=20provider=20model=20selections=20by?= =?UTF-8?q?=20@Michaelyklam=20(fixes=20#2405)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + static/ui.js | 32 ++++++++++++++------ tests/test_provider_mismatch.py | 53 +++++++++++++++++++++++++++++++-- 3 files changed, 74 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59381e3e..c7b37b3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - **PR #2406** by @Michaelyklam (fixes #2398) — The fallback synchronous `POST /api/chat` route now passes the active WebUI config into the conversation-history sanitizer, so text-mode providers do not receive historical native `image_url` content parts when direct API callers use the legacy chat endpoint. This brings the sync route in line with the streaming chat path fixed for #2297. - **PR #2408** by @Michaelyklam (fixes #2404) — Auto-compression cards now close the current live Activity burst before rendering, so post-compression tools start a fresh `Activity` row instead of joining the pre-compression tool group across a real timeline/context boundary. +- **PR #2411** by @Michaelyklam (fixes #2405) — Named `custom:*` providers no longer lose vendor-prefixed model selections when the static model picker has not hydrated that model yet. The frontend now treats named custom providers as routable aggregators for both mismatch-warning suppression and missing-dropdown fallback, and live-fetched models keep explicit `@custom:name:` provider context so selections persist instead of snapping back to the configured default. ### Documentation diff --git a/static/ui.js b/static/ui.js index 94d8ad43..d32173b1 100644 --- a/static/ui.js +++ b/static/ui.js @@ -729,6 +729,18 @@ function _providerFromModelValue(modelId){ if(value.startsWith('@')&&value.includes(':')) return value.slice(1,value.lastIndexOf(':')); return ''; } +function _providerSkipsModelMismatchWarning(providerId){ + const p=String(providerId||'').toLowerCase(); + return !p||p==='custom'||p.startsWith('custom:')||p==='openrouter'; +} +function _providerDefersMissingModelFallback(providerId){ + const p=String(providerId||'').toLowerCase(); + // Named custom providers and OpenRouter can legitimately route vendor-prefixed + // model IDs that are not present in the current static catalog. Do not + // silently rewrite those sessions to the default just because the option has + // not been hydrated yet (#2405). + return p.startsWith('custom:')||p==='openrouter'; +} function _modelStateForSelect(sel, modelId){ const value=String(modelId||'').trim(); if(!value) return {model:'',model_provider:null}; @@ -990,21 +1002,20 @@ function _addLiveModelsToSelect(provider, models, sel){ sel.appendChild(providerGroup); } const existingIds=new Set([...sel.options].map(o=>o.value)); - // Normalized dedup: strip @provider: prefix and unify separators so + // Normalized dedup: strip one @provider: prefix and namespace so // 'minimax/minimax-m2.7' matches '@nous:minimax/minimax-m2.7' (#907). - // Strip ONLY the first colon — Ollama tag IDs are multi-colon - // (e.g. '@ollama-cloud:qwen3-vl:235b-instruct') and split(':',2) would - // truncate the tag suffix in JS (the limit arg discards extras, unlike Python). const _normId=id=>{ let s=String(id||''); - if(s.startsWith('@')&&s.includes(':')) s=s.substring(s.indexOf(':')+1); // strip only @provider: - s=s.split('/').pop(); // strip namespace prefix + if(s.startsWith('@')&&s.includes(':')) s=s.substring(s.indexOf(':')+1); + s=s.split('/').pop(); return s.replace(/-/g,'.').toLowerCase(); }; const existingNorm=new Set([...sel.options].map(o=>_normId(o.value))); let added=0; const _ap=(window._activeProvider||'').toLowerCase(); - const _isPortalFetch=_ap && _ap!=='openrouter' && _ap!=='custom' && _ap!=='openai-codex' && provider===_ap; + const _providerLower=String(provider||'').toLowerCase(); + const _isNamedCustomActiveProvider=_ap.startsWith('custom:'); + const _isPortalFetch=_ap && _ap!=='openrouter' && _ap!=='custom' && _ap!=='openai-codex' && (_providerLower===_ap||_isNamedCustomActiveProvider&&_providerLower===_ap); for(const m of models){ let mid=m.id; if(_isPortalFetch && !mid.startsWith('@')){ @@ -1075,7 +1086,7 @@ async function _fetchLiveModels(provider, sel){ */ function _checkProviderMismatch(modelId){ const ap=(window._activeProvider||'').toLowerCase(); - if(!ap||ap==='custom'||ap.startsWith('custom:')||ap==='openrouter') return null; // can't reliably check + if(_providerSkipsModelMismatchWarning(ap)) return null; // can't reliably check // @provider: prefixed IDs came from that provider's live model list — no mismatch possible if(modelId.startsWith('@')) return null; const slash=modelId.indexOf('/'); @@ -4574,13 +4585,16 @@ function syncTopbar(){ // default rather than silently retaining the previous chat's selection (#1771). if(!applied){ const deferModelCorrection=Boolean(S.session._modelResolutionDeferred); + const missingModelIsRoutable=_providerDefersMissingModelFallback(S.session.model_provider||window._activeProvider||null); // Also defer if a live model fetch is still in flight — the model may be // in the list once the fetch completes. Persisting now would corrupt the // session with the wrong model before live models arrive (#1169). const liveStillPending=window._activeProvider&&_liveModelFetchPending.has(window._activeProvider); - if(liveStillPending){ + if(liveStillPending||missingModelIsRoutable){ // Live fetch in flight — don't touch sel.value or S.session.model yet. // _addLiveModelsToSelect() will re-apply S.session.model once done (#1169). + // Named custom providers/OpenRouter can also route vendor-prefixed IDs + // outside the static catalog, so preserve the user's explicit choice. } else { const fallback=_applySessionModelFallback(modelSel); if(fallback&&!deferModelCorrection){ diff --git a/tests/test_provider_mismatch.py b/tests/test_provider_mismatch.py index 495a3e0e..d5ac11f6 100644 --- a/tests/test_provider_mismatch.py +++ b/tests/test_provider_mismatch.py @@ -128,27 +128,43 @@ class TestCheckProviderMismatch: src = _read("static/ui.js") idx = src.find("function _checkProviderMismatch") block = src[idx:idx + 800] - assert "openrouter" in block.lower(), ( + assert "_providerSkipsModelMismatchWarning(ap)" in block, ( "_checkProviderMismatch must skip the check for openrouter" ) + helper_idx = src.find("function _providerSkipsModelMismatchWarning") + helper = src[helper_idx:helper_idx + 350] + assert "openrouter" in helper.lower(), ( + "_providerSkipsModelMismatchWarning must skip OpenRouter" + ) def test_skips_check_for_custom(self): """Custom endpoints can serve any model — skip the warning.""" src = _read("static/ui.js") idx = src.find("function _checkProviderMismatch") block = src[idx:idx + 800] - assert "custom" in block.lower(), ( + assert "_providerSkipsModelMismatchWarning(ap)" in block, ( "_checkProviderMismatch must skip the check for custom provider" ) + helper_idx = src.find("function _providerSkipsModelMismatchWarning") + helper = src[helper_idx:helper_idx + 350] + assert "p==='custom'" in helper, ( + "_providerSkipsModelMismatchWarning must skip bare custom providers" + ) def test_skips_check_for_named_custom_provider(self): """Named custom providers are aggregators too — skip the warning.""" src = _read("static/ui.js") idx = src.find("function _checkProviderMismatch") block = src[idx:idx + 800] - assert "startsWith('custom:')" in block, ( + assert "_providerSkipsModelMismatchWarning(ap)" in block, ( "_checkProviderMismatch must skip named custom providers like custom:zenmux" ) + helper_idx = src.find("function _providerSkipsModelMismatchWarning") + assert helper_idx != -1, "named custom provider skip helper must exist" + helper = src[helper_idx:helper_idx + 350] + assert "p.startsWith('custom:')" in helper, ( + "named custom providers must be treated like custom aggregators" + ) def test_active_provider_stored_on_model_load(self): """populateModelDropdown must store active_provider from /api/models.""" @@ -1167,6 +1183,37 @@ class TestFrontendModelProviderState: assert "_providerQualifiedModelValueForSelect(sel, modelId)" in src assert "return _modelStateForSelect(sel,modelId).model" in src + def test_named_custom_live_models_keep_provider_prefix(self): + """Live models from custom:* providers should keep explicit provider context.""" + src = _read("static/ui.js") + idx = src.find("function _addLiveModelsToSelect") + assert idx != -1, "_addLiveModelsToSelect must exist" + block = src[idx:idx + 2200] + assert "_isNamedCustomActiveProvider=_ap.startsWith('custom:')" in block, ( + "named custom providers must be recognized during live model hydration" + ) + assert "_providerLower=String(provider||'').toLowerCase()" in block + assert "_providerLower===_ap||_isNamedCustomActiveProvider&&_providerLower===_ap" in block, ( + "custom:* live model fetches must qualify added model IDs with @custom:name:" + ) + + def test_named_custom_missing_dropdown_model_does_not_persist_fallback(self): + """syncTopbar must not overwrite custom:* selections just because the static picker lacks them.""" + src = _read("static/ui.js") + helper_idx = src.find("function _providerDefersMissingModelFallback") + assert helper_idx != -1, "custom-provider missing-model fallback helper must exist" + helper = src[helper_idx:helper_idx + 500] + assert "p.startsWith('custom:')" in helper, ( + "named custom providers can route vendor-prefixed models outside the static catalog" + ) + idx = src.find("function syncTopbar") + assert idx != -1, "syncTopbar must exist" + block = src[idx:idx + 5200] + assert "missingModelIsRoutable=_providerDefersMissingModelFallback" in block + assert "liveStillPending||missingModelIsRoutable" in block, ( + "syncTopbar must preserve routable custom:* selections instead of forcing fallback persistence" + ) + def test_unknown_prefix_model_passes_through_unchanged(monkeypatch): """Models with unknown/custom prefixes must never be stripped — regression test for #751."""