From 2169759c509a8e039a67b4b158a2a3facfedc853 Mon Sep 17 00:00:00 2001 From: renatomott Date: Wed, 29 Apr 2026 20:56:44 -0300 Subject: [PATCH] fix: preserve model badges across cached deploys --- api/config.py | 87 ++++++++++++++++++++++++- static/style.css | 4 ++ static/ui.js | 28 +++++++- tests/test_model_picker_badges.py | 104 ++++++++++++++++++++++++++++++ 4 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 tests/test_model_picker_badges.py diff --git a/api/config.py b/api/config.py index d337abb6..002c246e 100644 --- a/api/config.py +++ b/api/config.py @@ -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, } diff --git a/static/style.css b/static/style.css index 1d8a8921..5f15ecbe 100644 --- a/static/style.css +++ b/static/style.css @@ -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;} diff --git a/static/ui.js b/static/ui.js index 48872120..a64a34a8 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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