diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c6661a2..b50d5c96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ - **Deep-link straight to a profile with `?profile=`.** Opening the WebUI with a `?profile=NAME` query parameter now switches to that profile on boot. The name is validated against the existing profile allowlist (a nonexistent profile boots normally, path-traversal / cross-profile attempts are rejected), the switch goes through the same access-enforced `switchToProfile`, and boot fails safe on anything invalid. Thanks @rodboev. (#5730, #5682) +- **Model picker no longer duplicates or drops proxy/catalog models.** When the same model was reachable via both a custom proxy and a first-party catalog (e.g. `grok-4.5`), the picker could show it twice; the dedup fix collapses true duplicates to one selectable row while keeping genuinely-distinct models that merely share a name suffix — including colon-suffixed proxy models like `…:free` / `…:thinking` / `…:latest` — independently selectable. The currently-selected model always survives dedup, and model restore no longer substitutes a suffix-sharing sibling for a missing model. Thanks @rodboev. (#5997, #5989) + +- **Simplified Chinese (zh-CN) localization fill.** Untranslated English strings in the zh-CN locale are now translated, so Simplified Chinese users no longer see English fall-through text across recently-added UI. Thanks @Loukky. (#6024) + - **WebUI-created git worktrees can now be cleaned up (no more orphan accumulation).** The agent locks every worktree it creates, and git refuses to remove a locked worktree with a single `--force`, so WebUI-created worktrees were piling up unremovable. Session-worktree removal now runs a fail-soft `git worktree unlock` right before the remove (mirroring the agent's own cleanup ordering); the existing dirty/uncommitted-changes guard still runs first, so user data stays protected. Thanks @ayxuerui. (#6028, #6023) ### Fixed diff --git a/static/i18n.js b/static/i18n.js index 37d95520..0a62d7cc 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -10424,8 +10424,8 @@ const LOCALES = { thinking: '思考过程', expand_all: '全部展开', collapse_all: '全部折叠', - show_earlier_step_one: 'Show 1 earlier step', - show_earlier_steps: 'Show {0} earlier steps', + show_earlier_step_one: '显示前 1 个步骤', + show_earlier_steps: '显示前 {0} 个步骤', edit_failed: '编辑失败:', regen_failed: '重新生成失败:', reconnect_active: '回复仍在生成中,准备好后要重新加载吗?', @@ -10623,9 +10623,9 @@ const LOCALES = { settings_default_message_mode_interrupt: '中断当前轮次', settings_default_message_mode_steer: '引导(中间修正)', settings_label_busy_placeholder_hint: '显示忙碌占位提示', - settings_label_new_chat_on_workspace_switch: 'Start a new chat when switching workspace', - settings_desc_new_chat_on_workspace_switch: 'When on, selecting a different workspace starts a fresh conversation bound to that workspace and leaves your current chat on its original workspace, avoiding stale cross-workspace context. When off (default), switching workspace changes the current workspace in place.', - workspace_switched_new_chat: 'Started a new chat in {0}', + settings_label_new_chat_on_workspace_switch: '切换工作区时开始新的聊天', + settings_desc_new_chat_on_workspace_switch: '开启此功能后,选择不同的工作区会开启一个与该工作区绑定的全新对话,你当前的聊天则保留在原工作区,避免出现过时的跨工作区上下文。关闭此功能(默认状态)后,切换工作区会直接更改当前工作区。 ', + workspace_switched_new_chat: '在{0}中开始了新的聊天', settings_desc_busy_placeholder_hint: '在会话运行且草稿为空时,显示带有当前忙碌操作的编辑器占位符。', composer_placeholder_busy_queue: 'Enter = 队列 | /interrupt | /background | /steer', composer_placeholder_busy_interrupt: 'Enter = 中断 | /queue | /background | /steer', @@ -10648,16 +10648,16 @@ const LOCALES = { workspace_empty_dir: '此工作区为空。', workspace_show_hidden_files: '显示隐藏文件', workspace_show_hidden_files_desc: '将 .DS_Store、.git、node_modules 以及其他隐藏文件/系统文件包含在文件树中。', - workspace_panel_show: 'Show workspace panel', - workspace_panel_hide: 'Hide workspace panel', - workspace_panel_close: 'Close workspace panel', - workspace_panel_toggle: 'Toggle workspace files panel', - workspace_panel_views: 'Workspace panel views', - workspace_parent_directory: 'Parent directory', - workspace_upload_file: 'Upload file', - workspace_close_preview: 'Close preview', - workspace_files_tab: 'Files', - workspace_artifacts_tab: 'Artifacts', + workspace_panel_show: '显示工作区面板', + workspace_panel_hide: '隐藏工作区面板', + workspace_panel_close: '关闭工作区面板', + workspace_panel_toggle: '切换工作区文件面板', + workspace_panel_views: '工作区面板视图', + workspace_parent_directory: '父目录', + workspace_upload_file: '上传文件', + workspace_close_preview: '关闭预览', + workspace_files_tab: '文件', + workspace_artifacts_tab: '产物', workspace_hidden_files_visible: '隐藏 显示', workspace_hidden_files_visible_title: '隐藏文件已显示 — 点击查看选项', workspace_options: '工作区选项', @@ -10740,20 +10740,20 @@ const LOCALES = { session_copy_link_desc: '复制此对话的直接链接', session_link_copied: '对话链接已复制到剪贴板', session_link_copy_failed: '无法复制对话链接:', - share_session: 'Share', - share_session_tooltip: 'Create a public read-only share link', - share_session_status_active: 'Public share active', - share_session_existing_confirm: 'This conversation already has a public read-only share link.\n\nChoose Copy existing to reuse that link, or Refresh snapshot to rebuild the public snapshot from the latest visible messages.', - share_session_copy_existing: 'Copy existing', - share_session_refresh_snapshot: 'Refresh snapshot', - share_session_link_copied: 'Share link copied to clipboard', - share_session_created: 'Public share link created', - share_session_failed: 'Share failed: ', - stop_sharing_session: 'Stop sharing', - stop_sharing_session_tooltip: 'Revoke the public read-only share link for this conversation', - stop_sharing_session_confirm: 'Revoke this public share link? Anyone opening the old link will see that it is no longer available.', - share_session_revoked: 'Public share link revoked', - share_session_revoke_failed: 'Failed to revoke share link: ', + share_session: '共享', + share_session_tooltip: '创建一个公开的只读共享链接', + share_session_status_active: '公开共享已激活', + share_session_existing_confirm: '此对话已拥有一个公开的只读共享链接。\n\n选择 复制现有链接 以复用该链接,或选择 刷新快照,依据最新可见消息重建公开快照。', + share_session_copy_existing: '复制现有链接', + share_session_refresh_snapshot: '刷新快照', + share_session_link_copied: '共享链接已复制到剪贴板', + share_session_created: '已创建公开共享链接', + share_session_failed: '共享失败: ', + stop_sharing_session: '停止共享', + stop_sharing_session_tooltip: '撤销此对话的公开只读共享链接', + stop_sharing_session_confirm: '撤销此公开共享链接吗?任何打开旧链接的人都会看到该链接已不可用。', + share_session_revoked: '公开共享链接已撤销', + share_session_revoke_failed: '撤销共享链接失败: ', new_file_prompt: '新文件名(例如 notes.md):', new_file: '新建文件', new_folder: '新建文件夹', @@ -10881,7 +10881,7 @@ const LOCALES = { settings_label_sidebar_density: '侧边栏密度', cmd_reasoning: '切换思维可见性(显示/隐藏)、设置工作强度或查看当前状态', settings_label_external_sessions: '显示外部会话', - settings_label_claude_code_sessions: 'Show Claude Code sessions', + settings_label_claude_code_sessions: '显示 Claude Code 会话', settings_label_cron_sessions: '显示定时任务会话', settings_label_webhook_sessions: '显示Webhook会话', settings_label_previous_messaging_sessions: '显示以前的消息会话', @@ -11225,7 +11225,7 @@ const LOCALES = { settings_auto_title_refresh_20: '每 20 轮对话', settings_desc_auto_title_refresh: '基于最新对话自动重新生成会话标题,使其随对话发展保持相关。需要配置 LLM 标题生成模型。', settings_desc_external_sessions: '在会话列表中显示来自 CLI、Telegram、Discord、Slack 等渠道的对话。点击可导入并继续对话。', - settings_desc_claude_code_sessions: 'Show Claude Code imported sessions in the sidebar, or hide them while leaving other external sessions visible.', + settings_desc_claude_code_sessions: '在侧边栏中显示已导入的Claude Code会话,或者隐藏它们,同时保持其他外部会话可见。', settings_desc_cron_sessions: '将定时任务输出以对话形式显示在侧边栏。仅在启用非 WebUI 会话时此功能才会生效。默认关闭;高频任务可能会使侧边栏信息泛滥。', settings_desc_webhook_sessions: '将Webhook运行情况以对话形式显示在侧边栏。仅在启用非 WebUI 会话时此功能才会生效。默认关闭;大量的路由信息可能会使侧边栏信息泛滥。', settings_desc_previous_messaging_sessions: '显示被 reset 或 compression 替换的较旧的 Discord、Telegram、Slack 和 Weixin 会话。', @@ -11563,18 +11563,18 @@ const LOCALES = { cron_schedule_preset_weekly: '每周', cron_schedule_preset_monthly: '每月', cron_schedule_preset_custom: '自定义', - cron_schedule_time_label: 'Time', - cron_schedule_conj_on: 'on', - cron_schedule_conj_on_day: 'on day', - cron_schedule_conj_at: 'at', - cron_schedule_conj_at_minute: 'at minute', - cron_weekday_sun: 'Sunday', - cron_weekday_mon: 'Monday', - cron_weekday_tue: 'Tuesday', - cron_weekday_wed: 'Wednesday', - cron_weekday_thu: 'Thursday', - cron_weekday_fri: 'Friday', - cron_weekday_sat: 'Saturday', + cron_schedule_time_label: '时间', + cron_schedule_conj_on: '在', + cron_schedule_conj_on_day: '指定天数', + cron_schedule_conj_at: '在', + cron_schedule_conj_at_minute: '指定分钟数', + cron_weekday_sun: '星期日', + cron_weekday_mon: '星期一', + cron_weekday_tue: '星期二', + cron_weekday_wed: '星期三', + cron_weekday_thu: '星期四', + cron_weekday_fri: '星期五', + cron_weekday_sat: '星期六', cron_schedule_hour_label: '小时', cron_schedule_minute_label: '分钟', cron_schedule_weekday_label: '星期 (0-6,周日-周六)', @@ -11643,8 +11643,8 @@ const LOCALES = { session_worktree_remove_ahead_warning: (ahead) => `${ahead} 个未推送的提交将丢失。`, session_duplicate: '复制会话', session_duplicate_desc: '用相同工作区和模型创建副本', - session_export_html: 'Export as HTML', - session_export_html_desc: 'Download this conversation as a self-contained HTML file', + session_export_html: '导出为 HTML', + session_export_html_desc: '将此对话下载为一个独立的HTML文件', session_duplicate_failed: '复制失败:', session_stop_response: '停止回复', session_stop_response_desc: '取消此会话正在进行的回复', @@ -11689,15 +11689,15 @@ const LOCALES = { settings_check_now: '立即检查', settings_checking: '检查中\u2026', settings_up_to_date: '已是最新 \u2713', - update_later: 'Later', - update_now: 'Update Now', - update_updating: 'Updating…', - update_force: 'Force update', - update_clear_lock_retry: 'Clear lock and retry update', - update_hard_refresh_now: 'Hard refresh now', - update_no_target: 'No update target selected. Refresh update status and retry.', - update_failed_network: 'Update failed: could not reach the WebUI server. It may have restarted or the connection was interrupted. Please wait a few seconds, reload the page, then check the server if it still does not come back.', - update_failed_prefix: 'Update failed: ', + update_later: '稍后', + update_now: '现在更新', + update_updating: '更新中…', + update_force: '强制更新', + update_clear_lock_retry: '清除锁定并重试更新', + update_hard_refresh_now: '立即强制刷新', + update_no_target: '未选择更新目标。请刷新更新状态并重试。', + update_failed_network: '更新失败:无法连接到 WebUI 服务器。服务可能已重启,或者连接被中断。请稍等几秒,重新加载页面,如果服务器仍未恢复,请检查服务器状态。 ', + update_failed_prefix: '更新失败: ', settings_updates_available: '有 {count} 个更新可用', settings_updates_disabled: '更新检查已禁用', settings_update_check_failed: '更新检查失败', @@ -11745,7 +11745,7 @@ const LOCALES = { done: '完成', settings_option_compact_worklog: '紧凑工作日志', settings_option_transparent_stream: '透明流模式', - settings_option_final_answer_only: 'Final answer only', + settings_option_final_answer_only: '只给出最终答案', settings_label_tab_visibility: '侧边栏标签', settings_desc_tab_visibility: '选择在侧边栏和导航栏中显示哪些标签。聊天和设置始终可见。', diff --git a/static/ui.js b/static/ui.js index 1a19a6f7..4b3f96ca 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2732,6 +2732,49 @@ function _providerFromModelValue(modelId){ if(value.startsWith('@')&&value.includes(':')) return value.slice(1,value.lastIndexOf(':')); return ''; } +function _modelPickerOptionIdentity(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.replace(/-/g,'.').toLowerCase(); +} +function _deduplicateModelPickerOptions(sel,selectedValue){ + if(!sel||!sel.querySelectorAll) return 0; + let removed=0; + for(const group of sel.querySelectorAll('optgroup')){ + 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,_getOptionProviderId(opt)); + if(!identity) continue; + if(!byIdentity.has(identity)) byIdentity.set(identity,[]); + byIdentity.get(identity).push(opt); + } + for(const candidates of byIdentity.values()){ + if(candidates.length<2) continue; + const selected=candidates.find(opt=>opt.value===selectedValue); + const routable=candidates.find(opt=>String(opt.value||'').startsWith('@')); + const survivor=selected||routable||candidates[0]; + for(const opt of candidates){ + if(opt===survivor) continue; + group.removeChild(opt); + removed++; + } + } + } + return removed; +} function _providerSkipsModelMismatchWarning(providerId){ const p=String(providerId||'').toLowerCase(); return !p||p==='custom'||p.startsWith('custom:')||p==='openrouter'; @@ -3018,9 +3061,14 @@ function _findModelInDropdown(modelId, sel, preferredProviderId){ const pref=String(preferredProviderId||'').toLowerCase(); if(!pref || !exactProv || exactProv===pref) return modelId; } - // 1. Normalize: lowercase, strip namespace prefix, replace hyphens→dots. - // Also strip @provider: prefix from deduplicated model IDs (#1228, #1313). - const norm=s=>s.toLowerCase().replace(/^[^/]+\//,'').replace(/^@([^:]+:)+/,'').replace(/-/g,'.'); + // 1. Restore lookup keeps the older hierarchy-preserving matcher instead of + // the picker-dedup identity, so missing qualified models do not substitute a + // different suffix-sharing sibling. + const norm=s=>String(s||'') + .toLowerCase() + .replace(/^@([^:]+:)+/,'') + .replace(/^[^/]+\//,'') + .replace(/-/g,'.'); const target=norm(modelId); let explicitProvider=''; const rawModel=String(modelId||''); @@ -3122,6 +3170,7 @@ function _applyModelToDropdown(modelId, sel, preferredProviderId, opts){ } function _ensureModelOptionInDropdown(modelId, sel, preferredProviderId){ if(!modelId||!sel) return null; + if(typeof _deduplicateModelPickerOptions==='function') _deduplicateModelPickerOptions(sel,sel.value); const applied=_applyModelToDropdown(modelId,sel,preferredProviderId); if(applied) return applied; const value=modelId; @@ -3296,6 +3345,9 @@ async function populateModelDropdown(opts={}){ } sel.appendChild(og); } + if(typeof _deduplicateModelPickerOptions==='function'){ + _deduplicateModelPickerOptions(sel,previousSelection&&previousSelection.model||''); + } _reconcileModelDropdownSelection(sel,data,previousSelection,opts); if(typeof syncModelChip==='function') syncModelChip(); const dd=$('composerModelDropdown'); @@ -3346,32 +3398,47 @@ function _addLiveModelsToSelect(provider, models, sel){ providerGroup.dataset.provider=provider; } const existingIds=new Set([...sel.options].map(o=>o.value)); - // Normalized dedup strips provider/custom prefixes and namespaces (#907, #3478). - const _normId=id=>{ - let s=String(id||''); - if(s.startsWith('@')&&s.includes(':')){ - if(s.startsWith('@custom:')){ - s=s.substring(s.lastIndexOf(':')+1)||s; - }else{ - 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 _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; - if(existingNorm.has(_normId(mid))) continue; // dedup cross-prefix duplicates (#907) + 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('@'); + if(!(!existingRoutable&&incomingRoutable)) continue; // let proxy replace catalog twin + } + } const opt=document.createElement('option'); opt.value=mid; opt.textContent=m.label||m.id; @@ -3383,9 +3450,12 @@ function _addLiveModelsToSelect(provider, models, sel){ opt.dataset.fast='0'; } providerGroup.appendChild(opt); + existingIds.add(mid); + existingNorm.add(identity); _dynamicModelLabels[mid]=m.label||m.id; added++; } + if(typeof _deduplicateModelPickerOptions==='function') _deduplicateModelPickerOptions(sel,currentVal); const currentState=(currentVal&&typeof _modelStateForSelect==='function') ? _modelStateForSelect(sel, currentVal) : {model:currentVal||'', model_provider:(S.session&&S.session.model_provider)||null}; @@ -3763,6 +3833,7 @@ function renderModelDropdown(){ const dd=$(opts.dropdownId||'composerModelDropdown'); const sel=$(opts.selectId||'modelSelect'); if(!dd||!sel) return; + if(typeof _deduplicateModelPickerOptions==='function') _deduplicateModelPickerOptions(sel,sel.value); // Whether the search input should auto-grab focus on (re-)render. Default true // preserves the composer picker's behavior exactly; the settings picker passes // false on coarse-pointer devices so opening it doesn't pop the mobile keyboard. diff --git a/tests/test_issue1228_model_picker_duplicate_ids.py b/tests/test_issue1228_model_picker_duplicate_ids.py index bc999962..d2fecb6c 100644 --- a/tests/test_issue1228_model_picker_duplicate_ids.py +++ b/tests/test_issue1228_model_picker_duplicate_ids.py @@ -262,6 +262,29 @@ console.log(_findModelInDropdown('google/gemma-4-27b', sel, 'custom:beta') || '' resolved = subprocess.run(["node", "-e", script], capture_output=True, text=True, check=True) assert resolved.stdout.strip() == "@custom:beta:google/gemma-4-27b" + def test_find_model_returns_no_match_for_missing_qualified_same_suffix_sibling(self): + import re + import subprocess + + src = self._read_js() + helper = re.search(r"function _getOptionProviderId\(opt\)\{.*?\n\}", src, re.S) + finder = re.search(r"function _findModelInDropdown\(modelId, sel, preferredProviderId\)\{.*?\n\}", src, re.S) + assert helper, "_getOptionProviderId() not found in ui.js" + assert finder, "_findModelInDropdown() not found in ui.js" + + script = f""" +{helper.group(0)} +{finder.group(0)} +const sel = {{ + options: [ + {{ value: 'vendor-b/catalog/deepseek-v4-pro', parentElement: {{ tagName: 'OPTGROUP', dataset: {{ provider: 'custom:beta' }} }} }}, + ] +}}; +console.log(_findModelInDropdown('vendor-a/deepseek-v4-pro', sel, 'custom:alpha') || ''); +""" + resolved = subprocess.run(["node", "-e", script], capture_output=True, text=True, check=True) + assert resolved.stdout.strip() == "" + class TestResolveModelProviderColonInProviderId(unittest.TestCase): """resolve_model_provider() must handle provider_ids containing ':'. diff --git a/tests/test_issue5989_custom_proxy_picker_dedup.py b/tests/test_issue5989_custom_proxy_picker_dedup.py new file mode 100644 index 00000000..6aa7d548 --- /dev/null +++ b/tests/test_issue5989_custom_proxy_picker_dedup.py @@ -0,0 +1,166 @@ +"""Regression coverage for #5989's cross-source model picker duplicate.""" + +import json +import re +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") + + +def _function(source, name): + match = re.search(rf"function {name}\([^)]*\)\{{", source) + assert match, f"{name} not found in ui.js" + start = match.start() + depth = 0 + for index in range(match.end() - 1, len(source)): + if source[index] == "{": + depth += 1 + elif source[index] == "}": + depth -= 1 + if depth == 0: + return source[start:index + 1] + raise AssertionError(f"unterminated {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} +class Node {{ + constructor(tag) {{ this.tagName=tag.toUpperCase(); this.children=[]; this.dataset={{}}; this.parentElement=null; }} + appendChild(child) {{ child.parentElement=this; this.children.push(child); return child; }} + removeChild(child) {{ this.children=this.children.filter(item=>item!==child); child.parentElement=null; }} + querySelectorAll(selector) {{ + if(selector==='optgroup') return this.children.filter(child=>child.tagName==='OPTGROUP'); + return []; + }} + get options() {{ + return this.tagName==='SELECT' + ? this.children.flatMap(child=>child.tagName==='OPTGROUP'?child.children:[child]) + : undefined; + }} +}} +globalThis.window={{_activeProvider:'custom:llm-proxy'}}; +globalThis.document={{createElement:tag=>new Node(tag)}}; +globalThis._dynamicModelLabels={{}}; +globalThis._modelStateForSelect=()=>({{model:'',model_provider:null}}); +globalThis._applyModelToDropdown=()=>null; +globalThis.S={{session:null}}; +function addCatalog(select, value) {{ + const group=select.querySelectorAll('optgroup')[0] || (()=>{{ + const item=new Node('optgroup'); item.dataset.provider='custom:llm-proxy'; select.appendChild(item); return item; + }})(); + const catalog=new Node('option'); catalog.value=value; catalog.textContent=value; group.appendChild(catalog); +}} +function snapshot(select) {{ + if(!select.value && select.options[0]) select.value=select.options[0].value; + _deduplicateModelPickerOptions(select,select.value); + return {{groups:select.querySelectorAll('optgroup').map(item=>item.children.map(option=>option.value)),selected:select.value}}; +}} +function makeSelect() {{ + const select=new Node('select'); + const group=new Node('optgroup'); group.dataset.provider='custom:llm-proxy'; select.appendChild(group); + return select; +}} +const liveFirst=makeSelect(); +_addLiveModelsToSelect('custom:llm-proxy',[{{id:'x-ai/grok-4.5',label:'Grok 4.5'}}],liveFirst); +addCatalog(liveFirst,'x-ai/grok-4.5'); +liveFirst.value=liveFirst.options[0].value; +_addLiveModelsToSelect('custom:llm-proxy',[{{id:'x-ai/grok-4.5',label:'Grok 4.5'}}],liveFirst); +const otherGroup=new Node('optgroup'); otherGroup.dataset.provider='custom:other-proxy'; +const other=new Node('option'); other.value='x-ai/grok-4.5'; otherGroup.appendChild(other); liveFirst.appendChild(otherGroup); +const catalogFirst=makeSelect(); +addCatalog(catalogFirst,'x-ai/grok-4.5'); +_addLiveModelsToSelect('custom:llm-proxy',[{{id:'x-ai/grok-4.5',label:'Grok 4.5'}}],catalogFirst); +const selectedBare=makeSelect(); +addCatalog(selectedBare,'x-ai/grok-4.5'); +selectedBare.value='x-ai/grok-4.5'; +_addLiveModelsToSelect('custom:llm-proxy',[{{id:'x-ai/grok-4.5',label:'Grok 4.5'}}],selectedBare); +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); +const unnamespaced=makeSelect(); +addCatalog(unnamespaced,'gpt-4o'); +_addLiveModelsToSelect('custom:llm-proxy',[{{id:'@custom:llm-proxy:gpt-4o',label:'GPT-4o'}}],unnamespaced); +console.log(JSON.stringify({{ + liveFirst:snapshot(liveFirst), + catalogFirst:snapshot(catalogFirst), + selectedBare:snapshot(selectedBare), + sameSuffix:snapshot(sameSuffix), + sameGroupColonSuffix:snapshot(sameGroupColonSuffix), + crossProviderLive:snapshot(crossProviderLive), + unnamespaced:snapshot(unnamespaced), +}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_cross_source_proxy_and_catalog_twin_has_one_routable_row(): + result = _run_harness() + assert result["liveFirst"] == { + "groups": [["@custom:llm-proxy:x-ai/grok-4.5"], ["x-ai/grok-4.5"]], + "selected": "@custom:llm-proxy:x-ai/grok-4.5", + } + + +def test_catalog_first_keeps_the_routable_proxy_row(): + assert _run_harness()["catalogFirst"] == { + "groups": [["@custom:llm-proxy:x-ai/grok-4.5"]], + "selected": "@custom:llm-proxy:x-ai/grok-4.5", + } + + +def test_selected_bare_occurrence_survives_same_group_dedup(): + assert _run_harness()["selectedBare"] == { + "groups": [["x-ai/grok-4.5"]], + "selected": "x-ai/grok-4.5", + } + + +def test_same_suffix_models_in_one_group_do_not_collapse(): + assert _run_harness()["sameSuffix"] == { + "groups": [["vendor-a/deepseek-v4-pro", "vendor-b/catalog/deepseek-v4-pro"]], + "selected": "vendor-a/deepseek-v4-pro", + } + + +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"]], + "selected": "@custom:a:gpt-4o", + } + + +def test_unnamespaced_custom_proxy_value_deduplicates_with_catalog(): + assert _run_harness()["unnamespaced"] == { + "groups": [["@custom:llm-proxy:gpt-4o"]], + "selected": "@custom:llm-proxy:gpt-4o", + }