diff --git a/CHANGELOG.md b/CHANGELOG.md
index 342ed6e0..fe2f3254 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,11 @@
## [Unreleased]
+## [v0.51.271] — 2026-06-05 — Release IM (stage-m1 — named custom provider binding)
+
+### Fixed
+- **A named custom provider binding (`@custom:name:model`) is preserved when starting a chat** instead of being overridden by the bare-model family fallback, so a session pinned to a specific named custom endpoint routes to that endpoint. Live model ``/` ` entries also carry their provider via `data-provider` for accurate selection. (#3626, @rodboev)
+
### Tests
- **Regression test locking the approval/clarify card re-show invariant on session switch** (#3668). Switching away from a session blocked on a `clarify`/`approval` prompt and switching back must re-show the card for the returning session — the prompts are cached per-session in memory and re-rendered by `_renderPendingPromptsForActiveSession()` on every `loadSession()`, with `startClarifyPolling`/`startApprovalPolling` + the SSE `initial` event covering the uncached (fresh-reload) path. A node-driver test runs the real extracted JS through the switch-away→switch-back sequence (RED/GREEN-validated against a simulated over-broad teardown). This behavior shipped in v0.51.19 (#1829); the test prevents a future regression. (#3668)
diff --git a/static/sessions.js b/static/sessions.js
index 303dfbc3..7d37edb9 100644
--- a/static/sessions.js
+++ b/static/sessions.js
@@ -616,8 +616,9 @@ async function newSession(flash, options={}){
if(s.startsWith('openai'))return 'openai';if(s.startsWith('anthropic')||s.startsWith('claude'))return 'anthropic';
if(s.startsWith('google')||s.startsWith('gemini'))return 'google';return s;};
const _familyMismatch=_familyProvider&&_fallbackProvider&&_normProv(_fallbackProvider)!==_familyProvider;
+ const _fallbackIsNamedCustom=String(_fallbackProvider||'').toLowerCase().startsWith('custom:');
reqBody.model_provider=newModelState.model_provider
- ||((_bareModel&&!_familyMismatch)?(_fallbackProvider||null):null)
+ ||((_bareModel&&!_familyMismatch&&!_fallbackIsNamedCustom)?(_fallbackProvider||null):null)
||null;
}
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify(reqBody)});
diff --git a/static/ui.js b/static/ui.js
index 33280250..f89d5c12 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -1376,13 +1376,13 @@ function _addLiveModelsToSelect(provider, models, sel){
if(!providerGroup){
providerGroup=document.createElement('optgroup');
providerGroup.label=provider.charAt(0).toUpperCase()+provider.slice(1)+' (live)';
+ providerGroup.dataset.provider=provider;
sel.appendChild(providerGroup);
+ }else if(!providerGroup.dataset.provider){
+ providerGroup.dataset.provider=provider;
}
const existingIds=new Set([...sel.options].map(o=>o.value));
- // Normalized dedup: strip @provider: prefix and namespace so
- // 'minimax/minimax-m2.7' matches '@nous:minimax/minimax-m2.7' (#907).
- // Named custom IDs (@custom:name:model) have a known two-segment prefix
- // and must strip both to dedup against bare model IDs (#3478).
+ // Normalized dedup strips provider/custom prefixes and namespaces (#907, #3478).
const _normId=id=>{
let s=String(id||'');
if(s.startsWith('@')&&s.includes(':')){
@@ -1412,6 +1412,7 @@ function _addLiveModelsToSelect(provider, models, sel){
opt.value=mid;
opt.textContent=m.label||m.id;
opt.title='Live model — fetched from provider';
+ opt.dataset.provider=provider;
providerGroup.appendChild(opt);
_dynamicModelLabels[mid]=m.label||m.id;
added++;
diff --git a/tests/test_chat_start_provider_fallback.py b/tests/test_chat_start_provider_fallback.py
index ba236e5f..018a1c53 100644
--- a/tests/test_chat_start_provider_fallback.py
+++ b/tests/test_chat_start_provider_fallback.py
@@ -104,7 +104,11 @@ modelSelect = makeSelect(args.options || [], args.initialValue || '');
if (args.persisted) localStorage.setItem(MODEL_STATE_KEY, JSON.stringify(args.persisted));
var S = {session: {model_provider: args.sessionProvider || null}};
-process.stdout.write(JSON.stringify({provider: _modelProviderForSend(args.model)}));
+if (args.mode === 'modelState') {
+ process.stdout.write(JSON.stringify(_modelStateForSelect(modelSelect, args.model)));
+} else {
+ process.stdout.write(JSON.stringify({provider: _modelProviderForSend(args.model)}));
+}
"""
node_test = pytest.mark.skipif(NODE is None, reason="node not on PATH")
@@ -129,6 +133,19 @@ def _run_helper(driver_path, payload):
return json.loads(result.stdout)["provider"]
+def _run_model_state_helper(driver_path, payload):
+ payload = {"mode": "modelState", **payload}
+ result = subprocess.run(
+ [NODE, driver_path, str(UI_JS_PATH), json.dumps(payload)],
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ if result.returncode != 0:
+ raise RuntimeError(f"node driver failed:\nSTDOUT={result.stdout}\nSTDERR={result.stderr}")
+ return json.loads(result.stdout)
+
+
@node_test
def test_model_provider_for_send_preserves_session_provider(driver_path):
provider = _run_helper(driver_path, {
@@ -181,3 +198,40 @@ def test_model_provider_for_send_uses_only_matching_persisted_state(driver_path)
assert matching == "xai-oauth"
assert unrelated is None
+
+
+@node_test
+def test_model_state_reads_live_custom_provider_from_option_metadata(driver_path):
+ state = _run_model_state_helper(driver_path, {
+ "model": "llama-3.3-custom",
+ "initialValue": "llama-3.3-custom",
+ "options": [{
+ "provider": "custom:lab",
+ "optionProvider": "custom:lab",
+ "value": "llama-3.3-custom",
+ }],
+ })
+
+ assert state == {"model": "llama-3.3-custom", "model_provider": "custom:lab"}
+
+
+def test_live_custom_models_are_tagged_with_provider_metadata():
+ ui_src = UI_JS_PATH.read_text(encoding="utf-8")
+ start = ui_src.index("function _addLiveModelsToSelect")
+ body = ui_src[start:ui_src.index("async function _fetchLiveModels", start)]
+
+ assert "providerGroup.dataset.provider=provider;" in body
+ assert "opt.dataset.provider=provider;" in body
+
+
+def test_new_session_does_not_fallback_to_stale_named_custom_provider():
+ sessions_src = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
+ start = sessions_src.index("async function newSession(")
+ body = sessions_src[start:sessions_src.index("const data=await api('/api/session/new'", start)]
+ assignment = body[body.index("reqBody.model_provider="):].split(";", 1)[0]
+
+ assert "const _fallbackIsNamedCustom=String(_fallbackProvider||'').toLowerCase().startsWith('custom:');" in body
+ assert "newModelState.model_provider" in assignment
+ assert "!_familyMismatch" in assignment
+ assert "!_fallbackIsNamedCustom" in assignment
+ assert "_fallbackProvider||null" in assignment