Merge PR #6895 into stage/0924-h
This commit is contained in:
commit
1cb16f6037
3 files changed
+512
-10
No files matched your search
+93
-7
@@ -3011,12 +3011,54 @@ function _getOptionProviderId(opt){
|
||||
return group.dataset.provider;
|
||||
}
|
||||
const value=String(opt.value||'');
|
||||
if(value.startsWith('@') && value.includes(':')) return value.slice(1,value.lastIndexOf(':'));
|
||||
if(value.startsWith('@') && value.includes(':')){
|
||||
// Non-greedy parse for @custom:<slug>:<model> — provider is the slug only.
|
||||
// Preserves endpoint-style host:port custom slugs (e.g. custom:localhost:11434)
|
||||
// while keeping colon-bearing model ids (e.g. @custom:backup:model-a:free -> custom:backup).
|
||||
if(value.startsWith('@custom:')){
|
||||
const afterCustom=value.substring('@custom:'.length);
|
||||
const parts=afterCustom.split(':');
|
||||
if(parts.length>=3 && /^\d+$/.test(parts[1])){
|
||||
const port=parseInt(parts[1], 10);
|
||||
const host=parts[0];
|
||||
const hl=host.toLowerCase();
|
||||
if(port>=1 && port<=65535 && (hl==='localhost' || host.includes('.'))){
|
||||
return 'custom:'+host+':'+parts[1];
|
||||
}
|
||||
}
|
||||
const firstColon=afterCustom.indexOf(':');
|
||||
if(firstColon>=0) return 'custom:'+afterCustom.substring(0,firstColon);
|
||||
return 'custom:'+afterCustom;
|
||||
}
|
||||
// Other @provider:model — provider is up to first colon
|
||||
return value.slice(1,value.indexOf(':'));
|
||||
}
|
||||
return '';
|
||||
}
|
||||
function _providerFromModelValue(modelId){
|
||||
const value=String(modelId||'').trim();
|
||||
if(value.startsWith('@')&&value.includes(':')) return value.slice(1,value.lastIndexOf(':'));
|
||||
if(value.startsWith('@')&&value.includes(':')){
|
||||
// Non-greedy parse for @custom:<slug>:<model> — provider is the slug only.
|
||||
// Preserves endpoint-style host:port custom slugs (e.g. custom:localhost:11434)
|
||||
// while keeping colon-bearing model ids (e.g. @custom:backup:model-a:free -> custom:backup).
|
||||
if(value.startsWith('@custom:')){
|
||||
const afterCustom=value.substring('@custom:'.length);
|
||||
const parts=afterCustom.split(':');
|
||||
if(parts.length>=3 && /^\d+$/.test(parts[1])){
|
||||
const port=parseInt(parts[1], 10);
|
||||
const host=parts[0];
|
||||
const hl=host.toLowerCase();
|
||||
if(port>=1 && port<=65535 && (hl==='localhost' || host.includes('.'))){
|
||||
return 'custom:'+host+':'+parts[1];
|
||||
}
|
||||
}
|
||||
const firstColon=afterCustom.indexOf(':');
|
||||
if(firstColon>=0) return 'custom:'+afterCustom.substring(0,firstColon);
|
||||
return 'custom:'+afterCustom;
|
||||
}
|
||||
// Other @provider:model — provider is up to first colon
|
||||
return value.slice(1,value.indexOf(':'));
|
||||
}
|
||||
return '';
|
||||
}
|
||||
function _modelPickerOptionIdentity(modelId, providerId){
|
||||
@@ -3027,9 +3069,19 @@ function _modelPickerOptionIdentity(modelId, providerId){
|
||||
if(exactPrefix && value.toLowerCase().startsWith(exactPrefix.toLowerCase())){
|
||||
value=value.substring(exactPrefix.length);
|
||||
}else if(value.startsWith('@custom:')){
|
||||
const namedProvider=value.substring('@custom:'.length);
|
||||
const splitAt=namedProvider.indexOf(':');
|
||||
value=splitAt>=0 ? namedProvider.substring(splitAt+1) : namedProvider;
|
||||
const afterCustom=value.substring('@custom:'.length);
|
||||
const parts=afterCustom.split(':');
|
||||
let splitAt=-1;
|
||||
if(parts.length>=3 && /^\d+$/.test(parts[1])){
|
||||
const port=parseInt(parts[1], 10);
|
||||
const host=parts[0];
|
||||
const hl=host.toLowerCase();
|
||||
if(port>=1 && port<=65535 && (hl==='localhost' || host.includes('.'))){
|
||||
splitAt=parts[0].length + 1 + parts[1].length;
|
||||
}
|
||||
}
|
||||
if(splitAt<0) splitAt=afterCustom.indexOf(':');
|
||||
value=splitAt>=0 ? afterCustom.substring(splitAt+1) : afterCustom;
|
||||
}else{
|
||||
value=value.substring(value.indexOf(':')+1);
|
||||
}
|
||||
@@ -3088,7 +3140,25 @@ function _modelStateForSelect(sel, modelId){
|
||||
// id (e.g. model-a:free) synthesized as @custom:backup:model-a:free would
|
||||
// otherwise mis-parse to provider "custom:backup:model-a" (#6221 re-gate).
|
||||
const routedProvider=selected?String(_getOptionProviderId(selected)||'').trim():'';
|
||||
return {model:routedModel||value,model_provider:routedProvider||explicitProvider};
|
||||
// Normally-rendered catalog options only carry the qualified
|
||||
// @custom:<slug>:<model> value — data-model is set solely by the fallback
|
||||
// injection path (_ensureModelOptionInDropdown). When it is missing, strip
|
||||
// the @custom:<slug>: prefix instead of sending the raw dropdown value as
|
||||
// the model id (#6884). The prefix must come from the option metadata's
|
||||
// authoritative provider (routedProvider), NOT from explicitProvider: the
|
||||
// latter re-parses the value at its LAST colon, so a colon-bearing model
|
||||
// id like @custom:backup:model-a:free would otherwise strip to just
|
||||
// "free" (re-gate on the #6221 family). Only custom providers are
|
||||
// stripped: a non-custom qualified id like @safe:gpt-4o-mini is a real
|
||||
// provider namespace and must be preserved (#1771).
|
||||
const effectiveProvider=routedProvider||explicitProvider;
|
||||
const effectiveProviderLc=effectiveProvider.toLowerCase();
|
||||
const isCustomProvider=effectiveProviderLc==='custom'||effectiveProviderLc.startsWith('custom:');
|
||||
const explicitPrefix=`@${effectiveProvider}:`;
|
||||
const strippedModel=isCustomProvider&&value.toLowerCase().startsWith(explicitPrefix.toLowerCase())
|
||||
?value.slice(explicitPrefix.length)
|
||||
:value;
|
||||
return {model:routedModel||strippedModel||value,model_provider:effectiveProvider};
|
||||
}
|
||||
// Resolve the provider from the option whose VALUE matches the requested
|
||||
// model — never blindly from sel.selectedOptions[0] (#5567). During a profile
|
||||
@@ -4465,7 +4535,23 @@ function renderModelDropdown(){
|
||||
const _provider=String((m&&m.providerId)||(m&&m.badge&&m.badge.provider)||((typeof _providerFromModelValue==='function')?_providerFromModelValue(m&&m.value):'')||'').trim();
|
||||
return (_provider&&_provider!=='default')?_provider:null;
|
||||
};
|
||||
const _isSelectedModelRow=(m)=>String((m&&m.value)||'')===String((_selectedModelState&&_selectedModelState.model)||(sel&&sel.value)||'')&&String(_modelProviderForSelectedBadge(m)||'')===String((_selectedModelState&&_selectedModelState.model_provider)||'');
|
||||
const _isSelectedModelRow=(m)=>{
|
||||
const _rowModel=String((m&&m.value)||'');
|
||||
const _rowProvider=String(_modelProviderForSelectedBadge(m)||'');
|
||||
const _stateModel=String((_selectedModelState&&_selectedModelState.model)||(sel&&sel.value)||'');
|
||||
const _stateProvider=String((_selectedModelState&&_selectedModelState.model_provider)||'');
|
||||
// Normalize both sides to the same model/provider identity. Catalog rows
|
||||
// carry the qualified @custom:<slug>:<model> value while the outgoing
|
||||
// state model is bare (#6884) — a raw string comparison would leave no
|
||||
// row marked active/"Selected" after a restore. _modelPickerOptionIdentity
|
||||
// is the same identity used for picker dedup, so the row that survives is
|
||||
// exactly the one the send path resolves.
|
||||
const _norm=(model,provider)=>typeof _modelPickerOptionIdentity==='function'
|
||||
?_modelPickerOptionIdentity(model,provider)
|
||||
:String(model||'');
|
||||
return _norm(_rowModel,_rowProvider)===_norm(_stateModel,_stateProvider)
|
||||
&&_rowProvider===_stateProvider;
|
||||
};
|
||||
const _selectedModelBadge=(m)=>_isSelectedModelRow(m)
|
||||
?`<span class="model-opt-badge model-opt-badge--selected">${esc(t('model_badge_selected')||'Selected')}</span>`
|
||||
:'';
|
||||
|
||||
@@ -160,6 +160,412 @@ def test_colon_bearing_missing_fallback_keeps_authoritative_provider():
|
||||
}
|
||||
|
||||
|
||||
# Non-default model of a named custom_providers[] entry. The catalog render
|
||||
# paths build the option with the qualified "@custom:<slug>:<model>" value and
|
||||
# NEVER set data-model (that attribute is only set by the fallback injection
|
||||
# path _ensureModelOptionInDropdown). Regression for #6884: the returned model
|
||||
# id must be the stripped bare name ("sol"), not the raw dropdown value.
|
||||
_NON_DEFAULT_CUSTOM_DRIVER = r"""
|
||||
const fs = require('fs');
|
||||
const uiSrc = fs.readFileSync(process.argv[1], 'utf8');
|
||||
|
||||
function extractFunction(source, name) {
|
||||
const marker = 'function ' + name + '(';
|
||||
const start = source.indexOf(marker);
|
||||
if (start < 0) throw new Error('not found: ' + name);
|
||||
const brace = source.indexOf('{', source.indexOf(')', start));
|
||||
let depth = 0;
|
||||
for (let i = brace; i < source.length; i++) {
|
||||
if (source[i] === '{') depth += 1;
|
||||
else if (source[i] === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return source.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
throw new Error('unterminated: ' + name);
|
||||
}
|
||||
|
||||
eval([
|
||||
'_getOptionProviderId',
|
||||
'_providerFromModelValue',
|
||||
'_modelStateForSelect',
|
||||
].map(name => extractFunction(uiSrc, name)).join('\n'));
|
||||
|
||||
globalThis.document = {
|
||||
createElement(tag) {
|
||||
return {
|
||||
tagName: String(tag).toUpperCase(),
|
||||
value: '',
|
||||
textContent: '',
|
||||
dataset: {},
|
||||
parentElement: null,
|
||||
};
|
||||
},
|
||||
};
|
||||
globalThis.getModelLabel = value => String(value || '');
|
||||
globalThis.window = { _configuredModelBadges: {} };
|
||||
|
||||
const group = {tagName: 'OPTGROUP', dataset: {provider: 'custom:hetmer.net'}};
|
||||
const luna = {
|
||||
value: 'luna',
|
||||
textContent: 'luna',
|
||||
dataset: {},
|
||||
parentElement: group,
|
||||
};
|
||||
const sol = {
|
||||
value: '@custom:hetmer.net:sol',
|
||||
textContent: 'sol',
|
||||
dataset: {}, // no data-model — normal catalog render path
|
||||
parentElement: group,
|
||||
};
|
||||
// Colon-bearing model id on the SAME normal catalog render path (no
|
||||
// data-model). Regression for the #6884 re-gate: the prefix to strip must
|
||||
// come from the option metadata's authoritative provider (custom:hetmer.net),
|
||||
// NOT from the last-colon value reparse (which would yield the malformed
|
||||
// "custom:hetmer.net:model-a" prefix and truncate the model to "free").
|
||||
const modelAFree = {
|
||||
value: '@custom:hetmer.net:model-a:free',
|
||||
textContent: 'model-a:free',
|
||||
dataset: {}, // no data-model — normal catalog render path
|
||||
parentElement: group,
|
||||
};
|
||||
const localhostGroup = {tagName: 'OPTGROUP', dataset: {provider: 'custom:localhost:11434'}};
|
||||
const llama = {
|
||||
value: '@custom:localhost:11434:llama3.2',
|
||||
textContent: 'llama3.2',
|
||||
dataset: {},
|
||||
parentElement: localhostGroup,
|
||||
};
|
||||
const select = {
|
||||
id: 'modelSelect',
|
||||
options: [luna, sol, modelAFree, llama],
|
||||
querySelectorAll() { return []; },
|
||||
get selectedOptions() { return [sol]; },
|
||||
get value() { return sol.value; },
|
||||
set value(value) {},
|
||||
};
|
||||
|
||||
process.stdout.write(JSON.stringify({
|
||||
nonDefault: _modelStateForSelect(select, '@custom:hetmer.net:sol'),
|
||||
defaultUnprefixed: _modelStateForSelect(select, 'luna'),
|
||||
colonBearingModel: _modelStateForSelect(select, '@custom:hetmer.net:model-a:free'),
|
||||
localhostEndpoint: _modelStateForSelect(select, '@custom:localhost:11434:llama3.2'),
|
||||
missingOptionCustomInput: _modelStateForSelect(select, '@custom:localhost:11434:mistral-custom'),
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_non_default_named_custom_provider_model_strips_qualified_prefix():
|
||||
assert NODE is not None
|
||||
result = subprocess.run(
|
||||
[NODE, "-e", _NON_DEFAULT_CUSTOM_DRIVER, str(UI_JS)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
# #6884: the normally-rendered option has no data-model, so the qualified
|
||||
# "@custom:hetmer.net:sol" value must be stripped to the bare model id.
|
||||
assert payload["nonDefault"] == {
|
||||
"model": "sol",
|
||||
"model_provider": "custom:hetmer.net",
|
||||
}
|
||||
# Sanity: the unprefixed default option still returns its value as-is.
|
||||
assert payload["defaultUnprefixed"] == {
|
||||
"model": "luna",
|
||||
"model_provider": "custom:hetmer.net",
|
||||
}
|
||||
# Colon-bearing model id on the same normal catalog render path: the strip
|
||||
# prefix comes from the option metadata provider (custom:hetmer.net), so
|
||||
# the full "model-a:free" survives — not just the "free" suffix (re-gate).
|
||||
assert payload["colonBearingModel"] == {
|
||||
"model": "model-a:free",
|
||||
"model_provider": "custom:hetmer.net",
|
||||
}
|
||||
# Endpoint-style custom provider (e.g. host:port derived from base_url authority):
|
||||
# preserves full custom:localhost:11434 for both catalog and missing-option paths.
|
||||
assert payload["localhostEndpoint"] == {
|
||||
"model": "llama3.2",
|
||||
"model_provider": "custom:localhost:11434",
|
||||
}
|
||||
assert payload["missingOptionCustomInput"] == {
|
||||
"model": "mistral-custom",
|
||||
"model_provider": "custom:localhost:11434",
|
||||
}
|
||||
|
||||
|
||||
# Round-trip: send -> persist -> restore for a colon-bearing named
|
||||
# custom-provider model. The send path emits the bare id ("model-a:free");
|
||||
# on restore, _findModelInDropdown reverse-matches the bare id back to the
|
||||
# qualified catalog option ("@custom:hetmer.net:model-a:free") and the
|
||||
# dropdown re-renders. _isSelectedModelRow must normalize both sides to the
|
||||
# same model/provider identity so exactly ONE row renders as active /
|
||||
# "Selected" — with the model id intact, not truncated.
|
||||
_ROUND_TRIP_DRIVER = r"""
|
||||
const fs = require('fs');
|
||||
const ui = fs.readFileSync(process.argv[2], 'utf8');
|
||||
|
||||
function extractFunc(name) {
|
||||
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\(');
|
||||
const start = ui.search(re);
|
||||
if (start < 0) throw new Error(name + ' not found');
|
||||
let openParen = ui.indexOf('(', start);
|
||||
let i = openParen + 1;
|
||||
let parenDepth = 1;
|
||||
while (parenDepth > 0 && i < ui.length) {
|
||||
if (ui[i] === '(') parenDepth++;
|
||||
else if (ui[i] === ')') parenDepth--;
|
||||
i++;
|
||||
}
|
||||
i = ui.indexOf('{', i);
|
||||
let depth = 1;
|
||||
i++;
|
||||
while (depth > 0 && i < ui.length) {
|
||||
if (ui[i] === '{') depth++;
|
||||
else if (ui[i] === '}') depth--;
|
||||
i++;
|
||||
}
|
||||
return ui.slice(start, i);
|
||||
}
|
||||
|
||||
function makeClassList(initial) {
|
||||
const set = new Set(initial || []);
|
||||
return {
|
||||
_set: set,
|
||||
add(cls) { set.add(cls); },
|
||||
remove(cls) { set.delete(cls); },
|
||||
contains(cls) { return set.has(cls); },
|
||||
toggle(cls, force) {
|
||||
if (force === true) { set.add(cls); return true; }
|
||||
if (force === false) { set.delete(cls); return false; }
|
||||
if (set.has(cls)) { set.delete(cls); return false; }
|
||||
set.add(cls);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function defineClassName(node) {
|
||||
Object.defineProperty(node, 'className', {
|
||||
get() { return [...node.classList._set].join(' '); },
|
||||
set(v) { node.classList = makeClassList(String(v || '').split(/\s+/).filter(Boolean)); },
|
||||
});
|
||||
}
|
||||
|
||||
function makeNode(tag) {
|
||||
const node = {
|
||||
tagName: String(tag || '').toUpperCase(),
|
||||
children: [],
|
||||
dataset: {},
|
||||
style: {},
|
||||
parentElement: null,
|
||||
textContent: '',
|
||||
value: '',
|
||||
tabIndex: 0,
|
||||
onclick: null,
|
||||
_listeners: {},
|
||||
_innerHTML: '',
|
||||
appendChild(child) {
|
||||
child.parentElement = this;
|
||||
this.children.push(child);
|
||||
if (this.tagName === 'OPTGROUP' && this._ownerSelect && child.tagName === 'OPTION') {
|
||||
this._ownerSelect.options.push(child);
|
||||
}
|
||||
return child;
|
||||
},
|
||||
addEventListener(type, handler) { this._listeners[type] = handler; },
|
||||
querySelector(selector) { return this._qs ? this._qs[selector] || null : null; },
|
||||
setAttribute(name, value) { this[name] = value; },
|
||||
focus() { this._focused = true; },
|
||||
};
|
||||
node.classList = makeClassList();
|
||||
defineClassName(node);
|
||||
Object.defineProperty(node, 'innerHTML', {
|
||||
get() { return this._innerHTML; },
|
||||
set(v) {
|
||||
this._innerHTML = String(v || '');
|
||||
this.children = [];
|
||||
this._qs = {};
|
||||
if (this.tagName === 'DIV' && this._innerHTML.includes('model-search-input')) {
|
||||
const input = makeNode('input');
|
||||
input.className = 'model-search-input';
|
||||
const clear = makeNode('button');
|
||||
clear.className = 'model-search-clear';
|
||||
this._qs['.model-search-input'] = input;
|
||||
this._qs['.model-search-clear'] = clear;
|
||||
} else if (this.tagName === 'DIV' && this._innerHTML.includes('model-custom-input')) {
|
||||
const input = makeNode('input');
|
||||
input.className = 'model-custom-input';
|
||||
const btn = makeNode('button');
|
||||
btn.className = 'model-custom-btn';
|
||||
this._qs['.model-custom-input'] = input;
|
||||
this._qs['.model-custom-btn'] = btn;
|
||||
}
|
||||
},
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
function makeOption(value, label, parent) {
|
||||
const opt = makeNode('option');
|
||||
opt.value = value;
|
||||
opt.textContent = label || value;
|
||||
opt.parentElement = parent || null;
|
||||
return opt;
|
||||
}
|
||||
|
||||
function makeSelect(groups, selectedValue) {
|
||||
const sel = { id: 'modelSelect', children: [], options: [], _value: selectedValue || '' };
|
||||
Object.defineProperty(sel, 'value', {get(){return sel._value;}, set(v){sel._value=String(v||'');}});
|
||||
Object.defineProperty(sel, 'selectedOptions', {get(){const o=sel.options.find(x=>x.value===sel._value);return o?[o]:[];}});
|
||||
sel.appendChild=function(option){option.parentElement=null;sel.options.push(option);};
|
||||
sel.querySelectorAll=function(){return [];};
|
||||
for (const group of groups || []) {
|
||||
const og = makeNode('optgroup');
|
||||
og.label = group.provider || '';
|
||||
og.dataset.provider = group.provider_id || '';
|
||||
og._ownerSelect = sel;
|
||||
for (const model of group.models || []) og.appendChild(makeOption(model.id, model.label || model.id, og));
|
||||
sel.children.push(og);
|
||||
sel.options.push(...og.children);
|
||||
}
|
||||
return sel;
|
||||
}
|
||||
|
||||
function snapshot(dd) {
|
||||
const out = [];
|
||||
const walk = (node) => {
|
||||
for (const child of (node.children || [])) {
|
||||
out.push({
|
||||
className: child.className,
|
||||
textContent: child.textContent,
|
||||
html: child._innerHTML || '',
|
||||
});
|
||||
if (child.children && child.children.length) walk(child);
|
||||
}
|
||||
};
|
||||
walk(dd);
|
||||
return out;
|
||||
}
|
||||
|
||||
const payload = JSON.parse(process.argv[3]);
|
||||
const dropdown = makeNode('div');
|
||||
dropdown.classList.add('open');
|
||||
const modelSelect = makeSelect(payload.groups, payload.selectedValue || payload.groups[0].models[0].id);
|
||||
|
||||
function $(id) {
|
||||
if (id === 'composerModelDropdown') return dropdown;
|
||||
if (id === 'modelSelect') return modelSelect;
|
||||
return null;
|
||||
}
|
||||
const window = { _configuredModelBadges: payload.configuredBadges || {} };
|
||||
const document = { createElement(tag) { return makeNode(tag); } };
|
||||
function esc(v) { return String(v || ''); }
|
||||
function t(key, ...args) {
|
||||
if (key === 'model_show_all_models') return `Show all ${args[0]} models`;
|
||||
return key;
|
||||
}
|
||||
function li() { return 'x'; }
|
||||
function getModelLabel(v) { return String(v || ''); }
|
||||
function _providerFromModelValue(v) {
|
||||
const value = String(v || '');
|
||||
if (value.startsWith('@') && value.includes(':')) return value.slice(1, value.lastIndexOf(':'));
|
||||
return '';
|
||||
}
|
||||
function _normalizeConfiguredModelKey(v) { return String(v || '').toLowerCase(); }
|
||||
function _getConfiguredModelBadge(value, badgeMap) { return badgeMap[value] || null; }
|
||||
function closeModelDropdown() {}
|
||||
function syncModelChip() {}
|
||||
function _refreshOpenModelDropdown() {}
|
||||
function _deduplicateModelPickerOptions() { return 0; }
|
||||
async function selectModelFromDropdown(value, provider) {
|
||||
_ensureModelOptionInDropdown(value, modelSelect, provider);
|
||||
window.__picked=_modelStateForSelect(modelSelect,modelSelect.value);
|
||||
}
|
||||
|
||||
for (const name of [
|
||||
'_modelPickerOptionIdentity',
|
||||
'_readModelOverflowData',
|
||||
'_appendOverflowOptionsToGroup',
|
||||
'_isEquivalentConfiguredModelEntry',
|
||||
'_getOptionProviderId',
|
||||
'_modelStateForSelect',
|
||||
'_findModelInDropdown',
|
||||
'_applyModelToDropdown',
|
||||
'_ensureModelOptionInDropdown',
|
||||
'renderModelDropdown',
|
||||
]) {
|
||||
eval(extractFunc(name));
|
||||
}
|
||||
|
||||
// Send path: the picked state carries the bare, intact model id.
|
||||
const sent = _modelStateForSelect(modelSelect, '@custom:hetmer.net:model-a:free');
|
||||
// Restore path: _findModelInDropdown reverse-matches the bare id to the
|
||||
// qualified catalog option, the picker restores sel.value to it and
|
||||
// re-renders the dropdown.
|
||||
const restoreValue = _findModelInDropdown(sent.model, modelSelect, sent.model_provider);
|
||||
modelSelect.value = restoreValue;
|
||||
renderModelDropdown();
|
||||
const rows = snapshot(dropdown).filter(n => String(n.className || '').includes('model-opt'));
|
||||
const activeRows = rows.filter(n => String(n.className || '').includes(' active'));
|
||||
process.stdout.write(JSON.stringify({
|
||||
sent,
|
||||
restoreValue,
|
||||
activeCount: activeRows.length,
|
||||
activeRow: activeRows.length ? {
|
||||
className: activeRows[0].className,
|
||||
text: activeRows[0].textContent,
|
||||
html: activeRows[0].html,
|
||||
} : null,
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_colon_bearing_custom_provider_round_trip_restores_selected_row(tmp_path):
|
||||
driver = tmp_path / "round_trip_driver.js"
|
||||
driver.write_text(_ROUND_TRIP_DRIVER, encoding="utf-8")
|
||||
payload = {
|
||||
"groups": [
|
||||
{
|
||||
"provider": "hetmer.net",
|
||||
"provider_id": "custom:hetmer.net",
|
||||
"models": [
|
||||
{"id": "luna", "label": "luna"},
|
||||
{"id": "@custom:hetmer.net:model-a:free", "label": "model-a:free"},
|
||||
],
|
||||
}
|
||||
],
|
||||
"configuredBadges": {},
|
||||
"selectedValue": "luna",
|
||||
}
|
||||
assert NODE is not None
|
||||
result = subprocess.run(
|
||||
[NODE, str(driver), str(UI_JS), json.dumps(payload)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
actual = json.loads(result.stdout)
|
||||
|
||||
# Send path: bare model id, intact (NOT truncated to "free").
|
||||
assert actual["sent"] == {
|
||||
"model": "model-a:free",
|
||||
"model_provider": "custom:hetmer.net",
|
||||
}
|
||||
# Restore path: the bare id maps back to the qualified catalog option.
|
||||
assert actual["restoreValue"] == "@custom:hetmer.net:model-a:free"
|
||||
# Exactly one row renders as active/"Selected", with the intact model id.
|
||||
assert actual["activeCount"] == 1, actual
|
||||
assert "model-opt-badge--selected" in actual["activeRow"]["html"]
|
||||
assert "model-a:free" in actual["activeRow"]["html"]
|
||||
assert ">free<" not in actual["activeRow"]["html"].replace("model-a:free", "")
|
||||
|
||||
|
||||
_RENDERED_CLICK_DRIVER = r"""
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
@@ -34,15 +34,25 @@ def test_model_picker_renders_selected_badge_without_replacing_configured_badge(
|
||||
|
||||
|
||||
def test_selected_badge_is_keyed_to_current_model_value():
|
||||
assert "String((m&&m.value)||'')===String((_selectedModelState&&_selectedModelState.model)||(sel&&sel.value)||'')" in UI_JS
|
||||
# The selected badge must key on the resolved model identity, not a raw
|
||||
# string of the dropdown value: catalog rows carry the qualified
|
||||
# @custom:<slug>:<model> value while the outgoing state model is bare
|
||||
# (#6884), so both sides are normalized via _modelPickerOptionIdentity
|
||||
# before comparison.
|
||||
assert "const _norm=(model,provider)=>typeof _modelPickerOptionIdentity==='function'" in UI_JS
|
||||
assert "_norm(_rowModel,_rowProvider)===_norm(_stateModel,_stateProvider)" in UI_JS
|
||||
|
||||
|
||||
def test_selected_badge_is_keyed_to_current_model_provider():
|
||||
assert "const _selectedModelState=(typeof _modelStateForSelect==='function')?_modelStateForSelect(sel,sel.value)" in UI_JS
|
||||
assert "const _modelProviderForSelectedBadge=(m)=>" in UI_JS
|
||||
assert "return (_provider&&_provider!=='default')?_provider:null;" in UI_JS
|
||||
assert "String(_modelProviderForSelectedBadge(m)||'')===String((_selectedModelState&&_selectedModelState.model_provider)||'')" in UI_JS
|
||||
assert "const _isSelectedModelRow=(m)=>" in UI_JS
|
||||
# Provider identity still gates the row match — the normalized model
|
||||
# comparison alone would collapse same-id rows across provider groups.
|
||||
assert "_rowProvider=String(_modelProviderForSelectedBadge(m)||'')" in UI_JS
|
||||
assert "_stateProvider=String((_selectedModelState&&_selectedModelState.model_provider)||'')" in UI_JS
|
||||
assert "_rowProvider===_stateProvider" in UI_JS
|
||||
assert "const _isSelectedModelRow=(m)=>{" in UI_JS
|
||||
assert "row.className='model-opt'+(_isSelectedModelRow(m)?' active':'');" in UI_JS
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user