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(/