diff --git a/static/onboarding.js b/static/onboarding.js index ef1eb55a7..f1da28523 100644 --- a/static/onboarding.js +++ b/static/onboarding.js @@ -209,6 +209,9 @@ function _renderOnboardingModelField(){ if(ONBOARDING.form.provider==='custom'){ return `

${t('onboarding_custom_model_help')}

`; } + if(typeof _mountSearchableModelSelect==='function'){ + return `
${t('onboarding_model_label')}

${t('onboarding_workspace_help')}

`; + } const options=choices.map(m=>``).join(''); return `

${t('onboarding_workspace_help')}

`; } @@ -356,6 +359,17 @@ function _renderOnboardingBody(){ ${_renderOnboardingModelField()}`; const wsSel=$('onboardingWorkspaceSelect'); if(wsSel && ONBOARDING.form.workspace) wsSel.value=ONBOARDING.form.workspace; + const modelPickerRoot=$('onboardingModelPickerRoot'); + if(modelPickerRoot && typeof _mountSearchableModelSelect==='function'){ + _mountSearchableModelSelect({ + root:modelPickerRoot, + selectId:'onboardingModelSelect', + customInputId:'onboardingModelInput', + choices:_getOnboardingProviderModelChoices(), + selectedValue:ONBOARDING.form.model, + onModelChange:(value)=>{ ONBOARDING.form.model=(value||'').trim(); }, + }); + } const modelSel=$('onboardingModelSelect'); if(modelSel && ONBOARDING.form.model) modelSel.value=ONBOARDING.form.model; return; diff --git a/static/ui.js b/static/ui.js index e3530de5f..1ead7f223 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2443,6 +2443,117 @@ function _appendOverflowOptionsToGroup(group, extraModels){ return appended; } +function _mountSearchableModelSelect(opts={}){ + const root=opts.root; + if(!root) return null; + const choices=Array.isArray(opts.choices) + ? opts.choices + .map(choice=>choice&&choice.id?{id:String(choice.id),label:String(choice.label||choice.id)}:null) + .filter(Boolean) + : []; + const selectedValue=String(opts.selectedValue||''); + const onModelChange=typeof opts.onModelChange==='function' ? opts.onModelChange : ()=>{}; + const selectId=opts.selectId||''; + const customInputId=opts.customInputId||''; + const listedChoiceIds=new Set(choices.map(choice=>choice.id)); + const listedSelection=listedChoiceIds.has(selectedValue) ? selectedValue : ''; + const customSelection=listedSelection ? '' : selectedValue; + let lastListedValue=listedSelection||(choices[0]?choices[0].id:''); + root.innerHTML= + `
`+ + ``+ + ``+ + `
`+ + ``+ + `
${esc(t('model_custom_label')||'Custom model ID')}
`+ + `
`+ + ``+ + ``+ + `
`; + const searchInput=root.querySelector('.model-search-input'); + const clearButton=root.querySelector('.model-search-clear'); + const selectEl=selectId ? root.querySelector(`#${selectId}`) : root.querySelector('select'); + const customInput=customInputId ? root.querySelector(`#${customInputId}`) : root.querySelector('.model-custom-input'); + const customButton=root.querySelector('.model-custom-btn'); + if(!searchInput||!clearButton||!selectEl||!customInput||!customButton) return null; + + const noMatchesOption=document.createElement('option'); + noMatchesOption.value=''; + noMatchesOption.textContent='No matching models'; + noMatchesOption.disabled=true; + noMatchesOption.hidden=true; + selectEl.appendChild(noMatchesOption); + + for(const choice of choices){ + const option=document.createElement('option'); + option.value=choice.id; + option.textContent=choice.label; + selectEl.appendChild(option); + } + if(listedSelection){ + selectEl.value=listedSelection; + }else if(customSelection){ + selectEl.selectedIndex=-1; + }else if(choices.length){ + selectEl.value=choices[0].id; + onModelChange(lastListedValue); + } + customInput.value=customSelection; + + const applyFilter=()=>{ + const needle=(searchInput.value||'').trim().toLowerCase(); + let visibleCount=0; + for(const option of Array.from(selectEl.options)){ + if(option===noMatchesOption) continue; + const haystack=`${option.textContent||''} ${option.value||''}`.toLowerCase(); + const visible=!needle||haystack.includes(needle); + option.hidden=!visible; + if(visible) visibleCount++; + } + noMatchesOption.hidden=visibleCount!==0; + }; + + const applyCustomSelection=()=>{ + onModelChange((customInput.value||'').trim()); + }; + + searchInput.addEventListener('input', applyFilter); + clearButton.addEventListener('click', ()=>{ + searchInput.value=''; + applyFilter(); + searchInput.focus(); + }); + selectEl.addEventListener('change', ()=>{ + customInput.value=''; + lastListedValue=selectEl.value||lastListedValue; + onModelChange(lastListedValue); + }); + customInput.addEventListener('input', ()=>{ + const value=(customInput.value||'').trim(); + if(value){ + selectEl.selectedIndex=-1; + onModelChange(value); + return; + } + customInput.value=''; + if(lastListedValue){ + selectEl.value=lastListedValue; + } + applyCustomSelection(); + }); + customInput.addEventListener('keydown', (event)=>{ + if(event.key!=='Enter') return; + event.preventDefault(); + applyCustomSelection(); + }); + customButton.addEventListener('click', (event)=>{ + event.preventDefault(); + applyCustomSelection(); + }); + applyFilter(); + return {searchInput,selectEl,customInput,customButton}; +} + function renderModelDropdown(){ const dd=$('composerModelDropdown'); const sel=$('modelSelect'); diff --git a/tests/test_byok_model_dropdown.py b/tests/test_byok_model_dropdown.py index 8b9bdcd51..6a8329aa8 100644 --- a/tests/test_byok_model_dropdown.py +++ b/tests/test_byok_model_dropdown.py @@ -173,6 +173,19 @@ class TestLiveModelsProviderNormalization: assert c._resolve_provider_alias(None) is None +def test_shared_searchable_model_picker_helper_has_search_and_custom_controls(): + src = read("static/ui.js") + m = re.search(r"function _mountSearchableModelSelect\(opts=\{\}\)\{.*?\n\}", src, re.DOTALL) + assert m, "_mountSearchableModelSelect helper not found in static/ui.js" + fn = m.group(0) + assert "model-search-input" in fn + assert "model-custom-input" in fn + assert "model-custom-btn" in fn + assert "onModelChange" in fn + assert "selectEl.selectedIndex=-1;" in fn + assert "onModelChange(lastListedValue);" in fn + + # ── api/routes.py — /api/models/live custom_providers fallback ──────────────── class TestLiveModelsCustomProviderFallback: diff --git a/tests/test_issue1362_codex_oauth_onboarding.py b/tests/test_issue1362_codex_oauth_onboarding.py index 6c3358aa9..087e388d8 100644 --- a/tests/test_issue1362_codex_oauth_onboarding.py +++ b/tests/test_issue1362_codex_oauth_onboarding.py @@ -4,7 +4,10 @@ from __future__ import annotations import json import os +import shutil import stat +import subprocess +import tempfile import threading import time from pathlib import Path @@ -12,6 +15,8 @@ from pathlib import Path import pytest REPO = Path(__file__).resolve().parents[1] +UI_JS = REPO / "static" / "ui.js" +NODE = shutil.which("node") def test_onboarding_codex_oauth_routes_use_post_start_cancel_and_get_poll(): @@ -595,3 +600,338 @@ def test_frontend_has_anthropic_oauth_support(): assert "window.open(" not in js[js.find("startAnthropicOAuth"):] assert "accessToken" not in js[js.find("startAnthropicOAuth"):] assert "refreshToken" not in js[js.find("startAnthropicOAuth"):] + + +def test_onboarding_non_custom_provider_mounts_searchable_model_picker(): + js = (REPO / "static" / "onboarding.js").read_text(encoding="utf-8") + assert "onboardingModelPickerRoot" in js + assert "_mountSearchableModelSelect" in js + assert "choices:_getOnboardingProviderModelChoices()" in js + assert "selectId:'onboardingModelSelect'" in js + assert "customInputId:'onboardingModelInput'" in js + + +def test_onboarding_custom_provider_text_input_branch_stays_intact(): + js = (REPO / "static" / "onboarding.js").read_text(encoding="utf-8") + assert "ONBOARDING.form.provider==='custom'" in js + assert 'id="onboardingModelInput"' in js + assert "onboarding_custom_model_placeholder" in js + assert "onboarding_custom_model_help" in js + + +@pytest.mark.skipif(NODE is None, reason="node is required") +def test_onboarding_searchable_picker_runtime_and_submit_fallback(): + driver = r""" +const fs = require('fs'); +const src = fs.readFileSync(process.argv[2], 'utf8'); + +function extractFunc(name) { + const re = new RegExp('function\\s+' + name + '\\s*\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + const parenStart = src.indexOf('(', start); + let parenDepth = 0; + let inString = null; + let escaped = false; + let bodyStart = -1; + for (let i = parenStart; i < src.length; i++) { + const ch = src[i]; + if (inString) { + if (escaped) escaped = false; + else if (ch === '\\') escaped = true; + else if (ch === inString) inString = null; + continue; + } + if (ch === "'" || ch === '"' || ch === '`') { + inString = ch; + continue; + } + if (ch === '(') parenDepth++; + else if (ch === ')') { + parenDepth--; + if (parenDepth === 0) { + bodyStart = src.indexOf('{', i); + break; + } + } + } + if (bodyStart < 0) throw new Error(name + ' body not found'); + let depth = 0; + inString = null; + escaped = false; + for (let i = bodyStart; i < src.length; i++) { + const ch = src[i]; + if (inString) { + if (escaped) escaped = false; + else if (ch === '\\') escaped = true; + else if (ch === inString) inString = null; + continue; + } + if (ch === "'" || ch === '"' || ch === '`') { + inString = ch; + continue; + } + if (ch === '{') depth++; + else if (ch === '}') { + depth--; + if (depth === 0) return src.slice(start, i + 1); + } + } + throw new Error(name + ' brace scan failed'); +} + +const elementsById = new Map(); + +class FakeElement { + constructor(tagName) { + this.tagName = tagName.toUpperCase(); + this.children = []; + this.parentNode = null; + this.className = ''; + this.id = ''; + this.hidden = false; + this.disabled = false; + this.textContent = ''; + this._value = ''; + this._selectedIndex = -1; + this._listeners = {}; + } + + appendChild(child) { + child.parentNode = this; + this.children.push(child); + if (child.id) elementsById.set(child.id, child); + if (this.tagName === 'SELECT' && child.tagName === 'OPTION' && this._selectedIndex < 0 && !child.disabled) { + this.selectedIndex = this.children.length - 1; + } + return child; + } + + addEventListener(type, handler) { + this._listeners[type] = handler; + } + + dispatch(type, extra = {}) { + const handler = this._listeners[type]; + if (!handler) return; + handler({ + type, + target: this, + key: extra.key, + preventDefault() {}, + }); + } + + focus() {} + + querySelector(selector) { + const queue = [...this.children]; + while (queue.length) { + const node = queue.shift(); + if (selector.startsWith('.')) { + const cls = selector.slice(1); + if ((node.className || '').split(/\s+/).includes(cls)) return node; + } else if (selector.startsWith('#')) { + if (node.id === selector.slice(1)) return node; + } else if (node.tagName === selector.toUpperCase()) { + return node; + } + queue.push(...node.children); + } + return null; + } + + set innerHTML(html) { + this.children = []; + const selectId = (html.match(/