diff --git a/app.js b/app.js index 8bb3140..14ea014 100644 --- a/app.js +++ b/app.js @@ -504,6 +504,9 @@ class CLIProxyManager { const tokensHourBtn = document.getElementById('tokens-hour-btn'); const tokensDayBtn = document.getElementById('tokens-day-btn'); const chartLineSelects = document.querySelectorAll('.chart-line-select'); + const modelPriceForm = document.getElementById('model-price-form'); + const resetModelPricesBtn = document.getElementById('reset-model-prices'); + const modelPriceSelect = document.getElementById('model-price-model-select'); if (refreshUsageStats) { refreshUsageStats.addEventListener('click', () => this.loadUsageStats()); @@ -528,6 +531,18 @@ class CLIProxyManager { }); }); } + if (modelPriceForm) { + modelPriceForm.addEventListener('submit', (event) => { + event.preventDefault(); + this.handleModelPriceSubmit(); + }); + } + if (resetModelPricesBtn) { + resetModelPricesBtn.addEventListener('click', () => this.handleModelPriceReset()); + } + if (modelPriceSelect) { + modelPriceSelect.addEventListener('change', () => this.prefillModelPriceInputs()); + } // 模态框 const closeBtn = document.querySelector('.close'); @@ -617,6 +632,7 @@ class CLIProxyManager { // 使用统计状态 requestsChart = null; tokensChart = null; + costChart = null; currentUsageData = null; chartLineSelections = ['none', 'none', 'none']; chartLineSelectIds = ['chart-line-select-0', 'chart-line-select-1', 'chart-line-select-2']; @@ -625,6 +641,9 @@ class CLIProxyManager { { borderColor: '#a855f7', backgroundColor: 'rgba(168, 85, 247, 0.15)' }, { borderColor: '#10b981', backgroundColor: 'rgba(16, 185, 129, 0.15)' } ]; + modelPriceStorageKey = 'cli-proxy-model-prices-v2'; + modelPrices = {}; + modelPriceInitialized = false; showModal() { const modal = document.getElementById('modal'); diff --git a/build.cjs b/build.cjs index 702b064..f70f68b 100644 --- a/build.cjs +++ b/build.cjs @@ -169,42 +169,44 @@ function build() { console.log(`使用版本号: ${version}`); html = html.replace(/__VERSION__/g, version); - html = html.replace( - '', - `` - ); + html = html.replace( + '', + () => `` + ); + + html = html.replace( + '', + () => `` + ); - html = html.replace( - '', - `` - ); - - const scriptTagRegex = /]*src="app\.js"[^>]*><\/script>/i; - if (scriptTagRegex.test(html)) { - html = html.replace( - scriptTagRegex, - `` - ); + const scriptTagRegex = /]*src="app\.js"[^>]*><\/script>/i; + if (scriptTagRegex.test(html)) { + html = html.replace( + scriptTagRegex, + () => `` + ); } else { console.warn('未找到 app.js 脚本标签,未内联应用代码。'); } - const logoDataUrl = loadLogoDataUrl(); - if (logoDataUrl) { - const logoScript = ``; - if (html.includes('')) { - html = html.replace('', `${logoScript}\n`); - } else { - html += `\n${logoScript}`; - } - } else { - console.warn('未找到可内联的 Logo 文件,将保持运行时加载。'); + const logoDataUrl = loadLogoDataUrl(); + if (logoDataUrl) { + const logoScript = ``; + const closingBodyTag = ''; + const closingBodyIndex = html.lastIndexOf(closingBodyTag); + if (closingBodyIndex !== -1) { + html = `${html.slice(0, closingBodyIndex)}${logoScript}\n${closingBodyTag}${html.slice(closingBodyIndex + closingBodyTag.length)}`; + } else { + html += `\n${logoScript}`; + } + } else { + console.warn('未找到可内联的 Logo 文件,将保持运行时加载。'); } const outputPath = path.join(distDir, 'index.html'); diff --git a/i18n.js b/i18n.js index e5d2c86..608a30b 100644 --- a/i18n.js +++ b/i18n.js @@ -430,6 +430,25 @@ const i18n = { 'usage_stats.tokens_count': 'Token数量', 'usage_stats.models': '模型统计', 'usage_stats.success_rate': '成功率', + 'usage_stats.total_cost': '总花费', + 'usage_stats.total_cost_hint': '基于已设置的模型单价', + 'usage_stats.model_price_title': '模型价格', + 'usage_stats.model_price_reset': '清除价格', + 'usage_stats.model_price_model_label': '选择模型', + 'usage_stats.model_price_select_placeholder': '选择模型', + 'usage_stats.model_price_select_hint': '模型列表来自使用统计明细', + 'usage_stats.model_price_prompt': '提示价格 ($/1M tokens)', + 'usage_stats.model_price_completion': '补全价格 ($/1M tokens)', + 'usage_stats.model_price_save': '保存价格', + 'usage_stats.model_price_empty': '暂未设置任何模型价格', + 'usage_stats.model_price_model': '模型', + 'usage_stats.model_price_saved': '模型价格已保存', + 'usage_stats.model_price_model_required': '请选择要设置价格的模型', + 'usage_stats.cost_trend': '花费统计', + 'usage_stats.cost_axis_label': '花费 ($)', + 'usage_stats.cost_need_price': '请先设置模型价格', + 'usage_stats.cost_need_usage': '暂无使用数据,无法计算花费', + 'usage_stats.cost_no_data': '没有可计算的花费数据', 'stats.success': '成功', 'stats.failure': '失败', @@ -982,6 +1001,25 @@ const i18n = { 'usage_stats.tokens_count': 'Token Count', 'usage_stats.models': 'Model Statistics', 'usage_stats.success_rate': 'Success Rate', + 'usage_stats.total_cost': 'Total Cost', + 'usage_stats.total_cost_hint': 'Based on configured model pricing', + 'usage_stats.model_price_title': 'Model Pricing', + 'usage_stats.model_price_reset': 'Clear Prices', + 'usage_stats.model_price_model_label': 'Model', + 'usage_stats.model_price_select_placeholder': 'Choose a model', + 'usage_stats.model_price_select_hint': 'Models come from usage details', + 'usage_stats.model_price_prompt': 'Prompt price ($/1M tokens)', + 'usage_stats.model_price_completion': 'Completion price ($/1M tokens)', + 'usage_stats.model_price_save': 'Save Price', + 'usage_stats.model_price_empty': 'No model prices set', + 'usage_stats.model_price_model': 'Model', + 'usage_stats.model_price_saved': 'Model price saved', + 'usage_stats.model_price_model_required': 'Please choose a model to set pricing', + 'usage_stats.cost_trend': 'Cost Overview', + 'usage_stats.cost_axis_label': 'Cost ($)', + 'usage_stats.cost_need_price': 'Set a model price to view cost stats', + 'usage_stats.cost_need_usage': 'No usage data available to calculate cost', + 'usage_stats.cost_no_data': 'No cost data yet', 'stats.success': 'Success', 'stats.failure': 'Failure', diff --git a/index.html b/index.html index ed4117e..61c3d05 100644 --- a/index.html +++ b/index.html @@ -916,6 +916,17 @@
TPM(近30分钟)
+ +
+
+ +
+
+
--
+
总花费
+
基于已设置的模型单价
+
+
@@ -984,6 +995,18 @@ + +
+
+

