From 64db8bd794eeef7414352afee597e1313f22cf32 Mon Sep 17 00:00:00 2001 From: ts2111 Date: Sun, 17 May 2026 21:22:06 +0200 Subject: [PATCH] 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. --- api/config.py | 23 +++++++++++++++++++++ static/commands.js | 50 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/api/config.py b/api/config.py index 4351f997..e7d460b9 100644 --- a/api/config.py +++ b/api/config.py @@ -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 ───────────────────────────────────────────────────────────── diff --git a/static/commands.js b/static/commands.js index 95c0e429..91a6d728 100644 --- a/static/commands.js +++ b/static/commands.js @@ -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();