// Page 4: 費用分析 — per-model donut, cache trend, token mix, forecast function PageCost({ filters, density, chartStyle }) { const { BRANDS, DAILY_AGG, TOKEN_MIX, TODAY, DAYS } = window.MOCK; // 真實 per-model 統計從 cost.byModel endpoint 來;不再用 MODELS array + share 寫死 const liveModels = (window.LIVE && window.LIVE.byModel && window.LIVE.byModel.models) || []; const byModel = liveModels.map((m) => ({ id: m.model, provider: m.provider || (m.model && m.model.toLowerCase().includes('claude') ? 'anthropic' : m.model && m.model.toLowerCase().includes('gpt') ? 'openai' : 'other'), cost: m.cost_usd || 0, requests: m.requests || 0, tokens: m.tokens || 0, // 後端尚未回 per-model price / latency / 14d trend,以下三項為 placeholder priceIn: null, priceOut: null, latency: null, trend: null, })).sort((a, b) => b.cost - a.cost); const totalCost = byModel.reduce((s, m) => s + m.cost, 0); const projectedMonth = DAYS > 0 ? totalCost * (30 / DAYS) : 0; const monthBudget = 18500; // TODO: 接預算管理表 const cacheTrend = TOKEN_MIX.map(d => d.cacheHit); const tokenMix = TOKEN_MIX.map(d => ({ date: d.date, input: d.fresh, output: d.output, cache_read: d.cacheRead, cache_creation: d.cacheCreate, })); return (
({ value: m.cost, color: TOKENS.chart[i % TOKENS.chart.length] }))} size={130} thickness={20} centerLabel="總計" centerValue={fmtUSD(totalCost, 0)} />
{byModel.map((m, i) => (
{m.id} {fmtUSD(m.cost, 0)} {fmtPct(m.cost / totalCost, 0)}
))}
= 0.7 ? '#15803D' : '#B45309', fontWeight: 600 }}> 現在 {fmtPct(cacheTrend[cacheTrend.length - 1])} } />
d.date)} />
↑ Cache 建立 4/29 飆高} />
); } function ForecastBar({ projected, budget, actual, daysIn }) { const projectedPct = (projected / budget) * 100; const actualPct = (actual / budget) * 100; const overBudget = projected > budget; return (
本月實際 ({daysIn}/30 天)
{fmtUSD(actual, 0)}
月底預測
{fmtUSD(projected, 0)}
月度預算
{fmtUSD(budget, 0)}
使用率
80 ? '#B45309' : '#15803D' }}>{projectedPct.toFixed(0)}%
預算上限
已實際支出 線性預測 {overBudget && ⚠ 預期超出預算 {fmtUSD(projected - budget, 0)}}
); } function ModelComparisonTable({ models, totalCost }) { return ( {models.map((m, i) => ( ))}
模型 輸入 $/M 輸出 $/M 平均延遲 使用占比 費用 請求數
{m.id}
{m.priceIn != null ? '$' + m.priceIn.toFixed(2) : '—'} {m.priceOut != null ? '$' + m.priceOut.toFixed(2) : '—'} {m.latency != null ? m.latency + 'ms' : '—'} {totalCost > 0 ? fmtPct(m.cost / totalCost, 1) : '—'} {fmtUSD(m.cost, m.cost < 10 ? 4 : 0)} {fmtNum(m.requests || 0)}
); } function CacheTrendChart({ data, dates }) { const w = 440, h = 180; const padL = 32, padR = 12, padT = 14, padB = 24; const innerW = w - padL - padR; const innerH = h - padT - padB; const target = 0.7; const max = 1; const stepX = innerW / (data.length - 1); const path = data.map((v, i) => { const x = padL + i * stepX; const y = padT + innerH - (v / max) * innerH; return (i === 0 ? 'M' : 'L') + x.toFixed(1) + ',' + y.toFixed(1); }).join(' '); const targetY = padT + innerH - (target / max) * innerH; return ( {[0.25, 0.5, 0.75, 1].map(t => { const y = padT + innerH - t * innerH; return ( {(t * 100).toFixed(0)}% ); })} 目標 70% {data.map((v, i) => ( ))} {[0, Math.floor(data.length / 2), data.length - 1].map(i => ( {dates[i].slice(5)} ))} ); } function TokenMixChart({ data }) { const w = 440, h = 180; const padL = 40, padR = 12, padT = 14, padB = 24; const innerW = w - padL - padR; const innerH = h - padT - padB; const totals = data.map(d => d.input + d.output + d.cache_read + d.cache_creation); const max = Math.max(...totals) * 1.05; const barW = innerW / data.length * 0.8; const stepX = innerW / data.length; const colors = { input: '#1D4ED8', output: '#15803D', cache_read: '#6D28D9', cache_creation: '#B45309' }; const labels = { input: '輸入', output: '輸出', cache_read: 'Cache 讀', cache_creation: 'Cache 建' }; return (
{[0.25, 0.5, 0.75, 1].map(t => { const y = padT + innerH - t * innerH; return ( {fmtCompact(max * t)} ); })} {data.map((d, i) => { const x = padL + i * stepX + (stepX - barW) / 2; let yAcc = padT + innerH; return ['cache_creation', 'cache_read', 'output', 'input'].map(k => { const h_ = (d[k] / max) * innerH; yAcc -= h_; return ; }); })}
{Object.entries(labels).map(([k, l]) => ( {l} ))}
); } function CacheBrandTable({ brands }) { const sorted = [...brands].sort((a, b) => a.cacheHit - b.cacheHit).slice(0, 10); return ( {sorted.map((b, i) => { const gap = 0.7 - b.cacheHit; const savings = gap > 0 ? b.totalCost * gap * 0.85 : 0; return ( ); })}
# 品牌 對話數 Cache 命中 潛在節省 vs 目標 70%
{i + 1}
{b.name}
{fmtNum(b.totalConvos)} {fmtPct(b.cacheHit)} ~{fmtUSD(savings, 0)}/月
= 0.7 ? '#15803D' : b.cacheHit >= 0.5 ? '#B45309' : '#DC2626', }}>
); } window.PageCost = PageCost;