fix(#4737): retry model catalog fetch once after cold-cache fallback
This commit is contained in:
parent
b84bf1c38e
commit
d73bea19cd
2 changed files with 356 additions and 4 deletions
16
static/ui.js
16
static/ui.js
|
|
@ -2390,6 +2390,7 @@ function _persistSessionModelCorrection(model, provider, opts){
|
|||
return opts&&opts.propagateErrors ? request : request.catch(()=>{});
|
||||
}
|
||||
let _modelDropdownRequestSeq=0;
|
||||
let _modelCatalogFallbackRetried=false;
|
||||
|
||||
function _applySessionModelFallback(sel){
|
||||
if(!sel) return null;
|
||||
|
|
@ -2414,10 +2415,12 @@ async function populateModelDropdown(opts={}){
|
|||
const sel=$('modelSelect');
|
||||
if(!sel) return;
|
||||
if(typeof _modelDropdownRequestSeq!=='number') _modelDropdownRequestSeq=0;
|
||||
if(typeof _modelCatalogFallbackRetried!=='boolean') _modelCatalogFallbackRetried=false;
|
||||
const requestSeq=++_modelDropdownRequestSeq;
|
||||
try{
|
||||
const modelsUrl=new URL('api/models',document.baseURI||location.href);
|
||||
if(opts&&opts.freshness) modelsUrl.searchParams.set('freshness',opts.freshness);
|
||||
const requestedFreshness=opts&&opts.freshness?String(opts.freshness):'';
|
||||
if(requestedFreshness) modelsUrl.searchParams.set('freshness',requestedFreshness);
|
||||
const _modelsRes=await fetch(modelsUrl.href,{credentials:'include'});
|
||||
if(requestSeq!==_modelDropdownRequestSeq) return;
|
||||
const customRedirectIfUnauth=opts&&typeof opts.redirectIfUnauth==='function'?opts.redirectIfUnauth:null;
|
||||
|
|
@ -2469,9 +2472,10 @@ async function populateModelDropdown(opts={}){
|
|||
return groups;
|
||||
};
|
||||
|
||||
const groups=(Array.isArray(data.groups)&&data.groups.length)
|
||||
? data.groups
|
||||
: _synthGroupsFromConfigured();
|
||||
const usedConfiguredFallback=!(Array.isArray(data.groups)&&data.groups.length);
|
||||
const groups=usedConfiguredFallback
|
||||
? _synthGroupsFromConfigured()
|
||||
: data.groups;
|
||||
|
||||
if(!groups.length) return; // no server groups and no configured fallback
|
||||
const previousSelection=_captureModelDropdownSelection(sel);
|
||||
|
|
@ -2518,6 +2522,10 @@ async function populateModelDropdown(opts={}){
|
|||
// Kick off a background live-model fetch for the active provider.
|
||||
// This runs after the static list is already shown (no blocking flicker).
|
||||
if(data.active_provider) _fetchLiveModels(data.active_provider, sel, requestSeq);
|
||||
if(usedConfiguredFallback && requestedFreshness!=='session_visit' && !_modelCatalogFallbackRetried){
|
||||
_modelCatalogFallbackRetried=true;
|
||||
populateModelDropdown({freshness:'session_visit'}).catch(()=>{});
|
||||
}
|
||||
}catch(e){
|
||||
if(requestSeq!==_modelDropdownRequestSeq) return;
|
||||
// API unavailable -- keep the hardcoded HTML options as fallback
|
||||
|
|
|
|||
344
tests/test_4737_first_tab_model_catalog_refresh.py
Normal file
344
tests/test_4737_first_tab_model_catalog_refresh.py
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
"""Browserless regression coverage for first-tab model catalog refresh (#4737)."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
NODE = shutil.which("node")
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
NODE is None,
|
||||
reason="node is required to execute the model catalog refresh harness",
|
||||
)
|
||||
|
||||
|
||||
_DRIVER = r"""
|
||||
const fs = require('fs');
|
||||
const scenario = JSON.parse(process.argv[2] || '{}');
|
||||
const fnSource = fs.readFileSync(process.argv[3], 'utf8');
|
||||
|
||||
class FakeStorage {
|
||||
constructor(seed = {}) {
|
||||
this.store = { ...seed };
|
||||
}
|
||||
|
||||
getItem(key) {
|
||||
return Object.prototype.hasOwnProperty.call(this.store, key)
|
||||
? this.store[key]
|
||||
: null;
|
||||
}
|
||||
|
||||
setItem(key, value) {
|
||||
this.store[key] = String(value);
|
||||
}
|
||||
|
||||
removeItem(key) {
|
||||
delete this.store[key];
|
||||
}
|
||||
}
|
||||
|
||||
class FakeNode {
|
||||
constructor(tagName) {
|
||||
this.tagName = tagName.toUpperCase();
|
||||
this.children = [];
|
||||
this.dataset = {};
|
||||
this.classList = { contains: () => false };
|
||||
this.style = {};
|
||||
this.textContent = '';
|
||||
this.label = '';
|
||||
this.value = '';
|
||||
this.parentNode = null;
|
||||
}
|
||||
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
child.parentNode = this;
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeSelect extends FakeNode {
|
||||
constructor() {
|
||||
super('select');
|
||||
this.id = 'modelSelect';
|
||||
this._innerHTML = '';
|
||||
this._value = '';
|
||||
}
|
||||
|
||||
set innerHTML(value) {
|
||||
this._innerHTML = value;
|
||||
this.children = [];
|
||||
}
|
||||
|
||||
get innerHTML() {
|
||||
return this._innerHTML;
|
||||
}
|
||||
|
||||
get options() {
|
||||
const options = [];
|
||||
for (const child of this.children) {
|
||||
if (child.tagName === 'OPTGROUP') {
|
||||
options.push(...child.children);
|
||||
} else if (child.tagName === 'OPTION') {
|
||||
options.push(child);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
set value(value) {
|
||||
this._value = value;
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
querySelector(selector) {
|
||||
if (selector === 'optgroup > option, option') {
|
||||
return this.options[0] || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildFetchResponse(payload, status = 200) {
|
||||
return {
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
json: async () => payload,
|
||||
text: async () => JSON.stringify(payload),
|
||||
};
|
||||
}
|
||||
|
||||
function buildHarness(currentScenario) {
|
||||
const select = new FakeSelect();
|
||||
const dropdown = new FakeNode('div');
|
||||
const fetchCalls = [];
|
||||
const fetchQueue = (currentScenario.fetchResponses || []).map((payload) => buildFetchResponse(payload));
|
||||
|
||||
globalThis.window = globalThis;
|
||||
globalThis.document = {
|
||||
baseURI: 'http://localhost/session/abc',
|
||||
createElement(tag) {
|
||||
return new FakeNode(tag);
|
||||
},
|
||||
};
|
||||
globalThis.location = { href: 'http://localhost/session/abc' };
|
||||
globalThis.sessionStorage = new FakeStorage();
|
||||
globalThis.localStorage = new FakeStorage();
|
||||
globalThis.S = { pendingFiles: [] };
|
||||
globalThis._dynamicModelLabels = {};
|
||||
globalThis._modelEndpointErrors = {};
|
||||
globalThis._defaultModel = null;
|
||||
globalThis._activeProvider = null;
|
||||
globalThis._configuredModelBadges = {};
|
||||
globalThis._modelDropdownRequestSeq = 0;
|
||||
globalThis._modelCatalogFallbackRetried = false;
|
||||
globalThis.$ = (id) => {
|
||||
if (id === 'modelSelect') return select;
|
||||
if (id === 'composerModelDropdown') return dropdown;
|
||||
return null;
|
||||
};
|
||||
globalThis.getModelLabel = (id) => `label:${id}`;
|
||||
globalThis._captureModelDropdownSelection = () => null;
|
||||
globalThis._reconcileModelDropdownSelection = (_sel, data) => {
|
||||
const firstGroup = Array.isArray(data.groups) && data.groups.length ? data.groups[0] : null;
|
||||
const firstModel = firstGroup && Array.isArray(firstGroup.models) && firstGroup.models.length
|
||||
? firstGroup.models[0]
|
||||
: null;
|
||||
if (firstModel) _sel.value = firstModel.id;
|
||||
};
|
||||
globalThis.syncModelChip = () => {};
|
||||
globalThis.renderModelDropdown = () => {};
|
||||
globalThis._positionModelDropdown = () => {};
|
||||
globalThis._redirectIfUnauth = () => false;
|
||||
globalThis.console = { warn() {}, debug() {}, log() {} };
|
||||
globalThis._fetchLiveModels = () => {};
|
||||
globalThis.fetch = async (url) => {
|
||||
fetchCalls.push(String(url));
|
||||
if (!fetchQueue.length) throw new Error(`unexpected fetch: ${url}`);
|
||||
return fetchQueue.shift();
|
||||
};
|
||||
|
||||
return { select, fetchCalls };
|
||||
}
|
||||
|
||||
async function runScenario(currentScenario) {
|
||||
const { select, fetchCalls } = buildHarness(currentScenario);
|
||||
let _modelDropdownRequestSeq = 0;
|
||||
let _modelCatalogFallbackRetried = false;
|
||||
eval(fnSource);
|
||||
await populateModelDropdown(currentScenario.opts || {});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
return {
|
||||
fetchCalls,
|
||||
optionValues: select.options.map((opt) => opt.value),
|
||||
};
|
||||
}
|
||||
|
||||
runScenario(scenario).then((result) => {
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
}).catch((error) => {
|
||||
process.stderr.write(String(error && error.stack || error));
|
||||
process.exit(1);
|
||||
});
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def driver_path(tmp_path_factory):
|
||||
path = tmp_path_factory.mktemp("model-catalog-refresh-driver") / "driver.js"
|
||||
path.write_text(_DRIVER, encoding="utf-8")
|
||||
return str(path)
|
||||
|
||||
|
||||
def _run(driver_path, scenario):
|
||||
marker = "async function populateModelDropdown("
|
||||
start = UI_JS.index(marker)
|
||||
paren_depth = 1
|
||||
idx = start + len(marker)
|
||||
while idx < len(UI_JS) and paren_depth > 0:
|
||||
char = UI_JS[idx]
|
||||
if char == "(":
|
||||
paren_depth += 1
|
||||
elif char == ")":
|
||||
paren_depth -= 1
|
||||
idx += 1
|
||||
if paren_depth != 0:
|
||||
raise AssertionError("could not locate populateModelDropdown signature")
|
||||
brace_start = UI_JS.index("{", idx)
|
||||
depth = 0
|
||||
for idx in range(brace_start, len(UI_JS)):
|
||||
char = UI_JS[idx]
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
fn_source = UI_JS[start : idx + 1]
|
||||
break
|
||||
else:
|
||||
raise AssertionError("could not extract populateModelDropdown")
|
||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as handle:
|
||||
handle.write(fn_source)
|
||||
fn_source_path = handle.name
|
||||
try:
|
||||
process = subprocess.run(
|
||||
[NODE, driver_path, json.dumps(scenario), fn_source_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
finally:
|
||||
Path(fn_source_path).unlink(missing_ok=True)
|
||||
if process.returncode != 0:
|
||||
raise RuntimeError(process.stderr.strip() or process.stdout.strip())
|
||||
return json.loads(process.stdout)
|
||||
|
||||
|
||||
def test_populate_model_dropdown_refetches_once_when_server_returns_empty_groups(driver_path):
|
||||
payload = _run(
|
||||
driver_path,
|
||||
{
|
||||
"fetchResponses": [
|
||||
{
|
||||
"active_provider": "anthropic",
|
||||
"default_model": "claude-sonnet-4",
|
||||
"configured_model_badges": {
|
||||
"claude-sonnet-4": {"provider": "anthropic"},
|
||||
},
|
||||
"groups": [],
|
||||
},
|
||||
{
|
||||
"active_provider": "anthropic",
|
||||
"default_model": "claude-sonnet-4",
|
||||
"configured_model_badges": {
|
||||
"claude-sonnet-4": {"provider": "anthropic"},
|
||||
},
|
||||
"groups": [
|
||||
{
|
||||
"provider": "Anthropic",
|
||||
"provider_id": "anthropic",
|
||||
"models": [
|
||||
{"id": "claude-sonnet-4", "label": "Claude Sonnet 4"},
|
||||
{"id": "claude-opus-4", "label": "Claude Opus 4"},
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert len(payload["fetchCalls"]) == 2
|
||||
assert "freshness=session_visit" in payload["fetchCalls"][1]
|
||||
assert "claude-opus-4" in payload["optionValues"]
|
||||
|
||||
|
||||
def test_populate_model_dropdown_does_not_refetch_when_server_groups_already_populated(driver_path):
|
||||
payload = _run(
|
||||
driver_path,
|
||||
{
|
||||
"fetchResponses": [
|
||||
{
|
||||
"active_provider": "anthropic",
|
||||
"default_model": "claude-sonnet-4",
|
||||
"configured_model_badges": {
|
||||
"claude-sonnet-4": {"provider": "anthropic"},
|
||||
},
|
||||
"groups": [
|
||||
{
|
||||
"provider": "Anthropic",
|
||||
"provider_id": "anthropic",
|
||||
"models": [
|
||||
{"id": "claude-sonnet-4", "label": "Claude Sonnet 4"},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert len(payload["fetchCalls"]) == 1
|
||||
assert payload["optionValues"] == ["claude-sonnet-4"]
|
||||
|
||||
|
||||
def test_populate_model_dropdown_retries_at_most_once_even_if_refetch_is_still_empty(driver_path):
|
||||
payload = _run(
|
||||
driver_path,
|
||||
{
|
||||
"fetchResponses": [
|
||||
{
|
||||
"active_provider": "anthropic",
|
||||
"default_model": "claude-sonnet-4",
|
||||
"configured_model_badges": {
|
||||
"claude-sonnet-4": {"provider": "anthropic"},
|
||||
},
|
||||
"groups": [],
|
||||
},
|
||||
{
|
||||
"active_provider": "anthropic",
|
||||
"default_model": "claude-sonnet-4",
|
||||
"configured_model_badges": {
|
||||
"claude-sonnet-4": {"provider": "anthropic"},
|
||||
},
|
||||
"groups": [],
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert len(payload["fetchCalls"]) == 2
|
||||
assert payload["fetchCalls"][1].endswith("freshness=session_visit")
|
||||
assert payload["optionValues"] == ["claude-sonnet-4"]
|
||||
Loading…
Reference in a new issue