Release v0.51.271 — Release IM (stage-m1 — named custom provider binding #3626) (#3676)

* fix(providers): preserve named custom provider binding in model send (#3626)

Co-authored-by: Rod Boev <rod.boev@gmail.com>

* docs(changelog): v0.51.271 — Release IM (stage-m1, #3626 only; #3629 dropped)

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-06-05 11:58:25 -07:00
1 parent bcd8c05f24
commit d882949173
4 files changed
+67 -6

No files matched your search

+5
View File
@@ -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 `<option>`/`<optgroup>` 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)
+2 -1
View File
@@ -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)});
+5 -4
View File
@@ -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++;
+55 -1
View File
@@ -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