fix: preserve model badges across cached deploys

This commit is contained in:
renatomott
2026-04-29 20:56:44 -03:00
committed by Hermes Agent
parent 33a145a669
commit 2169759c50
4 changed files with 219 additions and 4 deletions
+86 -1
View File
@@ -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,
}
+4
View File
@@ -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
View File
@@ -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);
}
+104
View File
@@ -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."
)