mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-28 12:40:26 +00:00
fix: preserve model badges across cached deploys
This commit is contained in:
+86
-1
@@ -1238,12 +1238,13 @@ def _is_valid_models_cache(cache: object) -> bool:
|
||||
"""Return True when a disk cache payload has the full /api/models shape."""
|
||||
if not isinstance(cache, dict):
|
||||
return False
|
||||
if not {"active_provider", "default_model", "groups"}.issubset(cache):
|
||||
if not {"active_provider", "default_model", "configured_model_badges", "groups"}.issubset(cache):
|
||||
return False
|
||||
active_provider = cache.get("active_provider")
|
||||
return (
|
||||
(active_provider is None or isinstance(active_provider, str))
|
||||
and isinstance(cache.get("default_model"), str)
|
||||
and isinstance(cache.get("configured_model_badges"), dict)
|
||||
and isinstance(cache.get("groups"), list)
|
||||
)
|
||||
|
||||
@@ -1273,6 +1274,7 @@ def _save_models_cache_to_disk(cache: dict) -> None:
|
||||
{
|
||||
"active_provider": cache["active_provider"],
|
||||
"default_model": cache["default_model"],
|
||||
"configured_model_badges": cache["configured_model_badges"],
|
||||
"groups": cache["groups"],
|
||||
},
|
||||
f,
|
||||
@@ -1418,6 +1420,88 @@ def get_available_models() -> dict:
|
||||
default_model = get_effective_default_model(cfg)
|
||||
groups = []
|
||||
|
||||
def _norm_model_id(model_id: str) -> str:
|
||||
s = str(model_id or "").strip().lower()
|
||||
if s.startswith("@") and ":" in s:
|
||||
s = s.split(":", 1)[1]
|
||||
if "/" in s:
|
||||
s = s.split("/", 1)[1]
|
||||
return s.replace("-", ".")
|
||||
|
||||
def _build_configured_model_badges() -> dict[str, dict[str, str]]:
|
||||
configured_entries: list[dict[str, str]] = []
|
||||
if active_provider and default_model:
|
||||
configured_entries.append(
|
||||
{
|
||||
"provider": active_provider,
|
||||
"model": default_model,
|
||||
"role": "primary",
|
||||
"label": "Primary",
|
||||
}
|
||||
)
|
||||
fallback_cfg = cfg.get("fallback_providers", [])
|
||||
if isinstance(fallback_cfg, list):
|
||||
for idx, entry in enumerate(fallback_cfg, start=1):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
provider = _resolve_provider_alias(entry.get("provider"))
|
||||
model = str(entry.get("model") or "").strip()
|
||||
if not provider or not model:
|
||||
continue
|
||||
configured_entries.append(
|
||||
{
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"role": "fallback",
|
||||
"label": f"Fallback {idx}",
|
||||
}
|
||||
)
|
||||
|
||||
option_ids = [m.get("id", "") for g in groups for m in g.get("models", []) if m.get("id")]
|
||||
option_lookup = {str(opt_id): str(opt_id) for opt_id in option_ids}
|
||||
norm_lookup: dict[str, list[str]] = {}
|
||||
for opt_id in option_ids:
|
||||
norm_lookup.setdefault(_norm_model_id(opt_id), []).append(opt_id)
|
||||
|
||||
badges: dict[str, dict[str, str]] = {}
|
||||
for entry in configured_entries:
|
||||
provider = entry["provider"]
|
||||
model = entry["model"]
|
||||
raw_candidates = []
|
||||
for candidate in (
|
||||
model,
|
||||
f"{provider}/{model}",
|
||||
f"@{provider}:{model}",
|
||||
):
|
||||
if candidate and candidate not in raw_candidates:
|
||||
raw_candidates.append(candidate)
|
||||
|
||||
match_id = None
|
||||
for candidate in raw_candidates:
|
||||
if candidate in option_lookup:
|
||||
match_id = option_lookup[candidate]
|
||||
break
|
||||
if match_id is None:
|
||||
for candidate in raw_candidates:
|
||||
normalized = _norm_model_id(candidate)
|
||||
matches = norm_lookup.get(normalized, [])
|
||||
if not matches:
|
||||
continue
|
||||
provider_match = next(
|
||||
(m for m in matches if m.startswith(f"@{provider}:") or m.startswith(f"{provider}/")),
|
||||
None,
|
||||
)
|
||||
match_id = provider_match or matches[0]
|
||||
if match_id:
|
||||
break
|
||||
|
||||
badge_payload = {"role": entry["role"], "label": entry["label"], "provider": provider}
|
||||
for candidate in raw_candidates:
|
||||
badges[candidate] = badge_payload
|
||||
if match_id:
|
||||
badges[match_id] = badge_payload
|
||||
return badges
|
||||
|
||||
# 1. Read config.yaml model section
|
||||
cfg_base_url = "" # must be defined before conditional blocks (#117)
|
||||
model_cfg = cfg.get("model", {})
|
||||
@@ -1921,6 +2005,7 @@ def get_available_models() -> dict:
|
||||
return {
|
||||
"active_provider": active_provider,
|
||||
"default_model": default_model,
|
||||
"configured_model_badges": _build_configured_model_badges(),
|
||||
"groups": groups,
|
||||
}
|
||||
|
||||
|
||||
@@ -1144,7 +1144,11 @@
|
||||
.model-opt{padding:10px 14px;cursor:pointer;transition:background .12s;display:flex;flex-direction:column;gap:3px;align-items:flex-start;}
|
||||
.model-opt:hover{background:rgba(255,255,255,.07);}
|
||||
.model-opt.active{background:var(--accent-bg);}
|
||||
.model-opt-top{display:flex;align-items:center;gap:8px;flex-wrap:wrap;width:100%;}
|
||||
.model-opt-name{display:block;font-size:13px;color:var(--text);font-weight:500;line-height:1.25;}
|
||||
.model-opt-badge{display:inline-flex;align-items:center;justify-content:center;padding:2px 7px;border-radius:999px;font-size:10px;font-weight:700;letter-spacing:.02em;text-transform:uppercase;border:1px solid transparent;}
|
||||
.model-opt-badge--primary{background:rgba(50,184,198,.16);border-color:rgba(50,184,198,.32);color:#8fe7ef;}
|
||||
.model-opt-badge--fallback{background:rgba(255,184,77,.14);border-color:rgba(255,184,77,.28);color:#ffd18a;}
|
||||
.model-opt-id{display:block;font-size:10px;color:var(--muted);line-height:1.3;opacity:.72;word-break:break-word;}
|
||||
.model-custom-sep{padding-top:4px;border-top:1px solid var(--border);margin-top:4px;}
|
||||
.model-custom-row{display:flex;align-items:center;gap:6px;padding:6px 10px 8px;}
|
||||
|
||||
+25
-3
@@ -99,6 +99,7 @@ const _EXCALIDRAW_EXTS=/\.excalidraw$/i;
|
||||
|
||||
// Dynamic model labels -- populated by populateModelDropdown(), fallback to static map
|
||||
let _dynamicModelLabels={};
|
||||
window._configuredModelBadges=window._configuredModelBadges||{};
|
||||
|
||||
// ── Smart model resolver ────────────────────────────────────────────────────
|
||||
// Finds the best matching option value in a <select> for a given model ID.
|
||||
@@ -152,6 +153,7 @@ async function populateModelDropdown(){
|
||||
// Store default model so newSession() can apply it (#872).
|
||||
// Per-page-load — not synced across browser tabs.
|
||||
window._defaultModel=data.default_model||null;
|
||||
window._configuredModelBadges=data.configured_model_badges||{};
|
||||
// Clear existing options
|
||||
sel.innerHTML='';
|
||||
_dynamicModelLabels={};
|
||||
@@ -313,6 +315,24 @@ function _selectedModelOption(){
|
||||
return sel.options[sel.selectedIndex]||null;
|
||||
}
|
||||
|
||||
function _normalizeConfiguredModelKey(modelId){
|
||||
let s=String(modelId||'').trim().toLowerCase();
|
||||
if(s.startsWith('@')&&s.includes(':')) s=s.substring(s.indexOf(':')+1);
|
||||
if(s.includes('/')) s=s.split('/').pop();
|
||||
return s.replace(/-/g,'.');
|
||||
}
|
||||
|
||||
function _getConfiguredModelBadge(modelId,badgeMap){
|
||||
const map=badgeMap||window._configuredModelBadges||{};
|
||||
if(!modelId||!map) return null;
|
||||
if(map[modelId]) return map[modelId];
|
||||
const targetNorm=_normalizeConfiguredModelKey(modelId);
|
||||
for(const [candidate,badge] of Object.entries(map)){
|
||||
if(_normalizeConfiguredModelKey(candidate)===targetNorm) return badge;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function syncModelChip(){
|
||||
const sel=$('modelSelect');
|
||||
const chip=$('composerModelChip');
|
||||
@@ -346,14 +366,15 @@ function renderModelDropdown(){
|
||||
if(!dd||!sel) return;
|
||||
// Store model data for filtering
|
||||
const _modelData=[];
|
||||
const _badgeMap=window._configuredModelBadges||{};
|
||||
for(const child of Array.from(sel.children)){
|
||||
if(child.tagName==='OPTGROUP'){
|
||||
for(const opt of Array.from(child.children)){
|
||||
_modelData.push({value:opt.value,name:esc(opt.textContent||getModelLabel(opt.value)),id:esc(opt.value),group:child.label||''});
|
||||
_modelData.push({value:opt.value,name:esc(opt.textContent||getModelLabel(opt.value)),id:esc(opt.value),group:child.label||'',badge:_getConfiguredModelBadge(opt.value,_badgeMap)});
|
||||
}
|
||||
}
|
||||
if(child.tagName==='OPTION'){
|
||||
_modelData.push({value:child.value,name:esc(child.textContent||getModelLabel(child.value)),id:esc(child.value),group:''});
|
||||
_modelData.push({value:child.value,name:esc(child.textContent||getModelLabel(child.value)),id:esc(child.value),group:'',badge:_getConfiguredModelBadge(child.value,_badgeMap)});
|
||||
}
|
||||
}
|
||||
// Create search input FIRST before filterModels definition
|
||||
@@ -405,7 +426,8 @@ function renderModelDropdown(){
|
||||
}
|
||||
const row=document.createElement('div');
|
||||
row.className='model-opt'+(m.value===sel.value?' active':'');
|
||||
row.innerHTML=`<span class="model-opt-name">${m.name}</span><span class="model-opt-id">${m.id}</span>`;
|
||||
const badgeHtml=m.badge?`<span class="model-opt-badge model-opt-badge--${esc(m.badge.role||'configured')}">${esc(m.badge.label||'Configured')}</span>`:'';
|
||||
row.innerHTML=`<div class="model-opt-top"><span class="model-opt-name">${m.name}</span>${badgeHtml}</div><span class="model-opt-id">${m.id}</span>`;
|
||||
row.onclick=()=>selectModelFromDropdown(m.value);
|
||||
dd.appendChild(row);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
from pathlib import Path
|
||||
|
||||
from api import config
|
||||
|
||||
|
||||
def _models_with_cfg(model_cfg=None, fallback_providers=None, custom_providers=None, active_provider=None):
|
||||
old_cfg = config.cfg
|
||||
old_mtime = config._cfg_mtime
|
||||
old_cache = config._available_models_cache
|
||||
old_cache_ts = config._available_models_cache_ts
|
||||
try:
|
||||
config._available_models_cache = None
|
||||
config._available_models_cache_ts = 0.0
|
||||
config._cfg_mtime = 0.0
|
||||
config.cfg = {
|
||||
"model": model_cfg or {"provider": "openai-codex", "default": "gpt-5.4"},
|
||||
"fallback_providers": fallback_providers or [],
|
||||
"providers": custom_providers or {},
|
||||
}
|
||||
if active_provider:
|
||||
config.cfg["model"]["provider"] = active_provider
|
||||
return config.get_available_models()
|
||||
finally:
|
||||
config.cfg = old_cfg
|
||||
config._cfg_mtime = old_mtime
|
||||
config._available_models_cache = old_cache
|
||||
config._available_models_cache_ts = old_cache_ts
|
||||
|
||||
|
||||
def test_available_models_exposes_primary_and_fallback_badges():
|
||||
result = _models_with_cfg(
|
||||
model_cfg={"provider": "openai-codex", "default": "gpt-5.4"},
|
||||
fallback_providers=[
|
||||
{"provider": "copilot", "model": "gpt-4.1"},
|
||||
{"provider": "anthropic", "model": "claude-haiku-4.5"},
|
||||
],
|
||||
)
|
||||
|
||||
badges = result.get("configured_model_badges")
|
||||
assert isinstance(badges, dict), (
|
||||
"get_available_models() deve expor configured_model_badges para o frontend "
|
||||
"marcar visualmente o dropdown com a cadeia primária + fallback configurada."
|
||||
)
|
||||
assert badges.get("@openai-codex:gpt-5.4", {}).get("role") == "primary"
|
||||
assert badges.get("@openai-codex:gpt-5.4", {}).get("label") == "Primary"
|
||||
assert badges.get("@copilot:gpt-4.1", {}).get("role") == "fallback"
|
||||
assert badges.get("@copilot:gpt-4.1", {}).get("label") == "Fallback 1"
|
||||
assert badges.get("anthropic/claude-haiku-4.5", {}).get("role") == "fallback"
|
||||
assert badges.get("anthropic/claude-haiku-4.5", {}).get("label") == "Fallback 2"
|
||||
|
||||
|
||||
def test_get_available_models_cache_preserves_configured_model_badges(tmp_path, monkeypatch):
|
||||
cache_path = tmp_path / "models_cache.json"
|
||||
old_cfg = config.cfg
|
||||
old_mtime = config._cfg_mtime
|
||||
old_cache = config._available_models_cache
|
||||
old_cache_ts = config._available_models_cache_ts
|
||||
old_cache_path = config._models_cache_path
|
||||
try:
|
||||
monkeypatch.setattr(config, "_models_cache_path", cache_path)
|
||||
config._available_models_cache = None
|
||||
config._available_models_cache_ts = 0.0
|
||||
config._cfg_mtime = 0.0
|
||||
config.cfg = {
|
||||
"model": {"provider": "openai-codex", "default": "gpt-5.4"},
|
||||
"fallback_providers": [{"provider": "copilot", "model": "gpt-4.1"}],
|
||||
"providers": {},
|
||||
}
|
||||
|
||||
cold = config.get_available_models()
|
||||
assert cold.get("configured_model_badges", {}).get("@copilot:gpt-4.1", {}).get("label") == "Fallback 1"
|
||||
|
||||
config._available_models_cache = None
|
||||
config._available_models_cache_ts = 0.0
|
||||
warm = config.get_available_models()
|
||||
|
||||
assert "configured_model_badges" in warm, (
|
||||
"O cache persistido de /api/models não pode descartar configured_model_badges, "
|
||||
"senão o deploy/servidor reiniciado perde as TAGS do dropdown mesmo com o código novo."
|
||||
)
|
||||
assert warm["configured_model_badges"].get("@copilot:gpt-4.1", {}).get("label") == "Fallback 1"
|
||||
finally:
|
||||
config.cfg = old_cfg
|
||||
config._cfg_mtime = old_mtime
|
||||
config._available_models_cache = old_cache
|
||||
config._available_models_cache_ts = old_cache_ts
|
||||
monkeypatch.setattr(config, "_models_cache_path", old_cache_path)
|
||||
|
||||
|
||||
|
||||
def test_ui_renders_model_badges_from_api_payload():
|
||||
js = (Path(__file__).resolve().parent.parent / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
assert "window._configuredModelBadges=data.configured_model_badges||{};" in js, (
|
||||
"populateModelDropdown() deve guardar configured_model_badges do /api/models "
|
||||
"para que o dropdown reflita a cadeia configurada atual."
|
||||
)
|
||||
assert "model-opt-badge" in js, (
|
||||
"renderModelDropdown() deve renderizar um badge visual por modelo quando houver "
|
||||
"metadata de primário/fallback no payload."
|
||||
)
|
||||
assert "_getConfiguredModelBadge" in js, (
|
||||
"A UI precisa de um helper de matching resiliente para religar badges mesmo quando "
|
||||
"o update do catálogo mudar prefixos/formas do model ID."
|
||||
)
|
||||
Reference in New Issue
Block a user