diff --git a/CHANGELOG.md b/CHANGELOG.md index 89f50607..2eb06e4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.438] — 2026-06-15 — Release OY (model cost/health comparison table on Insights) + +### Added + +- **The Insights panel has an opt-in "Model health comparison" reference table.** A collapsible (default-collapsed) table listing common models with their provider, input/output cost per 1M tokens, and a plain-English replacement/when-to-use guide — a quick at-a-glance cost reference alongside the usage analytics. Every cell is HTML-escaped; the data is static (no new API calls), and the four new i18n keys are present across all locales. (#691) + ## [v0.51.437] — 2026-06-15 — Release OX (Escape blurs the composer for keyboard nav) ### Fixed diff --git a/static/i18n.js b/static/i18n.js index 5903f4af..07e8bf62 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -858,6 +858,10 @@ const LOCALES = { insights_model_tokens: 'Tokens', insights_model_cost: 'Cost', insights_model_share: 'Share', + insights_model_health_title: 'Model health comparison', + insights_model_health_provider: 'Provider', + insights_model_health_replacement: 'Replacement guide', + insights_model_health_cost_per_m: 'Cost / 1M', insights_no_usage_data: 'No usage data yet', insights_footer: 'Showing data from the last {days} days', insights_skill_usage_title: 'Skill Usage', @@ -2275,6 +2279,10 @@ const LOCALES = { insights_model_tokens: 'Token', insights_model_cost: 'Costo', insights_model_share: 'Quota', + insights_model_health_title: 'Model health comparison', + insights_model_health_provider: 'Provider', + insights_model_health_replacement: 'Replacement guide', + insights_model_health_cost_per_m: 'Cost / 1M', insights_no_usage_data: 'Nessun dato di utilizzo', insights_footer: 'Dati mostrati dagli ultimi {days} giorni', insights_skill_usage_title: 'Utilizzo Skill', @@ -3693,6 +3701,10 @@ const LOCALES = { insights_model_tokens: 'トークン', insights_model_cost: 'コスト', insights_model_share: 'シェア', + insights_model_health_title: 'Model health comparison', + insights_model_health_provider: 'Provider', + insights_model_health_replacement: 'Replacement guide', + insights_model_health_cost_per_m: 'Cost / 1M', insights_no_usage_data: '使用データはまだありません', insights_footer: '直近 {days} 日間のデータを表示', insights_skill_usage_title: 'スキル使用状況', @@ -5579,6 +5591,10 @@ const LOCALES = { insights_model_tokens: 'Tokens', insights_model_cost: 'Cost', insights_model_share: 'Share', + insights_model_health_title: 'Model health comparison', + insights_model_health_provider: 'Provider', + insights_model_health_replacement: 'Replacement guide', + insights_model_health_cost_per_m: 'Cost / 1M', insights_no_usage_data: 'No usage data yet', insights_footer: 'Showing data from the last {days} days', // TODO: translate insights_skill_usage_title: 'Skill Usage', // TODO: translate @@ -6916,6 +6932,10 @@ const LOCALES = { insights_model_tokens: 'Tokens', insights_model_cost: 'Cost', insights_model_share: 'Share', + insights_model_health_title: 'Model health comparison', + insights_model_health_provider: 'Provider', + insights_model_health_replacement: 'Replacement guide', + insights_model_health_cost_per_m: 'Cost / 1M', insights_no_usage_data: 'No usage data yet', insights_footer: 'Showing data from the last {days} days', // TODO: translate insights_skill_usage_title: 'Skill Usage', // TODO: translate @@ -8269,6 +8289,10 @@ const LOCALES = { insights_model_tokens: 'Tokens', insights_model_cost: 'Cost', insights_model_share: 'Share', + insights_model_health_title: 'Model health comparison', + insights_model_health_provider: 'Provider', + insights_model_health_replacement: 'Replacement guide', + insights_model_health_cost_per_m: 'Cost / 1M', insights_no_usage_data: 'No usage data yet', insights_footer: 'Showing data from the last {days} days', // TODO: translate insights_skill_usage_title: 'Skill Usage', // TODO: translate @@ -9615,6 +9639,10 @@ const LOCALES = { insights_model_tokens: '令牌', insights_model_cost: '费用', insights_model_share: '占比', + insights_model_health_title: 'Model health comparison', + insights_model_health_provider: 'Provider', + insights_model_health_replacement: 'Replacement guide', + insights_model_health_cost_per_m: 'Cost / 1M', insights_no_usage_data: '暂无使用数据', insights_footer: '显示最近 {days} 天的数据', insights_skill_usage_title: '技能使用统计', @@ -10507,6 +10535,10 @@ const LOCALES = { insights_model_tokens: 'Token', insights_model_cost: '成本', insights_model_share: '占比', + insights_model_health_title: 'Model health comparison', + insights_model_health_provider: 'Provider', + insights_model_health_replacement: 'Replacement guide', + insights_model_health_cost_per_m: 'Cost / 1M', insights_no_usage_data: '尚無用量資料', insights_footer: '顯示最近 {days} 天的資料', insights_skill_usage_title: '技能使用情況', @@ -12281,6 +12313,10 @@ const LOCALES = { insights_token_breakdown: 'Token Breakdown', // TODO: translate insights_tokens: 'Tokens', // TODO: translate insights_total: 'Total', // TODO: translate + insights_model_health_title: 'Model health comparison', // TODO: translate + insights_model_health_provider: 'Provider', // TODO: translate + insights_model_health_replacement: 'Replacement guide', // TODO: translate + insights_model_health_cost_per_m: 'Cost / 1M', // TODO: translate settings_desc_api_redact: 'Self-hosted users can disable for transparency (not recommended for shared instances).', // TODO: translate settings_label_api_redact: 'Redact sensitive data in API responses', // TODO: translate voice_error: 'Voice not supported in this browser', // TODO: translate @@ -13662,6 +13698,10 @@ const LOCALES = { insights_model_tokens: '토큰', insights_model_cost: '비용', insights_model_share: '비율', + insights_model_health_title: 'Model health comparison', + insights_model_health_provider: 'Provider', + insights_model_health_replacement: 'Replacement guide', + insights_model_health_cost_per_m: 'Cost / 1M', insights_no_usage_data: '아직 사용 데이터가 없습니다', insights_footer: 'Showing data from the last {days} days', // TODO: translate insights_skill_usage_title: 'Skill Usage', // TODO: translate @@ -14423,6 +14463,10 @@ const LOCALES = { insights_model_tokens: 'Jetons', insights_model_cost: 'Coût', insights_model_share: 'Partager', + insights_model_health_title: 'Model health comparison', + insights_model_health_provider: 'Provider', + insights_model_health_replacement: 'Replacement guide', + insights_model_health_cost_per_m: 'Cost / 1M', insights_no_usage_data: 'Aucune donnée d\'utilisation pour l\'instant', insights_footer: 'Affichage des données des {days} derniers jours', insights_skill_usage_title: 'Utilisation des Skills', @@ -16423,6 +16467,10 @@ const LOCALES = { insights_model_tokens: 'Jetonlar', insights_model_cost: 'Maliyet', insights_model_share: 'Paylaşmak', + insights_model_health_title: 'Model health comparison', + insights_model_health_provider: 'Provider', + insights_model_health_replacement: 'Replacement guide', + insights_model_health_cost_per_m: 'Cost / 1M', insights_no_usage_data: 'Henüz kullanım verisi yok', insights_footer: 'Son {days} günün verileri gösteriliyor', insights_skill_usage_title: 'Skill Usage', // TODO: translate @@ -16754,6 +16802,10 @@ const LOCALES = { fork_from_here: 'Rozgałęź stąd', forked_from: 'Rozgałęzione z', subagent_children: 'Sesje subagentów', + insights_model_health_title: 'Porównanie kondycji modeli', + insights_model_health_provider: 'Dostawca', + insights_model_health_replacement: 'Przewodnik zastąpienia', + insights_model_health_cost_per_m: 'Koszt / 1 mln', btw_asking: 'Zadawanie pytania pobocznego…', btw_label: 'Pytanie poboczne — poza historią', btw_done: 'Odpowiedziano na pytanie poboczne', diff --git a/static/panels.js b/static/panels.js index 210fb593..d4d6010f 100644 --- a/static/panels.js +++ b/static/panels.js @@ -3441,6 +3441,30 @@ async function copyLogsAll() { } // ── Insights panel ── +const STATIC_MODEL_HEALTH_ROWS = [ + {id:'openai/gpt-5.4-mini', provider:'OpenAI', inputCostPerM:0.25, outputCostPerM:2.00, replacement:'Default economical general-purpose model'}, + {id:'openai/gpt-5.4', provider:'OpenAI', inputCostPerM:2.00, outputCostPerM:10.00, replacement:'Use for complex synthesis; fall back to Mini for routine turns'}, + {id:'anthropic/claude-sonnet-4.5', provider:'Anthropic', inputCostPerM:3.00, outputCostPerM:15.00, replacement:'Strong coding and analysis option; use Mini for low-risk chat'}, + {id:'google/gemini-2.5-pro', provider:'Google', inputCostPerM:1.25, outputCostPerM:10.00, replacement:'Long-context research option; use Flash for speed-sensitive work'}, + {id:'google/gemini-2.5-flash', provider:'Google', inputCostPerM:0.30, outputCostPerM:2.50, replacement:'Low-latency replacement for lighter multimodal or research turns'}, +]; + +function _renderModelHealthCost(row) { + const input = Number(row.inputCostPerM || 0); + const output = Number(row.outputCostPerM || 0); + return `$${input.toFixed(2)} / $${output.toFixed(2)}`; +} + +function _renderStaticModelHealthTable() { + const rows = STATIC_MODEL_HEALTH_ROWS.map(row => `
+ ${esc(row.id)} + ${esc(row.provider)} + ${esc(_renderModelHealthCost(row))} + ${esc(row.replacement)} +
`).join(''); + return `
${esc(t('insights_model_health_title'))}
${esc(t('insights_model_name'))}${esc(t('insights_model_health_provider'))}${esc(t('insights_model_health_cost_per_m'))}${esc(t('insights_model_health_replacement'))}
${rows}
`; +} + async function loadInsights(animate) { const box = $('insightsContent'); const refreshBtn = $('insightsRefreshBtn'); @@ -3684,6 +3708,7 @@ function _renderInsights(d, box, wikiStatus, skillUsage) { } else { modelsHtml = `
${esc(t('insights_models'))}
${esc(t('insights_no_usage_data'))}
`; } + const modelHealthHtml = _renderStaticModelHealthTable(); // Activity by day of week let dowHtml = ''; @@ -3737,6 +3762,7 @@ function _renderInsights(d, box, wikiStatus, skillUsage) { ${overviewCards.map(c => `
${c.icon}
${c.value}
${esc(c.label)}
`).join('')} ${dailyHtml} + ${modelHealthHtml}
${tokenCards} ${modelsHtml} diff --git a/static/style.css b/static/style.css index 201d50df..f475e01f 100644 --- a/static/style.css +++ b/static/style.css @@ -5282,6 +5282,14 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;} .insights-table-head{display:grid;grid-template-columns:1fr 80px;padding:4px 0;border-bottom:1px solid var(--border);font-weight:600;color:var(--muted);font-size:11px;} .insights-table-row{display:grid;grid-template-columns:1fr 80px;padding:6px 0;border-bottom:1px solid var(--border,.05);} .insights-model-table .insights-table-head,.insights-model-table .insights-table-row{grid-template-columns:minmax(90px,1.5fr) 64px 76px 74px 52px;gap:8px;align-items:center;} +.insights-model-health-card{padding:0;} +.insights-model-health-card>summary{cursor:pointer;padding:12px 14px;} +.insights-model-health-card>summary .insights-card-title{display:inline-block;margin-bottom:0;} +.insights-model-health-card[open]>summary{padding-bottom:8px;} +.insights-model-health-card .insights-model-health-table{padding:0 14px 12px;} +.insights-model-health-table{overflow-x:auto;display:block;} +.insights-model-health-table .insights-table-head,.insights-model-health-table .insights-table-row{grid-template-columns:minmax(130px,1.5fr) 86px 92px minmax(150px,1.4fr);gap:8px;align-items:center;min-width:540px;} +.insights-model-health-replacement{overflow-wrap:anywhere;color:var(--muted);} .insights-model-cost,.insights-model-tokens{font-variant-numeric:tabular-nums;} .insights-model-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .insights-empty{font-size:12px;color:var(--muted);padding:12px 0;} diff --git a/tests/test_issue691_model_health_table.py b/tests/test_issue691_model_health_table.py new file mode 100644 index 00000000..3ca73648 --- /dev/null +++ b/tests/test_issue691_model_health_table.py @@ -0,0 +1,88 @@ +from pathlib import Path + + +REPO = Path(__file__).resolve().parents[1] +PANELS_JS = (REPO / "static" / "panels.js").read_text(encoding="utf-8") +I18N_JS = (REPO / "static" / "i18n.js").read_text(encoding="utf-8") +STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8") + + +def _function_body(src: str, signature: str) -> str: + start = src.index(signature) + brace = src.index("{", start) + depth = 0 + for i in range(brace, len(src)): + if src[i] == "{": + depth += 1 + elif src[i] == "}": + depth -= 1 + if depth == 0: + return src[start : i + 1] + raise AssertionError(f"function body not found: {signature}") + + +def test_static_model_health_metadata_and_renderer_exist(): + renderer = _function_body(PANELS_JS, "function _renderStaticModelHealthTable") + + assert "const STATIC_MODEL_HEALTH_ROWS = [" in PANELS_JS + assert "inputCostPerM" in PANELS_JS + assert "outputCostPerM" in PANELS_JS + assert "replacement:" in PANELS_JS + assert '
' in renderer + assert "" in renderer + assert "insights-model-health-table" in renderer + assert "insights_model_health_cost_per_m" in renderer + assert "insights_model_health_replacement" in renderer + assert "insights_model_health_quality" not in renderer + assert "insights_model_health_hallucination" not in renderer + + +def test_render_insights_includes_static_table_before_usage_models(): + render = _function_body(PANELS_JS, "function _renderInsights") + + assert "const modelHealthHtml = _renderStaticModelHealthTable();" in render + assert "${modelHealthHtml}" in render + assert render.index("${modelHealthHtml}") < render.index("insights-usage-grid") + assert "modelsHtml" in render + + +def test_no_runtime_quality_or_hallucination_fetch_added(): + load = _function_body(PANELS_JS, "async function loadInsights") + + assert "quality" not in load.lower() + assert "hallucination" not in load.lower() + assert "/api/model" not in load + assert "api(`/api/insights?days=${period}`)" in load + + +def test_model_health_i18n_keys_exist_in_locale_blocks(): + keys = [ + "insights_model_health_title", + "insights_model_health_provider", + "insights_model_health_replacement", + "insights_model_health_cost_per_m", + ] + # Split i18n.js into per-locale blocks (top-level " : {" entries) and + # assert EVERY locale carries all four keys — a `count >= N` check silently + # tolerates a locale missing the keys (English fallback). #3634 pt gap. + import re + + locale_starts = [ + (m.group(1), m.start()) + for m in re.finditer(r"""^ ['"]?([A-Za-z_-]+)['"]?: \{$""", I18N_JS, re.MULTILINE) + ] + assert len(locale_starts) >= 13, f"expected 13+ locale blocks, found {len(locale_starts)}" + bounds = locale_starts + [("__end__", len(I18N_JS))] + for i, (code, start) in enumerate(locale_starts): + block = I18N_JS[start : bounds[i + 1][1]] + for key in keys: + assert f"{key}:" in block, f"locale '{code}' is missing {key}" + + +def test_model_health_table_css_is_responsive_and_contained(): + assert ".insights-model-health-table" in STYLE_CSS + assert ".insights-model-health-table .insights-table-head" in STYLE_CSS + assert ".insights-model-health-card>summary" in STYLE_CSS + assert "minmax(130px,1.5fr)" in STYLE_CSS + assert "overflow-x:auto" in STYLE_CSS + assert ".insights-model-health-replacement" in STYLE_CSS