diff --git a/CHANGELOG.md b/CHANGELOG.md
index fbbe736b..83f50fd4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,12 @@
## [Unreleased]
+## [v0.51.504] — 2026-06-18 — Release RO (group OpenRouter / Nous models by vendor in the model picker)
+
+### Added
+
+- **The model picker now groups OpenRouter and Nous models into collapsible vendor sub-groups (#4440).** When one of those providers exposes a large catalog (8+ models), the dropdown splits it by vendor prefix — `openai`, `anthropic`, `google`, etc. — under collapsible sub-headings, sorted by model count, with single-model vendors left as flat rows (no one-item headings). This tames OpenRouter's very large flat model list without affecting smaller providers, which render unchanged. Sub-groups expand automatically while searching. Thanks @rodboev.
+
## [v0.51.503] — 2026-06-18 — Release RN (process-wakeup turns no longer render as user messages)
### Fixed
diff --git a/static/style.css b/static/style.css
index 5de39077..44c15646 100644
--- a/static/style.css
+++ b/static/style.css
@@ -2519,6 +2519,7 @@
.model-dropdown.open{display:block;}
.model-scope-note{position:sticky;top:0;z-index:2;padding:9px 14px;border-bottom:1px solid var(--border);color:var(--text);font-size:11px;font-weight:650;line-height:1.4;background:color-mix(in srgb,var(--surface) 82%,var(--accent-bg));box-shadow:0 1px 0 rgba(0,0,0,.12);}
.model-group{padding:8px 14px 4px;font-size:10px;font-weight:700;letter-spacing:.04em;color:var(--muted);text-transform:uppercase;border-top:1px solid var(--border2);margin-top:2px;}.model-group.collapsible{cursor:pointer;user-select:none}.model-group.collapsible:before{content:'\25b6';display:inline-block;font-size:8px;margin-right:5px;transition:transform .15s ease}.model-group.collapsible.open:before{transform:rotate(90deg)}.model-group-body:empty{display:none}
+.model-group.sub{padding:4px 14px 2px 22px;font-size:9px;border-top:none;margin-top:0;letter-spacing:.03em;color:var(--muted);background:transparent;}.model-group.sub.collapsible:before{content:'\25b6';display:inline-block;font-size:7px;margin-right:4px;transition:transform .15s ease}.model-group.sub.collapsible.open:before{transform:rotate(90deg)}.model-group-body.sub{padding-left:8px;border-left:1px solid var(--border);}
.model-opt{padding:10px 14px;cursor:pointer;transition:background .12s;display:flex;flex-direction:column;gap:3px;align-items:flex-start;}
.model-opt:hover{background:rgba(255,255,255,.07);}
.model-opt.is-highlighted{background:rgba(255,255,255,.1);outline:1px solid var(--border2);outline-offset:-1px;}
diff --git a/static/ui.js b/static/ui.js
index 999cd908..983db3be 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -2301,6 +2301,13 @@ function renderModelDropdown(){
}
return _groupMeta.get(groupKey);
};
+ const _vendorPrefix=(rawId)=>{
+ const stripped=String(rawId||'').replace(/^@([^:]+:)+/,'');
+ const slash=stripped.indexOf('/');
+ return slash>0?stripped.slice(0,slash):'';
+ };
+ const SUB_GROUP_PROVIDERS=new Set(['openrouter','nous']);
+ const SUB_GROUP_MIN_MODELS=8;
for(const child of Array.from(sel.children)){
if(child.tagName==='OPTGROUP'){
const providerId=child.dataset&&child.dataset.provider?child.dataset.provider:'';
@@ -2520,6 +2527,17 @@ function renderModelDropdown(){
const _hit=_modelData.find(m=>m&&!m.endpointErrorOnly&&String(m.value||'')===_selVal);
return _hit?_hit.groupKey:null;
})();
+ const _makeModelRow=(m,sel,shouldRenderHeading)=>{
+ const row=document.createElement('div');
+ row.className='model-opt'+(m.value===sel.value?' active':'');
+ const badgeHtml=m.badge?`${esc(m.badge.label||'Configured')}`:'';
+ const _plainGroup=m.group?String(m.group).replace(/\s*\(\d+\s+of\s+\d+\)\s*$/,''):'';
+ const _underOwnHeading=shouldRenderHeading&&!!(m.groupKey&&_groupWrappers[m.groupKey]);
+ const providerChip=(_plainGroup&&!_underOwnHeading)?`${esc(_plainGroup)}`:'';
+ row.innerHTML=`
${esc(m.name)}${badgeHtml}${providerChip}
${esc(m.id)}`;
+ row.onclick=()=>selectModelFromDropdown(m.value,m.providerId||(m.badge&&m.badge.provider)||null);
+ return row;
+ };
const _filterModels=(term)=>{
term=term.trim().toLowerCase();
const hasSearch=!!term;
@@ -2673,30 +2691,55 @@ function renderModelDropdown(){
if(closed) _forceOpenGroups.add(groupKey); else _forceOpenGroups.delete(groupKey);
heading.classList.toggle('open',closed);
});
- }
- for(const m of groupRows){
- const row=document.createElement('div');
- row.className='model-opt'+(m.value===sel.value?' active':'');
- const badgeHtml=m.badge?`${esc(m.badge.label||'Configured')}`:'';
- // The group heading carries the overflow count ("Provider (15 of 30)"); the
- // per-row provider chip must show the PLAIN provider label only — otherwise
- // the count is stamped redundantly on every row and reads as nonsense after
- // "Show all" expands the group (e.g. row 30 still showing "(15 of 30)"). (#3691)
- const _plainGroup=m.group?String(m.group).replace(/\s*\(\d+\s+of\s+\d+\)\s*$/,''):'';
- // Only show the per-row provider chip when the row is NOT already under its
- // own provider heading — i.e. it's a flat/hoisted row appended straight to
- // the dropdown (no group wrapper). Repeating the provider name on every row
- // beneath its own "PROVIDER" heading is pure visual noise. The chip still
- // orients hoisted/configured rows and search results that render flat.
- const _underOwnHeading=shouldRenderHeading&&!!(m.groupKey&&_groupWrappers[m.groupKey]);
- const providerChip=(_plainGroup&&!_underOwnHeading)?`${esc(_plainGroup)}`:'';
- row.innerHTML=`${esc(m.name)}${badgeHtml}${providerChip}
${esc(m.id)}`;
- row.onclick=()=>selectModelFromDropdown(m.value,m.providerId||(m.badge&&m.badge.provider)||null);
- if(m.groupKey&&_groupWrappers[m.groupKey]){
- _groupWrappers[m.groupKey].appendChild(row);
- }else{
- dd.appendChild(row);
+ const useSubGroups=(
+ SUB_GROUP_PROVIDERS.has(meta.providerId) &&
+ groupRows.length>=SUB_GROUP_MIN_MODELS
+ );
+ if(useSubGroups){
+ const byPrefix=new Map();
+ for(const m of groupRows){
+ const pfx=_vendorPrefix(m.value)||'other';
+ if(!byPrefix.has(pfx)) byPrefix.set(pfx,[]);
+ byPrefix.get(pfx).push(m);
+ }
+ const sorted=[...byPrefix.entries()].sort((a,b)=>{
+ if(a[0]==='other') return 1;
+ if(b[0]==='other') return -1;
+ return b[1].length-a[1].length;
+ });
+ for(const [pfx,pfxRows] of sorted){
+ if(pfxRows.length>=2){
+ const subKey=`${groupKey}::${pfx}`;
+ if(!(subKey in _groupOpenState)) _groupOpenState[subKey]=true;
+ if(hasSearch) _groupOpenState[subKey]=true;
+ const subHeading=document.createElement('div');
+ subHeading.className='model-group sub collapsible';
+ subHeading.dataset.group=subKey;
+ if(_groupOpenState[subKey]) subHeading.classList.add('open');
+ subHeading.textContent=pfx;
+ const subWrapper=document.createElement('div');
+ subWrapper.className='model-group-body sub';
+ subWrapper.dataset.group=subKey;
+ if(!_groupOpenState[subKey]) subWrapper.style.display='none';
+ subHeading.addEventListener('click',(e)=>{
+ e.stopPropagation();
+ const closed=subWrapper.style.display==='none';
+ subWrapper.style.display=closed?'':'none';
+ _groupOpenState[subKey]=closed;
+ subHeading.classList.toggle('open',closed);
+ });
+ wrapper.appendChild(subHeading);
+ wrapper.appendChild(subWrapper);
+ for(const m of pfxRows) subWrapper.appendChild(_makeModelRow(m,sel,shouldRenderHeading));
+ } else {
+ for(const m of pfxRows) wrapper.appendChild(_makeModelRow(m,sel,shouldRenderHeading));
+ }
+ }
+ } else {
+ for(const m of groupRows) wrapper.appendChild(_makeModelRow(m,sel,shouldRenderHeading));
}
+ } else {
+ for(const m of groupRows) dd.appendChild(_makeModelRow(m,sel,shouldRenderHeading));
}
if(!term&&hiddenCount){
const showAll=document.createElement('div');
@@ -2738,7 +2781,14 @@ function renderModelDropdown(){
};
_si.addEventListener('input',()=>_filterModels(_si.value));
// Keyboard navigation through filtered model rows (#2791).
- const _visibleModelRows=()=>Array.from(dd.querySelectorAll('.model-opt,.model-opt-more')).filter(el=>!el.closest('.model-group-body')||el.closest('.model-group-body').style.display!=='none');
+ const _visibleModelRows=()=>Array.from(dd.querySelectorAll('.model-opt,.model-opt-more')).filter(el=>{
+ let node=el.parentElement;
+ while(node&&node!==dd){
+ if(node.classList.contains('model-group-body')&&node.style.display==='none') return false;
+ node=node.parentElement;
+ }
+ return true;
+ });
const _activeRowIndex=(rows)=>rows.findIndex(r=>r.classList.contains('is-highlighted'));
const _highlightRow=(rows,idx)=>{
for(const r of rows) r.classList.remove('is-highlighted');
diff --git a/tests/test_model_picker_subgroups.py b/tests/test_model_picker_subgroups.py
new file mode 100644
index 00000000..94a8bb41
--- /dev/null
+++ b/tests/test_model_picker_subgroups.py
@@ -0,0 +1,37 @@
+from pathlib import Path
+import re
+
+ROOT=Path(__file__).resolve().parents[1]
+UI=(ROOT/"static"/"ui.js").read_text(encoding="utf-8")
+CSS=(ROOT/"static"/"style.css").read_text(encoding="utf-8")
+
+def test_vendor_subgroup_allowlist():
+ assert "SUB_GROUP_PROVIDERS" in UI
+ assert "openrouter" in UI
+ assert "nous" in UI
+
+def test_vendor_prefix_strips_routing_prefix():
+ # The strip pattern must match the existing normalizer at line 1749
+ assert "replace(/^@([^:]+:)+/,'')" in UI
+ assert re.search(r"indexOf\('/'\)|split\('/'\)", UI)
+
+def test_subgroup_keys_use_double_colon_separator():
+ assert "::" in UI
+ assert re.search(r"_groupOpenState\[subKey\]", UI)
+
+def test_single_model_vendor_buckets_stay_flat():
+ # pfxRows.length>=2 gate means single-model prefixes render flat
+ assert re.search(r"\.length\s*>=\s*2", UI)
+
+def test_visible_rows_walk_all_group_body_ancestors():
+ visible_match=re.search(r"const _visibleModelRows=\(\)=>[\s\S]+?\}\);", UI)
+ assert visible_match, "_visibleModelRows definition not found"
+ visible=visible_match.group(0)
+ assert "while(" in visible
+ assert "parentElement" in visible
+ assert "model-group-body" in visible
+ assert "closest('.model-group-body')" not in visible
+
+def test_nested_group_css_exists():
+ assert ".model-group.sub" in CSS
+ assert ".model-group-body.sub" in CSS