From e6aa9271c2273c51233e6ca4487cadc26930d656 Mon Sep 17 00:00:00 2001 From: Philippe Le Rohellec Date: Fri, 29 May 2026 10:24:44 -0700 Subject: [PATCH] fix(models): preserve provider-qualified model selections in the picker - Stop provider-qualified or slash-qualified model inputs from fuzzy-matching a sibling catalog entry when the exact requested model is missing from the curated picker list. - Preserve the raw typed selection so uncatalogued provider-routed models fall through to a temporary custom option instead of silently snapping to a nearby curated model. - Add generalized regression coverage for provider-qualified uncatalogued picker selections. --- static/ui.js | 7 +++++ tests/test_issue1188_fuzzy_match.py | 45 +++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/static/ui.js b/static/ui.js index c3624986..4570412a 100644 --- a/static/ui.js +++ b/static/ui.js @@ -1068,6 +1068,13 @@ function _findModelInDropdown(modelId, sel, preferredProviderId){ if(opts.includes(modelId)) return modelId; const exact=opts.find(o=>norm(o)===target); if(exact) return exact; + // If the request is provider-qualified (either explicit @provider:model or + // a slash-qualified vendor/model id), do NOT fuzzy-match a sibling model + // once exact/provider-aware lookup failed. Returning null lets the caller + // preserve the raw typed value instead of snapping to the closest catalog + // entry. This keeps uncatalogued models routable instead of silently turning + // them into a nearby curated sibling. + if(rawModel.startsWith('@')||rawModel.includes('/')) return null; // 3. Prefix/substring: require the candidate to start with the FULL normalized target // (not a truncated base). This avoids false matches like gpt.5.5 → gpt.5.4.mini (#1188). // Only fall back to the shorter base form if target itself is very short (a bare root diff --git a/tests/test_issue1188_fuzzy_match.py b/tests/test_issue1188_fuzzy_match.py index 5e4201eb..1b5697e8 100644 --- a/tests/test_issue1188_fuzzy_match.py +++ b/tests/test_issue1188_fuzzy_match.py @@ -45,10 +45,22 @@ function extractFunc(name) { } return ui.slice(start, i); } +function _getOptionProviderId(opt) { + if (!opt) return ''; + if (opt.dataset && opt.dataset.provider) return opt.dataset.provider; + const group = opt.parentElement; + if (group && group.tagName === 'OPTGROUP' && group.dataset && group.dataset.provider) { + return group.dataset.provider; + } + const value = String(opt.value || ''); + if (value.startsWith('@') && value.includes(':')) return value.slice(1, value.lastIndexOf(':')); + return ''; +} + eval(extractFunc('_findModelInDropdown')); const args = JSON.parse(process.argv[3]); const sel = { options: args.options.map(v => ({value: v})) }; -const got = _findModelInDropdown(args.modelId, sel); +const got = _findModelInDropdown(args.modelId, sel, args.preferredProvider || undefined); process.stdout.write(JSON.stringify(got)); """ @@ -60,11 +72,11 @@ def driver_path(tmp_path_factory): return str(p) -def _find(driver_path, model_id: str, options: list[str]): +def _find(driver_path, model_id: str, options: list[str], preferred: str | None = None): import json result = subprocess.run( [NODE, driver_path, str(UI_JS_PATH), - json.dumps({"modelId": model_id, "options": options})], + json.dumps({"modelId": model_id, "options": options, "preferredProvider": preferred})], capture_output=True, text=True, timeout=10, ) if result.returncode != 0: @@ -113,6 +125,33 @@ class TestPreservedFuzzyMatches: ) assert got == "@nous:openai/gpt-5.5" + def test_explicit_provider_hint_does_not_snap_to_sibling_model(self, driver_path): + """Provider-qualified requests must not fuzzy-match a sibling catalog entry. + + This is the generalized regression behind #3113: any slash-qualified or + @provider-qualified model that is missing from the curated catalog should + preserve its exact value instead of snapping to a nearby curated sibling. + """ + got = _find( + driver_path, + "vendor/special-model", + ["@openrouter:vendor/special-model-pro"], + preferred="openrouter", + ) + assert got is None, ( + "provider-qualified uncatalogued models must not fuzzy-match sibling models " + "(#3113)" + ) + + def test_provider_qualified_exact_match_still_resolves(self, driver_path): + got = _find( + driver_path, + "vendor/special-model", + ["@openrouter:vendor/special-model"], + preferred="openrouter", + ) + assert got == "@openrouter:vendor/special-model" + def test_bare_root_gpt_matches_versioned_option(self, driver_path): """Short root targets still fall back to the looser prefix match.""" got = _find(driver_path, "gpt", ["@nous:openai/gpt-5.4-mini"])