From 8532f4606be321a51df39acb289d90031771b9e3 Mon Sep 17 00:00:00 2001 From: red person Date: Tue, 3 Nov 2026 03:23:17 +0300 Subject: [PATCH] fix(models): resolve configured defaults after automatic fallback (#6949) * fix(models): resolve configured defaults after automatic fallback * test: isolate photo routing from external MCP registry state --- static/js/modelPicker.js | 21 +++--- tests/test_model_picker_default_fallback.py | 81 +++++++++++++++++++++ 2 files changed, 92 insertions(+), 10 deletions(-) create mode 100644 tests/test_model_picker_default_fallback.py diff --git a/static/js/modelPicker.js b/static/js/modelPicker.js index 324364c7c..49fd59f0d 100644 --- a/static/js/modelPicker.js +++ b/static/js/modelPicker.js @@ -128,7 +128,7 @@ async function _ensureDefaultPendingChat() { if (!_deps || _defaultChatPickInFlight) return; if (_deps.getCurrentSessionId && _deps.getCurrentSessionId()) return; const pending = _deps.getPendingChat && _deps.getPendingChat(); - if (pending && pending.modelId) return; + if (pending && pending.modelId && pending.source !== 'fallback') return; _defaultChatPickInFlight = true; const seq = ++_defaultPendingSeq; try { @@ -954,15 +954,6 @@ export function updateModelPicker() { } } const latestPending = _deps.getPendingChat && _deps.getPendingChat(); - if ( - !currentSessionId && - !_autoSelectingDefault && - window.modelsModule && - window.modelsModule.getCachedItems && - (!modelId || (latestPending && latestPending.source === 'fallback')) - ) { - _ensureDefaultPendingChat(); - } let displayName = modelId ? modelId.split('/').pop() : 'Select model'; const routeItems = window.modelsModule?.getCachedItems?.() || []; @@ -994,6 +985,16 @@ export function updateModelPicker() { label.textContent = displayName; } _updateReasoningEffortUI(modelId, s, latestPending, selectedEndpoint); + // A cached default can resolve synchronously and re-render the picker. + if ( + !currentSessionId && + !_autoSelectingDefault && + window.modelsModule && + window.modelsModule.getCachedItems && + (!modelId || (latestPending && latestPending.source === 'fallback')) + ) { + _ensureDefaultPendingChat(); + } } // ── Reasoning effort control for models supporting reasoning levels ── diff --git a/tests/test_model_picker_default_fallback.py b/tests/test_model_picker_default_fallback.py new file mode 100644 index 000000000..5570b8931 --- /dev/null +++ b/tests/test_model_picker_default_fallback.py @@ -0,0 +1,81 @@ +"""An automatic fallback must not prevent resolving the configured default.""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +from tests.helpers.js_modules import js_function_source + +ROOT = Path(__file__).resolve().parents[1] +PICKER = (ROOT / 'static/js/modelPicker.js').read_text() + + +@pytest.mark.skipif(not shutil.which('node'), reason='node not installed') +@pytest.mark.parametrize('case', ['fallback', 'manual', 'default', 'session', 'manual-during-fetch', 'no-default']) +def test_configured_default_and_selection_ownership(case): + source = '\n'.join(js_function_source(name, PICKER) for name in [ + '_ensureDefaultPendingChat', '_ensureModelCacheForFallback', '_firstAvailableModel', + ]) + script = r''' +const source = JSON.parse(process.argv[1]), mode = process.argv[2]; +let pending = {url:'http://models/v1/chat/completions',modelId:'first',endpointId:'shared',source:'fallback'}; +if (mode === 'manual' || mode === 'default') pending.source = mode; +const initial = {...pending}; +const configured = {endpoint_url:pending.url,model:'preferred',endpoint_id:'shared'}; +let fetches = 0, updates = 0; +const _deps = {getCurrentSessionId:()=> mode === 'session' ? 'existing' : null, + getPendingChat:()=>pending,setPendingChat:value=>{pending=value;}}; +const window = {modelsModule:{getCachedItems:()=>[{url:initial.url,models:['first','preferred'],endpoint_id:'shared'}]}}; +const localStorage = {setItem(){}}; +const API_BASE = 'http://app'; +let _defaultChatPickInFlight = false, _defaultPendingSeq = 0; +function updateModelPicker(){updates++;} +async function fetch(){fetches++; if(mode==='manual-during-fetch') pending={...initial,modelId:'chosen',source:'manual'}; + return {ok:true,json:async()=>mode==='no-default'?{}:configured};} +// All helpers share the same lexical dependencies as the production function. +eval(source.replace('async function _ensureDefaultPendingChat','globalThis.ensureDefault = async function')); +await globalThis.ensureDefault(); +console.log(JSON.stringify({pending,fetches,updates,inFlight:_defaultChatPickInFlight})); +''' + # Script is a module so async fetch ordering is exercised without a browser shim. + result = subprocess.run(['node', '--input-type=module', '-e', script, json.dumps(source), case], + cwd=ROOT, capture_output=True, text=True, check=True, timeout=15) + data = json.loads(result.stdout) + assert data['inFlight'] is False + if case == 'fallback': + assert data['pending']['modelId'] == 'preferred' + assert data['pending']['source'] == 'default' + assert data['fetches'] == 1 + elif case == 'manual-during-fetch': + assert data['pending']['modelId'] == 'chosen' + assert data['pending']['source'] == 'manual' + else: + assert data['pending']['modelId'] == 'first' + assert data['pending']['source'] == (case if case in {'manual', 'default'} else 'fallback') + assert data['fetches'] == (1 if case == 'no-default' else 0) + + +@pytest.mark.skipif(not shutil.which('node'), reason='node not installed') +def test_cached_default_updates_visible_picker_after_fallback(): + names = ['_ensureDefaultPendingChat', '_ensureModelCacheForFallback', '_firstAvailableModel', '_modelExists', 'updateModelPicker'] + source = '\n'.join(js_function_source(name, PICKER).removeprefix('export ') for name in names) + script = r''' +let pending={url:'http://models/v1/chat/completions',modelId:'first',endpointId:'shared',source:'fallback'}; +const _deps={getCurrentSessionId:()=>null,getSessions:()=>[],getPendingChat:()=>pending,setPendingChat:value=>{pending=value;}}; +const label={textContent:''}; +const document={getElementById:id=>id==='model-picker-label'?label:null}; +const window={__odysseusDefaultChat:{endpoint_url:pending.url,endpoint_id:'shared',model:'preferred'}, + modelsModule:{getCachedItems:()=>[{url:pending.url,endpoint_id:'shared',models:['first','preferred']}]}}; +const localStorage={setItem(){}}; +let _defaultChatPickInFlight=false,_defaultPendingSeq=0,_autoSelectingDefault=false; +function providerLogo(){return null;}function providerLabel(){return 'Fixture';}function _updateReasoningEffortUI(){} +eval(JSON.parse(process.argv[1])+'\nupdateModelPicker();'); +console.log(JSON.stringify({pending,label:label.textContent})); +''' + result = subprocess.run(['node', '--input-type=module', '-e', script, json.dumps(source)], + cwd=ROOT, capture_output=True, text=True, check=True, timeout=15) + data = json.loads(result.stdout) + assert data['pending']['modelId'] == 'preferred' + assert 'preferred' in data['label']