Stage 372: PR #2411 — fix: preserve named custom provider model selections by @Michaelyklam (fixes #2405)
This commit is contained in:
parent
862d32ce27
commit
780bddca04
3 changed files with 74 additions and 12 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
32
static/ui.js
32
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){
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Reference in a new issue