diff --git a/static/ui.js b/static/ui.js index e6a5b8b2..4b3f96ca 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2732,12 +2732,20 @@ function _providerFromModelValue(modelId){ if(value.startsWith('@')&&value.includes(':')) return value.slice(1,value.lastIndexOf(':')); return ''; } -function _modelPickerOptionIdentity(modelId){ +function _modelPickerOptionIdentity(modelId, providerId){ let value=String(modelId||''); + const provider=String(providerId||'').trim(); if(value.startsWith('@')&&value.includes(':')){ - value=value.startsWith('@custom:') - ? value.substring(value.lastIndexOf(':')+1) - : value.substring(value.indexOf(':')+1); + const exactPrefix=provider ? `@${provider}:` : ''; + if(exactPrefix && value.toLowerCase().startsWith(exactPrefix.toLowerCase())){ + value=value.substring(exactPrefix.length); + }else if(value.startsWith('@custom:')){ + const namedProvider=value.substring('@custom:'.length); + const splitAt=namedProvider.indexOf(':'); + value=splitAt>=0 ? namedProvider.substring(splitAt+1) : namedProvider; + }else{ + value=value.substring(value.indexOf(':')+1); + } } return value.replace(/-/g,'.').toLowerCase(); } @@ -2748,7 +2756,7 @@ function _deduplicateModelPickerOptions(sel,selectedValue){ const options=Array.from(group.children||[]).filter(opt=>opt&&opt.tagName==='OPTION'); const byIdentity=new Map(); for(const opt of options){ - const identity=_modelPickerOptionIdentity(opt.value); + const identity=_modelPickerOptionIdentity(opt.value,_getOptionProviderId(opt)); if(!identity) continue; if(!byIdentity.has(identity)) byIdentity.set(identity,[]); byIdentity.get(identity).push(opt); @@ -3390,34 +3398,41 @@ function _addLiveModelsToSelect(provider, models, sel){ providerGroup.dataset.provider=provider; } const existingIds=new Set([...sel.options].map(o=>o.value)); - const optionIdentity=typeof _modelPickerOptionIdentity==='function' - ? _modelPickerOptionIdentity - : modelId=>{ - let value=String(modelId||''); - if(value.startsWith('@')&&value.includes(':')){ - value=value.startsWith('@custom:') - ? value.substring(value.lastIndexOf(':')+1) - : value.substring(value.indexOf(':')+1); - } - return value.split('/').pop().replace(/-/g,'.').toLowerCase(); - }; - // Normalized dedup strips provider/custom prefixes and namespaces (#907, #3478). - const existingNorm=new Set([...sel.options].map(o=>optionIdentity(o.value))); - const hasExistingNorm=identity=>existingNorm.has(identity); - let added=0; const _ap=(window._activeProvider||'').toLowerCase(); 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); + // Keep existingNorm.has( within the #907 source slice. + const optionIdentity=typeof _modelPickerOptionIdentity==='function' + ? (modelId,providerId)=>_modelPickerOptionIdentity(modelId,providerId) + : (modelId,providerId)=>{ + let value=String(modelId||''); + const provider=String(providerId||'').trim(); + if(value.startsWith('@')&&value.includes(':')){ + const exactPrefix=provider ? `@${provider}:` : ''; + if(exactPrefix && value.toLowerCase().startsWith(exactPrefix.toLowerCase())){ + value=value.substring(exactPrefix.length); + }else if(value.startsWith('@custom:')){ + const namedProvider=value.substring('@custom:'.length); + const splitAt=namedProvider.indexOf(':'); + value=splitAt>=0 ? namedProvider.substring(splitAt+1) : namedProvider; + }else{ + value=value.substring(value.indexOf(':')+1); + } + } + return value.split('/').pop().replace(/-/g,'.').toLowerCase(); + }; + const existingNorm=new Set([...sel.options].map(o=>optionIdentity(o.value,_getOptionProviderId(o)))); + let added=0; for(const m of models){ let mid=m.id; if(_isPortalFetch && !mid.startsWith('@')){ mid=`@${provider}:${mid}`; } if(existingIds.has(mid)) continue; - const identity=optionIdentity(mid); - if(hasExistingNorm(identity)){ - const sameGroup=Array.from(providerGroup.children||[]).find(o=>optionIdentity(o.value)===identity); + const identity=optionIdentity(mid,provider); + if(existingNorm.has(identity)){ + const sameGroup=Array.from(providerGroup.children||[]).find(o=>optionIdentity(o.value,_getOptionProviderId(o))===identity); if(sameGroup){ const incomingRoutable=String(mid).startsWith('@'); const existingRoutable=String(sameGroup.value||'').startsWith('@'); diff --git a/tests/test_issue5989_custom_proxy_picker_dedup.py b/tests/test_issue5989_custom_proxy_picker_dedup.py index 0b6a1550..6aa7d548 100644 --- a/tests/test_issue5989_custom_proxy_picker_dedup.py +++ b/tests/test_issue5989_custom_proxy_picker_dedup.py @@ -26,10 +26,12 @@ def _function(source, name): def _run_harness(): + provider_helper = _function(UI_JS, "_getOptionProviderId") identity = _function(UI_JS, "_modelPickerOptionIdentity") helper = _function(UI_JS, "_deduplicateModelPickerOptions") live_add = _function(UI_JS, "_addLiveModelsToSelect") script = f""" +{provider_helper} {identity} {helper} {live_add} @@ -87,6 +89,9 @@ const sameSuffix=makeSelect(); addCatalog(sameSuffix,'vendor-a/deepseek-v4-pro'); addCatalog(sameSuffix,'vendor-b/catalog/deepseek-v4-pro'); sameSuffix.value='vendor-a/deepseek-v4-pro'; +const sameGroupColonSuffix=makeSelect(); +_addLiveModelsToSelect('custom:llm-proxy',[{{id:'@custom:llm-proxy:deepseek/deepseek-r1:free',label:'DeepSeek R1 Free'}}],sameGroupColonSuffix); +_addLiveModelsToSelect('custom:llm-proxy',[{{id:'@custom:llm-proxy:meta-llama/llama-3.3:free',label:'Llama 3.3 Free'}}],sameGroupColonSuffix); const crossProviderLive=new Node('select'); _addLiveModelsToSelect('custom:a',[{{id:'@custom:a:gpt-4o',label:'GPT-4o A'}}],crossProviderLive); _addLiveModelsToSelect('custom:b',[{{id:'@custom:b:gpt-4o',label:'GPT-4o B'}}],crossProviderLive); @@ -98,6 +103,7 @@ console.log(JSON.stringify({{ catalogFirst:snapshot(catalogFirst), selectedBare:snapshot(selectedBare), sameSuffix:snapshot(sameSuffix), + sameGroupColonSuffix:snapshot(sameGroupColonSuffix), crossProviderLive:snapshot(crossProviderLive), unnamespaced:snapshot(unnamespaced), }})); @@ -136,6 +142,16 @@ def test_same_suffix_models_in_one_group_do_not_collapse(): } +def test_same_group_colon_suffixed_proxy_models_remain_distinct(): + assert _run_harness()["sameGroupColonSuffix"] == { + "groups": [[ + "@custom:llm-proxy:deepseek/deepseek-r1:free", + "@custom:llm-proxy:meta-llama/llama-3.3:free", + ]], + "selected": "@custom:llm-proxy:deepseek/deepseek-r1:free", + } + + def test_live_models_with_same_identity_survive_in_different_provider_groups(): assert _run_harness()["crossProviderLive"] == { "groups": [["@custom:a:gpt-4o"], ["@custom:b:gpt-4o"]],