From d882949173a9db34a0f18a0f9f4d2333c2dccca3 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Fri, 5 Jun 2026 11:58:25 -0700 Subject: [PATCH] =?UTF-8?q?Release=20v0.51.271=20=E2=80=94=20Release=20IM?= =?UTF-8?q?=20(stage-m1=20=E2=80=94=20named=20custom=20provider=20binding?= =?UTF-8?q?=20#3626)=20(#3676)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(providers): preserve named custom provider binding in model send (#3626) Co-authored-by: Rod Boev * 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 --- CHANGELOG.md | 5 ++ static/sessions.js | 3 +- static/ui.js | 9 ++-- tests/test_chat_start_provider_fallback.py | 56 +++++++++++++++++++++- 4 files changed, 67 insertions(+), 6 deletions(-) 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