From 05127853429279e8dd898ab86799b9840cd18c30 Mon Sep 17 00:00:00 2001 From: Lee <7932644+strahe@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:47:38 +0800 Subject: [PATCH 1/2] feat(usage): show Grok-reported session costs --- .../profiledeck/internal/usage/models.ts | 12 ++ .../src/features/usage/UsageModelTable.svelte | 27 +++- .../src/features/usage/UsagePage.svelte | 53 ++++++-- .../src/features/usage/UsageTrendChart.svelte | 63 ++++++++-- desktop/frontend/src/lib/i18n.ts | 44 ++++++- desktop/frontend/tests/usage-page.test.ts | 17 +++ docs/grok-build/usage-cost.md | 16 ++- internal/bootstrap/service_test.go | 30 +++-- internal/cli/command_test.go | 18 ++- internal/cli/usage.go | 88 +++++++++---- internal/store/integrity.go | 8 +- .../202609150001_grok_build_reported_cost.go | 46 +++++++ internal/store/migrations/migrations_test.go | 74 +++++++++++ internal/store/schema_contract.go | 14 +++ internal/store/store_test.go | 5 + internal/store/usage.go | 116 +++++++++++++++--- internal/store/usage_facts_test.go | 47 +++++++ internal/store/usage_grok_build.go | 18 ++- internal/store/usage_report.go | 76 ++++++++---- internal/store/usage_report_test.go | 14 ++- internal/usage/codex_sync.go | 24 ++-- internal/usage/grok_build.go | 48 ++++++-- internal/usage/grok_build_sync.go | 19 ++- internal/usage/grok_build_sync_test.go | 49 +++++++- internal/usage/grok_build_test.go | 48 +++++++- internal/usage/report_service.go | 97 ++++++++++----- internal/usage/report_service_test.go | 24 ++++ internal/usage/service.go | 65 ++++++---- internal/usage/usage.go | 56 +++++---- 29 files changed, 997 insertions(+), 219 deletions(-) create mode 100644 internal/store/migrations/202609150001_grok_build_reported_cost.go diff --git a/desktop/frontend/bindings/github.com/strahe/profiledeck/internal/usage/models.ts b/desktop/frontend/bindings/github.com/strahe/profiledeck/internal/usage/models.ts index 25ef46e..1a747c7 100644 --- a/desktop/frontend/bindings/github.com/strahe/profiledeck/internal/usage/models.ts +++ b/desktop/frontend/bindings/github.com/strahe/profiledeck/internal/usage/models.ts @@ -17,6 +17,13 @@ export interface UsageAggregateSummary { "unknown_cost_event_count": number; "estimated_token_count": number; "pricing_coverage": number; + "known_reported_cost_usd": string; + "reported_cost_status": string; + "reported_cost_event_count": number; + "partial_reported_cost_event_count": number; + "unknown_reported_cost_event_count": number; + "reported_cost_token_count": number; + "reported_cost_coverage": number; "undated_event_count": number; } @@ -88,6 +95,11 @@ export interface UsageSummaryResult { "cost_status": string; "unknown_cost_event_count": number; "estimated_cost_event_count": number; + "reported_cost_usd": string | null; + "reported_cost_status": string; + "unknown_reported_cost_event_count": number; + "reported_cost_event_count": number; + "partial_reported_cost_event_count": number; } export interface UsageTrendPoint { diff --git a/desktop/frontend/src/features/usage/UsageModelTable.svelte b/desktop/frontend/src/features/usage/UsageModelTable.svelte index 9cdbe57..774de5b 100644 --- a/desktop/frontend/src/features/usage/UsageModelTable.svelte +++ b/desktop/frontend/src/features/usage/UsageModelTable.svelte @@ -5,8 +5,9 @@ import { Badge } from "$lib/components/ui/badge"; import * as Table from "$lib/components/ui/table"; import { currentDesktopLocale } from "$lib/i18n"; + import { cn } from "$lib/utils"; - let { models }: { models: UsageModelSummary[] } = $props(); + let { models, showReportedCost = false }: { models: UsageModelSummary[]; showReportedCost?: boolean } = $props(); function formatInteger(value: number): string { return new Intl.NumberFormat(currentDesktopLocale()).format(value); @@ -22,14 +23,26 @@ maximumFractionDigits: 6, }).format(parsed); } + + function formatReportedCurrency(value: string): string { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return value; + return new Intl.NumberFormat(currentDesktopLocale(), { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 10, + }).format(parsed); + }
- + {$_("usage.models.model")} - {$_("usage.models.knownCost")} + {$_("usage.models.apiEquivalentCost")} + {#if showReportedCost}{$_("usage.models.reportedCost")}{/if} {$_("usage.models.tokens")} {$_("usage.models.sessions")} @@ -44,6 +57,14 @@ {#if model.summary.cost_status !== "estimated"}{model.summary.pricing_coverage > 0 ? $_("usage.pricing.partial") : $_("usage.pricing.unknown")}{/if}
+ {#if showReportedCost} + +
+ {model.summary.reported_cost_coverage > 0 ? formatReportedCurrency(model.summary.known_reported_cost_usd) : "—"} + {#if model.summary.reported_cost_status !== "reported"}{model.summary.reported_cost_coverage > 0 ? $_("usage.reportedCostStatus.partial") : $_("usage.reportedCostStatus.unavailable")}{/if} +
+
+ {/if} {formatInteger(model.summary.total_tokens)} {formatInteger(model.summary.session_count)} diff --git a/desktop/frontend/src/features/usage/UsagePage.svelte b/desktop/frontend/src/features/usage/UsagePage.svelte index 66c9efd..321165b 100644 --- a/desktop/frontend/src/features/usage/UsagePage.svelte +++ b/desktop/frontend/src/features/usage/UsagePage.svelte @@ -19,11 +19,12 @@ import * as ToggleGroup from "$lib/components/ui/toggle-group"; import { desktopErrorMessage, isCancelError } from "$lib/desktop-errors"; import { currentDesktopLocale, translate } from "$lib/i18n"; + import { cn } from "$lib/utils"; import UsageModelTable from "./UsageModelTable.svelte"; import UsageTrendChart from "./UsageTrendChart.svelte"; type UsageRange = "today" | "7d" | "30d" | "all"; - type UsageMetric = "cost" | "tokens"; + type UsageMetric = "cost" | "reported" | "tokens"; type Props = { providerID: string; providerName: string; @@ -164,7 +165,7 @@ } function changeMetric(value: string) { - if (value !== "cost" && value !== "tokens") { + if (value !== "cost" && value !== "tokens" && (value !== "reported" || providerID !== "grok-build")) { metricSelection = metric; return; } @@ -205,6 +206,17 @@ }).format(parsed); } + function formatReportedCurrency(value: string): string { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return value; + return new Intl.NumberFormat(currentDesktopLocale(), { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 10, + }).format(parsed); + } + function formatLastSync(unixMS: number): string { if (unixMS <= 0) return translate("usage.neverSynced"); const value = new Date(unixMS); @@ -227,7 +239,9 @@ + Number(value.import.invalid_lines > 0 || value.import.unsupported_lines > 0) + Number(value.summary.undated_event_count > 0) + Number(value.summary.partial_cost_event_count > 0) - + Number(value.summary.event_count > 0 && value.summary.unknown_cost_event_count > 0); + + Number(value.summary.event_count > 0 && value.summary.unknown_cost_event_count > 0) + + Number(providerID === "grok-build" && value.summary.partial_reported_cost_event_count > 0) + + Number(providerID === "grok-build" && value.summary.event_count > 0 && value.summary.unknown_reported_cost_event_count > 0); } @@ -282,7 +296,7 @@ {#if (loading && (!report || report.summary.event_count === 0)) || (report?.summary.event_count === 0 && initialSyncPending)}
{:else if report} - {#if autoSyncStatus?.outcome === "warning" || report.import.invalid_lines > 0 || report.import.unsupported_lines > 0 || report.summary.undated_event_count > 0 || report.summary.partial_cost_event_count > 0 || (report.summary.event_count > 0 && report.summary.unknown_cost_event_count > 0)} + {#if autoSyncStatus?.outcome === "warning" || report.import.invalid_lines > 0 || report.import.unsupported_lines > 0 || report.summary.undated_event_count > 0 || report.summary.partial_cost_event_count > 0 || (report.summary.event_count > 0 && report.summary.unknown_cost_event_count > 0) || (providerID === "grok-build" && (report.summary.partial_reported_cost_event_count > 0 || (report.summary.event_count > 0 && report.summary.unknown_reported_cost_event_count > 0)))} {$_("usage.dataQuality.title")} @@ -297,6 +311,8 @@ {#if report.summary.undated_event_count > 0}
  • {$_("usage.dataQuality.undated", { values: { count: formatInteger(report.summary.undated_event_count) } })}
  • {/if} {#if report.summary.partial_cost_event_count > 0}
  • {$_("usage.dataQuality.partialPricing", { values: { count: formatInteger(report.summary.partial_cost_event_count) } })}
  • {/if} {#if report.summary.event_count > 0 && report.summary.unknown_cost_event_count > 0}
  • {$_("usage.dataQuality.pricing", { values: { count: formatInteger(report.summary.unknown_cost_event_count), coverage: formatPercent(report.summary.pricing_coverage) } })}
  • {/if} + {#if providerID === "grok-build" && report.summary.partial_reported_cost_event_count > 0}
  • {$_("usage.dataQuality.partialReportedCost", { values: { count: formatInteger(report.summary.partial_reported_cost_event_count) } })}
  • {/if} + {#if providerID === "grok-build" && report.summary.event_count > 0 && report.summary.unknown_reported_cost_event_count > 0}
  • {$_("usage.dataQuality.reportedCost", { values: { count: formatInteger(report.summary.unknown_reported_cost_event_count), coverage: formatPercent(report.summary.reported_cost_coverage) } })}
  • {/if} @@ -313,7 +329,7 @@ {:else} -
    +
    @@ -332,6 +348,28 @@

    + {#if providerID === "grok-build"} + + +
    + {$_("usage.reportedCost")} + {#if report.summary.reported_cost_status !== "reported"}{report.summary.reported_cost_coverage > 0 ? $_("usage.reportedCostStatus.partial") : $_("usage.reportedCostStatus.unavailable")}{/if} +
    + + {report.summary.reported_cost_coverage > 0 ? formatReportedCurrency(report.summary.known_reported_cost_usd) : "—"} + +

    + {#if report.summary.reported_cost_status === "reported"} + {$_("usage.reportedCostSource")} + {:else if report.summary.reported_cost_coverage > 0} + {$_("usage.reportedCostCoverage", { values: { coverage: formatPercent(report.summary.reported_cost_coverage) } })} + {:else} + {$_("usage.reportedCostUnavailable")} + {/if} +

    +
    +
    + {/if} {$_("usage.sessions")} @@ -351,7 +389,8 @@ {$_("usage.trendTitle")} - {$_("usage.chart.cost")} + {providerID === "grok-build" ? $_("usage.chart.apiCost") : $_("usage.chart.cost")} + {#if providerID === "grok-build"}{$_("usage.chart.reportedCost")}{/if} {$_("usage.chart.tokens")} @@ -363,7 +402,7 @@ {$_("usage.modelsTitle")} - + {/if} {/if} diff --git a/desktop/frontend/src/features/usage/UsageTrendChart.svelte b/desktop/frontend/src/features/usage/UsageTrendChart.svelte index b3c99ae..f1c5459 100644 --- a/desktop/frontend/src/features/usage/UsageTrendChart.svelte +++ b/desktop/frontend/src/features/usage/UsageTrendChart.svelte @@ -5,7 +5,7 @@ import { currentDesktopLocale, translate } from "$lib/i18n"; - type UsageMetric = "cost" | "tokens"; + type UsageMetric = "cost" | "reported" | "tokens"; type TooltipSource = "pointer" | "focus"; type TooltipState = { index: number; @@ -58,15 +58,27 @@ } function metricValue(point: UsageTrendPoint): number { - if (metric === "cost") return costValue(point); + if (metric !== "tokens") return costValue(point); return point.summary.fresh_input_tokens + point.summary.cached_input_tokens + point.summary.output_tokens; } function costValue(point: UsageTrendPoint): number { - const value = Number(point.summary.known_estimated_cost_usd); + const value = Number(metric === "reported" ? point.summary.known_reported_cost_usd : point.summary.known_estimated_cost_usd); return Number.isFinite(value) ? Math.max(0, value) : 0; } + function chartTitle(): string { + if (metric === "cost") return translate("usage.chart.costTitle"); + if (metric === "reported") return translate("usage.chart.reportedCostTitle"); + return translate("usage.chart.tokenTitle"); + } + + function chartDescription(): string { + if (metric === "cost") return translate("usage.chart.costDescription"); + if (metric === "reported") return translate("usage.chart.reportedCostDescription"); + return translate("usage.chart.tokenDescription"); + } + function segmentHeight(value: number): number { return Math.max(0, value / maximum * plotHeight); } @@ -81,7 +93,7 @@ style: "currency", currency: "USD", minimumFractionDigits: 0, - maximumFractionDigits: value > 0 && value < 0.01 ? 6 : 2, + maximumFractionDigits: metric === "reported" ? 10 : value > 0 && value < 0.01 ? 6 : 2, }).format(value); } @@ -107,6 +119,17 @@ }).format(parsed); } + function formatReportedCurrency(value: string): string { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return value; + return new Intl.NumberFormat(currentDesktopLocale(), { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 10, + }).format(parsed); + } + function formatPercent(value: number): string { return new Intl.NumberFormat(currentDesktopLocale(), { style: "percent", @@ -180,6 +203,16 @@ coverage: formatPercent(point.summary.pricing_coverage), }); } + if (metric === "reported") { + const key = point.summary.partial_reported_cost_event_count > 0 + ? "usage.chart.partialReportedCostBucketAria" + : "usage.chart.reportedCostBucketAria"; + return translate(key, { + bucket: fullBucketLabel(point.start_unix_ms), + cost: formatReportedCurrency(point.summary.known_reported_cost_usd), + coverage: formatPercent(point.summary.reported_cost_coverage), + }); + } return translate("usage.chart.tokenBucketAria", { bucket: fullBucketLabel(point.start_unix_ms), fresh: formatInteger(point.summary.fresh_input_tokens), @@ -286,8 +319,8 @@ {/if} - {metric === "cost" ? $_("usage.chart.costTitle") : $_("usage.chart.tokenTitle")} - {metric === "cost" ? $_("usage.chart.costDescription") : $_("usage.chart.tokenDescription")} + {chartTitle()} + {chartDescription()} {#each ticks as tick, index (index)} {@const y = margin.top + index * plotHeight / 4} @@ -323,7 +356,7 @@ class="cursor-pointer outline-none focus-visible:[&_rect[data-focus]]:stroke-ring" > - {#if metric === "cost"} + {#if metric !== "tokens"} {:else} @@ -346,13 +379,19 @@ style={`left: ${tooltip.left}px; top: ${tooltip.top}px;`} >
    {fullBucketLabel(tooltipPoint.start_unix_ms)}
    - {#if metric === "cost"} -
    {$_("usage.chart.knownCost")}{formatCurrency(tooltipPoint.summary.known_estimated_cost_usd)}
    - {#if tooltipPoint.summary.partial_cost_event_count > 0} + {#if metric !== "tokens"} +
    + {metric === "reported" ? $_("usage.chart.knownReportedCost") : $_("usage.chart.knownCost")} + {metric === "reported" ? formatReportedCurrency(tooltipPoint.summary.known_reported_cost_usd) : formatCurrency(tooltipPoint.summary.known_estimated_cost_usd)} +
    + {#if metric === "cost" && tooltipPoint.summary.partial_cost_event_count > 0}
    {$_("usage.chart.partialEstimate")}
    + {:else if metric === "reported" && tooltipPoint.summary.partial_reported_cost_event_count > 0} +
    {$_("usage.chart.partialReportedCost")}
    {/if} - {#if tooltipPoint.summary.event_count > 0 && tooltipPoint.summary.pricing_coverage < 1} -
    {$_("usage.chart.coverage")}{formatPercent(tooltipPoint.summary.pricing_coverage)}
    + {@const coverage = metric === "reported" ? tooltipPoint.summary.reported_cost_coverage : tooltipPoint.summary.pricing_coverage} + {#if tooltipPoint.summary.event_count > 0 && coverage < 1} +
    {$_("usage.chart.coverage")}{formatPercent(coverage)}
    {/if} {:else}
    {$_("usage.total")}{formatInteger(tooltipPoint.summary.total_tokens)}
    diff --git a/desktop/frontend/src/lib/i18n.ts b/desktop/frontend/src/lib/i18n.ts index 7bca62d..ac5c280 100644 --- a/desktop/frontend/src/lib/i18n.ts +++ b/desktop/frontend/src/lib/i18n.ts @@ -729,6 +729,10 @@ const messages = { totalTokens: "Total tokens", sessions: "Sessions", apiEquivalentCost: "API-equivalent cost", + reportedCost: "Grok-reported cost", + reportedCostSource: "Local session records · not an invoice", + reportedCostCoverage: "Known subtotal · reported for {coverage} of tokens", + reportedCostUnavailable: "No reported cost in these session records", freshInput: "Fresh input", cachedInput: "Cached input", output: "Output", @@ -753,8 +757,10 @@ const messages = { fileErrors: "{count} session files could not be imported.", lines: "Skipped {invalid} invalid and {unsupported} unsupported log lines.", undated: "{count} undated records are excluded from the trend.", - partialPricing: "{count} records use a base estimate because Codex did not report cache-write tokens.", + partialPricing: "{count} records use a base estimate because cache-write tokens cannot be priced.", pricing: "{count} records have unknown pricing; the chart shows the known subtotal with {coverage} coverage.", + partialReportedCost: "{count} records contain a partial Grok-reported cost.", + reportedCost: "{count} records have no Grok-reported cost; the report shows the known subtotal with {coverage} coverage.", }, range: { label: "Usage range", @@ -766,23 +772,37 @@ const messages = { chart: { metricLabel: "Trend metric", cost: "Cost", + apiCost: "API equivalent", + reportedCost: "Grok reported", tokens: "Tokens", costTitle: "API-equivalent cost trend", + reportedCostTitle: "Grok-reported cost trend", tokenTitle: "Token usage trend", costDescription: "Estimated API-equivalent cost over time, using your local time.", + reportedCostDescription: "Cost reported in local Grok session records over time.", tokenDescription: "Input, cached input, and output tokens over time, using your local time.", costBucketAria: "{bucket}: known API-equivalent cost {cost}, pricing coverage {coverage}", partialCostBucketAria: "{bucket}: partial API-equivalent base cost {cost}; cache-write tokens are unavailable; pricing coverage {coverage}", + reportedCostBucketAria: "{bucket}: known Grok-reported cost {cost}, reporting coverage {coverage}", + partialReportedCostBucketAria: "{bucket}: partial Grok-reported cost {cost}, reporting coverage {coverage}", tokenBucketAria: "{bucket}: fresh input {fresh}, cached input {cached}, output {output}, total {total} tokens", knownCost: "Known cost", + knownReportedCost: "Grok-reported cost", coverage: "Coverage", partialEstimate: "Partial estimate · some cache-write cost is not included", + partialReportedCost: "Partial amount reported by Grok", }, models: { model: "Model", sessions: "Sessions", tokens: "Tokens", knownCost: "Known cost", + apiEquivalentCost: "API-equivalent", + reportedCost: "Grok reported", + }, + reportedCostStatus: { + partial: "Partial", + unavailable: "Unavailable", }, pricing: { partial: "Partial", @@ -1854,6 +1874,10 @@ const messages = { totalTokens: "总 Tokens", sessions: "会话数", apiEquivalentCost: "API 等价成本", + reportedCost: "Grok 上报成本", + reportedCostSource: "来自本地会话记录 · 不是账单", + reportedCostCoverage: "已知小计 · 覆盖 {coverage} 的 Tokens", + reportedCostUnavailable: "这些会话记录中没有上报成本", freshInput: "新输入", cachedInput: "缓存输入", output: "输出", @@ -1878,8 +1902,10 @@ const messages = { fileErrors: "{count} 个 session 文件无法导入。", lines: "已跳过 {invalid} 条无效日志和 {unsupported} 条不支持的日志。", undated: "{count} 条无时间记录未计入趋势图。", - partialPricing: "{count} 条记录只计算基础成本,因为 Codex 没有提供 cache-write Tokens。", + partialPricing: "{count} 条记录只计算基础成本,因为 cache-write Tokens 无法计价。", pricing: "{count} 条记录没有价格;图表显示已知小计,覆盖率为 {coverage}。", + partialReportedCost: "{count} 条记录只包含 Grok 上报的部分成本。", + reportedCost: "{count} 条记录没有 Grok 上报成本;报告显示已知小计,覆盖率为 {coverage}。", }, range: { label: "用量时间范围", @@ -1891,23 +1917,37 @@ const messages = { chart: { metricLabel: "趋势指标", cost: "成本", + apiCost: "API 等价", + reportedCost: "Grok 上报", tokens: "Tokens", costTitle: "API 等价成本趋势", + reportedCostTitle: "Grok 上报成本趋势", tokenTitle: "Token 用量趋势", costDescription: "按本地时间显示 API 等价估算成本变化。", + reportedCostDescription: "按时间显示 Grok 本地会话记录中的上报成本。", tokenDescription: "按本地时间显示输入、缓存输入和输出 Tokens 变化。", costBucketAria: "{bucket}:已知 API 等价成本 {cost},定价覆盖率 {coverage}", partialCostBucketAria: "{bucket}:部分 API 等价基础成本 {cost};缺少 cache-write tokens;定价覆盖率 {coverage}", + reportedCostBucketAria: "{bucket}:已知 Grok 上报成本 {cost},上报覆盖率 {coverage}", + partialReportedCostBucketAria: "{bucket}:Grok 上报的部分成本 {cost},上报覆盖率 {coverage}", tokenBucketAria: "{bucket}:新输入 {fresh},缓存输入 {cached},输出 {output},总计 {total} Tokens", knownCost: "已知成本", + knownReportedCost: "Grok 上报成本", coverage: "覆盖率", partialEstimate: "部分估算 · 未包含部分 cache-write 成本", + partialReportedCost: "Grok 只上报了部分金额", }, models: { model: "模型", sessions: "会话", tokens: "Tokens", knownCost: "已知成本", + apiEquivalentCost: "API 等价成本", + reportedCost: "Grok 上报成本", + }, + reportedCostStatus: { + partial: "部分上报", + unavailable: "未上报", }, pricing: { partial: "部分定价", diff --git a/desktop/frontend/tests/usage-page.test.ts b/desktop/frontend/tests/usage-page.test.ts index 1af7d1d..5d02647 100644 --- a/desktop/frontend/tests/usage-page.test.ts +++ b/desktop/frontend/tests/usage-page.test.ts @@ -76,6 +76,13 @@ function usageReport(): UsageReportResult { unknown_cost_event_count: 0, estimated_token_count: 2_000_000, pricing_coverage: 1, + known_reported_cost_usd: "0.5452000000", + reported_cost_status: "reported", + reported_cost_event_count: 1, + partial_reported_cost_event_count: 0, + unknown_reported_cost_event_count: 0, + reported_cost_token_count: 2_000_000, + reported_cost_coverage: 1, undated_event_count: 0, }, trend: [], @@ -110,6 +117,12 @@ function emptyUsageReport(): UsageReportResult { known_estimated_cost_usd: "0.000000", estimated_cost_event_count: 0, estimated_token_count: 0, + known_reported_cost_usd: "0.0000000000", + reported_cost_status: "unknown", + reported_cost_event_count: 0, + unknown_reported_cost_event_count: 0, + reported_cost_token_count: 0, + reported_cost_coverage: 0, }, }; } @@ -175,6 +188,10 @@ describe("UsagePage initial sync", () => { await waitFor(() => expect(backend.report).toHaveBeenCalledTimes(2)); expect(await screen.findByText("$8.00")).toBeInTheDocument(); + expect(screen.getByText("$0.5452")).toBeInTheDocument(); + expect(screen.getByText("Grok-reported cost")).toBeInTheDocument(); + expect(screen.getByRole("radio", { name: "Grok reported" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Grok reported" })).toBeInTheDocument(); expect(screen.queryByText("No usage yet")).not.toBeInTheDocument(); }); diff --git a/docs/grok-build/usage-cost.md b/docs/grok-build/usage-cost.md index 8d77b0e..cc988ab 100644 --- a/docs/grok-build/usage-cost.md +++ b/docs/grok-build/usage-cost.md @@ -1,6 +1,6 @@ # Grok Build Usage and Cost -ProfileDeck reads local Grok Build session records to show token usage, activity, and estimated API-equivalent cost. Reports stay offline and do not assign activity to a Profile, saved login, or account. +ProfileDeck reads local Grok Build session records to show token usage, activity, static API-equivalent cost estimates, and cost reported by Grok Build. Reports stay offline and do not assign activity to a Profile, saved login, or account. ## Sync in the Desktop app @@ -51,7 +51,7 @@ profiledeck-cli usage summary --provider grok-build profiledeck-cli usage summary --provider grok-build --json ``` -The summary includes event count, input and output tokens, cached input, total tokens, estimated cost when available, and the number of events with unknown cost. +The summary includes event count, input and output tokens, cached input, total tokens, API-equivalent cost when available, Grok-reported cost when available, and the number of events with unknown cost for each figure. ## View a report @@ -62,9 +62,9 @@ profiledeck-cli usage report --provider grok-build --range 30d --json profiledeck-cli usage report --provider grok-build --range all ``` -The default range is `7d`. Reports use your computer's local time zone and include token totals, session count, cache hit rate, known cost, pricing coverage, model details, and sync status. Records without a timestamp are included in all-time totals and model details, reported separately, and excluded from the timeline. +The default range is `7d`. Reports use your computer's local time zone and include token totals, session count, cache hit rate, API-equivalent and Grok-reported cost subtotals, coverage for both figures, model details, and sync status. Records without a timestamp are included in all-time totals and model details, reported separately, and excluded from the timeline. -## Understand cost estimates +## Understand cost figures ProfileDeck uses the short-context xAI Standard API-equivalent prices included with the installed version: @@ -81,9 +81,13 @@ The prices come from [xAI pricing](https://docs.x.ai/developers/pricing). [Grok When a session record includes cache-creation tokens, ProfileDeck keeps the event and reports a partial estimate because usage facts currently price only input, cached input, and output tokens. -Amounts recorded by Grok Build are not imported as billing data. An unrecognized model keeps its token totals but has unknown cost. Existing estimates are not recalculated when a later ProfileDeck version changes its built-in prices; facts with unknown cost can receive an estimate when their model becomes recognized. +When a completed turn contains `costUsdTicks`, ProfileDeck imports the per-model value as Grok-reported cost; 10,000,000,000 ticks equal US$1. A top-level value is used only when the turn contains exactly one model, so the same amount is never assigned to several models. Missing or nonpositive values remain unknown, and `costIsPartial` is shown as partial. -These estimates are not invoices, credits, quotas, or account balances. ProfileDeck does not contact xAI or a billing API when syncing or producing a report. +API-equivalent estimates and Grok-reported amounts remain separate and are never added together. Reports show the known subtotal and coverage when some completed turns are missing either figure. An unrecognized model keeps its token totals and Grok-reported amount but has unknown API-equivalent cost. Existing estimates are not recalculated when a later ProfileDeck version changes its built-in prices; facts with unknown API-equivalent cost can receive an estimate when their model becomes recognized. + +The Grok Build Usage Limit panel may cover only activity since the process started or was last resumed, while ProfileDeck reports completed turns found in local session history for the selected dates. Their totals can therefore differ even when both values came from the same local session. + +Neither figure is an invoice, credit balance, quota, or account charge. ProfileDeck does not contact xAI or a billing API when syncing or producing a report. ## Privacy limits diff --git a/internal/bootstrap/service_test.go b/internal/bootstrap/service_test.go index 7d8d982..55092a8 100644 --- a/internal/bootstrap/service_test.go +++ b/internal/bootstrap/service_test.go @@ -187,7 +187,8 @@ func TestInitializeBacksUpValidatedMarkerGapBeforeReapplyingMarker(t *testing.T) snapshot := inspectDatabaseSnapshot(t, runtimeService.Paths().Database) if len(snapshot.markers) != 0 || !snapshot.usageTable || snapshot.grokUsageTable || - snapshot.usageObservationTable || !snapshot.pathKeyIndex { + snapshot.usageObservationTable || snapshot.reportedCostColumns || + !snapshot.pathKeyIndex { t.Fatalf("database changed before backup: %#v", snapshot) } }, @@ -204,7 +205,8 @@ func TestInitializeBacksUpValidatedMarkerGapBeforeReapplyingMarker(t *testing.T) snapshot := inspectDatabaseSnapshot(t, runtimeService.Paths().Database) if len(snapshot.markers) != migrationCount || !snapshot.usageTable || !snapshot.grokUsageTable || - !snapshot.usageObservationTable || !snapshot.pathKeyIndex || + !snapshot.usageObservationTable || !snapshot.reportedCostColumns || + !snapshot.pathKeyIndex || snapshot.setting != `{"kept":true}` { t.Fatalf("database after upgrade = %#v", snapshot) } @@ -291,7 +293,7 @@ func TestInitializeRejectsMarkerGapSchemaDriftBeforeBackup(t *testing.T) { } } -func TestInitializeBacksUpPreviousBaselineBeforeUsageObservationParserMigration(t *testing.T) { +func TestInitializeBacksUpPreviousBaselineBeforeGrokBuildReportedCostMigration(t *testing.T) { ctx := context.Background() runtimeService := newRuntimeService(t) createPreviousBaseline(t, ctx, runtimeService) @@ -304,7 +306,8 @@ func TestInitializeBacksUpPreviousBaselineBeforeUsageObservationParserMigration( snapshot := inspectDatabaseSnapshot(t, runtimeService.Paths().Database) if len(snapshot.markers) != len(storemigrations.Migrations.Sorted())-1 || !snapshot.usageTable || !snapshot.grokUsageTable || - !snapshot.usageObservationTable || !snapshot.pathKeyIndex || + !snapshot.usageObservationTable || snapshot.reportedCostColumns || + !snapshot.pathKeyIndex || snapshot.setting != `{"kept":true}` { t.Fatalf("previous usage baseline changed before backup: %#v", snapshot) } @@ -313,7 +316,7 @@ func TestInitializeBacksUpPreviousBaselineBeforeUsageObservationParserMigration( result, err := NewService(runtimeService, backups, nil).Initialize(ctx) if err != nil { - t.Fatalf("apply usage observation parser migration: %v", err) + t.Fatalf("apply Grok Build reported cost migration: %v", err) } if result.MigrationsApplied != 1 || backups.calls != 1 { t.Fatalf("upgrade result = %#v, backups = %d", result, backups.calls) @@ -321,7 +324,8 @@ func TestInitializeBacksUpPreviousBaselineBeforeUsageObservationParserMigration( snapshot := inspectDatabaseSnapshot(t, runtimeService.Paths().Database) if len(snapshot.markers) != len(storemigrations.Migrations.Sorted()) || !snapshot.usageTable || !snapshot.grokUsageTable || - !snapshot.usageObservationTable || !snapshot.pathKeyIndex || + !snapshot.usageObservationTable || !snapshot.reportedCostColumns || + !snapshot.pathKeyIndex || snapshot.setting != `{"kept":true}` { t.Fatalf("database after upgrade = %#v", snapshot) } @@ -386,6 +390,7 @@ type databaseSnapshot struct { usageTable bool grokUsageTable bool usageObservationTable bool + reportedCostColumns bool pathKeyIndex bool setting string } @@ -428,7 +433,8 @@ func createPreviousBaseline(t *testing.T, ctx context.Context, runtimeService *r t.Fatalf("registered migrations = %d, want at least 2", len(registered)) } execDatabaseStatements(t, runtimeService.Paths().Database, - `ALTER TABLE usage_import_observations DROP COLUMN parser_revision`, + `ALTER TABLE usage_facts DROP COLUMN reported_cost_status`, + `ALTER TABLE usage_facts DROP COLUMN reported_cost_usd_ticks`, ) execDatabaseStatements(t, runtimeService.Paths().Database, `DELETE FROM bun_migrations WHERE name = '`+registered[len(registered)-1].Name+`'`, @@ -438,6 +444,8 @@ func createPreviousBaseline(t *testing.T, ctx context.Context, runtimeService *r func dropIncrementalUsageSchema(t *testing.T, path string) { t.Helper() execDatabaseStatements(t, path, + `ALTER TABLE usage_facts DROP COLUMN reported_cost_status`, + `ALTER TABLE usage_facts DROP COLUMN reported_cost_usd_ticks`, `DROP TABLE usage_import_observations`, `ALTER TABLE codex_usage_import_files DROP COLUMN parser_state_json`, `ALTER TABLE codex_usage_import_files DROP COLUMN checkpoint_event_digest`, @@ -517,6 +525,14 @@ func inspectDatabaseSnapshot(t *testing.T, path string) databaseSnapshot { t.Fatal(err) } snapshot.usageObservationTable = usageObservationCount > 0 + var reportedCostColumnCount int + if err := db.QueryRow(` + SELECT COUNT(1) FROM pragma_table_info('usage_facts') + WHERE name IN ('reported_cost_usd_ticks', 'reported_cost_status') + `).Scan(&reportedCostColumnCount); err != nil { + t.Fatal(err) + } + snapshot.reportedCostColumns = reportedCostColumnCount == 2 var pathKeyIndexCount int if err := db.QueryRow(`SELECT COUNT(1) FROM sqlite_master WHERE type = 'index' AND name = 'idx_profile_targets_path_key'`).Scan(&pathKeyIndexCount); err != nil { t.Fatal(err) diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go index 7c3e5b9..fdf8669 100644 --- a/internal/cli/command_test.go +++ b/internal/cli/command_test.go @@ -711,6 +711,9 @@ func TestUsageSyncCodexAndSummaryJSON(t *testing.T) { t.Fatalf("expected human report to contain %q, got %q", expected, humanReport) } } + if strings.Contains(humanReport, "Grok-reported cost") { + t.Fatalf("Codex report unexpectedly included provider-reported cost: %q", humanReport) + } if _, err := runCLI(t, "--config-dir", configDir, "usage", "report", "--range", "14d"); err == nil { t.Fatalf("expected invalid usage report range to fail") } @@ -771,9 +774,16 @@ func TestUsageSyncGrokBuildUsesGlobalHomeAndOmitsFileIdentifiers(t *testing.T) { if summary.ProviderID != grokconfig.ProviderID || summary.EventCount != 1 || summary.TotalTokens != 120 || - summary.CostStatus != "estimated" { + summary.CostStatus != "estimated" || + summary.ReportedCostUSD == nil || + *summary.ReportedCostUSD != "0.0000000999" || + summary.ReportedCostStatus != "reported" { t.Fatalf("Grok summary = %#v", summary) } + humanSummary, err := runCLI(t, append(base, "usage", "summary", "--provider", grokconfig.ProviderID)...) + if err != nil || !strings.Contains(humanSummary, "Grok-reported cost usd: 0.0000000999") { + t.Fatalf("human Grok summary = %q, err = %v", humanSummary, err) + } reportOut, err := runCLI(t, append(base, "usage", "report", "--provider", grokconfig.ProviderID, "--range", "all", "--json")...) if err != nil { t.Fatalf("report Grok Build usage: %v", err) @@ -782,10 +792,16 @@ func TestUsageSyncGrokBuildUsesGlobalHomeAndOmitsFileIdentifiers(t *testing.T) { decodeCLIJSON(t, []byte(reportOut), &report) if report.ProviderID != grokconfig.ProviderID || report.Summary.EventCount != 1 || + report.Summary.KnownReportedCostUSD != "0.0000000999" || + report.Summary.ReportedCostStatus != "reported" || len(report.Models) != 1 || report.Models[0].Model != "grok-build-latest" { t.Fatalf("Grok report = %#v", report) } + humanReport, err := runCLI(t, append(base, "usage", "report", "--provider", grokconfig.ProviderID, "--range", "all")...) + if err != nil || !strings.Contains(humanReport, "known Grok-reported cost usd: 0.0000000999") { + t.Fatalf("human Grok report = %q, err = %v", humanReport, err) + } } func TestUsageSyncCodexDefaultsToCodexHomeEnv(t *testing.T) { diff --git a/internal/cli/usage.go b/internal/cli/usage.go index 68169a0..7c7b3bb 100644 --- a/internal/cli/usage.go +++ b/internal/cli/usage.go @@ -10,6 +10,7 @@ import ( urfavecli "github.com/urfave/cli/v3" + grokconfig "github.com/strahe/profiledeck/internal/grokbuild/config" "github.com/strahe/profiledeck/internal/usage" ) @@ -207,9 +208,9 @@ func writeUsageSummary(w io.Writer, result usage.UsageSummaryResult) error { cost = *result.EstimatedCostUSD } sources := strings.Join(result.Sources, ",") - _, err := fmt.Fprintf( + if _, err := fmt.Fprintf( w, - "Usage summary\nprovider: %s\nsource: %s\nsources: %s\nevents: %d\ninput tokens: %d\ncached input tokens: %d\noutput tokens: %d\ntotal tokens: %d\ncost status: %s\nestimated cost usd: %s\nunknown cost events: %d\n", + "Usage summary\nprovider: %s\nsource: %s\nsources: %s\nevents: %d\ninput tokens: %d\ncached input tokens: %d\noutput tokens: %d\ntotal tokens: %d\nAPI-equivalent cost status: %s\nAPI-equivalent estimated cost usd: %s\nunknown API-equivalent cost events: %d\n", result.ProviderID, result.Source, sources, @@ -221,6 +222,22 @@ func writeUsageSummary(w io.Writer, result usage.UsageSummaryResult) error { result.CostStatus, cost, result.UnknownCostEventCount, + ); err != nil { + return err + } + if result.ProviderID != grokconfig.ProviderID { + return nil + } + reportedCost := "unknown" + if result.ReportedCostUSD != nil { + reportedCost = *result.ReportedCostUSD + } + _, err := fmt.Fprintf( + w, + "Grok-reported cost status: %s\nGrok-reported cost usd: %s\nunknown Grok-reported cost events: %d\n", + result.ReportedCostStatus, + reportedCost, + result.UnknownReportedCostEventCount, ) return err } @@ -230,9 +247,18 @@ func writeUsageReport(w io.Writer, result usage.UsageReportResult) error { if result.Import.LastSyncedAtUnixMS > 0 { lastSync = time.UnixMilli(result.Import.LastSyncedAtUnixMS).Format(time.RFC3339) } + reportedCostSummary := "" + if result.ProviderID == grokconfig.ProviderID { + reportedCostSummary = fmt.Sprintf( + "known Grok-reported cost usd: %s\nGrok-reported cost status: %s\nGrok-reported cost coverage: %.1f%%\n", + result.Summary.KnownReportedCostUSD, + result.Summary.ReportedCostStatus, + result.Summary.ReportedCostCoverage*100, + ) + } if _, err := fmt.Fprintf( w, - "Usage report\nprovider: %s\nrange: %s\ntime zone: %s\nevents: %d\nsessions: %d\nfresh input tokens: %d\ncached input tokens: %d\noutput tokens: %d\ntotal tokens: %d\ncache hit rate: %.1f%%\nknown API-equivalent estimated cost usd: %s\ncost status: %s\npricing coverage: %.1f%%\nundated events: %d\ntracked files: %d\nlast sync: %s\ninvalid lines: %d\nunsupported lines: %d\npricing basis: %s\n\nTrend\n", + "Usage report\nprovider: %s\nrange: %s\ntime zone: %s\nevents: %d\nsessions: %d\nfresh input tokens: %d\ncached input tokens: %d\noutput tokens: %d\ntotal tokens: %d\ncache hit rate: %.1f%%\nknown API-equivalent estimated cost usd: %s\nAPI-equivalent cost status: %s\npricing coverage: %.1f%%\n%sundated events: %d\ntracked files: %d\nlast sync: %s\ninvalid lines: %d\nunsupported lines: %d\npricing basis: %s\n\nTrend\n", result.ProviderID, result.Range.Preset, result.Range.TimeZone, @@ -246,6 +272,7 @@ func writeUsageReport(w io.Writer, result usage.UsageReportResult) error { result.Summary.KnownEstimatedCostUSD, result.Summary.CostStatus, result.Summary.PricingCoverage*100, + reportedCostSummary, result.Summary.UndatedEventCount, result.Import.TrackedFiles, lastSync, @@ -257,19 +284,26 @@ func writeUsageReport(w io.Writer, result usage.UsageReportResult) error { } table := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) - if _, err := fmt.Fprintln(table, "bucket\tfresh input\tcached input\toutput\ttotal"); err != nil { + showReportedCost := result.ProviderID == grokconfig.ProviderID + if showReportedCost { + if _, err := fmt.Fprintln(table, "bucket\tfresh input\tcached input\toutput\ttotal\tAPI-equivalent cost usd\tGrok-reported cost usd"); err != nil { + return err + } + } else if _, err := fmt.Fprintln(table, "bucket\tfresh input\tcached input\toutput\ttotal\tAPI-equivalent cost usd"); err != nil { return err } for _, point := range result.Trend { - if _, err := fmt.Fprintf( - table, - "%s\t%d\t%d\t%d\t%d\n", - usageBucketLabel(result.Range, point.StartUnixMS), - point.Summary.FreshInputTokens, - point.Summary.CachedInputTokens, - point.Summary.OutputTokens, - point.Summary.TotalTokens, - ); err != nil { + if showReportedCost { + if _, err := fmt.Fprintf(table, "%s\t%d\t%d\t%d\t%d\t%s\t%s\n", + usageBucketLabel(result.Range, point.StartUnixMS), point.Summary.FreshInputTokens, + point.Summary.CachedInputTokens, point.Summary.OutputTokens, point.Summary.TotalTokens, + point.Summary.KnownEstimatedCostUSD, point.Summary.KnownReportedCostUSD); err != nil { + return err + } + } else if _, err := fmt.Fprintf(table, "%s\t%d\t%d\t%d\t%d\t%s\n", + usageBucketLabel(result.Range, point.StartUnixMS), point.Summary.FreshInputTokens, + point.Summary.CachedInputTokens, point.Summary.OutputTokens, point.Summary.TotalTokens, + point.Summary.KnownEstimatedCostUSD); err != nil { return err } } @@ -280,20 +314,26 @@ func writeUsageReport(w io.Writer, result usage.UsageReportResult) error { return err } table = tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) - if _, err := fmt.Fprintln(table, "model\tsessions\ttokens\tcache hit\tknown cost usd\tstatus"); err != nil { + if showReportedCost { + if _, err := fmt.Fprintln(table, "model\tsessions\ttokens\tcache hit\tAPI-equivalent cost usd\tAPI cost status\tGrok-reported cost usd\tGrok-reported cost status"); err != nil { + return err + } + } else if _, err := fmt.Fprintln(table, "model\tsessions\ttokens\tcache hit\tAPI-equivalent cost usd\tAPI cost status"); err != nil { return err } for _, model := range result.Models { - if _, err := fmt.Fprintf( - table, - "%s\t%d\t%d\t%.1f%%\t%s\t%s\n", - model.Model, - model.Summary.SessionCount, - model.Summary.TotalTokens, - model.Summary.CacheHitRate*100, - model.Summary.KnownEstimatedCostUSD, - model.Summary.CostStatus, - ); err != nil { + if showReportedCost { + if _, err := fmt.Fprintf(table, "%s\t%d\t%d\t%.1f%%\t%s\t%s\t%s\t%s\n", + model.Model, model.Summary.SessionCount, model.Summary.TotalTokens, + model.Summary.CacheHitRate*100, model.Summary.KnownEstimatedCostUSD, + model.Summary.CostStatus, model.Summary.KnownReportedCostUSD, + model.Summary.ReportedCostStatus); err != nil { + return err + } + } else if _, err := fmt.Fprintf(table, "%s\t%d\t%d\t%.1f%%\t%s\t%s\n", + model.Model, model.Summary.SessionCount, model.Summary.TotalTokens, + model.Summary.CacheHitRate*100, model.Summary.KnownEstimatedCostUSD, + model.Summary.CostStatus); err != nil { return err } } diff --git a/internal/store/integrity.go b/internal/store/integrity.go index c05ca17..0cd3634 100644 --- a/internal/store/integrity.go +++ b/internal/store/integrity.go @@ -288,7 +288,13 @@ var schemaContracts = func() []schemaContract { observationParserRevision.tableSpecs, usageImportObservationParserRevisionTableSpec, ) - return []schemaContract{stable, grokBuild, incremental, observationParserRevision} + reportedCost := observationParserRevision + reportedCost.migrationKey = "grok_build_reported_cost" + reportedCost.tableSpecs = replaceTableSpec( + reportedCost.tableSpecs, + grokBuildReportedCostFactTableSpec, + ) + return []schemaContract{stable, grokBuild, incremental, observationParserRevision, reportedCost} }() func jsonObjectExpression(column string) string { diff --git a/internal/store/migrations/202609150001_grok_build_reported_cost.go b/internal/store/migrations/202609150001_grok_build_reported_cost.go new file mode 100644 index 0000000..aea6b4a --- /dev/null +++ b/internal/store/migrations/202609150001_grok_build_reported_cost.go @@ -0,0 +1,46 @@ +package migrations + +import ( + "context" + + "github.com/uptrace/bun" +) + +func init() { + Migrations.MustRegister(upGrokBuildReportedCost, downGrokBuildReportedCost) +} + +func upGrokBuildReportedCost(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + if err := addUsageColumnIfMissing( + ctx, + tx, + "usage_facts", + "reported_cost_usd_ticks", + "INTEGER CHECK (reported_cost_usd_ticks IS NULL OR reported_cost_usd_ticks > 0)", + ); err != nil { + return err + } + return addUsageColumnIfMissing( + ctx, + tx, + "usage_facts", + "reported_cost_status", + `INTEGER NOT NULL DEFAULT 0 + CHECK (reported_cost_status IN (0, 1, 2)) + CHECK ( + (reported_cost_status IN (1, 2) AND reported_cost_usd_ticks IS NOT NULL) + OR (reported_cost_status = 0 AND reported_cost_usd_ticks IS NULL) + )`, + ) + }) +} + +func downGrokBuildReportedCost(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + if err := dropUsageColumnIfExists(ctx, tx, "usage_facts", "reported_cost_status"); err != nil { + return err + } + return dropUsageColumnIfExists(ctx, tx, "usage_facts", "reported_cost_usd_ticks") + }) +} diff --git a/internal/store/migrations/migrations_test.go b/internal/store/migrations/migrations_test.go index 1feb93d..93e03f0 100644 --- a/internal/store/migrations/migrations_test.go +++ b/internal/store/migrations/migrations_test.go @@ -236,3 +236,77 @@ func TestUsageObservationParserRevisionUpgradeIsReplaySafeAndReversible(t *testi t.Fatal("rollback retained observation parser revision column") } } + +func TestGrokBuildReportedCostUpgradeIsReplaySafeAndReversible(t *testing.T) { + ctx := context.Background() + sqlDB, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "migration.db")) + if err != nil { + t.Fatal(err) + } + db := bun.NewDB(sqlDB, sqlitedialect.New()) + defer db.Close() + for _, step := range []struct { + name string + migrate func(context.Context, *bun.DB) error + }{ + {name: "stable baseline", migrate: upStableBaseline}, + {name: "Grok Build usage", migrate: upGrokBuildUsageImport}, + {name: "incremental checkpoint", migrate: upUsageIncrementalCheckpoint}, + {name: "observation parser revision", migrate: upUsageObservationParserRevision}, + } { + if err := step.migrate(ctx, db); err != nil { + t.Fatalf("create %s: %v", step.name, err) + } + } + if _, err := sqlDB.ExecContext(ctx, ` + INSERT INTO providers (id, name, adapter_id, created_at_unix_ms, updated_at_unix_ms) + VALUES ('grok-build', 'Grok Build', 'grok-build', 1, 1); + INSERT INTO usage_sources (provider_id, source_key, identity_revision) + VALUES ('grok-build', 'grok-build-session-jsonl', 1); + INSERT INTO usage_models (source_id, model_key) VALUES (1, 'grok-4.6-build'); + INSERT INTO usage_facts (event_key, source_id, model_id, total_tokens, cost_status) + VALUES (X'0101010101010101010101010101010101010101010101010101010101010101', 1, 1, 1, 0) + `); err != nil { + t.Fatalf("seed pre-upgrade usage fact: %v", err) + } + + if err := upGrokBuildReportedCost(ctx, db); err != nil { + t.Fatalf("upgrade reported cost: %v", err) + } + var ticks sql.NullInt64 + var status int64 + if err := sqlDB.QueryRowContext(ctx, ` + SELECT reported_cost_usd_ticks, reported_cost_status FROM usage_facts + `).Scan(&ticks, &status); err != nil { + t.Fatalf("read upgraded usage fact: %v", err) + } + if ticks.Valid || status != 0 { + t.Fatalf("legacy reported cost = ticks %#v, status %d", ticks, status) + } + if _, err := sqlDB.ExecContext(ctx, ` + UPDATE usage_facts SET reported_cost_usd_ticks = 12345, reported_cost_status = 1 + `); err != nil { + t.Fatalf("store reported cost: %v", err) + } + if err := upGrokBuildReportedCost(ctx, db); err != nil { + t.Fatalf("replay reported cost migration: %v", err) + } + if _, err := sqlDB.ExecContext(ctx, ` + UPDATE usage_facts SET reported_cost_status = 0 + `); err == nil { + t.Fatal("reported cost invariant accepted mismatched status") + } + + if err := downGrokBuildReportedCost(ctx, db); err != nil { + t.Fatalf("rollback reported cost: %v", err) + } + for _, column := range []string{"reported_cost_usd_ticks", "reported_cost_status"} { + exists, err := usageColumnExists(ctx, db, "usage_facts", column) + if err != nil { + t.Fatalf("inspect rolled-back column %s: %v", column, err) + } + if exists { + t.Fatalf("rollback retained usage_facts.%s", column) + } + } +} diff --git a/internal/store/schema_contract.go b/internal/store/schema_contract.go index 0ae4b3a..b560081 100644 --- a/internal/store/schema_contract.go +++ b/internal/store/schema_contract.go @@ -469,6 +469,20 @@ var usageImportObservationParserRevisionTableSpec = func() tableSpec { return spec }() +var grokBuildReportedCostFactTableSpec = func() tableSpec { + spec := copyTableSpec(stableBaselineTableSpec("usage_facts")) + spec.columns = append(spec.columns, + columnSpec{name: "reported_cost_usd_ticks", columnType: "INTEGER"}, + columnSpec{name: "reported_cost_status", columnType: "INTEGER", notNull: true, requireDefault: true, defaultValue: "0"}, + ) + spec.checks = append(spec.checks, + "CHECK (reported_cost_usd_ticks IS NULL OR reported_cost_usd_ticks > 0)", + "CHECK (reported_cost_status IN (0, 1, 2))", + "CHECK ((reported_cost_status IN (1, 2) AND reported_cost_usd_ticks IS NOT NULL) OR (reported_cost_status = 0 AND reported_cost_usd_ticks IS NULL))", + ) + return spec +}() + func stableBaselineTableSpec(name string) tableSpec { for _, spec := range stableBaselineTableSpecs { if spec.name == name { diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 3171dae..aea5877 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -254,6 +254,11 @@ func TestAppliedSchemaUsesItsVersionedContract(t *testing.T) { if _, err := db.db.DB.ExecContext(ctx, `ALTER TABLE usage_sources DROP COLUMN completed_generation`); err != nil { t.Fatalf("remove usage completion generation: %v", err) } + for _, column := range []string{"reported_cost_status", "reported_cost_usd_ticks"} { + if _, err := db.db.DB.ExecContext(ctx, fmt.Sprintf("ALTER TABLE usage_facts DROP COLUMN %s", column)); err != nil { + t.Fatalf("remove reported usage cost column %s: %v", column, err) + } + } if _, err := db.db.DB.ExecContext(ctx, `DROP TABLE grok_build_usage_import_files`); err != nil { t.Fatalf("restore unmarked Stable schema: %v", err) } diff --git a/internal/store/usage.go b/internal/store/usage.go index 46ad257..807c225 100644 --- a/internal/store/usage.go +++ b/internal/store/usage.go @@ -94,6 +94,28 @@ func (status UsageCostStatus) valid() bool { return status >= UsageCostStatusUnknown && status <= UsageCostStatusPartial } +type UsageReportedCostStatus int64 + +const ( + // These values are persisted in usage_facts and require a migration to change. + UsageReportedCostStatusUnknown UsageReportedCostStatus = 0 + UsageReportedCostStatusReported UsageReportedCostStatus = 1 + UsageReportedCostStatusPartial UsageReportedCostStatus = 2 +) + +func (status UsageReportedCostStatus) String() string { + switch status { + case UsageReportedCostStatusUnknown: + return "unknown" + case UsageReportedCostStatusReported: + return "reported" + case UsageReportedCostStatusPartial: + return "partial" + default: + return "" + } +} + type UsageSource struct { ID int64 ProviderID string @@ -108,17 +130,19 @@ type UsageSource struct { } type CreateUsageFactParams struct { - EventKey UsageKey - SourceID int64 - SessionKey string - ModelKey string - OccurredAtUnixMS int64 - InputTokens int64 - CachedInputTokens int64 - OutputTokens int64 - TotalTokens int64 - EstimatedCostMicros *int64 - CostStatus UsageCostStatus + EventKey UsageKey + SourceID int64 + SessionKey string + ModelKey string + OccurredAtUnixMS int64 + InputTokens int64 + CachedInputTokens int64 + OutputTokens int64 + TotalTokens int64 + EstimatedCostMicros *int64 + CostStatus UsageCostStatus + ReportedCostUSDTicks *int64 + ReportedCostStatus UsageReportedCostStatus } type UsageInsertResult struct { @@ -414,8 +438,8 @@ func (s *Store) insertUsageFactsWithSessionPolicy( INSERT INTO usage_facts ( event_key, source_id, session_id, model_id, occurred_at_unix_ms, input_tokens, cached_input_tokens, output_tokens, total_tokens, - estimated_cost_micros, cost_status - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + estimated_cost_micros, cost_status, reported_cost_usd_ticks, reported_cost_status + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(event_key) DO NOTHING `) if err != nil { @@ -443,6 +467,16 @@ func (s *Store) insertUsageFactsWithSessionPolicy( } defer costUpgradeStmt.Close() + reportedCostUpgradeStmt, err := s.executor().PrepareContext(ctx, ` + UPDATE usage_facts + SET reported_cost_usd_ticks = ?, reported_cost_status = ? + WHERE id = ? AND reported_cost_status = ? + `) + if err != nil { + return UsageInsertResult{}, err + } + defer reportedCostUpgradeStmt.Close() + sessionIDs := make(map[string]int64) modelIDs := make(map[string]int64) result := UsageInsertResult{} @@ -463,6 +497,13 @@ func (s *Store) insertUsageFactsWithSessionPolicy( if err != nil { return UsageInsertResult{}, err } + reportedStatus, reportedCost, err := usageReportedCostStorageValues( + fact.ReportedCostStatus, + fact.ReportedCostUSDTicks, + ) + if err != nil { + return UsageInsertResult{}, err + } insert, err := insertStmt.ExecContext( ctx, @@ -477,6 +518,8 @@ func (s *Store) insertUsageFactsWithSessionPolicy( fact.TotalTokens, cost, status, + reportedCost, + reportedStatus, ) if err != nil { return UsageInsertResult{}, err @@ -491,13 +534,14 @@ func (s *Store) insertUsageFactsWithSessionPolicy( } var existingID, existingSourceID, existingOccurredAtUnixMS int64 - var existingSessionID sql.NullInt64 + var existingSessionID, existingReportedCost sql.NullInt64 var existingSessionKey string - var inputTokens, cachedInputTokens, outputTokens, totalTokens int64 + var inputTokens, cachedInputTokens, outputTokens, totalTokens, existingReportedStatus int64 if err := s.executor().QueryRowContext(ctx, ` SELECT facts.id, facts.source_id, facts.session_id, COALESCE(sessions.session_key, ''), facts.occurred_at_unix_ms, - facts.input_tokens, facts.cached_input_tokens, facts.output_tokens, facts.total_tokens + facts.input_tokens, facts.cached_input_tokens, facts.output_tokens, facts.total_tokens, + facts.reported_cost_usd_ticks, facts.reported_cost_status FROM usage_facts AS facts LEFT JOIN usage_sessions AS sessions ON sessions.source_id = facts.source_id AND sessions.id = facts.session_id @@ -512,6 +556,8 @@ func (s *Store) insertUsageFactsWithSessionPolicy( &cachedInputTokens, &outputTokens, &totalTokens, + &existingReportedCost, + &existingReportedStatus, ); err != nil { return UsageInsertResult{}, err } @@ -522,6 +568,13 @@ func (s *Store) insertUsageFactsWithSessionPolicy( outputTokens != fact.OutputTokens || totalTokens != fact.TotalTokens { return UsageInsertResult{}, ErrUsageFactConflict } + if existingReportedStatus != int64(UsageReportedCostStatusUnknown) && + reportedStatus != UsageReportedCostStatusUnknown && + (!existingReportedCost.Valid || fact.ReportedCostUSDTicks == nil || + existingReportedCost.Int64 != *fact.ReportedCostUSDTicks || + existingReportedStatus != int64(reportedStatus)) { + return UsageInsertResult{}, ErrUsageFactConflict + } replaceObservation := fact.OccurredAtUnixMS > 0 && (existingOccurredAtUnixMS == 0 || fact.OccurredAtUnixMS < existingOccurredAtUnixMS) @@ -561,6 +614,17 @@ func (s *Store) insertUsageFactsWithSessionPolicy( return UsageInsertResult{}, err } } + if reportedStatus != UsageReportedCostStatusUnknown { + if _, err := reportedCostUpgradeStmt.ExecContext( + ctx, + reportedCost, + reportedStatus, + existingID, + UsageReportedCostStatusUnknown, + ); err != nil { + return UsageInsertResult{}, err + } + } result.Duplicates++ } return result, nil @@ -714,6 +778,26 @@ func usageCostStorageValues(status UsageCostStatus, estimatedCostMicros *int64) } } +func usageReportedCostStorageValues( + status UsageReportedCostStatus, + reportedCostUSDTicks *int64, +) (UsageReportedCostStatus, any, error) { + switch status { + case UsageReportedCostStatusUnknown: + if reportedCostUSDTicks != nil { + return 0, nil, errors.New("unknown reported usage cost must not include ticks") + } + return UsageReportedCostStatusUnknown, nil, nil + case UsageReportedCostStatusReported, UsageReportedCostStatusPartial: + if reportedCostUSDTicks == nil || *reportedCostUSDTicks <= 0 { + return 0, nil, errors.New("reported usage cost is invalid") + } + return status, *reportedCostUSDTicks, nil + default: + return 0, nil, errors.New("reported usage cost status is invalid") + } +} + func (s *Store) CompleteUsageSync(ctx context.Context, params CompleteUsageSyncParams) error { if err := validateUsageSyncCompletion(params); err != nil { return err diff --git a/internal/store/usage_facts_test.go b/internal/store/usage_facts_test.go index fe3db13..bc864cb 100644 --- a/internal/store/usage_facts_test.go +++ b/internal/store/usage_facts_test.go @@ -3,6 +3,7 @@ package store import ( "context" "database/sql" + "errors" "path/filepath" "strings" "testing" @@ -34,6 +35,52 @@ func TestUsageSchemaSupportsPartialCost(t *testing.T) { } } +func TestUsageFactsUpgradeReportedCostWithoutOverwritingIt(t *testing.T) { + ctx := context.Background() + db := openTestStore(t, ctx, filepath.Join(t.TempDir(), "profiledeck.db"), false) + defer closeTestStore(t, db) + if _, err := db.Migrate(ctx); err != nil { + t.Fatalf("migrate usage store: %v", err) + } + createUsageProviderFixture(t, ctx, db, "grok-build") + source, err := db.BeginUsageSync(ctx, "grok-build", "grok-build-session-jsonl", 1) + if err != nil { + t.Fatalf("begin usage sync: %v", err) + } + fact := CreateUsageFactParams{ + EventKey: testUsageKey("reported-cost-upgrade"), SourceID: source.ID, + SessionKey: "session", ModelKey: "grok-4.6-build", + InputTokens: 10, OutputTokens: 2, TotalTokens: 12, + CostStatus: UsageCostStatusUnknown, + } + if result, err := db.InsertUsageFacts(ctx, testUsageFactBatch(source, []CreateUsageFactParams{fact})); err != nil || result.Inserted != 1 { + t.Fatalf("insert unknown reported cost: result=%#v err=%v", result, err) + } + ticks := int64(5_452_000_000) + fact.ReportedCostUSDTicks = &ticks + fact.ReportedCostStatus = UsageReportedCostStatusReported + if result, err := db.InsertUsageFacts(ctx, testUsageFactBatch(source, []CreateUsageFactParams{fact})); err != nil || result.Duplicates != 1 { + t.Fatalf("upgrade reported cost: result=%#v err=%v", result, err) + } + if result, err := db.InsertUsageFacts(ctx, testUsageFactBatch(source, []CreateUsageFactParams{fact})); err != nil || result.Duplicates != 1 { + t.Fatalf("repeat reported cost: result=%#v err=%v", result, err) + } + otherTicks := ticks + 1 + fact.ReportedCostUSDTicks = &otherTicks + if _, err := db.InsertUsageFacts(ctx, testUsageFactBatch(source, []CreateUsageFactParams{fact})); !errors.Is(err, ErrUsageFactConflict) { + t.Fatalf("conflicting reported cost error = %v, want ErrUsageFactConflict", err) + } + var persistedTicks, persistedStatus int64 + if err := db.executor().QueryRowContext(ctx, ` + SELECT reported_cost_usd_ticks, reported_cost_status FROM usage_facts WHERE event_key = ? + `, fact.EventKey).Scan(&persistedTicks, &persistedStatus); err != nil { + t.Fatalf("read reported cost: %v", err) + } + if persistedTicks != ticks || persistedStatus != int64(UsageReportedCostStatusReported) { + t.Fatalf("persisted reported cost = %d/%d", persistedTicks, persistedStatus) + } +} + func TestUsageFactsAreIdempotentAndSummarized(t *testing.T) { ctx := context.Background() dbPath := filepath.Join(t.TempDir(), "profiledeck.db") diff --git a/internal/store/usage_grok_build.go b/internal/store/usage_grok_build.go index db14478..930a2ff 100644 --- a/internal/store/usage_grok_build.go +++ b/internal/store/usage_grok_build.go @@ -32,11 +32,12 @@ type GrokBuildUsageImportFile struct { } type CommitGrokBuildUsageImportParams struct { - ProviderID string - Generation int64 - Facts []CreateUsageFactParams - File GrokBuildUsageImportFile - Expected *GrokBuildUsageImportFile + ProviderID string + Generation int64 + Facts []CreateUsageFactParams + File GrokBuildUsageImportFile + Expected *GrokBuildUsageImportFile + ReplayExistingFacts bool } type GrokBuildUsageSyncFinalization struct { @@ -240,6 +241,13 @@ func validateGrokBuildUsageImportBatch(params CommitGrokBuildUsageImportParams) if err := validateGrokBuildUsageImportFile(*params.Expected); err != nil { return err } + if params.ReplayExistingFacts { + if params.File.ParserRevision <= params.Expected.ParserRevision || + params.File.ImportedFacts != int64(len(params.Facts)) { + return errors.New("invalid Grok Build usage parser replay") + } + return nil + } if params.File.ImportedFacts < params.Expected.ImportedFacts || params.File.ImportedFacts-params.Expected.ImportedFacts != int64(len(params.Facts)) { return errors.New("usage import progress does not match its Grok Build facts") diff --git a/internal/store/usage_report.go b/internal/store/usage_report.go index d73abd9..9959d2a 100644 --- a/internal/store/usage_report.go +++ b/internal/store/usage_report.go @@ -9,17 +9,21 @@ import ( ) type UsageSummary struct { - ProviderID string - Sources []string - EventCount int64 - InputTokens int64 - CachedInputTokens int64 - OutputTokens int64 - TotalTokens int64 - EstimatedCostMicros int64 - UnknownCostEvents int64 - PartialCostEvents int64 - EstimatedCostEventCount int64 + ProviderID string + Sources []string + EventCount int64 + InputTokens int64 + CachedInputTokens int64 + OutputTokens int64 + TotalTokens int64 + EstimatedCostMicros int64 + UnknownCostEvents int64 + PartialCostEvents int64 + EstimatedCostEventCount int64 + ReportedCostUSDTicks int64 + UnknownReportedCostEvents int64 + PartialReportedCostEvents int64 + ReportedCostEventCount int64 } type UsageReportQuery struct { @@ -35,19 +39,24 @@ type UsageTimeBucket struct { } type UsageAggregate struct { - EventCount int64 - SessionCount int64 - FreshInputTokens int64 - InputTokens int64 - CachedInputTokens int64 - OutputTokens int64 - TotalTokens int64 - EstimatedCostMicros int64 - EstimatedTokenCount int64 - UnknownCostEvents int64 - EstimatedCostEventCount int64 - PartialCostEventCount int64 - UndatedEventCount int64 + EventCount int64 + SessionCount int64 + FreshInputTokens int64 + InputTokens int64 + CachedInputTokens int64 + OutputTokens int64 + TotalTokens int64 + EstimatedCostMicros int64 + EstimatedTokenCount int64 + UnknownCostEvents int64 + EstimatedCostEventCount int64 + PartialCostEventCount int64 + ReportedCostUSDTicks int64 + ReportedCostTokenCount int64 + UnknownReportedCostEvents int64 + ReportedCostEventCount int64 + PartialReportedCostEventCount int64 + UndatedEventCount int64 } type UsageTrendAggregate struct { @@ -114,6 +123,10 @@ func (s *Store) usageSummary(ctx context.Context, providerID string) (UsageSumma summary.UnknownCostEvents = aggregate.UnknownCostEvents summary.PartialCostEvents = aggregate.PartialCostEventCount summary.EstimatedCostEventCount = aggregate.EstimatedCostEventCount + summary.ReportedCostUSDTicks = aggregate.ReportedCostUSDTicks + summary.UnknownReportedCostEvents = aggregate.UnknownReportedCostEvents + summary.PartialReportedCostEvents = aggregate.PartialReportedCostEventCount + summary.ReportedCostEventCount = aggregate.ReportedCostEventCount return summary, nil } @@ -407,11 +420,19 @@ func usageFactAggregateColumns(alias, undatedExpression string) string { COALESCE(SUM(CASE WHEN %[1]s.cost_status = %[4]d THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN %[1]s.cost_status = %[2]d THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN %[1]s.cost_status = %[3]d THEN 1 ELSE 0 END), 0), - %[5]s`, + COALESCE(SUM(COALESCE(%[1]s.reported_cost_usd_ticks, 0)), 0), + COALESCE(SUM(CASE WHEN %[1]s.reported_cost_status IN (%[5]d, %[6]d) THEN %[1]s.total_tokens ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN %[1]s.reported_cost_status = %[7]d THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN %[1]s.reported_cost_status = %[5]d THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN %[1]s.reported_cost_status = %[6]d THEN 1 ELSE 0 END), 0), + %[8]s`, alias, UsageCostStatusEstimated, UsageCostStatusPartial, UsageCostStatusUnknown, + UsageReportedCostStatusReported, + UsageReportedCostStatusPartial, + UsageReportedCostStatusUnknown, undatedExpression, ) } @@ -436,6 +457,11 @@ func usageAggregateScanTargets(aggregate *UsageAggregate) []any { &aggregate.UnknownCostEvents, &aggregate.EstimatedCostEventCount, &aggregate.PartialCostEventCount, + &aggregate.ReportedCostUSDTicks, + &aggregate.ReportedCostTokenCount, + &aggregate.UnknownReportedCostEvents, + &aggregate.ReportedCostEventCount, + &aggregate.PartialReportedCostEventCount, &aggregate.UndatedEventCount, } } diff --git a/internal/store/usage_report_test.go b/internal/store/usage_report_test.go index 05b3591..a3bc9a5 100644 --- a/internal/store/usage_report_test.go +++ b/internal/store/usage_report_test.go @@ -71,11 +71,14 @@ func TestUsageReportAggregatesRangeModelsBucketsAndImportHealth(t *testing.T) { cost10 := int64(10) cost20 := int64(20) cost5 := int64(5) + reported100 := int64(100) + reported200 := int64(200) + reported50 := int64(50) facts := []CreateUsageFactParams{ - {EventKey: testUsageKey("event-a1"), SourceID: source.ID, SessionKey: "session-a", ModelKey: "model-a", OccurredAtUnixMS: 1_000, InputTokens: 100, CachedInputTokens: 40, OutputTokens: 20, TotalTokens: 120, EstimatedCostMicros: &cost10, CostStatus: UsageCostStatusEstimated}, + {EventKey: testUsageKey("event-a1"), SourceID: source.ID, SessionKey: "session-a", ModelKey: "model-a", OccurredAtUnixMS: 1_000, InputTokens: 100, CachedInputTokens: 40, OutputTokens: 20, TotalTokens: 120, EstimatedCostMicros: &cost10, CostStatus: UsageCostStatusEstimated, ReportedCostUSDTicks: &reported100, ReportedCostStatus: UsageReportedCostStatusReported}, {EventKey: testUsageKey("event-a2"), SourceID: source.ID, SessionKey: "session-a", ModelKey: "model-a", OccurredAtUnixMS: 1_500, InputTokens: 50, CachedInputTokens: 10, OutputTokens: 10, TotalTokens: 60, CostStatus: UsageCostStatusUnknown}, - {EventKey: testUsageKey("event-b1"), SourceID: source.ID, SessionKey: "session-b", ModelKey: "model-b", OccurredAtUnixMS: 2_500, InputTokens: 80, CachedInputTokens: 80, OutputTokens: 20, TotalTokens: 100, EstimatedCostMicros: &cost20, CostStatus: UsageCostStatusEstimated}, - {EventKey: testUsageKey("event-undated"), SourceID: source.ID, SessionKey: "session-c", ModelKey: "model-b", InputTokens: 30, OutputTokens: 5, TotalTokens: 35, EstimatedCostMicros: &cost5, CostStatus: UsageCostStatusEstimated}, + {EventKey: testUsageKey("event-b1"), SourceID: source.ID, SessionKey: "session-b", ModelKey: "model-b", OccurredAtUnixMS: 2_500, InputTokens: 80, CachedInputTokens: 80, OutputTokens: 20, TotalTokens: 100, EstimatedCostMicros: &cost20, CostStatus: UsageCostStatusEstimated, ReportedCostUSDTicks: &reported200, ReportedCostStatus: UsageReportedCostStatusPartial}, + {EventKey: testUsageKey("event-undated"), SourceID: source.ID, SessionKey: "session-c", ModelKey: "model-b", InputTokens: 30, OutputTokens: 5, TotalTokens: 35, EstimatedCostMicros: &cost5, CostStatus: UsageCostStatusEstimated, ReportedCostUSDTicks: &reported50, ReportedCostStatus: UsageReportedCostStatusReported}, } if result, err := db.InsertUsageFacts(ctx, testUsageFactBatch(source, facts)); err != nil || result.Inserted != len(facts) { t.Fatalf("expected usage fixture insert, result=%#v err=%v", result, err) @@ -115,6 +118,11 @@ func TestUsageReportAggregatesRangeModelsBucketsAndImportHealth(t *testing.T) { if report.Summary.EstimatedCostMicros != 30 || report.Summary.EstimatedTokenCount != 220 || report.Summary.UnknownCostEvents != 1 || report.Summary.UndatedEventCount != 1 { t.Fatalf("unexpected ranged cost and undated aggregate: %#v", report.Summary) } + if report.Summary.ReportedCostUSDTicks != 300 || report.Summary.ReportedCostTokenCount != 220 || + report.Summary.ReportedCostEventCount != 1 || report.Summary.PartialReportedCostEventCount != 1 || + report.Summary.UnknownReportedCostEvents != 1 { + t.Fatalf("unexpected ranged reported cost aggregate: %#v", report.Summary) + } if len(report.Trend) != 3 || report.Trend[0].TotalTokens != 180 || report.Trend[1].EventCount != 0 || report.Trend[2].TotalTokens != 100 { t.Fatalf("unexpected zero-filled trend: %#v", report.Trend) } diff --git a/internal/usage/codex_sync.go b/internal/usage/codex_sync.go index 494b12c..b98880e 100644 --- a/internal/usage/codex_sync.go +++ b/internal/usage/codex_sync.go @@ -593,17 +593,19 @@ func usageEventsToFactParams(sourceID int64, events []Event) []store.CreateUsage facts := make([]store.CreateUsageFactParams, 0, len(events)) for _, event := range events { facts = append(facts, store.CreateUsageFactParams{ - EventKey: event.EventKey, - SourceID: sourceID, - SessionKey: event.SessionID, - ModelKey: event.Model, - OccurredAtUnixMS: event.OccurredAtUnixMS, - InputTokens: event.InputTokens, - CachedInputTokens: event.CachedInputTokens, - OutputTokens: event.OutputTokens, - TotalTokens: event.TotalTokens, - EstimatedCostMicros: event.EstimatedCostMicros, - CostStatus: event.CostStatus, + EventKey: event.EventKey, + SourceID: sourceID, + SessionKey: event.SessionID, + ModelKey: event.Model, + OccurredAtUnixMS: event.OccurredAtUnixMS, + InputTokens: event.InputTokens, + CachedInputTokens: event.CachedInputTokens, + OutputTokens: event.OutputTokens, + TotalTokens: event.TotalTokens, + EstimatedCostMicros: event.EstimatedCostMicros, + CostStatus: event.CostStatus, + ReportedCostUSDTicks: event.ReportedCostUSDTicks, + ReportedCostStatus: event.ReportedCostStatus, }) } return facts diff --git a/internal/usage/grok_build.go b/internal/usage/grok_build.go index b53607f..b3f0f09 100644 --- a/internal/usage/grok_build.go +++ b/internal/usage/grok_build.go @@ -23,7 +23,7 @@ import ( const ( SourceGrokBuildSessionJSONL = "grok-build-session-jsonl" - GrokBuildUsageParserRevision = int64(2) + GrokBuildUsageParserRevision = int64(3) GrokBuildUsageIdentityRevision = int64(1) maxGrokBuildSessionLineBytes = 16 * 1024 * 1024 @@ -447,17 +447,20 @@ func grokBuildEventsFromTerminal( if cost != nil && row.CacheCreationTokens.value > 0 { status = CostStatusPartial } + reportedCost, reportedStatus := grokBuildReportedCost(row, *usage, len(models) == 1) events = append(events, Event{ - EventKey: GrokBuildEventID(promptID, model), - SessionID: sessionKey, - Model: model, - OccurredAtUnixMS: occurredAt, - InputTokens: tokens.InputTokens, - CachedInputTokens: tokens.CachedInputTokens, - OutputTokens: tokens.OutputTokens, - TotalTokens: tokens.TotalTokens, - EstimatedCostMicros: cost, - CostStatus: status, + EventKey: GrokBuildEventID(promptID, model), + SessionID: sessionKey, + Model: model, + OccurredAtUnixMS: occurredAt, + InputTokens: tokens.InputTokens, + CachedInputTokens: tokens.CachedInputTokens, + OutputTokens: tokens.OutputTokens, + TotalTokens: tokens.TotalTokens, + EstimatedCostMicros: cost, + CostStatus: status, + ReportedCostUSDTicks: reportedCost, + ReportedCostStatus: reportedStatus, }) } if len(events) == 0 { @@ -466,6 +469,29 @@ func grokBuildEventsFromTerminal( return events, false, nil } +func grokBuildReportedCost( + model grokBuildUsageModel, + usage grokBuildUsage, + singleModel bool, +) (*int64, store.UsageReportedCostStatus) { + ticks := model.CostUSDTicks + partial := model.CostIsPartial.set && model.CostIsPartial.value + if ticks == nil || *ticks <= 0 { + if !singleModel || usage.CostUSDTicks == nil || *usage.CostUSDTicks <= 0 { + return nil, ReportedCostStatusUnknown + } + ticks = usage.CostUSDTicks + partial = usage.CostIsPartial.set && usage.CostIsPartial.value + } else if singleModel && usage.CostIsPartial.set && usage.CostIsPartial.value { + partial = true + } + value := *ticks + if partial { + return &value, ReportedCostStatusPartial + } + return &value, ReportedCostStatusReported +} + func validateGrokBuildUsage(usage grokBuildUsage) error { if !usage.NumTurns.set { return errors.New("terminal usage is missing turn totals") diff --git a/internal/usage/grok_build_sync.go b/internal/usage/grok_build_sync.go index 0a4f868..695bf90 100644 --- a/internal/usage/grok_build_sync.go +++ b/internal/usage/grok_build_sync.go @@ -133,6 +133,7 @@ func (integration grokBuildIntegration) Sync( continue } eventsToStore := parsed.Events + replayExistingFacts := false importedFacts := int64(len(parsed.Events)) invalidLines := parsed.InvalidLines unsupportedLines := parsed.UnsupportedLines @@ -143,7 +144,12 @@ func (integration grokBuildIntegration) Sync( continue } if fullParse { - eventsToStore = parsed.Events[cursor.ImportedFacts:] + if cursor.ParserRevision == GrokBuildUsageParserRevision { + eventsToStore = parsed.Events[cursor.ImportedFacts:] + } else { + eventsToStore = parsed.Events + replayExistingFacts = true + } importedFacts = int64(len(parsed.Events)) } else { importedFacts = cursor.ImportedFacts + int64(len(parsed.Events)) @@ -178,11 +184,12 @@ func (integration grokBuildIntegration) Sync( insertResult, err := db.CommitGrokBuildUsageImport( ctx, store.CommitGrokBuildUsageImportParams{ - ProviderID: grokconfig.ProviderID, - Generation: source.SyncGeneration, - Facts: usageEventsToFactParams(source.ID, eventsToStore), - File: desired, - Expected: expected, + ProviderID: grokconfig.ProviderID, + Generation: source.SyncGeneration, + Facts: usageEventsToFactParams(source.ID, eventsToStore), + File: desired, + Expected: expected, + ReplayExistingFacts: replayExistingFacts, }, ) if errors.Is(err, store.ErrUsageCursorConflict) { diff --git a/internal/usage/grok_build_sync_test.go b/internal/usage/grok_build_sync_test.go index ea5f5db..4af1625 100644 --- a/internal/usage/grok_build_sync_test.go +++ b/internal/usage/grok_build_sync_test.go @@ -131,11 +131,58 @@ func TestUsageSyncGrokBuildImportsCurrentSessionFormat(t *testing.T) { }) if err != nil || report.Summary.EventCount != 1 || report.Summary.TotalTokens != 1_100 || report.Summary.CostStatus != CostStatusPartial.String() || - report.Summary.KnownEstimatedCostUSD != "0.002300" || report.Summary.PartialCostEventCount != 1 { + report.Summary.KnownEstimatedCostUSD != "0.002300" || report.Summary.PartialCostEventCount != 1 || + report.Summary.KnownReportedCostUSD != "0.0000012345" || + report.Summary.ReportedCostStatus != ReportedCostStatusReported.String() || + report.Summary.ReportedCostEventCount != 1 || report.Summary.ReportedCostCoverage != 1 { t.Fatalf("current Grok Build report = %#v, err = %v", report, err) } } +func TestUsageSyncGrokBuildParserUpgradeBackfillsReportedCost(t *testing.T) { + ctx := context.Background() + configDir := t.TempDir() + grokHome := t.TempDir() + fixture, err := os.ReadFile(filepath.Join("testdata", "grok-build-v1.0.25", "valid.jsonl")) + if err != nil { + t.Fatalf("read current Grok Build fixture: %v", err) + } + writeGrokBuildUsageFixture(t, grokHome, "workspace-upgrade", "session-upgrade", string(fixture)) + environment := newGrokBuildUsageTestEnvironment(t, configDir, grokHome) + initialized, err := bootstrap.NewService(environment.runtime, nil, nil).Initialize(ctx) + if err != nil { + t.Fatalf("initialize runtime: %v", err) + } + if _, err := environment.service.SyncGrokBuild(ctx); err != nil { + t.Fatalf("initial Grok Build sync: %v", err) + } + + rawDB, err := sql.Open("sqlite", initialized.DatabasePath) + if err != nil { + t.Fatalf("open usage database: %v", err) + } + if _, err := rawDB.ExecContext(ctx, ` + UPDATE usage_facts SET reported_cost_usd_ticks = NULL, reported_cost_status = 0; + UPDATE grok_build_usage_import_files SET parser_revision = ? + `, GrokBuildUsageParserRevision-1); err != nil { + _ = rawDB.Close() + t.Fatalf("downgrade reported cost fixture: %v", err) + } + if err := rawDB.Close(); err != nil { + t.Fatalf("close usage database: %v", err) + } + + result, err := environment.service.SyncGrokBuild(ctx) + if err != nil || result.SkippedDuplicateEvents != 1 || result.InvalidLines != 0 { + t.Fatalf("parser upgrade sync = %#v, err = %v, cause = %v", result, err, errors.Unwrap(err)) + } + summary, err := environment.service.Summary(ctx, UsageSummaryRequest{ProviderID: grokconfig.ProviderID}) + if err != nil || summary.ReportedCostUSD == nil || *summary.ReportedCostUSD != "0.0000012345" || + summary.ReportedCostStatus != ReportedCostStatusReported.String() { + t.Fatalf("backfilled reported cost summary = %#v, err = %v", summary, err) + } +} + func TestBackgroundGrokBuildSyncRetriesObservationAfterParserUpgrade(t *testing.T) { ctx := context.Background() configDir := t.TempDir() diff --git a/internal/usage/grok_build_test.go b/internal/usage/grok_build_test.go index 1246c44..b4e4830 100644 --- a/internal/usage/grok_build_test.go +++ b/internal/usage/grok_build_test.go @@ -45,6 +45,10 @@ func TestParseGrokBuildSyntheticFixture(t *testing.T) { first.CostStatus != CostStatusEstimated { t.Fatalf("first event cost = %#v", first) } + if first.ReportedCostUSDTicks == nil || *first.ReportedCostUSDTicks != 999 || + first.ReportedCostStatus != ReportedCostStatusReported { + t.Fatalf("first event reported cost = %#v", first) + } known, unknown := result.Events[1], result.Events[2] if known.Model != "grok-4.5" || known.OccurredAtUnixMS != 0 || @@ -60,6 +64,11 @@ func TestParseGrokBuildSyntheticFixture(t *testing.T) { if known.SessionID != unknown.SessionID { t.Fatalf("one terminal produced different session keys: %q != %q", known.SessionID, unknown.SessionID) } + if known.ReportedCostUSDTicks != nil || known.ReportedCostStatus != ReportedCostStatusUnknown || + unknown.ReportedCostUSDTicks == nil || *unknown.ReportedCostUSDTicks != 123 || + unknown.ReportedCostStatus != ReportedCostStatusPartial { + t.Fatalf("model reported costs = known %#v, unknown %#v", known, unknown) + } } func TestParseGrokBuildQuarantinesMalformedFixtures(t *testing.T) { @@ -94,11 +103,42 @@ func TestParseGrokBuildCurrentSessionFixture(t *testing.T) { if event.Model != "grok-4.6-build" || event.InputTokens != 1_000 || event.CachedInputTokens != 200 || event.OutputTokens != 100 || event.TotalTokens != 1_100 || event.EstimatedCostMicros == nil || - *event.EstimatedCostMicros != 2_300 || event.CostStatus != CostStatusPartial { + *event.EstimatedCostMicros != 2_300 || event.CostStatus != CostStatusPartial || + event.ReportedCostUSDTicks == nil || *event.ReportedCostUSDTicks != 12_345 || + event.ReportedCostStatus != ReportedCostStatusReported { t.Fatalf("current Grok Build event = %#v", event) } } +func TestParseGrokBuildUsesTopLevelReportedCostOnlyForSingleModel(t *testing.T) { + single := `{"timestamp":1,"method":"_x.ai/session/update","params":{"sessionId":"session","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt","stop_reason":"end_turn","usage":{"inputTokens":10,"outputTokens":2,"totalTokens":12,"cachedReadTokens":1,"reasoningTokens":1,"modelCalls":1,"apiDurationMs":5,"costUsdTicks":7,"costIsPartial":true,"modelUsage":{"grok-build-latest":{"inputTokens":10,"outputTokens":2,"totalTokens":12,"cachedReadTokens":1,"reasoningTokens":1,"modelCalls":1,"apiDurationMs":5}},"numTurns":1}},"_meta":{}}}` + events, skipped, err := parseGrokBuildSessionLine([]byte(single)) + if err != nil || skipped || len(events) != 1 || events[0].ReportedCostUSDTicks == nil || + *events[0].ReportedCostUSDTicks != 7 || events[0].ReportedCostStatus != ReportedCostStatusPartial { + t.Fatalf("single-model reported cost fallback = %#v, skipped=%t, err=%v", events, skipped, err) + } + + multiple := strings.Replace(single, + `"inputTokens":10,"outputTokens":2,"totalTokens":12,"cachedReadTokens":1,"reasoningTokens":1,"modelCalls":1,"apiDurationMs":5,"costUsdTicks":7`, + `"inputTokens":20,"outputTokens":4,"totalTokens":24,"cachedReadTokens":2,"reasoningTokens":2,"modelCalls":2,"apiDurationMs":10,"costUsdTicks":7`, + 1, + ) + multiple = strings.Replace(multiple, + `"modelUsage":{"grok-build-latest":{"inputTokens":10,"outputTokens":2,"totalTokens":12,"cachedReadTokens":1,"reasoningTokens":1,"modelCalls":1,"apiDurationMs":5}}`, + `"modelUsage":{"grok-build-latest":{"inputTokens":10,"outputTokens":2,"totalTokens":12,"cachedReadTokens":1,"reasoningTokens":1,"modelCalls":1,"apiDurationMs":5},"grok-4.5":{"inputTokens":10,"outputTokens":2,"totalTokens":12,"cachedReadTokens":1,"reasoningTokens":1,"modelCalls":1,"apiDurationMs":5}}`, + 1, + ) + events, skipped, err = parseGrokBuildSessionLine([]byte(multiple)) + if err != nil || skipped || len(events) != 2 { + t.Fatalf("multi-model reported cost parse = %#v, skipped=%t, err=%v", events, skipped, err) + } + for _, event := range events { + if event.ReportedCostUSDTicks != nil || event.ReportedCostStatus != ReportedCostStatusUnknown { + t.Fatalf("top-level reported cost was duplicated across models: %#v", events) + } + } +} + func TestParseGrokBuildTerminalValidationFailsClosed(t *testing.T) { base := `{"timestamp":1,"method":"_x.ai/session/update","params":{"sessionId":"session","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt","stop_reason":"end_turn","agent_result":"discard me","usage":{"inputTokens":10,"outputTokens":2,"totalTokens":12,"cachedReadTokens":1,"reasoningTokens":1,"modelCalls":1,"apiDurationMs":5,"costUsdTicks":7,"costIsPartial":false,"modelUsage":{"grok-build-latest":{"inputTokens":10,"outputTokens":2,"totalTokens":12,"cachedReadTokens":1,"reasoningTokens":1,"modelCalls":1,"apiDurationMs":5,"costUsdTicks":7,"costIsPartial":false}},"numTurns":1}},"_meta":{}}}` tests := map[string]string{ @@ -192,6 +232,12 @@ func TestGrokBuildEventAndSessionIdentityContracts(t *testing.T) { if GrokBuildEventDigest([]Event{event}, 1) != GrokBuildEventDigest([]Event{alias}, 1) { t.Fatal("prefix digest included fork-local session or timestamp") } + reportedTicks := int64(123) + alias.ReportedCostUSDTicks = &reportedTicks + alias.ReportedCostStatus = ReportedCostStatusReported + if GrokBuildEventDigest([]Event{event}, 1) != GrokBuildEventDigest([]Event{alias}, 1) { + t.Fatal("prefix digest included parser-derived reported cost") + } alias.TotalTokens++ if GrokBuildEventDigest([]Event{event}, 1) == GrokBuildEventDigest([]Event{alias}, 1) { t.Fatal("prefix digest did not cover token values") diff --git a/internal/usage/report_service.go b/internal/usage/report_service.go index 2bd2c29..c8b2cb9 100644 --- a/internal/usage/report_service.go +++ b/internal/usage/report_service.go @@ -31,22 +31,29 @@ type UsageResolvedRange struct { } type UsageAggregateSummary struct { - EventCount int64 `json:"event_count"` - SessionCount int64 `json:"session_count"` - FreshInputTokens int64 `json:"fresh_input_tokens"` - InputTokens int64 `json:"input_tokens"` - CachedInputTokens int64 `json:"cached_input_tokens"` - OutputTokens int64 `json:"output_tokens"` - TotalTokens int64 `json:"total_tokens"` - CacheHitRate float64 `json:"cache_hit_rate"` - KnownEstimatedCostUSD string `json:"known_estimated_cost_usd"` - CostStatus string `json:"cost_status"` - EstimatedCostEventCount int64 `json:"estimated_cost_event_count"` - PartialCostEventCount int64 `json:"partial_cost_event_count"` - UnknownCostEventCount int64 `json:"unknown_cost_event_count"` - EstimatedTokenCount int64 `json:"estimated_token_count"` - PricingCoverage float64 `json:"pricing_coverage"` - UndatedEventCount int64 `json:"undated_event_count"` + EventCount int64 `json:"event_count"` + SessionCount int64 `json:"session_count"` + FreshInputTokens int64 `json:"fresh_input_tokens"` + InputTokens int64 `json:"input_tokens"` + CachedInputTokens int64 `json:"cached_input_tokens"` + OutputTokens int64 `json:"output_tokens"` + TotalTokens int64 `json:"total_tokens"` + CacheHitRate float64 `json:"cache_hit_rate"` + KnownEstimatedCostUSD string `json:"known_estimated_cost_usd"` + CostStatus string `json:"cost_status"` + EstimatedCostEventCount int64 `json:"estimated_cost_event_count"` + PartialCostEventCount int64 `json:"partial_cost_event_count"` + UnknownCostEventCount int64 `json:"unknown_cost_event_count"` + EstimatedTokenCount int64 `json:"estimated_token_count"` + PricingCoverage float64 `json:"pricing_coverage"` + KnownReportedCostUSD string `json:"known_reported_cost_usd"` + ReportedCostStatus string `json:"reported_cost_status"` + ReportedCostEventCount int64 `json:"reported_cost_event_count"` + PartialReportedCostEventCount int64 `json:"partial_reported_cost_event_count"` + UnknownReportedCostEventCount int64 `json:"unknown_reported_cost_event_count"` + ReportedCostTokenCount int64 `json:"reported_cost_token_count"` + ReportedCostCoverage float64 `json:"reported_cost_coverage"` + UndatedEventCount int64 `json:"undated_event_count"` } type UsageTrendPoint struct { @@ -188,29 +195,55 @@ func (service *Service) usageReportAt(ctx context.Context, req UsageReportReques func usageAggregateSummary(aggregate store.UsageAggregate) UsageAggregateSummary { cacheHitRate := ratio(aggregate.CachedInputTokens, aggregate.InputTokens) pricingCoverage := ratio(aggregate.EstimatedTokenCount, aggregate.TotalTokens) + reportedCostCoverage := ratio(aggregate.ReportedCostTokenCount, aggregate.TotalTokens) costStatus := CostStatusUnknown.String() if aggregate.EventCount > 0 && aggregate.UnknownCostEvents == 0 && aggregate.PartialCostEventCount > 0 { costStatus = CostStatusPartial.String() } else if aggregate.EventCount > 0 && aggregate.UnknownCostEvents == 0 { costStatus = CostStatusEstimated.String() } + reportedCostStatus := aggregateReportedCostStatus( + aggregate.EventCount, + aggregate.ReportedCostEventCount, + aggregate.PartialReportedCostEventCount, + aggregate.UnknownReportedCostEvents, + ) return UsageAggregateSummary{ - EventCount: aggregate.EventCount, - SessionCount: aggregate.SessionCount, - FreshInputTokens: aggregate.FreshInputTokens, - InputTokens: aggregate.InputTokens, - CachedInputTokens: aggregate.CachedInputTokens, - OutputTokens: aggregate.OutputTokens, - TotalTokens: aggregate.TotalTokens, - CacheHitRate: cacheHitRate, - KnownEstimatedCostUSD: USDStringFromMicros(aggregate.EstimatedCostMicros), - CostStatus: costStatus, - EstimatedCostEventCount: aggregate.EstimatedCostEventCount, - PartialCostEventCount: aggregate.PartialCostEventCount, - UnknownCostEventCount: aggregate.UnknownCostEvents, - EstimatedTokenCount: aggregate.EstimatedTokenCount, - PricingCoverage: pricingCoverage, - UndatedEventCount: aggregate.UndatedEventCount, + EventCount: aggregate.EventCount, + SessionCount: aggregate.SessionCount, + FreshInputTokens: aggregate.FreshInputTokens, + InputTokens: aggregate.InputTokens, + CachedInputTokens: aggregate.CachedInputTokens, + OutputTokens: aggregate.OutputTokens, + TotalTokens: aggregate.TotalTokens, + CacheHitRate: cacheHitRate, + KnownEstimatedCostUSD: USDStringFromMicros(aggregate.EstimatedCostMicros), + CostStatus: costStatus, + EstimatedCostEventCount: aggregate.EstimatedCostEventCount, + PartialCostEventCount: aggregate.PartialCostEventCount, + UnknownCostEventCount: aggregate.UnknownCostEvents, + EstimatedTokenCount: aggregate.EstimatedTokenCount, + PricingCoverage: pricingCoverage, + KnownReportedCostUSD: USDStringFromTicks(aggregate.ReportedCostUSDTicks), + ReportedCostStatus: reportedCostStatus, + ReportedCostEventCount: aggregate.ReportedCostEventCount, + PartialReportedCostEventCount: aggregate.PartialReportedCostEventCount, + UnknownReportedCostEventCount: aggregate.UnknownReportedCostEvents, + ReportedCostTokenCount: aggregate.ReportedCostTokenCount, + ReportedCostCoverage: reportedCostCoverage, + UndatedEventCount: aggregate.UndatedEventCount, + } +} + +func aggregateReportedCostStatus(eventCount, reported, partial, unknown int64) string { + known := reported + partial + switch { + case eventCount <= 0 || known <= 0: + return ReportedCostStatusUnknown.String() + case partial > 0 || unknown > 0 || known < eventCount: + return ReportedCostStatusPartial.String() + default: + return ReportedCostStatusReported.String() } } diff --git a/internal/usage/report_service_test.go b/internal/usage/report_service_test.go index da98379..706f543 100644 --- a/internal/usage/report_service_test.go +++ b/internal/usage/report_service_test.go @@ -159,6 +159,30 @@ func TestUsageReportEmptyDefaultsAndValidation(t *testing.T) { assertAppErrorCode(t, err, apperror.UsageInvalid) } +func TestAggregateReportedCostStatusDistinguishesPartialCoverage(t *testing.T) { + tests := []struct { + name string + events int64 + reported int64 + partial int64 + unknown int64 + want string + }{ + {name: "empty", want: "unknown"}, + {name: "all missing", events: 2, unknown: 2, want: "unknown"}, + {name: "fully reported", events: 2, reported: 2, want: "reported"}, + {name: "provider marked partial", events: 2, reported: 1, partial: 1, want: "partial"}, + {name: "mixed coverage", events: 2, reported: 1, unknown: 1, want: "partial"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := aggregateReportedCostStatus(test.events, test.reported, test.partial, test.unknown); got != test.want { + t.Fatalf("reported cost status = %q, want %q", got, test.want) + } + }) + } +} + func reportEvent(id, sessionID, model string, occurredAt, input, cached, output int64, cost *int64, status store.UsageCostStatus) store.CreateUsageFactParams { return store.CreateUsageFactParams{ EventKey: usageTestEventKey(id), SessionKey: sessionID, ModelKey: model, OccurredAtUnixMS: occurredAt, diff --git a/internal/usage/service.go b/internal/usage/service.go index bdbd531..6148e99 100644 --- a/internal/usage/service.go +++ b/internal/usage/service.go @@ -49,18 +49,23 @@ type UsageSummaryRequest struct { } type UsageSummaryResult struct { - ProviderID string `json:"provider_id"` - Source string `json:"source"` - Sources []string `json:"sources"` - EventCount int64 `json:"event_count"` - InputTokens int64 `json:"input_tokens"` - CachedInputTokens int64 `json:"cached_input_tokens"` - OutputTokens int64 `json:"output_tokens"` - TotalTokens int64 `json:"total_tokens"` - EstimatedCostUSD *string `json:"estimated_cost_usd"` - CostStatus string `json:"cost_status"` - UnknownCostEventCount int64 `json:"unknown_cost_event_count"` - EstimatedCostEventCount int64 `json:"estimated_cost_event_count"` + ProviderID string `json:"provider_id"` + Source string `json:"source"` + Sources []string `json:"sources"` + EventCount int64 `json:"event_count"` + InputTokens int64 `json:"input_tokens"` + CachedInputTokens int64 `json:"cached_input_tokens"` + OutputTokens int64 `json:"output_tokens"` + TotalTokens int64 `json:"total_tokens"` + EstimatedCostUSD *string `json:"estimated_cost_usd"` + CostStatus string `json:"cost_status"` + UnknownCostEventCount int64 `json:"unknown_cost_event_count"` + EstimatedCostEventCount int64 `json:"estimated_cost_event_count"` + ReportedCostUSD *string `json:"reported_cost_usd"` + ReportedCostStatus string `json:"reported_cost_status"` + UnknownReportedCostEventCount int64 `json:"unknown_reported_cost_event_count"` + ReportedCostEventCount int64 `json:"reported_cost_event_count"` + PartialReportedCostEventCount int64 `json:"partial_reported_cost_event_count"` } func (service *Service) Sync(ctx context.Context, req UsageSyncRequest) (UsageSyncResult, error) { @@ -265,18 +270,32 @@ func (service *Service) Summary(ctx context.Context, req UsageSummaryRequest) (U return UsageSummaryResult{}, apperror.Wrap(apperror.StoreStatusFailed, "failed to read usage summary", err) } result := UsageSummaryResult{ - ProviderID: providerID, - Source: summarySource(summary.Sources), - Sources: summary.Sources, - EventCount: summary.EventCount, - InputTokens: summary.InputTokens, - CachedInputTokens: summary.CachedInputTokens, - OutputTokens: summary.OutputTokens, - TotalTokens: summary.TotalTokens, - CostStatus: CostStatusEstimated.String(), - UnknownCostEventCount: summary.UnknownCostEvents + summary.PartialCostEvents, - EstimatedCostEventCount: summary.EstimatedCostEventCount, + ProviderID: providerID, + Source: summarySource(summary.Sources), + Sources: summary.Sources, + EventCount: summary.EventCount, + InputTokens: summary.InputTokens, + CachedInputTokens: summary.CachedInputTokens, + OutputTokens: summary.OutputTokens, + TotalTokens: summary.TotalTokens, + CostStatus: CostStatusEstimated.String(), + UnknownCostEventCount: summary.UnknownCostEvents + summary.PartialCostEvents, + EstimatedCostEventCount: summary.EstimatedCostEventCount, + ReportedCostStatus: ReportedCostStatusUnknown.String(), + UnknownReportedCostEventCount: summary.UnknownReportedCostEvents, + ReportedCostEventCount: summary.ReportedCostEventCount, + PartialReportedCostEventCount: summary.PartialReportedCostEvents, } + if summary.ReportedCostEventCount+summary.PartialReportedCostEvents > 0 { + reportedCost := USDStringFromTicks(summary.ReportedCostUSDTicks) + result.ReportedCostUSD = &reportedCost + } + result.ReportedCostStatus = aggregateReportedCostStatus( + summary.EventCount, + summary.ReportedCostEventCount, + summary.PartialReportedCostEvents, + summary.UnknownReportedCostEvents, + ) // The legacy summary contract has no partial-cost state. Keep treating any // incomplete subtotal as unknown instead of overstating precision. if result.UnknownCostEventCount > 0 { diff --git a/internal/usage/usage.go b/internal/usage/usage.go index 9bb250c..433ec7a 100644 --- a/internal/usage/usage.go +++ b/internal/usage/usage.go @@ -14,18 +14,21 @@ import ( ) const ( - ProviderCodex = "codex" - SourceCodexSessionJSONL = "codex-session-jsonl" - CostStatusEstimated = store.UsageCostStatusEstimated - CostStatusPartial = store.UsageCostStatusPartial - CostStatusUnknown = store.UsageCostStatusUnknown - CodexUsageParserRevision = int64(1) - PricingBasis = "openai-standard-api" - PricingSourceURL = "https://developers.openai.com/api/docs/pricing" - PricingVerifiedAt = "2026-09-12" - GrokBuildPricingBasis = "xai-standard-api-short-context" - GrokBuildPricingSource = "https://docs.x.ai/developers/pricing" - GrokBuildPricingVerified = "2026-09-12" + ProviderCodex = "codex" + SourceCodexSessionJSONL = "codex-session-jsonl" + CostStatusEstimated = store.UsageCostStatusEstimated + CostStatusPartial = store.UsageCostStatusPartial + CostStatusUnknown = store.UsageCostStatusUnknown + ReportedCostStatusReported = store.UsageReportedCostStatusReported + ReportedCostStatusPartial = store.UsageReportedCostStatusPartial + ReportedCostStatusUnknown = store.UsageReportedCostStatusUnknown + CodexUsageParserRevision = int64(1) + PricingBasis = "openai-standard-api" + PricingSourceURL = "https://developers.openai.com/api/docs/pricing" + PricingVerifiedAt = "2026-09-12" + GrokBuildPricingBasis = "xai-standard-api-short-context" + GrokBuildPricingSource = "https://docs.x.ai/developers/pricing" + GrokBuildPricingVerified = "2026-09-12" ) // CodexUsageIdentityRevision changes whenever fact identity semantics change; @@ -40,16 +43,18 @@ type TokenCounts struct { } type Event struct { - EventKey store.UsageKey - SessionID string - Model string - OccurredAtUnixMS int64 - InputTokens int64 - CachedInputTokens int64 - OutputTokens int64 - TotalTokens int64 - EstimatedCostMicros *int64 - CostStatus store.UsageCostStatus + EventKey store.UsageKey + SessionID string + Model string + OccurredAtUnixMS int64 + InputTokens int64 + CachedInputTokens int64 + OutputTokens int64 + TotalTokens int64 + EstimatedCostMicros *int64 + CostStatus store.UsageCostStatus + ReportedCostUSDTicks *int64 + ReportedCostStatus store.UsageReportedCostStatus } type SourceFile struct { @@ -339,3 +344,10 @@ func USDStringFromMicros(micros int64) string { } return fmt.Sprintf("%d.%06d", micros/1_000_000, micros%1_000_000) } + +func USDStringFromTicks(ticks int64) string { + if ticks < 0 { + ticks = 0 + } + return fmt.Sprintf("%d.%010d", ticks/10_000_000_000, ticks%10_000_000_000) +} From fea5dacfb75b4015fd747e2e38cc737ad5c356d2 Mon Sep 17 00:00:00 2001 From: Lee <7932644+strahe@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:40:02 +0800 Subject: [PATCH 2/2] fix(usage): correct Grok-reported cost reporting --- .../src/features/usage/UsageModelTable.svelte | 4 +- .../src/features/usage/UsagePage.svelte | 14 +++++-- .../src/features/usage/UsageTrendChart.svelte | 13 +++--- desktop/frontend/src/lib/i18n.ts | 18 ++++---- desktop/frontend/tests/usage-page.test.ts | 41 ++++++++++++++++++- docs/grok-build/usage-cost.md | 4 +- internal/cli/usage.go | 2 +- internal/store/usage_grok_build.go | 5 ++- internal/store/usage_report.go | 2 +- internal/store/usage_report_test.go | 2 +- internal/usage/grok_build_sync.go | 8 +--- internal/usage/grok_build_sync_test.go | 40 ++++++++++++++++++ internal/usage/report_service_test.go | 16 ++++++++ 13 files changed, 136 insertions(+), 33 deletions(-) diff --git a/desktop/frontend/src/features/usage/UsageModelTable.svelte b/desktop/frontend/src/features/usage/UsageModelTable.svelte index 774de5b..c2450c2 100644 --- a/desktop/frontend/src/features/usage/UsageModelTable.svelte +++ b/desktop/frontend/src/features/usage/UsageModelTable.svelte @@ -60,8 +60,8 @@ {#if showReportedCost}
    - {model.summary.reported_cost_coverage > 0 ? formatReportedCurrency(model.summary.known_reported_cost_usd) : "—"} - {#if model.summary.reported_cost_status !== "reported"}{model.summary.reported_cost_coverage > 0 ? $_("usage.reportedCostStatus.partial") : $_("usage.reportedCostStatus.unavailable")}{/if} + {model.summary.reported_cost_event_count + model.summary.partial_reported_cost_event_count > 0 ? formatReportedCurrency(model.summary.known_reported_cost_usd) : "—"} + {#if model.summary.reported_cost_status !== "reported"}{model.summary.reported_cost_event_count + model.summary.partial_reported_cost_event_count > 0 ? $_("usage.reportedCostStatus.partial") : $_("usage.reportedCostStatus.unavailable")}{/if}
    {/if} diff --git a/desktop/frontend/src/features/usage/UsagePage.svelte b/desktop/frontend/src/features/usage/UsagePage.svelte index 321165b..c6cab4c 100644 --- a/desktop/frontend/src/features/usage/UsagePage.svelte +++ b/desktop/frontend/src/features/usage/UsagePage.svelte @@ -217,6 +217,10 @@ }).format(parsed); } + function hasKnownReportedCost(summary: UsageReportResult["summary"]): boolean { + return summary.reported_cost_event_count + summary.partial_reported_cost_event_count > 0; + } + function formatLastSync(unixMS: number): string { if (unixMS <= 0) return translate("usage.neverSynced"); const value = new Date(unixMS); @@ -312,7 +316,7 @@ {#if report.summary.partial_cost_event_count > 0}
  • {$_("usage.dataQuality.partialPricing", { values: { count: formatInteger(report.summary.partial_cost_event_count) } })}
  • {/if} {#if report.summary.event_count > 0 && report.summary.unknown_cost_event_count > 0}
  • {$_("usage.dataQuality.pricing", { values: { count: formatInteger(report.summary.unknown_cost_event_count), coverage: formatPercent(report.summary.pricing_coverage) } })}
  • {/if} {#if providerID === "grok-build" && report.summary.partial_reported_cost_event_count > 0}
  • {$_("usage.dataQuality.partialReportedCost", { values: { count: formatInteger(report.summary.partial_reported_cost_event_count) } })}
  • {/if} - {#if providerID === "grok-build" && report.summary.event_count > 0 && report.summary.unknown_reported_cost_event_count > 0}
  • {$_("usage.dataQuality.reportedCost", { values: { count: formatInteger(report.summary.unknown_reported_cost_event_count), coverage: formatPercent(report.summary.reported_cost_coverage) } })}
  • {/if} + {#if providerID === "grok-build" && report.summary.event_count > 0 && report.summary.unknown_reported_cost_event_count > 0}
  • {$_("usage.dataQuality.reportedCost", { values: { count: formatInteger(report.summary.unknown_reported_cost_event_count) } })}
  • {/if} @@ -353,15 +357,17 @@
    {$_("usage.reportedCost")} - {#if report.summary.reported_cost_status !== "reported"}{report.summary.reported_cost_coverage > 0 ? $_("usage.reportedCostStatus.partial") : $_("usage.reportedCostStatus.unavailable")}{/if} + {#if report.summary.reported_cost_status !== "reported"}{hasKnownReportedCost(report.summary) ? $_("usage.reportedCostStatus.partial") : $_("usage.reportedCostStatus.unavailable")}{/if}
    - {report.summary.reported_cost_coverage > 0 ? formatReportedCurrency(report.summary.known_reported_cost_usd) : "—"} + {hasKnownReportedCost(report.summary) ? formatReportedCurrency(report.summary.known_reported_cost_usd) : "—"}

    {#if report.summary.reported_cost_status === "reported"} {$_("usage.reportedCostSource")} - {:else if report.summary.reported_cost_coverage > 0} + {:else if report.summary.partial_reported_cost_event_count > 0} + {$_("usage.reportedCostPartial")} + {:else if hasKnownReportedCost(report.summary)} {$_("usage.reportedCostCoverage", { values: { coverage: formatPercent(report.summary.reported_cost_coverage) } })} {:else} {$_("usage.reportedCostUnavailable")} diff --git a/desktop/frontend/src/features/usage/UsageTrendChart.svelte b/desktop/frontend/src/features/usage/UsageTrendChart.svelte index f1c5459..bf7829c 100644 --- a/desktop/frontend/src/features/usage/UsageTrendChart.svelte +++ b/desktop/frontend/src/features/usage/UsageTrendChart.svelte @@ -204,10 +204,13 @@ }); } if (metric === "reported") { - const key = point.summary.partial_reported_cost_event_count > 0 - ? "usage.chart.partialReportedCostBucketAria" - : "usage.chart.reportedCostBucketAria"; - return translate(key, { + if (point.summary.partial_reported_cost_event_count > 0) { + return translate("usage.chart.partialReportedCostBucketAria", { + bucket: fullBucketLabel(point.start_unix_ms), + cost: formatReportedCurrency(point.summary.known_reported_cost_usd), + }); + } + return translate("usage.chart.reportedCostBucketAria", { bucket: fullBucketLabel(point.start_unix_ms), cost: formatReportedCurrency(point.summary.known_reported_cost_usd), coverage: formatPercent(point.summary.reported_cost_coverage), @@ -390,7 +393,7 @@

    {$_("usage.chart.partialReportedCost")}
    {/if} {@const coverage = metric === "reported" ? tooltipPoint.summary.reported_cost_coverage : tooltipPoint.summary.pricing_coverage} - {#if tooltipPoint.summary.event_count > 0 && coverage < 1} + {#if tooltipPoint.summary.event_count > 0 && coverage < 1 && (metric !== "reported" || tooltipPoint.summary.partial_reported_cost_event_count === 0)}
    {$_("usage.chart.coverage")}{formatPercent(coverage)}
    {/if} {:else} diff --git a/desktop/frontend/src/lib/i18n.ts b/desktop/frontend/src/lib/i18n.ts index ac5c280..0737a7b 100644 --- a/desktop/frontend/src/lib/i18n.ts +++ b/desktop/frontend/src/lib/i18n.ts @@ -731,7 +731,8 @@ const messages = { apiEquivalentCost: "API-equivalent cost", reportedCost: "Grok-reported cost", reportedCostSource: "Local session records · not an invoice", - reportedCostCoverage: "Known subtotal · reported for {coverage} of tokens", + reportedCostCoverage: "Known subtotal · complete cost for {coverage} of tokens", + reportedCostPartial: "Known subtotal · some calls have no reported cost", reportedCostUnavailable: "No reported cost in these session records", freshInput: "Fresh input", cachedInput: "Cached input", @@ -760,7 +761,7 @@ const messages = { partialPricing: "{count} records use a base estimate because cache-write tokens cannot be priced.", pricing: "{count} records have unknown pricing; the chart shows the known subtotal with {coverage} coverage.", partialReportedCost: "{count} records contain a partial Grok-reported cost.", - reportedCost: "{count} records have no Grok-reported cost; the report shows the known subtotal with {coverage} coverage.", + reportedCost: "{count} records have no Grok-reported cost.", }, range: { label: "Usage range", @@ -783,8 +784,8 @@ const messages = { tokenDescription: "Input, cached input, and output tokens over time, using your local time.", costBucketAria: "{bucket}: known API-equivalent cost {cost}, pricing coverage {coverage}", partialCostBucketAria: "{bucket}: partial API-equivalent base cost {cost}; cache-write tokens are unavailable; pricing coverage {coverage}", - reportedCostBucketAria: "{bucket}: known Grok-reported cost {cost}, reporting coverage {coverage}", - partialReportedCostBucketAria: "{bucket}: partial Grok-reported cost {cost}, reporting coverage {coverage}", + reportedCostBucketAria: "{bucket}: known Grok-reported cost {cost}, complete reporting coverage {coverage}", + partialReportedCostBucketAria: "{bucket}: partial Grok-reported cost {cost}", tokenBucketAria: "{bucket}: fresh input {fresh}, cached input {cached}, output {output}, total {total} tokens", knownCost: "Known cost", knownReportedCost: "Grok-reported cost", @@ -1876,7 +1877,8 @@ const messages = { apiEquivalentCost: "API 等价成本", reportedCost: "Grok 上报成本", reportedCostSource: "来自本地会话记录 · 不是账单", - reportedCostCoverage: "已知小计 · 覆盖 {coverage} 的 Tokens", + reportedCostCoverage: "已知小计 · {coverage} 的 Tokens 有完整上报金额", + reportedCostPartial: "已知小计 · 部分调用没有上报成本", reportedCostUnavailable: "这些会话记录中没有上报成本", freshInput: "新输入", cachedInput: "缓存输入", @@ -1905,7 +1907,7 @@ const messages = { partialPricing: "{count} 条记录只计算基础成本,因为 cache-write Tokens 无法计价。", pricing: "{count} 条记录没有价格;图表显示已知小计,覆盖率为 {coverage}。", partialReportedCost: "{count} 条记录只包含 Grok 上报的部分成本。", - reportedCost: "{count} 条记录没有 Grok 上报成本;报告显示已知小计,覆盖率为 {coverage}。", + reportedCost: "{count} 条记录没有 Grok 上报成本。", }, range: { label: "用量时间范围", @@ -1928,8 +1930,8 @@ const messages = { tokenDescription: "按本地时间显示输入、缓存输入和输出 Tokens 变化。", costBucketAria: "{bucket}:已知 API 等价成本 {cost},定价覆盖率 {coverage}", partialCostBucketAria: "{bucket}:部分 API 等价基础成本 {cost};缺少 cache-write tokens;定价覆盖率 {coverage}", - reportedCostBucketAria: "{bucket}:已知 Grok 上报成本 {cost},上报覆盖率 {coverage}", - partialReportedCostBucketAria: "{bucket}:Grok 上报的部分成本 {cost},上报覆盖率 {coverage}", + reportedCostBucketAria: "{bucket}:已知 Grok 上报成本 {cost},完整上报覆盖率 {coverage}", + partialReportedCostBucketAria: "{bucket}:Grok 上报的部分成本 {cost}", tokenBucketAria: "{bucket}:新输入 {fresh},缓存输入 {cached},输出 {output},总计 {total} Tokens", knownCost: "已知成本", knownReportedCost: "Grok 上报成本", diff --git a/desktop/frontend/tests/usage-page.test.ts b/desktop/frontend/tests/usage-page.test.ts index 5d02647..0dc6574 100644 --- a/desktop/frontend/tests/usage-page.test.ts +++ b/desktop/frontend/tests/usage-page.test.ts @@ -1,5 +1,5 @@ import { act, render, screen, waitFor } from "@testing-library/svelte"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { UsageAutoSyncStatus } from "../bindings/github.com/strahe/profiledeck/desktop/backend/models"; import { @@ -128,6 +128,11 @@ function emptyUsageReport(): UsageReportResult { } describe("UsagePage initial sync", () => { + beforeEach(() => { + vi.clearAllMocks(); + runtime.handler = null; + }); + it("loads stored data immediately and suppresses an empty result until sync finishes", async () => { let resolveSync!: (status: UsageAutoSyncStatus) => void; runtime.handler = null; @@ -195,4 +200,38 @@ describe("UsagePage initial sync", () => { expect(screen.queryByText("No usage yet")).not.toBeInTheDocument(); }); + it("shows a partial reported subtotal without claiming token coverage", async () => { + const partialReport = usageReport(); + partialReport.summary = { + ...partialReport.summary, + reported_cost_status: "partial", + reported_cost_event_count: 0, + partial_reported_cost_event_count: 1, + reported_cost_token_count: 0, + reported_cost_coverage: 0, + }; + runtime.on.mockReturnValue(vi.fn()); + backend.report.mockReturnValue(cancellable(Promise.resolve(partialReport))); + backend.syncNow.mockReturnValue(cancellable(Promise.resolve(syncStatus({ + revision: 2, + syncing: false, + outcome: "success", + last_completed_at_unix_ms: 200, + last_success_at_unix_ms: 200, + })))); + + render(UsagePage, { + providerID: "grok-build", + providerName: "Grok Build", + providerExists: true, + onOpenProfiles: vi.fn(), + showError: vi.fn(), + }, { wrapper: TestProviders }); + + expect(await screen.findByText("$0.5452")).toBeInTheDocument(); + expect(screen.getByText("Known subtotal · some calls have no reported cost")).toBeInTheDocument(); + expect(screen.getByText("Partial")).toBeInTheDocument(); + expect(screen.queryByText(/complete cost for/)).not.toBeInTheDocument(); + }); + }); diff --git a/docs/grok-build/usage-cost.md b/docs/grok-build/usage-cost.md index cc988ab..8f2633e 100644 --- a/docs/grok-build/usage-cost.md +++ b/docs/grok-build/usage-cost.md @@ -81,9 +81,9 @@ The prices come from [xAI pricing](https://docs.x.ai/developers/pricing). [Grok When a session record includes cache-creation tokens, ProfileDeck keeps the event and reports a partial estimate because usage facts currently price only input, cached input, and output tokens. -When a completed turn contains `costUsdTicks`, ProfileDeck imports the per-model value as Grok-reported cost; 10,000,000,000 ticks equal US$1. A top-level value is used only when the turn contains exactly one model, so the same amount is never assigned to several models. Missing or nonpositive values remain unknown, and `costIsPartial` is shown as partial. +ProfileDeck shows the amount Grok recorded for completed local session records. If some calls are missing an amount, ProfileDeck shows the known subtotal as partial. Only records with a complete amount count toward reported-cost coverage; when no selected record contains an amount, the reported cost is unavailable. -API-equivalent estimates and Grok-reported amounts remain separate and are never added together. Reports show the known subtotal and coverage when some completed turns are missing either figure. An unrecognized model keeps its token totals and Grok-reported amount but has unknown API-equivalent cost. Existing estimates are not recalculated when a later ProfileDeck version changes its built-in prices; facts with unknown API-equivalent cost can receive an estimate when their model becomes recognized. +API-equivalent estimates and Grok-reported amounts remain separate and are never added together. An unrecognized model keeps its token totals and Grok-reported amount but has unknown API-equivalent cost. Existing estimates are not recalculated when a later ProfileDeck version changes its built-in prices; facts with unknown API-equivalent cost can receive an estimate when their model becomes recognized. The Grok Build Usage Limit panel may cover only activity since the process started or was last resumed, while ProfileDeck reports completed turns found in local session history for the selected dates. Their totals can therefore differ even when both values came from the same local session. diff --git a/internal/cli/usage.go b/internal/cli/usage.go index 7c7b3bb..ecfefd1 100644 --- a/internal/cli/usage.go +++ b/internal/cli/usage.go @@ -250,7 +250,7 @@ func writeUsageReport(w io.Writer, result usage.UsageReportResult) error { reportedCostSummary := "" if result.ProviderID == grokconfig.ProviderID { reportedCostSummary = fmt.Sprintf( - "known Grok-reported cost usd: %s\nGrok-reported cost status: %s\nGrok-reported cost coverage: %.1f%%\n", + "known Grok-reported cost usd: %s\nGrok-reported cost status: %s\ncomplete Grok-reported cost coverage: %.1f%%\n", result.Summary.KnownReportedCostUSD, result.Summary.ReportedCostStatus, result.Summary.ReportedCostCoverage*100, diff --git a/internal/store/usage_grok_build.go b/internal/store/usage_grok_build.go index 930a2ff..31d1563 100644 --- a/internal/store/usage_grok_build.go +++ b/internal/store/usage_grok_build.go @@ -242,9 +242,10 @@ func validateGrokBuildUsageImportBatch(params CommitGrokBuildUsageImportParams) return err } if params.ReplayExistingFacts { - if params.File.ParserRevision <= params.Expected.ParserRevision || + if params.File.ParserRevision < params.Expected.ParserRevision || + params.File.ImportedFacts < params.Expected.ImportedFacts || params.File.ImportedFacts != int64(len(params.Facts)) { - return errors.New("invalid Grok Build usage parser replay") + return errors.New("invalid Grok Build usage fact replay") } return nil } diff --git a/internal/store/usage_report.go b/internal/store/usage_report.go index 9959d2a..a17bbe4 100644 --- a/internal/store/usage_report.go +++ b/internal/store/usage_report.go @@ -421,7 +421,7 @@ func usageFactAggregateColumns(alias, undatedExpression string) string { COALESCE(SUM(CASE WHEN %[1]s.cost_status = %[2]d THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN %[1]s.cost_status = %[3]d THEN 1 ELSE 0 END), 0), COALESCE(SUM(COALESCE(%[1]s.reported_cost_usd_ticks, 0)), 0), - COALESCE(SUM(CASE WHEN %[1]s.reported_cost_status IN (%[5]d, %[6]d) THEN %[1]s.total_tokens ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN %[1]s.reported_cost_status = %[5]d THEN %[1]s.total_tokens ELSE 0 END), 0), COALESCE(SUM(CASE WHEN %[1]s.reported_cost_status = %[7]d THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN %[1]s.reported_cost_status = %[5]d THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN %[1]s.reported_cost_status = %[6]d THEN 1 ELSE 0 END), 0), diff --git a/internal/store/usage_report_test.go b/internal/store/usage_report_test.go index a3bc9a5..7adc7df 100644 --- a/internal/store/usage_report_test.go +++ b/internal/store/usage_report_test.go @@ -118,7 +118,7 @@ func TestUsageReportAggregatesRangeModelsBucketsAndImportHealth(t *testing.T) { if report.Summary.EstimatedCostMicros != 30 || report.Summary.EstimatedTokenCount != 220 || report.Summary.UnknownCostEvents != 1 || report.Summary.UndatedEventCount != 1 { t.Fatalf("unexpected ranged cost and undated aggregate: %#v", report.Summary) } - if report.Summary.ReportedCostUSDTicks != 300 || report.Summary.ReportedCostTokenCount != 220 || + if report.Summary.ReportedCostUSDTicks != 300 || report.Summary.ReportedCostTokenCount != 120 || report.Summary.ReportedCostEventCount != 1 || report.Summary.PartialReportedCostEventCount != 1 || report.Summary.UnknownReportedCostEvents != 1 { t.Fatalf("unexpected ranged reported cost aggregate: %#v", report.Summary) diff --git a/internal/usage/grok_build_sync.go b/internal/usage/grok_build_sync.go index 695bf90..b547895 100644 --- a/internal/usage/grok_build_sync.go +++ b/internal/usage/grok_build_sync.go @@ -144,12 +144,8 @@ func (integration grokBuildIntegration) Sync( continue } if fullParse { - if cursor.ParserRevision == GrokBuildUsageParserRevision { - eventsToStore = parsed.Events[cursor.ImportedFacts:] - } else { - eventsToStore = parsed.Events - replayExistingFacts = true - } + eventsToStore = parsed.Events + replayExistingFacts = true importedFacts = int64(len(parsed.Events)) } else { importedFacts = cursor.ImportedFacts + int64(len(parsed.Events)) diff --git a/internal/usage/grok_build_sync_test.go b/internal/usage/grok_build_sync_test.go index 4af1625..aa1e1f2 100644 --- a/internal/usage/grok_build_sync_test.go +++ b/internal/usage/grok_build_sync_test.go @@ -183,6 +183,46 @@ func TestUsageSyncGrokBuildParserUpgradeBackfillsReportedCost(t *testing.T) { } } +func TestUsageSyncGrokBuildSameRevisionBackfillsReportedCost(t *testing.T) { + ctx := context.Background() + configDir := t.TempDir() + grokHome := t.TempDir() + line := syntheticGrokBuildUsageLine( + "session-enriched", + "prompt-enriched", + "grok-4.6-build", + 1_750_000_000, + TokenCounts{InputTokens: 100, CachedInputTokens: 40, OutputTokens: 20, TotalTokens: 120}, + ) + path := writeGrokBuildUsageFixture(t, grokHome, "workspace-enriched", "session-enriched", line) + environment := newGrokBuildUsageTestEnvironment(t, configDir, grokHome) + if _, err := bootstrap.NewService(environment.runtime, nil, nil).Initialize(ctx); err != nil { + t.Fatalf("initialize runtime: %v", err) + } + if _, err := environment.service.SyncGrokBuild(ctx); err != nil { + t.Fatalf("initial Grok Build sync: %v", err) + } + before, err := environment.service.Summary(ctx, UsageSummaryRequest{ProviderID: grokconfig.ProviderID}) + if err != nil || before.ReportedCostUSD != nil || before.ReportedCostStatus != ReportedCostStatusUnknown.String() { + t.Fatalf("initial reported cost summary = %#v, err = %v", before, err) + } + + enriched := strings.ReplaceAll(line, `"costIsPartial":false`, `"costIsPartial":false,"costUsdTicks":5452000000`) + if strings.Count(enriched, `"costUsdTicks"`) != 2 { + t.Fatalf("reported cost fixture was not enriched: %s", enriched) + } + writeAppUsageFile(t, path, enriched) + result, err := environment.service.SyncGrokBuild(ctx) + if err != nil || result.ImportedEvents != 0 || result.SkippedDuplicateEvents != 1 || len(result.Errors) != 0 { + t.Fatalf("same-revision cost backfill = %#v, err = %v", result, err) + } + after, err := environment.service.Summary(ctx, UsageSummaryRequest{ProviderID: grokconfig.ProviderID}) + if err != nil || after.ReportedCostUSD == nil || *after.ReportedCostUSD != "0.5452000000" || + after.ReportedCostStatus != ReportedCostStatusReported.String() { + t.Fatalf("backfilled reported cost summary = %#v, err = %v", after, err) + } +} + func TestBackgroundGrokBuildSyncRetriesObservationAfterParserUpgrade(t *testing.T) { ctx := context.Background() configDir := t.TempDir() diff --git a/internal/usage/report_service_test.go b/internal/usage/report_service_test.go index 706f543..6a3f3ae 100644 --- a/internal/usage/report_service_test.go +++ b/internal/usage/report_service_test.go @@ -183,6 +183,22 @@ func TestAggregateReportedCostStatusDistinguishesPartialCoverage(t *testing.T) { } } +func TestUsageAggregateSummaryCountsOnlyCompleteReportedCostCoverage(t *testing.T) { + summary := usageAggregateSummary(store.UsageAggregate{ + EventCount: 2, + TotalTokens: 220, + ReportedCostUSDTicks: 300, + ReportedCostTokenCount: 120, + ReportedCostEventCount: 1, + PartialReportedCostEventCount: 1, + }) + if summary.ReportedCostStatus != ReportedCostStatusPartial.String() || + summary.ReportedCostCoverage != float64(120)/float64(220) || + summary.ReportedCostTokenCount != 120 { + t.Fatalf("reported cost summary = %#v", summary) + } +} + func reportEvent(id, sessionID, model string, occurredAt, input, cached, output int64, cost *int64, status store.UsageCostStatus) store.CreateUsageFactParams { return store.CreateUsageFactParams{ EventKey: usageTestEventKey(id), SessionKey: sessionID, ModelKey: model, OccurredAtUnixMS: occurredAt,