* fix(settings): persist provider-native auxiliary model IDs Co-authored-by: lowlandsheperd <lowlandsheperd@users.noreply.github.com> * docs(changelog): stamp #7261 provider-native aux model IDs --------- Co-authored-by: n <a@n> Co-authored-by: lowlandsheperd <lowlandsheperd@users.noreply.github.com>
254 lines
7.4 KiB
Python
254 lines
7.4 KiB
Python
"""Regression coverage for provider-qualified auxiliary model persistence.
|
|
|
|
``GET /api/models`` may expose WebUI-only ``@provider:model`` routing IDs, but
|
|
auxiliary configuration stores provider and model in separate fields. The
|
|
provider-native model value must be used by both the settings UI and the shared
|
|
backend persistence boundary.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
PANELS_JS_PATH = REPO / "static" / "panels.js"
|
|
NODE = shutil.which("node")
|
|
|
|
|
|
@pytest.mark.skipif(NODE is None, reason="node not available")
|
|
def test_auxiliary_picker_uses_provider_native_model_values():
|
|
"""A custom-provider catalog must render and select provider-native values."""
|
|
script = r"""
|
|
const fs = require('fs');
|
|
const src = fs.readFileSync(process.argv[1], 'utf8');
|
|
|
|
function extract(name){
|
|
const re = new RegExp('function\\s+' + name + '\\s*\\(');
|
|
const start = src.search(re);
|
|
if(start < 0) return '';
|
|
let i = src.indexOf('{', start);
|
|
let depth = 0;
|
|
while(i < src.length){
|
|
const ch = src[i];
|
|
if(ch === '{') depth += 1;
|
|
else if(ch === '}'){
|
|
depth -= 1;
|
|
if(depth === 0) return src.slice(start, i + 1);
|
|
}
|
|
i += 1;
|
|
}
|
|
throw new Error(name + ' parse failed');
|
|
}
|
|
|
|
global.t = () => '';
|
|
global.document = {
|
|
createElement: () => ({value:'', textContent:'', selected:false}),
|
|
};
|
|
|
|
function makeSelect(){
|
|
return {
|
|
children: [],
|
|
_value: '',
|
|
set innerHTML(_value){ this.children = []; this._value = ''; },
|
|
get innerHTML(){ return ''; },
|
|
get options(){ return this.children; },
|
|
appendChild(opt){ this.children.push(opt); },
|
|
insertBefore(opt, before){
|
|
const idx = this.children.indexOf(before);
|
|
if(idx < 0) this.children.push(opt);
|
|
else this.children.splice(idx, 0, opt);
|
|
},
|
|
set value(value){
|
|
this._value = value;
|
|
for(const opt of this.children) opt.selected = opt.value === value;
|
|
},
|
|
get value(){
|
|
const selected = this.children.find((opt) => opt.selected);
|
|
return selected ? selected.value : this._value;
|
|
},
|
|
};
|
|
}
|
|
|
|
const sharedHelper = extract('_modelBareNameForProvider');
|
|
const buildOptions = extract('_buildAuxModelOptions');
|
|
if(!buildOptions) throw new Error('_buildAuxModelOptions not found');
|
|
eval(sharedHelper + '\n' + buildOptions);
|
|
|
|
const select = makeSelect();
|
|
const providers = [{
|
|
slug: 'my-local-ai-gateway',
|
|
name: 'My Local AI Gateway',
|
|
models: [
|
|
{
|
|
id: '@my-local-ai-gateway:example-chat-model',
|
|
label: 'Example Chat Model',
|
|
},
|
|
{
|
|
id: '@my-local-ai-gateway:example-side-model',
|
|
label: '@my-local-ai-gateway:example-side-model',
|
|
},
|
|
{id: 'vendor/example-model', label: 'Vendor Example Model'},
|
|
{id: 'example-chat-model', label: 'Duplicate Chat Model'},
|
|
],
|
|
}];
|
|
|
|
const canonicalCurrent = _buildAuxModelOptions(
|
|
select,
|
|
'my-local-ai-gateway',
|
|
providers,
|
|
'@my-local-ai-gateway:example-side-model',
|
|
);
|
|
|
|
const modelOptions = select.options.filter(
|
|
(opt) => opt.value && opt.value !== '__custom__',
|
|
);
|
|
const helperCases = sharedHelper ? [
|
|
_modelBareNameForProvider('@custom:router-alias:chat-model', 'custom'),
|
|
_modelBareNameForProvider('@custom:backup:model:free', 'custom:backup'),
|
|
_modelBareNameForProvider('vendor/example-model', 'my-local-ai-gateway'),
|
|
_modelBareNameForProvider('@other-gateway:other-model', 'my-local-ai-gateway'),
|
|
] : null;
|
|
|
|
console.log(JSON.stringify({
|
|
values: modelOptions.map((opt) => opt.value),
|
|
labels: modelOptions.map((opt) => opt.textContent),
|
|
selected: select.value,
|
|
canonicalCurrent,
|
|
helperCases,
|
|
}));
|
|
"""
|
|
|
|
proc = subprocess.run(
|
|
[NODE, "-e", script, str(PANELS_JS_PATH)],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=20,
|
|
)
|
|
assert proc.returncode == 0, f"node probe failed:\n{proc.stderr}"
|
|
result = json.loads(proc.stdout.strip().splitlines()[-1])
|
|
|
|
assert result == {
|
|
"values": [
|
|
"example-chat-model",
|
|
"example-side-model",
|
|
"vendor/example-model",
|
|
],
|
|
"labels": [
|
|
"Example Chat Model",
|
|
"example-side-model",
|
|
"Vendor Example Model",
|
|
],
|
|
"selected": "example-side-model",
|
|
"canonicalCurrent": "example-side-model",
|
|
"helperCases": [
|
|
"router-alias:chat-model",
|
|
"model:free",
|
|
"vendor/example-model",
|
|
"@other-gateway:other-model",
|
|
],
|
|
}
|
|
|
|
|
|
def test_matching_legacy_value_exposes_explicit_apply_repair():
|
|
"""Loading a safe legacy prefix should offer, but not force, a config write."""
|
|
source = PANELS_JS_PATH.read_text(encoding="utf-8")
|
|
|
|
assert "if(canonicalModel!==cfg.model) needsCanonicalSave=true;" in source
|
|
assert "applyBtn.style.display=needsCanonicalSave?'':'none'" in source
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("provider", "requested_model", "persisted_model"),
|
|
[
|
|
(
|
|
"my-local-ai-gateway",
|
|
"@my-local-ai-gateway:example-side-model",
|
|
"example-side-model",
|
|
),
|
|
(
|
|
"custom",
|
|
"@custom:router-alias:chat-model",
|
|
"router-alias:chat-model",
|
|
),
|
|
("custom:backup", "@custom:backup:model:free", "model:free"),
|
|
(
|
|
"my-local-ai-gateway",
|
|
"vendor/example-model",
|
|
"vendor/example-model",
|
|
),
|
|
(
|
|
"my-local-ai-gateway",
|
|
"example-side-model",
|
|
"example-side-model",
|
|
),
|
|
],
|
|
)
|
|
def test_set_auxiliary_model_persists_provider_native_model(
|
|
monkeypatch,
|
|
tmp_path,
|
|
provider,
|
|
requested_model,
|
|
persisted_model,
|
|
):
|
|
from api import config
|
|
|
|
config_path = tmp_path / "config.yaml"
|
|
config_path.write_text(
|
|
"auxiliary:\n vision:\n provider: auto\n model: ''\n",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(config, "_get_config_path", lambda: config_path)
|
|
monkeypatch.setattr(config, "reload_config", lambda: None)
|
|
monkeypatch.setattr(
|
|
config,
|
|
"resolve_model_provider",
|
|
lambda model: (model, provider, None),
|
|
)
|
|
|
|
result = config.set_auxiliary_model("vision", provider, requested_model)
|
|
|
|
saved = config._load_yaml_config_file(config_path)["auxiliary"]["vision"]
|
|
assert saved["provider"] == provider
|
|
assert saved["model"] == persisted_model
|
|
assert result["provider"] == provider
|
|
assert result["model"] == persisted_model
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("provider", "model"),
|
|
[
|
|
("my-local-ai-gateway", "@other-gateway:other-model"),
|
|
("my-local-ai-gateway", "@my-local-ai-gateway:"),
|
|
("auto", "@my-local-ai-gateway:example-side-model"),
|
|
],
|
|
)
|
|
def test_set_auxiliary_model_rejects_invalid_qualified_pair_without_write(
|
|
monkeypatch,
|
|
tmp_path,
|
|
provider,
|
|
model,
|
|
):
|
|
from api import config
|
|
|
|
config_path = tmp_path / "config.yaml"
|
|
original = (
|
|
"auxiliary:\n"
|
|
" vision:\n"
|
|
" provider: openai\n"
|
|
" model: gpt-5.5\n"
|
|
)
|
|
config_path.write_text(original, encoding="utf-8")
|
|
monkeypatch.setattr(config, "_get_config_path", lambda: config_path)
|
|
monkeypatch.setattr(config, "reload_config", lambda: None)
|
|
|
|
with pytest.raises(ValueError, match="provider-qualified auxiliary model"):
|
|
config.set_auxiliary_model("vision", provider, model)
|
|
|
|
assert config_path.read_text(encoding="utf-8") == original
|