From 387263a62c32d1cbd25ad09ec93deb76ec7906d0 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Tue, 21 Jul 2026 19:09:11 +0200 Subject: [PATCH 1/6] feat(dashboard): rework usage charts with cache-aware diverging bars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the usage page's by-model, by-user-path, and by-label charts into horizontal diverging bar charts: one row per entity, input-side series growing left from zero and output-side series growing right, with an alternative stacked view (everything piling right) and the existing table view behind a three-way toggle. Charts flow two per row; a chart alone in its row stretches to full width. The charts now also surface cache traffic: - Provider prompt-cache reads join the input side as their own series (tokens and, as a read-time estimate priced from current catalog pricing, costs); the Input series becomes paid input (uncached + cache writes). - Locally cached tokens (GoModel response-cache hits) join each side they belong to: cached input on the left, cached output on the right. They are free, so costs mode shows no local series. Backend: the by-model/user-path/label aggregates gain a shared GroupCacheFields block (prompt-cache split, per-side local-cache tokens, estimated cached input cost), folded per row from raw_data in a second streaming pass shared by the SQLite, PostgreSQL, and MongoDB readers — the same pattern the daily series uses. The admin handlers price each group's cached tokens with the existing PricingResolver. Co-Authored-By: Claude Fable 5 --- cmd/gomodel/docs/docs.go | 57 +++++ docs/openapi.json | 57 +++++ .../admin/dashboard/static/css/dashboard.css | 21 ++ .../dashboard/static/js/modules/charts.js | 234 ++++++++++-------- .../static/js/modules/charts.test.cjs | 181 +++++++++++++- .../js/modules/dashboard-layout.test.cjs | 8 +- .../admin/dashboard/templates/page-usage.html | 38 ++- internal/admin/handler_usage.go | 18 +- internal/usage/group_cache_stats.go | 220 ++++++++++++++++ internal/usage/group_cache_stats_test.go | 202 +++++++++++++++ internal/usage/reader.go | 37 +++ internal/usage/reader_mongodb.go | 81 +++++- internal/usage/reader_postgresql.go | 39 +++ internal/usage/reader_sqlite.go | 39 +++ 14 files changed, 1110 insertions(+), 122 deletions(-) create mode 100644 internal/usage/group_cache_stats.go create mode 100644 internal/usage/group_cache_stats_test.go diff --git a/cmd/gomodel/docs/docs.go b/cmd/gomodel/docs/docs.go index 9ba7cfc55..89295320f 100644 --- a/cmd/gomodel/docs/docs.go +++ b/cmd/gomodel/docs/docs.go @@ -10100,6 +10100,16 @@ const docTemplate = `{ "usage.LabelUsage": { "type": "object", "properties": { + "cache_write_input_tokens": { + "type": "integer" + }, + "cached_input_cost": { + "type": "number", + "x-nullable": true + }, + "cached_input_tokens": { + "type": "integer" + }, "input_cost": { "type": "number", "x-nullable": true @@ -10110,6 +10120,12 @@ const docTemplate = `{ "label": { "type": "string" }, + "local_cached_input_tokens": { + "type": "integer" + }, + "local_cached_output_tokens": { + "type": "integer" + }, "output_cost": { "type": "number", "x-nullable": true @@ -10126,18 +10142,37 @@ const docTemplate = `{ }, "total_tokens": { "type": "integer" + }, + "uncached_input_tokens": { + "type": "integer" } } }, "usage.ModelUsage": { "type": "object", "properties": { + "cache_write_input_tokens": { + "type": "integer" + }, + "cached_input_cost": { + "type": "number", + "x-nullable": true + }, + "cached_input_tokens": { + "type": "integer" + }, "input_cost": { "type": "number" }, "input_tokens": { "type": "integer" }, + "local_cached_input_tokens": { + "type": "integer" + }, + "local_cached_output_tokens": { + "type": "integer" + }, "model": { "type": "string" }, @@ -10155,6 +10190,9 @@ const docTemplate = `{ }, "total_cost": { "type": "number" + }, + "uncached_input_tokens": { + "type": "integer" } } }, @@ -10405,6 +10443,16 @@ const docTemplate = `{ "usage.UserPathUsage": { "type": "object", "properties": { + "cache_write_input_tokens": { + "type": "integer" + }, + "cached_input_cost": { + "type": "number", + "x-nullable": true + }, + "cached_input_tokens": { + "type": "integer" + }, "input_cost": { "type": "number", "x-nullable": true @@ -10412,6 +10460,12 @@ const docTemplate = `{ "input_tokens": { "type": "integer" }, + "local_cached_input_tokens": { + "type": "integer" + }, + "local_cached_output_tokens": { + "type": "integer" + }, "output_cost": { "type": "number", "x-nullable": true @@ -10426,6 +10480,9 @@ const docTemplate = `{ "total_tokens": { "type": "integer" }, + "uncached_input_tokens": { + "type": "integer" + }, "user_path": { "type": "string" } diff --git a/docs/openapi.json b/docs/openapi.json index d6aac7c6b..7e4130fea 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -13165,6 +13165,16 @@ "usage.LabelUsage": { "type": "object", "properties": { + "cache_write_input_tokens": { + "type": "integer" + }, + "cached_input_cost": { + "type": "number", + "nullable": true + }, + "cached_input_tokens": { + "type": "integer" + }, "input_cost": { "type": "number", "nullable": true @@ -13175,6 +13185,12 @@ "label": { "type": "string" }, + "local_cached_input_tokens": { + "type": "integer" + }, + "local_cached_output_tokens": { + "type": "integer" + }, "output_cost": { "type": "number", "nullable": true @@ -13191,18 +13207,37 @@ }, "total_tokens": { "type": "integer" + }, + "uncached_input_tokens": { + "type": "integer" } } }, "usage.ModelUsage": { "type": "object", "properties": { + "cache_write_input_tokens": { + "type": "integer" + }, + "cached_input_cost": { + "type": "number", + "nullable": true + }, + "cached_input_tokens": { + "type": "integer" + }, "input_cost": { "type": "number" }, "input_tokens": { "type": "integer" }, + "local_cached_input_tokens": { + "type": "integer" + }, + "local_cached_output_tokens": { + "type": "integer" + }, "model": { "type": "string" }, @@ -13220,6 +13255,9 @@ }, "total_cost": { "type": "number" + }, + "uncached_input_tokens": { + "type": "integer" } } }, @@ -13470,6 +13508,16 @@ "usage.UserPathUsage": { "type": "object", "properties": { + "cache_write_input_tokens": { + "type": "integer" + }, + "cached_input_cost": { + "type": "number", + "nullable": true + }, + "cached_input_tokens": { + "type": "integer" + }, "input_cost": { "type": "number", "nullable": true @@ -13477,6 +13525,12 @@ "input_tokens": { "type": "integer" }, + "local_cached_input_tokens": { + "type": "integer" + }, + "local_cached_output_tokens": { + "type": "integer" + }, "output_cost": { "type": "number", "nullable": true @@ -13491,6 +13545,9 @@ "total_tokens": { "type": "integer" }, + "uncached_input_tokens": { + "type": "integer" + }, "user_path": { "type": "string" } diff --git a/internal/admin/dashboard/static/css/dashboard.css b/internal/admin/dashboard/static/css/dashboard.css index 1c04892d5..8d5fbd48c 100644 --- a/internal/admin/dashboard/static/css/dashboard.css +++ b/internal/admin/dashboard/static/css/dashboard.css @@ -2999,6 +2999,27 @@ textarea:focus { color: #fff; } +/* Usage charts flow two per row; a section alone in its row (odd count, + or neighbors hidden by x-show) grows to fill the full width. */ +.usage-charts-grid { + display: flex; + flex-wrap: wrap; + gap: 24px; + margin-bottom: 24px; +} + +.usage-charts-grid .model-chart-section { + flex: 1 1 calc(50% - 24px); + min-width: 420px; + margin-bottom: 0; +} + +@media (max-width: 520px) { + .usage-charts-grid .model-chart-section { + min-width: 0; + } +} + /* Usage by Model Chart Section */ .model-chart-section { background: var(--bg-surface); diff --git a/internal/admin/dashboard/static/js/modules/charts.js b/internal/admin/dashboard/static/js/modules/charts.js index 5e28c5113..eac6ba345 100644 --- a/internal/admin/dashboard/static/js/modules/charts.js +++ b/internal/admin/dashboard/static/js/modules/charts.js @@ -104,63 +104,97 @@ }; }, - _barChartConfig(colors, labels, values, palette) { + // Horizontal input/output bars: one row per entity (model, user + // path, label). In the default diverging view the input-side + // series (paid input, prompt-cached reads) grow left from zero + // and the output-side series (output, locally-cached tokens) grow + // right; in the stacked view everything piles rightward from zero + // as segments of one bar. Series reuse the overview chart's + // token palette (paid browns, translucent cache blues) so they + // read consistently across the dashboard; the legend (and, when + // diverging, the left/right split) carries identity, not color + // alone. Cache series appear only when they have data. + _horizontalUsageChartConfig(colors, labels, series, stacked) { + const resolve = (expr) => (typeof this._resolveLiveTokenColor === 'function' ? this._resolveLiveTokenColor(expr) : expr); + const costs = this.usageMode === 'costs'; + const fmtShort = (v) => costs ? '$' + Math.abs(v).toFixed(2) : this.formatTokensShort(Math.abs(v)); + const fmtExact = (v) => costs ? '$' + Math.abs(v).toFixed(4) : Math.abs(v).toLocaleString(); + const inputSide = (values) => values.map((v) => stacked ? Math.abs(v) : -Math.abs(v)); + const bar = (label, data, color) => ({ + label: label, + data: data, + backgroundColor: color, + borderColor: 'transparent', + borderWidth: 0, + borderRadius: 4, + maxBarThickness: 22 + }); + const hasData = (values) => (values || []).some((v) => Math.abs(v) > 0); + const datasets = [ + bar(costs ? 'Input Cost' : 'Input Tokens', inputSide(series.inputs), resolve('var(--token-input)')), + bar(costs ? 'Output Cost' : 'Output Tokens', series.outputs, resolve('var(--token-output)')) + ]; + if (hasData(series.prompts)) { + datasets.push(bar(costs ? 'Prompt Cached Cost' : 'Prompt Cached', inputSide(series.prompts), resolve('var(--token-prompt)'))); + } + // Local cache hits carry both sides, so each joins its own + // half of the axis (they pile together in the stacked view). + if (!costs && hasData(series.localIns)) { + datasets.push(bar('Locally Cached (Input)', inputSide(series.localIns), resolve('var(--token-local)'))); + } + if (!costs && hasData(series.localOuts)) { + datasets.push(bar('Locally Cached (Output)', series.localOuts, resolve('var(--token-local)'))); + } return { type: 'bar', data: { labels: labels, - datasets: [{ - data: values, - backgroundColor: labels.map((_, i) => palette[i % palette.length]), - borderColor: 'transparent', - borderWidth: 0, - borderRadius: 4 - }] + datasets: datasets }, options: { + indexAxis: 'y', responsive: true, maintainAspectRatio: false, animation: { duration: 0 }, layout: { padding: { top: 8 } }, scales: { x: { - grid: { display: false }, + stacked: true, + beginAtZero: true, + // Emphasize the zero divider between the diverging halves. + grid: stacked + ? { color: colors.grid } + : { color: (ctx) => (ctx.tick && ctx.tick.value === 0 ? colors.text : colors.grid) }, + border: { display: false }, ticks: { color: colors.text, - font: { size: 11, family: "'SF Mono', Menlo, Consolas, monospace" }, - maxRotation: 45, - minRotation: 0 + font: this._chartTickFont(), + callback: (v) => fmtShort(v) } }, y: { - grid: { color: colors.grid }, + stacked: true, + grid: { display: false }, border: { display: false }, ticks: { color: colors.text, - font: { size: 11, family: "'SF Mono', Menlo, Consolas, monospace" }, - callback: (v) => { - if (this.usageMode === 'costs') return '$' + v.toFixed(2); - return this.formatTokensShort(v); - } + font: this._chartTickFont(), + autoSkip: false } } }, plugins: { - legend: { display: false }, - tooltip: { - backgroundColor: colors.tooltipBg, - borderColor: colors.tooltipBorder, - borderWidth: 1, - titleColor: colors.tooltipText, - bodyColor: colors.tooltipText, - callbacks: { - label: (c) => { - const val = c.parsed.y; - if (this.usageMode === 'costs') return '$' + val.toFixed(4); - return this.formatTokensShort(val); - } + legend: { + labels: { color: colors.text, font: { size: 12 } } + }, + tooltip: this._chartTooltip(colors, { + label: (c) => c.dataset.label + ': ' + fmtExact(c.parsed.x), + footer: (items) => { + let total = 0; + items.forEach((it) => { total += Math.abs(Number(it.parsed.x)) || 0; }); + return 'Total: ' + fmtExact(total); } - } + }) } } }; @@ -356,27 +390,6 @@ return ((row && row.input_tokens) || 0) + ((row && row.output_tokens) || 0); }, - _barDataFrom(items, labelFor) { - const sorted = this._usageRowsBySelectedValue(items); - - const top = sorted.slice(0, 10); - const rest = sorted.slice(10); - - const labels = top.map(labelFor); - const values = top.map((row) => this._usageAggregateValue(row)); - - if (rest.length > 0) { - labels.push('Other'); - let otherVal = 0; - rest.forEach((row) => { - otherVal += this._usageAggregateValue(row); - }); - values.push(otherVal); - } - - return { labels, values }; - }, - _usageRowsBySelectedValue(items) { return [...(items || [])].sort((a, b) => { if (this.usageMode === 'costs') { @@ -410,18 +423,72 @@ return onlyPath !== '' && onlyPath !== '/'; }, - _barData() { - return this._barDataFrom(this.modelUsage, (m) => typeof this.qualifiedModelDisplay === 'function' - ? this.qualifiedModelDisplay(m) - : m.model); + // Per-row series split for the diverging charts. Rows keep the + // selected-metric ordering; past the top 10 they fold into a + // single "Other" row. + // + // Tokens mode mirrors the overview chart's accounting: the Input + // series is paid input (uncached + cache writes; full input when + // the split is absent), prompt-cache reads and locally-cached + // tokens are their own series. Costs mode splits the estimated + // prompt-cached read cost out of the input cost; local cache hits + // cost nothing, so they have no cost series. + _divergingDataFrom(items, labelFor) { + const sorted = this._usageRowsBySelectedValue(items); + const costs = this.usageMode === 'costs'; + const num = (v) => Number(v) || 0; + const inputOf = (row) => { + if (costs) return Math.max(0, num(row.input_cost) - num(row.cached_input_cost)); + const split = num(row.uncached_input_tokens) + num(row.cached_input_tokens) + num(row.cache_write_input_tokens); + return split > 0 ? num(row.uncached_input_tokens) + num(row.cache_write_input_tokens) : num(row.input_tokens); + }; + const outputOf = (row) => num(costs ? row.output_cost : row.output_tokens); + const promptOf = (row) => num(costs ? row.cached_input_cost : row.cached_input_tokens); + const localInputOf = (row) => costs ? 0 : num(row.local_cached_input_tokens); + const localOutputOf = (row) => costs ? 0 : num(row.local_cached_output_tokens); + + const top = sorted.slice(0, 10); + const rest = sorted.slice(10); + + const labels = top.map(labelFor); + const inputs = top.map(inputOf); + const outputs = top.map(outputOf); + const prompts = top.map(promptOf); + const localIns = top.map(localInputOf); + const localOuts = top.map(localOutputOf); + + if (rest.length > 0) { + labels.push('Other'); + const sum = (of) => rest.reduce((total, row) => total + of(row), 0); + inputs.push(sum(inputOf)); + outputs.push(sum(outputOf)); + prompts.push(sum(promptOf)); + localIns.push(sum(localInputOf)); + localOuts.push(sum(localOutputOf)); + } + + return { labels, inputs, outputs, prompts, localIns, localOuts }; }, - _userPathBarData() { - return this._barDataFrom(this.userPathUsage || [], (u) => u.user_path || '/'); + // A view value that renders on the canvas (as opposed to the table). + _isChartView(view) { + return (view || 'chart') === 'chart' || view === 'stacked'; }, - _labelBarData() { - return this._barDataFrom(this.labelUsage || [], (l) => l.label); + // Shared by the three usage charts: build the diverging or stacked + // config, grow the wrapper with the row count so horizontal bars + // stay readable instead of squeezing into a fixed height, and + // mount the chart. + _createUsageBarChart(canvas, items, labelFor, view) { + const series = this._divergingDataFrom(items, labelFor); + const config = this._horizontalUsageChartConfig(this.chartColors(), series.labels, series, view === 'stacked'); + + const wrap = canvas.parentElement; + if (wrap) { + wrap.style.height = Math.max(200, series.labels.length * 32 + 72) + 'px'; + } + + return new Chart(canvas, config); }, // Deterministic label -> palette color so a label keeps one color @@ -440,22 +507,6 @@ return { '--label-color': this.labelColor(label) }; }, - // The synthetic "Other" bar gets a neutral tone instead of a - // hashed identity color. - _labelBarPalette(labels) { - return labels.map((label) => label === 'Other' ? '#a8a29a' : this.labelColor(label)); - }, - - barLegendItems() { - const { labels, values } = this._barData(); - const colors = this._barColors(); - return labels.map((label, i) => ({ - label, - color: colors[i % colors.length], - value: this.usageMode === 'costs' ? '$' + values[i].toFixed(4) : this.formatTokensShort(values[i]) - })); - }, - toggleUsageChartView(target, view) { if (target === 'model') { this.modelUsageView = view; @@ -478,7 +529,7 @@ renderBarChart(retries) { if (retries === undefined) retries = 3; this.$nextTick(() => { - if (this.modelUsage.length === 0 || this.page !== 'usage' || (this.modelUsageView || 'chart') !== 'chart') { + if (this.modelUsage.length === 0 || this.page !== 'usage' || !this._isChartView(this.modelUsageView)) { if (this.usageBarChart) { this.usageBarChart.destroy(); this.usageBarChart = null; @@ -494,24 +545,21 @@ return; } - const colors = this.chartColors(); - const { labels, values } = this._barData(); - const palette = this._barColors(); - const config = this._barChartConfig(colors, labels, values, palette); - if (this.usageBarChart) { this.usageBarChart.destroy(); this.usageBarChart = null; } - this.usageBarChart = new Chart(canvas, config); + this.usageBarChart = this._createUsageBarChart(canvas, this.modelUsage, (m) => typeof this.qualifiedModelDisplay === 'function' + ? this.qualifiedModelDisplay(m) + : m.model, this.modelUsageView); }); }, renderUserPathChart(retries) { if (retries === undefined) retries = 3; this.$nextTick(() => { - if (!this.userPathUsageChartVisible() || this.page !== 'usage' || (this.userPathUsageView || 'chart') !== 'chart') { + if (!this.userPathUsageChartVisible() || this.page !== 'usage' || !this._isChartView(this.userPathUsageView)) { if (this.usageUserPathChart) { this.usageUserPathChart.destroy(); this.usageUserPathChart = null; @@ -527,24 +575,19 @@ return; } - const colors = this.chartColors(); - const { labels, values } = this._userPathBarData(); - const palette = this._barColors(); - const config = this._barChartConfig(colors, labels, values, palette); - if (this.usageUserPathChart) { this.usageUserPathChart.destroy(); this.usageUserPathChart = null; } - this.usageUserPathChart = new Chart(canvas, config); + this.usageUserPathChart = this._createUsageBarChart(canvas, this.userPathUsage || [], (u) => u.user_path || '/', this.userPathUsageView); }); }, renderLabelChart(retries) { if (retries === undefined) retries = 3; this.$nextTick(() => { - if ((this.labelUsage || []).length === 0 || this.page !== 'usage' || (this.labelUsageView || 'chart') !== 'chart') { + if ((this.labelUsage || []).length === 0 || this.page !== 'usage' || !this._isChartView(this.labelUsageView)) { if (this.usageLabelChart) { this.usageLabelChart.destroy(); this.usageLabelChart = null; @@ -560,17 +603,12 @@ return; } - const colors = this.chartColors(); - const { labels, values } = this._labelBarData(); - const palette = this._labelBarPalette(labels); - const config = this._barChartConfig(colors, labels, values, palette); - if (this.usageLabelChart) { this.usageLabelChart.destroy(); this.usageLabelChart = null; } - this.usageLabelChart = new Chart(canvas, config); + this.usageLabelChart = this._createUsageBarChart(canvas, this.labelUsage || [], (l) => l.label, this.labelUsageView); }); } }; diff --git a/internal/admin/dashboard/static/js/modules/charts.test.cjs b/internal/admin/dashboard/static/js/modules/charts.test.cjs index 0179d7ad1..2aa6ffbff 100644 --- a/internal/admin/dashboard/static/js/modules/charts.test.cjs +++ b/internal/admin/dashboard/static/js/modules/charts.test.cjs @@ -116,6 +116,9 @@ test('renderBarChart recreates the usage bar chart instance on refresh', () => { assert.equal(FakeChart.instances.length, 1); const firstChart = module.usageBarChart; + assert.equal(JSON.stringify(firstChart.data.labels), JSON.stringify(['gpt-5', 'gpt-4o'])); + assert.equal(JSON.stringify(firstChart.data.datasets[0].data), JSON.stringify([-11, -5])); + assert.equal(JSON.stringify(firstChart.data.datasets[1].data), JSON.stringify([13, 7])); module.modelUsage = [ { model: 'gpt-5', input_tokens: 21, output_tokens: 34, total_cost: 0.03 } @@ -126,10 +129,168 @@ test('renderBarChart recreates the usage bar chart instance on refresh', () => { assert.equal(FakeChart.instances.length, 2); assert.equal(firstChart.destroyCalls, 1); assert.equal(JSON.stringify(module.usageBarChart.data.labels), JSON.stringify(['gpt-5'])); - assert.equal(JSON.stringify(module.usageBarChart.data.datasets[0].data), JSON.stringify([55])); + assert.equal(JSON.stringify(module.usageBarChart.data.datasets[0].data), JSON.stringify([-21])); + assert.equal(JSON.stringify(module.usageBarChart.data.datasets[1].data), JSON.stringify([34])); + assert.equal(module.usageBarChart.options.indexAxis, 'y'); assert.equal(JSON.stringify(firstChart.updateCalls), JSON.stringify([])); }); +test('renderBarChart splits input and output costs in costs mode', () => { + FakeChart.instances = []; + const { module } = createChartsContext(); + module.page = 'usage'; + module.usageMode = 'costs'; + module.modelUsage = [ + { model: 'gpt-4o', input_tokens: 5, output_tokens: 7, input_cost: 0.01, output_cost: 0.04, total_cost: 0.05 }, + { model: 'gpt-5', input_tokens: 11, output_tokens: 13, input_cost: 0.02, output_cost: 0.06, total_cost: 0.08 } + ]; + + module.renderBarChart(); + + const chart = module.usageBarChart; + assert.equal(JSON.stringify(chart.data.labels), JSON.stringify(['gpt-5', 'gpt-4o'])); + assert.equal(JSON.stringify(chart.data.datasets[0].data), JSON.stringify([-0.02, -0.01])); + assert.equal(JSON.stringify(chart.data.datasets[1].data), JSON.stringify([0.06, 0.04])); + assert.equal(chart.data.datasets[0].label, 'Input Cost'); + assert.equal(chart.data.datasets[1].label, 'Output Cost'); +}); + +test('renderBarChart stacked view piles input and output rightward as positive values', () => { + FakeChart.instances = []; + const { module } = createChartsContext(); + module.page = 'usage'; + module.usageMode = 'tokens'; + module.modelUsageView = 'stacked'; + module.modelUsage = [ + { model: 'gpt-4o', input_tokens: 5, output_tokens: 7, total_cost: 0.01 }, + { model: 'gpt-5', input_tokens: 11, output_tokens: 13, total_cost: 0.02 } + ]; + + module.renderBarChart(); + + const chart = module.usageBarChart; + assert.notEqual(chart, null); + assert.equal(JSON.stringify(chart.data.labels), JSON.stringify(['gpt-5', 'gpt-4o'])); + assert.equal(JSON.stringify(chart.data.datasets[0].data), JSON.stringify([11, 5])); + assert.equal(JSON.stringify(chart.data.datasets[1].data), JSON.stringify([13, 7])); + assert.equal(chart.options.indexAxis, 'y'); + assert.equal(chart.options.scales.x.stacked, true); +}); + +test('toggleUsageChartView switches between diverging and stacked chart views', () => { + FakeChart.instances = []; + const { module } = createChartsContext(); + module.page = 'usage'; + module.usageMode = 'tokens'; + module.modelUsageView = 'chart'; + module.modelUsage = [ + { model: 'gpt-5', input_tokens: 10, output_tokens: 20, total_cost: 0.01 } + ]; + + module.renderBarChart(); + const divergingChart = module.usageBarChart; + assert.equal(JSON.stringify(divergingChart.data.datasets[0].data), JSON.stringify([-10])); + + module.toggleUsageChartView('model', 'stacked'); + + assert.equal(module.modelUsageView, 'stacked'); + assert.equal(divergingChart.destroyCalls, 1); + assert.equal(JSON.stringify(module.usageBarChart.data.datasets[0].data), JSON.stringify([10])); +}); + +test('renderBarChart adds cache series and splits paid input when cache data is present', () => { + FakeChart.instances = []; + const { module } = createChartsContext(); + module.page = 'usage'; + module.usageMode = 'tokens'; + module.modelUsage = [ + { + model: 'gpt-5', input_tokens: 100, output_tokens: 20, + uncached_input_tokens: 30, cached_input_tokens: 60, cache_write_input_tokens: 10, + local_cached_input_tokens: 100, local_cached_output_tokens: 20, total_cost: 0.02 + } + ]; + + module.renderBarChart(); + + const chart = module.usageBarChart; + assert.equal(chart.data.datasets.length, 5); + // Paid input = uncached + cache writes, on the left. + assert.equal(chart.data.datasets[0].label, 'Input Tokens'); + assert.equal(JSON.stringify(chart.data.datasets[0].data), JSON.stringify([-40])); + assert.equal(chart.data.datasets[1].label, 'Output Tokens'); + assert.equal(JSON.stringify(chart.data.datasets[1].data), JSON.stringify([20])); + // Prompt-cache reads join the input side; local cache splits per side. + assert.equal(chart.data.datasets[2].label, 'Prompt Cached'); + assert.equal(JSON.stringify(chart.data.datasets[2].data), JSON.stringify([-60])); + assert.equal(chart.data.datasets[3].label, 'Locally Cached (Input)'); + assert.equal(JSON.stringify(chart.data.datasets[3].data), JSON.stringify([-100])); + assert.equal(chart.data.datasets[4].label, 'Locally Cached (Output)'); + assert.equal(JSON.stringify(chart.data.datasets[4].data), JSON.stringify([20])); +}); + +test('renderBarChart keeps two series when rows carry no cache data', () => { + FakeChart.instances = []; + const { module } = createChartsContext(); + module.page = 'usage'; + module.usageMode = 'tokens'; + module.modelUsage = [ + { model: 'gpt-5', input_tokens: 100, output_tokens: 20, total_cost: 0.02 } + ]; + + module.renderBarChart(); + + assert.equal(module.usageBarChart.data.datasets.length, 2); + assert.equal(JSON.stringify(module.usageBarChart.data.datasets[0].data), JSON.stringify([-100])); +}); + +test('renderBarChart costs mode splits estimated cached cost out of input cost', () => { + FakeChart.instances = []; + const { module } = createChartsContext(); + module.page = 'usage'; + module.usageMode = 'costs'; + module.modelUsage = [ + { + model: 'gpt-5', input_tokens: 100, output_tokens: 20, + cached_input_tokens: 60, local_cached_input_tokens: 100, local_cached_output_tokens: 20, + input_cost: 0.5, output_cost: 0.3, total_cost: 0.8, cached_input_cost: 0.125 + } + ]; + + module.renderBarChart(); + + const chart = module.usageBarChart; + assert.equal(chart.data.datasets.length, 3); + assert.equal(chart.data.datasets[0].label, 'Input Cost'); + assert.equal(JSON.stringify(chart.data.datasets[0].data), JSON.stringify([-0.375])); + assert.equal(chart.data.datasets[2].label, 'Prompt Cached Cost'); + assert.equal(JSON.stringify(chart.data.datasets[2].data), JSON.stringify([-0.125])); + // Local cache hits cost nothing: no cost-mode series for them. + assert.ok(!chart.data.datasets.some((d) => d.label.startsWith('Locally Cached'))); +}); + +test('renderBarChart folds models past the top 10 into a combined Other row', () => { + FakeChart.instances = []; + const { module } = createChartsContext(); + module.page = 'usage'; + module.usageMode = 'tokens'; + module.modelUsage = Array.from({ length: 12 }, (_, i) => ({ + model: 'model-' + i, + input_tokens: 100 - i, + output_tokens: 200 - i, + total_cost: 0.01 + })); + + module.renderBarChart(); + + const chart = module.usageBarChart; + assert.equal(chart.data.labels.length, 11); + assert.equal(chart.data.labels[10], 'Other'); + // model-10 and model-11 fold together: inputs 90+89, outputs 190+189. + assert.equal(chart.data.datasets[0].data[10], -179); + assert.equal(chart.data.datasets[1].data[10], 379); +}); + test('renderBarChart prefers provider_name in model labels when available', () => { FakeChart.instances = []; const { module } = createChartsContext(); @@ -162,11 +323,12 @@ test('renderUserPathChart recreates the user path chart and uses usage mode valu assert.equal(FakeChart.instances.length, 1); const firstChart = module.usageUserPathChart; assert.equal(JSON.stringify(firstChart.data.labels), JSON.stringify(['/team/beta', '/team/alpha'])); - assert.equal(JSON.stringify(firstChart.data.datasets[0].data), JSON.stringify([24, 12])); + assert.equal(JSON.stringify(firstChart.data.datasets[0].data), JSON.stringify([-11, -5])); + assert.equal(JSON.stringify(firstChart.data.datasets[1].data), JSON.stringify([13, 7])); module.usageMode = 'costs'; module.userPathUsage = [ - { user_path: '/team/alpha', input_tokens: 21, output_tokens: 34, total_tokens: 55, total_cost: 0.03 } + { user_path: '/team/alpha', input_tokens: 21, output_tokens: 34, total_tokens: 55, input_cost: 0.01, output_cost: 0.02, total_cost: 0.03 } ]; module.renderUserPathChart(); @@ -174,7 +336,8 @@ test('renderUserPathChart recreates the user path chart and uses usage mode valu assert.equal(FakeChart.instances.length, 2); assert.equal(firstChart.destroyCalls, 1); assert.equal(JSON.stringify(module.usageUserPathChart.data.labels), JSON.stringify(['/team/alpha'])); - assert.equal(JSON.stringify(module.usageUserPathChart.data.datasets[0].data), JSON.stringify([0.03])); + assert.equal(JSON.stringify(module.usageUserPathChart.data.datasets[0].data), JSON.stringify([-0.01])); + assert.equal(JSON.stringify(module.usageUserPathChart.data.datasets[1].data), JSON.stringify([0.02])); }); test('user path usage chart hides when the only path is root', () => { @@ -272,7 +435,7 @@ test('labelColor is deterministic and drawn from the shared palette', () => { assert.equal(module.labelChipStyle('x')['--label-color'], module.labelColor('x')); }); -test('renderLabelChart orders bars by selected metric and colors them per label', () => { +test('renderLabelChart orders rows by selected metric and splits input and output', () => { FakeChart.instances = []; const { module } = createChartsContext(); module.page = 'usage'; @@ -288,11 +451,9 @@ test('renderLabelChart orders bars by selected metric and colors them per label' assert.equal(FakeChart.instances.length, 1); const chart = module.usageLabelChart; assert.equal(JSON.stringify(chart.data.labels), JSON.stringify(['prod', 'alpha'])); - assert.equal(JSON.stringify(chart.data.datasets[0].data), JSON.stringify([24, 12])); - assert.equal( - JSON.stringify(chart.data.datasets[0].backgroundColor), - JSON.stringify([module.labelColor('prod'), module.labelColor('alpha')]) - ); + assert.equal(JSON.stringify(chart.data.datasets[0].data), JSON.stringify([-11, -5])); + assert.equal(JSON.stringify(chart.data.datasets[1].data), JSON.stringify([13, 7])); + assert.equal(chart.options.indexAxis, 'y'); module.labelUsage = []; module.renderLabelChart(); diff --git a/internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs b/internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs index 2ecc2db63..ace8460fe 100644 --- a/internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs +++ b/internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs @@ -1499,11 +1499,11 @@ test("usage charts can switch between chart and table views", () => { assert.match( indexTemplate, - /class="chart-view-toggle"[\s\S]*@click="toggleUsageChartView\('model', 'chart'\)"[\s\S]*@click="toggleUsageChartView\('model', 'table'\)"/, + /class="chart-view-toggle"[\s\S]*@click="toggleUsageChartView\('model', 'chart'\)"[\s\S]*@click="toggleUsageChartView\('model', 'stacked'\)"[\s\S]*@click="toggleUsageChartView\('model', 'table'\)"/, ); assert.match( indexTemplate, - /
[\s\S]*<\/canvas>/, + /
[\s\S]*<\/canvas>/, ); assert.match( indexTemplate, @@ -1511,7 +1511,7 @@ test("usage charts can switch between chart and table views", () => { ); assert.match( indexTemplate, - /class="chart-view-toggle"[\s\S]*@click="toggleUsageChartView\('userPath', 'chart'\)"[\s\S]*@click="toggleUsageChartView\('userPath', 'table'\)"/, + /class="chart-view-toggle"[\s\S]*@click="toggleUsageChartView\('userPath', 'chart'\)"[\s\S]*@click="toggleUsageChartView\('userPath', 'stacked'\)"[\s\S]*@click="toggleUsageChartView\('userPath', 'table'\)"/, ); assert.match( indexTemplate, @@ -1519,7 +1519,7 @@ test("usage charts can switch between chart and table views", () => { ); assert.match( indexTemplate, - /
[\s\S]*<\/canvas>/, + /
[\s\S]*<\/canvas>/, ); assert.match( indexTemplate, diff --git a/internal/admin/dashboard/templates/page-usage.html b/internal/admin/dashboard/templates/page-usage.html index 134662b69..bf2c6fbd9 100644 --- a/internal/admin/dashboard/templates/page-usage.html +++ b/internal/admin/dashboard/templates/page-usage.html @@ -72,20 +72,26 @@

Usage Analytics

+ +
+

+
-
+