fix: support /model alias switch for cross-provider custom models

Backend (api/config.py):
- resolve_model_provider(): check custom_providers for prefix match
  BEFORE the config_base_url branch. Previously, providers with a
  base_url set (e.g. deepseek) would catch all slash-delimited model
  ids and return the config provider, preventing custom provider
  routing.
- get_available_models(): include model aliases in response so the
  frontend can resolve them on /model commands.

Frontend (static/commands.js):
- cmdModel(): resolve aliases by fetching /api/models before fuzzy
  matching the dropdown.
- Add bare-model fallback when the alias resolves to a slash-delimited
  provider/model id (e.g. "deepseek/deepseek-v4-flash").
- Add cross-provider fallback: when the model is from a custom provider
  not in the active provider dropdown, call /api/session/update directly
  with the provider/model id and provider override.
This commit is contained in:
ts2111
2026-05-17 21:22:06 +02:00
parent 02144aa863
commit 64db8bd794
2 changed files with 72 additions and 1 deletions
+23
View File
@@ -1772,6 +1772,19 @@ def resolve_model_provider(model_id: str) -> tuple:
and prefix != config_provider
):
return model_id, "openrouter", None
# Cross-provider via custom_providers: if the prefix matches a named custom
# provider entry (e.g. "ollama-local/glm-4.7-flash:q4_k_m"), route through it
# instead of falling back to the default config provider. MUST come BEFORE
# the config_base_url branch because many providers have a base_url set.
if prefix and config_provider and prefix != config_provider:
_custom_cfg = cfg.get("custom_providers", [])
if isinstance(_custom_cfg, list):
for _entry in _custom_cfg:
if isinstance(_entry, dict) and _entry.get("name", "").strip() == prefix:
_slug = _custom_provider_slug_from_name(prefix)
_base = (_entry.get("base_url") or "").strip()
return model_id, _slug, _base or None
# If a custom endpoint base_url is configured, don't reroute through OpenRouter
# just because the model name contains a slash (e.g. google/gemma-4-26b-a4b).
# The user has explicitly pointed at a base_url, so trust their routing config.
@@ -3812,11 +3825,21 @@ def get_available_models() -> dict:
or (g.get("provider_id") or "").startswith("custom:")
]
# 12. Include model aliases so the WebUI frontend can resolve them.
model_aliases: dict[str, str] = {}
try:
raw_aliases = cfg.get("model", {}).get("aliases", {})
if isinstance(raw_aliases, dict):
model_aliases = {str(k).strip(): str(v).strip() for k, v in raw_aliases.items() if k and v}
except Exception:
pass
return {
"active_provider": active_provider,
"default_model": default_model,
"configured_model_badges": _build_configured_model_badges(),
"groups": groups,
"aliases": model_aliases,
}
# ── FAST PATH ─────────────────────────────────────────────────────────────
+49 -1
View File
@@ -326,7 +326,22 @@ async function cmdModel(args){
if(!args){showToast(t('model_usage'));return;}
const sel=$('modelSelect');
if(!sel)return;
const q=args.toLowerCase();
let q=args.toLowerCase();
// Resolve alias before fuzzy matching the dropdown.
// Fetch /api/models which now includes an "aliases" key.
try {
const resp=await fetch('/api/models');
if(resp.ok){
const data=await resp.json();
const aliases=data.aliases||{};
for(const [alias,modelId] of Object.entries(aliases)){
if(alias.toLowerCase()===q){
q=modelId; // resolve alias to real model id e.g. "deepseek/deepseek-v4-flash"
break;
}
}
}
} catch(_){/* non-critical, fall through to fuzzy match */}
// Fuzzy match: find first option whose label or value contains the query
let match=null;
for(const opt of sel.options){
@@ -334,6 +349,39 @@ async function cmdModel(args){
match=opt.value;break;
}
}
// Fallback: if q has provider/ prefix (e.g. "deepseek/deepseek-v4-flash"),
// try the bare model name (which is how options appear for the active provider)
if(!match && q.includes('/')){
const bare=q.slice(q.lastIndexOf('/')+1);
for(const opt of sel.options){
if(opt.value.toLowerCase().includes(bare)||opt.textContent.toLowerCase().includes(bare)){
match=opt.value;break;
}
}
// Cross-provider fallback: if still no match, the model is from a
// different provider not in the dropdown. Call /api/session/update directly.
if(!match && S&&S.session&&S.session.session_id){
const provider=q.slice(0,q.indexOf('/'));
try{
const resp=await fetch('/api/session/update',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({
session_id:S.session.session_id,
model:q,
model_provider:provider,
}),
});
if(resp.ok){
S.session.model=q;
S.session.model_provider=provider;
if(typeof syncTopbar==='function') syncTopbar();
showToast(t('switched_to')+q);
return;
}
}catch(_){/* fall through to "no model match" */}
}
}
if(!match){showToast(t('no_model_match')+`"${args}"`);return;}
sel.value=match;
await sel.onchange();