花费统计

+
+
+
+ +
请先设置模型价格
+
+
+
@@ -1001,6 +1024,46 @@ + +
+
+

模型价格

+
+ +
+
+
+
+
+ + +

模型列表来自使用统计

+
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+
正在加载...
+
+
+
diff --git a/src/modules/usage.js b/src/modules/usage.js index 924d9d0..8e91be2 100644 --- a/src/modules/usage.js +++ b/src/modules/usage.js @@ -1,3 +1,7 @@ +const DEFAULT_MODEL_PRICE_STORAGE_KEY = 'cli-proxy-model-prices-v2'; +const LEGACY_MODEL_PRICE_STORAGE_KEY = 'cli-proxy-model-prices'; +const TOKENS_PER_PRICE_UNIT = 1_000_000; + // 获取API密钥的统计信息 export async function getKeyStats(usageData = null) { try { @@ -83,6 +87,7 @@ export async function loadUsageStats(usageData = null) { usage = response?.usage || null; } this.currentUsageData = usage; + this.ensureModelPriceState(); if (!usage) { throw new Error('usage payload missing'); @@ -91,6 +96,8 @@ export async function loadUsageStats(usageData = null) { // 更新概览卡片 this.updateUsageOverview(usage); this.updateChartLineSelectors(usage); + this.renderModelPriceOptions(usage); + this.renderSavedModelPrices(); // 读取当前图表周期 const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active'); @@ -104,11 +111,16 @@ export async function loadUsageStats(usageData = null) { // 更新API详细统计表格 this.updateApiStatsTable(usage); + this.updateCostSummaryAndChart(usage); } catch (error) { console.error('加载使用统计失败:', error); this.currentUsageData = null; this.updateChartLineSelectors(null); + this.ensureModelPriceState(); + this.renderModelPriceOptions(null); + this.renderSavedModelPrices(); + this.updateCostSummaryAndChart(null); // 清空概览数据 ['total-requests', 'success-requests', 'failed-requests', 'total-tokens', 'cached-tokens', 'reasoning-tokens', 'rpm-30m', 'tpm-30m'].forEach(id => { @@ -346,6 +358,234 @@ export function collectUsageDetails() { return this.collectUsageDetailsFromUsage(this.currentUsageData); } +export function migrateLegacyModelPrices() { + try { + if (typeof localStorage === 'undefined') { + return; + } + const storageKey = this.modelPriceStorageKey || DEFAULT_MODEL_PRICE_STORAGE_KEY; + const hasCurrent = localStorage.getItem(storageKey); + const legacyRaw = localStorage.getItem(LEGACY_MODEL_PRICE_STORAGE_KEY); + + if (!legacyRaw || hasCurrent) { + return; + } + + const parsed = JSON.parse(legacyRaw); + if (!parsed || typeof parsed !== 'object') { + return; + } + + const migrated = {}; + Object.entries(parsed).forEach(([model, price]) => { + if (!model) return; + const prompt = Number(price?.prompt); + const completion = Number(price?.completion); + const hasPrompt = Number.isFinite(prompt); + const hasCompletion = Number.isFinite(completion); + if (!hasPrompt && !hasCompletion) { + return; + } + migrated[model] = { + prompt: hasPrompt && prompt >= 0 ? prompt * 1000 : 0, + completion: hasCompletion && completion >= 0 ? completion * 1000 : 0 + }; + }); + + if (Object.keys(migrated).length) { + localStorage.setItem(storageKey, JSON.stringify(migrated)); + } + localStorage.removeItem(LEGACY_MODEL_PRICE_STORAGE_KEY); + } catch (error) { + console.warn('迁移模型价格失败:', error); + } +} + +export function ensureModelPriceState() { + if (this.modelPriceInitialized) { + return; + } + this.modelPriceStorageKey = this.modelPriceStorageKey || DEFAULT_MODEL_PRICE_STORAGE_KEY; + this.migrateLegacyModelPrices(); + this.modelPrices = this.loadModelPricesFromStorage(); + this.modelPriceInitialized = true; +} + +export function loadModelPricesFromStorage() { + const storageKey = this.modelPriceStorageKey || DEFAULT_MODEL_PRICE_STORAGE_KEY; + try { + if (typeof localStorage === 'undefined') { + return {}; + } + const raw = localStorage.getItem(storageKey); + if (!raw) { + return {}; + } + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') { + return {}; + } + const normalized = {}; + Object.entries(parsed).forEach(([model, price]) => { + if (!model) return; + const prompt = Number(price?.prompt); + const completion = Number(price?.completion); + if (!Number.isFinite(prompt) && !Number.isFinite(completion)) { + return; + } + normalized[model] = { + prompt: Number.isFinite(prompt) && prompt >= 0 ? prompt : 0, + completion: Number.isFinite(completion) && completion >= 0 ? completion : 0 + }; + }); + return normalized; + } catch (error) { + console.warn('读取模型价格失败:', error); + return {}; + } +} + +export function persistModelPrices(prices = {}) { + const storageKey = this.modelPriceStorageKey || DEFAULT_MODEL_PRICE_STORAGE_KEY; + this.modelPrices = prices; + try { + if (typeof localStorage === 'undefined') { + return; + } + localStorage.setItem(storageKey, JSON.stringify(prices)); + } catch (error) { + console.warn('保存模型价格失败:', error); + } +} + +export function renderModelPriceOptions(usage = null) { + const select = document.getElementById('model-price-model-select'); + if (!select) return; + const models = this.getModelNamesFromUsage(usage); + const previousValue = select.value; + select.innerHTML = ''; + + const placeholderOption = document.createElement('option'); + placeholderOption.value = ''; + placeholderOption.textContent = i18n.t('usage_stats.model_price_select_placeholder'); + select.appendChild(placeholderOption); + + models.forEach(name => { + const option = document.createElement('option'); + option.value = name; + option.textContent = name; + select.appendChild(option); + }); + + select.disabled = models.length === 0; + if (models.includes(previousValue)) { + select.value = previousValue; + } else { + select.value = ''; + } + this.prefillModelPriceInputs(); +} + +export function renderSavedModelPrices() { + const container = document.getElementById('model-price-list'); + if (!container) return; + const entries = Object.entries(this.modelPrices || {}); + if (!entries.length) { + container.innerHTML = `
${i18n.t('usage_stats.model_price_empty')}
`; + return; + } + + const rows = entries.map(([model, price]) => { + const prompt = Number(price?.prompt) || 0; + const completion = Number(price?.completion) || 0; + return ` +
+ ${model} + $${prompt.toFixed(4)} / 1M + $${completion.toFixed(4)} / 1M +
+ `; + }).join(''); + + container.innerHTML = ` +
+
+ ${i18n.t('usage_stats.model_price_model')} + ${i18n.t('usage_stats.model_price_prompt')} + ${i18n.t('usage_stats.model_price_completion')} +
+ ${rows} +
+ `; +} + +export function prefillModelPriceInputs() { + const select = document.getElementById('model-price-model-select'); + const promptInput = document.getElementById('model-price-prompt'); + const completionInput = document.getElementById('model-price-completion'); + if (!select || !promptInput || !completionInput) { + return; + } + const model = (select.value || '').trim(); + const price = this.modelPrices?.[model]; + if (price) { + promptInput.value = Number.isFinite(price.prompt) ? price.prompt : ''; + completionInput.value = Number.isFinite(price.completion) ? price.completion : ''; + } else { + promptInput.value = ''; + completionInput.value = ''; + } +} + +export function normalizePriceValue(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + return 0; + } + return Number(parsed.toFixed(6)); +} + +export function handleModelPriceSubmit() { + this.ensureModelPriceState(); + const select = document.getElementById('model-price-model-select'); + const promptInput = document.getElementById('model-price-prompt'); + const completionInput = document.getElementById('model-price-completion'); + if (!select || !promptInput || !completionInput) { + return; + } + + const model = (select.value || '').trim(); + if (!model) { + this.showNotification(i18n.t('usage_stats.model_price_model_required'), 'warning'); + return; + } + const prompt = this.normalizePriceValue(promptInput.value); + const completion = this.normalizePriceValue(completionInput.value); + + const next = { ...(this.modelPrices || {}) }; + next[model] = { prompt, completion }; + this.persistModelPrices(next); + this.renderSavedModelPrices(); + this.updateCostSummaryAndChart(); + this.showNotification(i18n.t('usage_stats.model_price_saved'), 'success'); +} + +export function handleModelPriceReset() { + this.persistModelPrices({}); + if (typeof localStorage !== 'undefined') { + const key = this.modelPriceStorageKey || DEFAULT_MODEL_PRICE_STORAGE_KEY; + try { + localStorage.removeItem(key); + localStorage.removeItem(LEGACY_MODEL_PRICE_STORAGE_KEY); + } catch (error) { + console.warn('清除模型价格失败:', error); + } + } + this.renderSavedModelPrices(); + this.prefillModelPriceInputs(); + this.updateCostSummaryAndChart(); +} + export function calculateTokenBreakdown(usage = null) { const details = this.collectUsageDetailsFromUsage(usage || this.currentUsageData); if (!details.length) { @@ -572,6 +812,231 @@ export function extractTotalTokens(detail) { }, 0); } +export function formatUsd(value) { + const num = Number(value); + if (!Number.isFinite(num)) { + return '$0.00'; + } + const fixed = num.toFixed(2); + const parts = Number(fixed).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }); + return `$${parts}`; +} + +export function calculateCostData(prices = null, usage = null) { + const priceTable = prices || this.modelPrices || {}; + const usagePayload = usage || this.currentUsageData; + const entries = Object.entries(priceTable || {}); + const result = { totalCost: 0, labels: [], datasets: [] }; + + if (!entries.length || !usagePayload) { + return result; + } + + const details = this.collectUsageDetailsFromUsage(usagePayload); + if (!details.length) { + return result; + } + + const labelSet = new Set(); + const costByModelDay = new Map(); + let totalCost = 0; + + details.forEach(detail => { + const parsedTimestamp = Date.parse(detail.timestamp); + if (Number.isNaN(parsedTimestamp)) { + return; + } + const dayLabel = this.formatDayLabel(new Date(parsedTimestamp)); + if (!dayLabel) { + return; + } + + const modelName = detail.__modelName || 'Unknown'; + const price = priceTable[modelName]; + if (!price) { + return; + } + + const tokens = detail?.tokens || {}; + const promptTokens = Number(tokens.input_tokens) || 0; + const completionTokens = Number(tokens.output_tokens) || 0; + const promptCost = (promptTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.prompt) || 0); + const completionCost = (completionTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.completion) || 0); + const detailCost = promptCost + completionCost; + + if (!Number.isFinite(detailCost) || detailCost <= 0) { + return; + } + + totalCost += detailCost; + labelSet.add(dayLabel); + + if (!costByModelDay.has(modelName)) { + costByModelDay.set(modelName, new Map()); + } + const dayMap = costByModelDay.get(modelName); + dayMap.set(dayLabel, (dayMap.get(dayLabel) || 0) + detailCost); + }); + + const labels = Array.from(labelSet).sort(); + const datasets = []; + costByModelDay.forEach((dayMap, modelName) => { + const series = labels.map(label => Number((dayMap.get(label) || 0).toFixed(4))); + datasets.push({ label: modelName, data: series }); + }); + + return { totalCost, labels, datasets }; +} + +export function setCostChartPlaceholder(messageKey = null) { + const placeholder = document.getElementById('cost-chart-placeholder'); + const canvas = document.getElementById('cost-chart'); + if (!placeholder || !canvas) { + return; + } + if (messageKey) { + placeholder.textContent = i18n.t(messageKey); + placeholder.style.display = 'flex'; + canvas.style.display = 'none'; + } else { + placeholder.style.display = 'none'; + canvas.style.display = 'block'; + } +} + +export function destroyCostChart() { + if (this.costChart) { + this.costChart.destroy(); + this.costChart = null; + } +} + +export function initializeCostChart(costData) { + const canvas = document.getElementById('cost-chart'); + if (!canvas) { + return; + } + this.destroyCostChart(); + + const datasets = (costData.datasets || []).map((dataset, index) => { + const style = this.chartLineStyles[index % this.chartLineStyles.length] || this.chartLineStyles[0]; + return { + ...dataset, + borderColor: style.borderColor, + backgroundColor: style.backgroundColor || 'rgba(59, 130, 246, 0.15)', + fill: true, + tension: 0.35, + pointBackgroundColor: style.borderColor, + pointBorderColor: '#ffffff', + pointBorderWidth: 2, + pointRadius: dataset.data.some(v => v > 0) ? 4 : 3 + }; + }); + + this.costChart = new Chart(canvas, { + type: 'line', + data: { + labels: costData.labels || [], + datasets + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: 'index', + intersect: false + }, + plugins: { + legend: { + display: true, + position: 'top', + align: 'start', + labels: { + usePointStyle: true + } + }, + tooltip: { + callbacks: { + label: context => { + const label = context.dataset.label || ''; + const value = Number(context.parsed.y) || 0; + return `${label}: ${this.formatUsd(value)}`; + } + } + } + }, + scales: { + x: { + title: { + display: true, + text: i18n.t('usage_stats.by_day') + } + }, + y: { + beginAtZero: true, + title: { + display: true, + text: i18n.t('usage_stats.cost_axis_label') + }, + ticks: { + callback: (value) => this.formatUsd(value).replace('$', '') + } + } + }, + elements: { + line: { + borderWidth: 2 + }, + point: { + borderWidth: 2 + } + } + } + }); + this.setCostChartPlaceholder(null); +} + +export function updateCostSummaryAndChart(usage = null) { + this.ensureModelPriceState(); + const totalCostEl = document.getElementById('total-cost'); + const hasPrices = Object.keys(this.modelPrices || {}).length > 0; + const usagePayload = usage || this.currentUsageData; + + if (!hasPrices) { + if (totalCostEl) { + totalCostEl.textContent = '--'; + } + this.destroyCostChart(); + this.setCostChartPlaceholder('usage_stats.cost_need_price'); + return; + } + + if (!usagePayload) { + if (totalCostEl) { + totalCostEl.textContent = '--'; + } + this.destroyCostChart(); + this.setCostChartPlaceholder('usage_stats.cost_need_usage'); + return; + } + + const costData = this.calculateCostData(this.modelPrices, usagePayload); + if (totalCostEl) { + totalCostEl.textContent = this.formatUsd(costData.totalCost); + } + + if (!costData.labels.length || !costData.datasets.length) { + this.destroyCostChart(); + this.setCostChartPlaceholder('usage_stats.cost_no_data'); + return; + } + + this.initializeCostChart(costData); +} + // 初始化图表 export function initializeCharts() { const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active'); @@ -824,6 +1289,16 @@ export const usageModule = { getActiveChartLineSelections, collectUsageDetailsFromUsage, collectUsageDetails, + migrateLegacyModelPrices, + ensureModelPriceState, + loadModelPricesFromStorage, + persistModelPrices, + renderModelPriceOptions, + renderSavedModelPrices, + prefillModelPriceInputs, + normalizePriceValue, + handleModelPriceSubmit, + handleModelPriceReset, calculateTokenBreakdown, calculateRecentPerMinuteRates, createHourlyBucketMeta, @@ -835,6 +1310,12 @@ export const usageModule = { formatPerMinuteValue, formatDayLabel, extractTotalTokens, + formatUsd, + calculateCostData, + setCostChartPlaceholder, + destroyCostChart, + initializeCostChart, + updateCostSummaryAndChart, initializeCharts, initializeRequestsChart, initializeTokensChart, diff --git a/styles.css b/styles.css index dc3fc06..7c148f8 100644 --- a/styles.css +++ b/styles.css @@ -3108,6 +3108,10 @@ input:checked+.slider:before { margin-top: 6px; } +.cost-summary-card .stat-icon { + background: #f59e0b; +} + .charts-container { display: grid; grid-template-columns: 1fr 1fr; @@ -3199,6 +3203,84 @@ input:checked+.slider:before { font-size: 12px; } +.cost-config-card .card-content { + display: flex; + flex-direction: column; + gap: 12px; +} + +.model-price-form { + display: flex; + flex-direction: column; + gap: 12px; +} + +.price-input-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +@media (max-width: 768px) { + .price-input-grid { + grid-template-columns: 1fr; + } +} + +.price-form-actions { + display: flex; + justify-content: flex-end; +} + +.model-price-list { + margin-top: 6px; +} + +.model-price-table { + display: flex; + flex-direction: column; + gap: 8px; +} + +.model-price-header, +.model-price-row { + display: grid; + grid-template-columns: 2fr 1fr 1fr; + gap: 12px; + padding: 10px 12px; + border: 1px solid var(--border-color); + border-radius: 10px; + align-items: center; +} + +.model-price-header { + font-weight: 700; + color: var(--text-primary); + background: var(--bg-secondary); +} + +.model-price-row { + background: var(--bg-primary); + color: var(--text-secondary); +} + +.chart-placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + color: var(--text-tertiary); + background: var(--bg-primary); + border: 1px dashed var(--border-color); + border-radius: 12px; +} + +.cost-chart-card { + grid-column: 1 / -1; +} + .model-item { display: flex; justify-content: space-between;