* 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:
4 files changed
+67
-6
No files matched your search
@@ -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
@@ -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
@@ -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++;
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user