mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-26 11:40:26 +00:00
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:
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user