From 6339f9c55588c14f97a57834f55632e4301f4bae Mon Sep 17 00:00:00 2001 From: Lee <7932644+strahe@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:35:40 +0800 Subject: [PATCH] feat(pricing): add versioned model price catalog --- Makefile | 7 +- desktop/backend/services.go | 13 + .../desktop/backend/usageservice.ts | 15 + .../profiledeck/internal/pricing/index.ts | 6 + .../profiledeck/internal/pricing/models.ts | 10 + .../profiledeck/internal/usage/models.ts | 2 + .../src/features/usage/UsageModelTable.svelte | 2 +- .../src/features/usage/UsagePage.svelte | 5 +- .../src/features/usage/UsageTrendChart.svelte | 17 +- .../features/workspace/GlobalSettings.svelte | 2 + .../features/workspace/PricingSettings.svelte | 87 +++ desktop/frontend/src/lib/i18n.ts | 52 +- desktop/main.go | 20 + docs/codex/usage-cost.md | 15 +- docs/grok-build/usage-cost.md | 21 +- docs/reference/data-security.md | 1 + docs/zh/codex/usage-cost.md | 15 +- docs/zh/grok-build/usage-cost.md | 27 +- docs/zh/reference/data-security.md | 1 + internal/app/application.go | 9 +- internal/architecture/boundary_test.go | 2 +- internal/bootstrap/service_test.go | 29 +- internal/cli/command_test.go | 32 +- internal/cli/pricing.go | 100 ++++ internal/cli/usage.go | 3 + internal/pricing/catalog.go | 286 ++++++++++ internal/pricing/catalog_test.go | 88 +++ internal/pricing/service.go | 270 +++++++++ internal/pricing/service_test.go | 166 ++++++ internal/store/integrity.go | 5 +- .../202609230001_pricing_catalog.go | 38 ++ internal/store/schema_contract.go | 15 + internal/store/store.go | 6 + internal/store/store_test.go | 2 +- internal/store/usage.go | 91 ++- internal/store/usage_report.go | 41 +- internal/usage/codex.go | 42 +- internal/usage/codex_sync.go | 91 +-- internal/usage/codex_test.go | 51 +- internal/usage/grok_build.go | 40 +- internal/usage/grok_build_sync.go | 20 +- internal/usage/grok_build_sync_test.go | 143 ++++- internal/usage/grok_build_test.go | 43 +- internal/usage/incremental_reader.go | 4 +- internal/usage/report_service.go | 23 +- internal/usage/report_service_test.go | 6 + internal/usage/service.go | 17 +- internal/usage/service_test.go | 34 +- internal/usage/usage.go | 221 ++++--- internal/usage/usage_test.go | 9 +- pricing/catalog.json | 537 ++++++++++++++++++ pricing/embed.go | 6 + 52 files changed, 2427 insertions(+), 361 deletions(-) create mode 100644 desktop/frontend/bindings/github.com/strahe/profiledeck/internal/pricing/index.ts create mode 100644 desktop/frontend/bindings/github.com/strahe/profiledeck/internal/pricing/models.ts create mode 100644 desktop/frontend/src/features/workspace/PricingSettings.svelte create mode 100644 internal/cli/pricing.go create mode 100644 internal/pricing/catalog.go create mode 100644 internal/pricing/catalog_test.go create mode 100644 internal/pricing/service.go create mode 100644 internal/pricing/service_test.go create mode 100644 internal/store/migrations/202609230001_pricing_catalog.go create mode 100644 pricing/catalog.json create mode 100644 pricing/embed.go diff --git a/Makefile b/Makefile index 4fedd23..4535701 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ UPDATE_SIGNING_KEY ?= UPDATE_PUBLIC_KEY ?= desktop/update/updater-public.pem RELEASES_DIR ?= $(CURDIR)/.task/release-assets -.PHONY: fmt vet lint lint-core lint-desktop test build source-hygiene core-boundary core-check security-check check clean desktop-bindings desktop-bindings-check desktop-taskfile-check desktop-frontend-install desktop-frontend-check desktop-build release-tools-check release-build release-build-macos release-build-linux-amd64 release-finalize release-linux-smoke verify-update-e2e desktop-check docs-install docs-dev docs-build docs-preview docs-check ci-check ci-core-check ci-desktop-check ci-security-check ci-release-build-macos ci-release-build-linux-amd64 ci-release-finalize ci-verify-update-e2e +.PHONY: fmt vet lint lint-core lint-desktop test build pricing-check source-hygiene core-boundary core-check security-check check clean desktop-bindings desktop-bindings-check desktop-taskfile-check desktop-frontend-install desktop-frontend-check desktop-build release-tools-check release-build release-build-macos release-build-linux-amd64 release-finalize release-linux-smoke verify-update-e2e desktop-check docs-install docs-dev docs-build docs-preview docs-check ci-check ci-core-check ci-desktop-check ci-security-check ci-release-build-macos ci-release-build-linux-amd64 ci-release-finalize ci-verify-update-e2e fmt: $(GOLANGCI_LINT) fmt $(GO_PKGS) @@ -63,6 +63,9 @@ lint-desktop: test: go test $(CORE_PKGS) +pricing-check: + go test ./internal/pricing + build: mkdir -p $(BIN_DIR) go build -o $(BIN_DIR)/$(BINARY) $(CMD) @@ -73,7 +76,7 @@ source-hygiene: core-boundary: go test ./internal/architecture -core-check: source-hygiene lint-core core-boundary test build +core-check: source-hygiene pricing-check lint-core core-boundary test build security-check: $(GOVULNCHECK) $(ACTIONLINT) $(GOVULNCHECK) ./... diff --git a/desktop/backend/services.go b/desktop/backend/services.go index 5c874d9..484f067 100644 --- a/desktop/backend/services.go +++ b/desktop/backend/services.go @@ -20,6 +20,7 @@ import ( "github.com/strahe/profiledeck/internal/doctor" "github.com/strahe/profiledeck/internal/grokbuild" grokconfig "github.com/strahe/profiledeck/internal/grokbuild/config" + "github.com/strahe/profiledeck/internal/pricing" "github.com/strahe/profiledeck/internal/profile" "github.com/strahe/profiledeck/internal/profiletarget" "github.com/strahe/profiledeck/internal/provider" @@ -1112,6 +1113,18 @@ func (s *UsageService) Summary(ctx context.Context, providerID string) (usage.Us return s.application.Usage().Summary(ctx, usage.UsageSummaryRequest{ProviderID: providerID}) } +func (s *UsageService) PricingStatus(ctx context.Context) (pricing.Status, error) { + return s.application.Pricing().Status(ctx) +} + +func (s *UsageService) CheckPricing(ctx context.Context) (pricing.Status, error) { + return s.application.Pricing().Check(ctx, true) +} + +func (s *UsageService) SetAutomaticPricing(ctx context.Context, enabled bool) (pricing.Status, error) { + return s.application.Pricing().SetAutomatic(ctx, enabled) +} + func (s *UsageService) AutoSyncStatus( ctx context.Context, providerID string, diff --git a/desktop/frontend/bindings/github.com/strahe/profiledeck/desktop/backend/usageservice.ts b/desktop/frontend/bindings/github.com/strahe/profiledeck/desktop/backend/usageservice.ts index aa16de7..8c212c8 100644 --- a/desktop/frontend/bindings/github.com/strahe/profiledeck/desktop/backend/usageservice.ts +++ b/desktop/frontend/bindings/github.com/strahe/profiledeck/desktop/backend/usageservice.ts @@ -5,6 +5,9 @@ // @ts-ignore: Unused imports import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as pricing$0 from "../../internal/pricing/models.js"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports import * as usage$0 from "../../internal/usage/models.js"; @@ -17,10 +20,22 @@ export function AutoSyncStatus(providerID: string): $CancellablePromise<$models. return $Call.ByID(2973972616, providerID); } +export function CheckPricing(): $CancellablePromise { + return $Call.ByID(2910122128); +} + +export function PricingStatus(): $CancellablePromise { + return $Call.ByID(2879249746); +} + export function Report(providerID: string, rangeValue: string): $CancellablePromise { return $Call.ByID(2463093428, providerID, rangeValue); } +export function SetAutomaticPricing(enabled: boolean): $CancellablePromise { + return $Call.ByID(2883654161, enabled); +} + export function Summary(providerID: string): $CancellablePromise { return $Call.ByID(2239958706, providerID); } diff --git a/desktop/frontend/bindings/github.com/strahe/profiledeck/internal/pricing/index.ts b/desktop/frontend/bindings/github.com/strahe/profiledeck/internal/pricing/index.ts new file mode 100644 index 0000000..4158350 --- /dev/null +++ b/desktop/frontend/bindings/github.com/strahe/profiledeck/internal/pricing/index.ts @@ -0,0 +1,6 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export type { + Status +} from "./models.js"; diff --git a/desktop/frontend/bindings/github.com/strahe/profiledeck/internal/pricing/models.ts b/desktop/frontend/bindings/github.com/strahe/profiledeck/internal/pricing/models.ts new file mode 100644 index 0000000..ee9e35d --- /dev/null +++ b/desktop/frontend/bindings/github.com/strahe/profiledeck/internal/pricing/models.ts @@ -0,0 +1,10 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export interface Status { + "automatic": boolean; + "catalog_version": number; + "last_checked_at_unix_ms": number; + "last_updated_at_unix_ms": number; + "last_error"?: string; +} 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 1a747c7..a21f718 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 @@ -44,6 +44,8 @@ export interface UsagePricingInfo { "source_url": string; "verified_at": string; "historical_repricing": boolean; + "catalog_version": number; + "multiple_versions": boolean; } export enum UsageRangePreset { diff --git a/desktop/frontend/src/features/usage/UsageModelTable.svelte b/desktop/frontend/src/features/usage/UsageModelTable.svelte index c2450c2..8f6a902 100644 --- a/desktop/frontend/src/features/usage/UsageModelTable.svelte +++ b/desktop/frontend/src/features/usage/UsageModelTable.svelte @@ -53,7 +53,7 @@ {model.model}
- {formatCurrency(model.summary.known_estimated_cost_usd)} + {model.summary.estimated_cost_event_count + model.summary.partial_cost_event_count > 0 ? formatCurrency(model.summary.known_estimated_cost_usd) : "—"} {#if model.summary.cost_status !== "estimated"}{model.summary.pricing_coverage > 0 ? $_("usage.pricing.partial") : $_("usage.pricing.unknown")}{/if}
diff --git a/desktop/frontend/src/features/usage/UsagePage.svelte b/desktop/frontend/src/features/usage/UsagePage.svelte index c6cab4c..6b4d712 100644 --- a/desktop/frontend/src/features/usage/UsagePage.svelte +++ b/desktop/frontend/src/features/usage/UsagePage.svelte @@ -340,7 +340,7 @@ {$_("usage.apiEquivalentCost")} {#if report.summary.cost_status !== "estimated"}{report.summary.pricing_coverage > 0 ? $_("usage.pricing.partial") : $_("usage.pricing.unknown")}{/if} - {formatCurrency(report.summary.known_estimated_cost_usd)} + {report.summary.estimated_cost_event_count + report.summary.partial_cost_event_count > 0 ? formatCurrency(report.summary.known_estimated_cost_usd) : "—"}

{#if report.summary.cost_status === "estimated"} {$_("usage.pricing.apiEstimate")} @@ -350,6 +350,9 @@ {$_("usage.pricing.coverage", { values: { coverage: formatPercent(report.summary.pricing_coverage) } })} {/if}

+ {#if report.pricing.multiple_versions} +

{$_("usage.pricing.multipleVersions")}

+ {/if} {#if providerID === "grok-build"} diff --git a/desktop/frontend/src/features/usage/UsageTrendChart.svelte b/desktop/frontend/src/features/usage/UsageTrendChart.svelte index bf7829c..3b416e8 100644 --- a/desktop/frontend/src/features/usage/UsageTrendChart.svelte +++ b/desktop/frontend/src/features/usage/UsageTrendChart.svelte @@ -67,6 +67,15 @@ return Number.isFinite(value) ? Math.max(0, value) : 0; } + function tooltipCost(point: UsageTrendPoint): string { + if (metric === "reported") { + if (point.summary.event_count > 0 && point.summary.reported_cost_event_count + point.summary.partial_reported_cost_event_count === 0) return "—"; + return formatReportedCurrency(point.summary.known_reported_cost_usd); + } + if (point.summary.event_count > 0 && point.summary.estimated_cost_event_count + point.summary.partial_cost_event_count === 0) return "—"; + return formatCurrency(point.summary.known_estimated_cost_usd); + } + function chartTitle(): string { if (metric === "cost") return translate("usage.chart.costTitle"); if (metric === "reported") return translate("usage.chart.reportedCostTitle"); @@ -190,6 +199,9 @@ function bucketAriaLabel(point: UsageTrendPoint): string { if (metric === "cost") { + if (point.summary.event_count > 0 && point.summary.estimated_cost_event_count + point.summary.partial_cost_event_count === 0) { + return translate("usage.chart.unknownCostBucketAria", { bucket: fullBucketLabel(point.start_unix_ms) }); + } if (point.summary.partial_cost_event_count > 0) { return translate("usage.chart.partialCostBucketAria", { bucket: fullBucketLabel(point.start_unix_ms), @@ -204,6 +216,9 @@ }); } if (metric === "reported") { + if (point.summary.event_count > 0 && point.summary.reported_cost_event_count + point.summary.partial_reported_cost_event_count === 0) { + return translate("usage.chart.unknownReportedCostBucketAria", { bucket: fullBucketLabel(point.start_unix_ms) }); + } if (point.summary.partial_reported_cost_event_count > 0) { return translate("usage.chart.partialReportedCostBucketAria", { bucket: fullBucketLabel(point.start_unix_ms), @@ -385,7 +400,7 @@ {#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)} + {tooltipCost(tooltipPoint)}
{#if metric === "cost" && tooltipPoint.summary.partial_cost_event_count > 0}
{$_("usage.chart.partialEstimate")}
diff --git a/desktop/frontend/src/features/workspace/GlobalSettings.svelte b/desktop/frontend/src/features/workspace/GlobalSettings.svelte index 5167fbc..8cb1052 100644 --- a/desktop/frontend/src/features/workspace/GlobalSettings.svelte +++ b/desktop/frontend/src/features/workspace/GlobalSettings.svelte @@ -14,6 +14,7 @@ import type { State as AgentState } from "../../../bindings/github.com/strahe/profiledeck/internal/agent/models"; import { currentDesktopLocale, type DesktopLanguage } from "$lib/i18n"; import AppBackupSettings from "./AppBackupSettings.svelte"; + import PricingSettings from "./PricingSettings.svelte"; let { section, @@ -272,6 +273,7 @@ {/if} + {:else} {/if} diff --git a/desktop/frontend/src/features/workspace/PricingSettings.svelte b/desktop/frontend/src/features/workspace/PricingSettings.svelte new file mode 100644 index 0000000..6aa3f83 --- /dev/null +++ b/desktop/frontend/src/features/workspace/PricingSettings.svelte @@ -0,0 +1,87 @@ + + + + + + {#snippet control()} + {#if busy === "automatic"}{/if} + + {/snippet} + + + {#snippet control()} + + {/snippet} + + + diff --git a/desktop/frontend/src/lib/i18n.ts b/desktop/frontend/src/lib/i18n.ts index 0737a7b..e82cb94 100644 --- a/desktop/frontend/src/lib/i18n.ts +++ b/desktop/frontend/src/lib/i18n.ts @@ -758,7 +758,7 @@ 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 cache-write tokens cannot be priced.", + partialPricing: "{count} records have a partial estimate; some charges may be missing.", 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.", @@ -783,14 +783,16 @@ const messages = { 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}", + unknownCostBucketAria: "{bucket}: API-equivalent cost unavailable", + partialCostBucketAria: "{bucket}: partial API-equivalent cost {cost}; some charges may be missing; pricing coverage {coverage}", reportedCostBucketAria: "{bucket}: known Grok-reported cost {cost}, complete reporting coverage {coverage}", + unknownReportedCostBucketAria: "{bucket}: Grok-reported cost unavailable", 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", coverage: "Coverage", - partialEstimate: "Partial estimate · some cache-write cost is not included", + partialEstimate: "Partial estimate · some charges may be missing", partialReportedCost: "Partial amount reported by Grok", }, models: { @@ -808,8 +810,9 @@ const messages = { pricing: { partial: "Partial", unknown: "Unknown", - apiEstimate: "Static API estimate · not billing or quota", - partialEstimate: "Base estimate · some cache-write cost is not included", + apiEstimate: "API-equivalent estimate · not billing or quota", + partialEstimate: "Partial API estimate · some charges may be missing", + multipleVersions: "Some estimates may use earlier prices.", coverage: "Cost available for {coverage} of tokens", }, }, @@ -915,6 +918,19 @@ const messages = { keyImportedTitle: "Recovery key imported", keyImportedDescription: "ProfileDeck can now use backups created with this key.", }, }, + pricing: { + title: "Usage prices", + description: "Keep model prices current for future usage estimates.", + automatic: { label: "Check prices automatically", description: "Check at most daily while the app is running, and before CLI usage sync when due." }, + status: "Price list", + check: "Check prices", + neverChecked: "Using the price list included with this version.", + lastChecked: "Last checked {time}", + updated: "Prices updated.", + current: "Prices are up to date.", + checkFailed: "Could not check prices. Usage estimates remain available. Try again later.", + settingsUnavailable: "Price settings are unavailable. Try again.", + }, updates: { title: "App updates", description: "Download updates in the background and choose when to restart.", @@ -1904,7 +1920,7 @@ const messages = { fileErrors: "{count} 个 session 文件无法导入。", lines: "已跳过 {invalid} 条无效日志和 {unsupported} 条不支持的日志。", undated: "{count} 条无时间记录未计入趋势图。", - partialPricing: "{count} 条记录只计算基础成本,因为 cache-write Tokens 无法计价。", + partialPricing: "{count} 条记录为部分估算,可能缺少部分费用。", pricing: "{count} 条记录没有价格;图表显示已知小计,覆盖率为 {coverage}。", partialReportedCost: "{count} 条记录只包含 Grok 上报的部分成本。", reportedCost: "{count} 条记录没有 Grok 上报成本。", @@ -1929,14 +1945,16 @@ const messages = { reportedCostDescription: "按时间显示 Grok 本地会话记录中的上报成本。", tokenDescription: "按本地时间显示输入、缓存输入和输出 Tokens 变化。", costBucketAria: "{bucket}:已知 API 等价成本 {cost},定价覆盖率 {coverage}", - partialCostBucketAria: "{bucket}:部分 API 等价基础成本 {cost};缺少 cache-write tokens;定价覆盖率 {coverage}", + unknownCostBucketAria: "{bucket}:API 等价成本未知", + partialCostBucketAria: "{bucket}:部分 API 等价成本 {cost};可能缺少部分费用;定价覆盖率 {coverage}", reportedCostBucketAria: "{bucket}:已知 Grok 上报成本 {cost},完整上报覆盖率 {coverage}", + unknownReportedCostBucketAria: "{bucket}:Grok 上报成本不可用", partialReportedCostBucketAria: "{bucket}:Grok 上报的部分成本 {cost}", tokenBucketAria: "{bucket}:新输入 {fresh},缓存输入 {cached},输出 {output},总计 {total} Tokens", knownCost: "已知成本", knownReportedCost: "Grok 上报成本", coverage: "覆盖率", - partialEstimate: "部分估算 · 未包含部分 cache-write 成本", + partialEstimate: "部分估算 · 可能缺少部分费用", partialReportedCost: "Grok 只上报了部分金额", }, models: { @@ -1954,8 +1972,9 @@ const messages = { pricing: { partial: "部分定价", unknown: "未知价格", - apiEstimate: "静态 API 估算 · 不是账单或额度", - partialEstimate: "基础估算 · 未包含部分 cache-write 成本", + apiEstimate: "API 等价估算 · 不是账单或额度", + partialEstimate: "部分 API 估算 · 可能缺少部分费用", + multipleVersions: "部分费用估算可能使用较早的价格。", coverage: "{coverage} 的 Tokens 有成本估算", }, }, @@ -2061,6 +2080,19 @@ const messages = { keyImportedTitle: "恢复密钥已导入", keyImportedDescription: "ProfileDeck 现在可以使用此密钥创建的备份。", }, }, + pricing: { + title: "用量价格", + description: "更新模型价格,用于之后的用量估算。", + automatic: { label: "自动检查价格", description: "应用运行期间每天最多检查一次;CLI 同步用量前若到期也会检查。" }, + status: "价格表", + check: "检查价格", + neverChecked: "正在使用当前版本内置的价格表。", + lastChecked: "上次检查 {time}", + updated: "价格已更新。", + current: "价格已是最新。", + checkFailed: "未能检查价格,仍可估算受支持的用量。请稍后重试。", + settingsUnavailable: "暂时无法读取价格设置,请重试。", + }, updates: { title: "应用更新", description: "在后台下载更新,并由你选择何时重启。", diff --git a/desktop/main.go b/desktop/main.go index 950c33b..86af988 100644 --- a/desktop/main.go +++ b/desktop/main.go @@ -121,6 +121,7 @@ func main() { hideMainWindowOnUserClose(mainWindow) setupTray(desktopCtx, wailsApp, mainWindow, services) setupUsageAutoSync(desktopCtx, wailsApp, services) + setupPricingUpdates(desktopCtx, wailsApp, core) setupCodexQuotaRuntime(desktopCtx, wailsApp, services) setupUpdateRuntime(desktopCtx, wailsApp, updates) setupApplicationBackupRuntime(desktopCtx, wailsApp, services) @@ -213,6 +214,25 @@ func setupUsageAutoSync(ctx context.Context, wailsApp *application.App, services }) } +func setupPricingUpdates(ctx context.Context, wailsApp *application.App, core *app.Application) { + removeStartedHandler := wailsApp.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { + go func() { + _, _ = core.Pricing().Check(ctx, false) + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + _, _ = core.Pricing().Check(ctx, false) + } + } + }() + }) + wailsApp.OnShutdown(removeStartedHandler) +} + func noStoreAssetMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Embedded assets change with each build; WebViews can otherwise reuse diff --git a/docs/codex/usage-cost.md b/docs/codex/usage-cost.md index 74735a7..e3d4e10 100644 --- a/docs/codex/usage-cost.md +++ b/docs/codex/usage-cost.md @@ -64,7 +64,7 @@ Reports use your computer's local time zone. They include token totals, session ## Understand cost estimates -ProfileDeck estimates each event from its exact model name and the [OpenAI Standard API prices](https://developers.openai.com/api/docs/pricing) included with the installed version when that event first receives a cost estimate. Installing a later version does not recalculate existing estimates. +ProfileDeck uses a price list based on [OpenAI Standard API prices](https://developers.openai.com/api/docs/pricing). It matches the exact model name and date of each event. If the date predates a verified price or the model name is an alias such as `chat-latest`, its cost remains unknown. Existing estimates do not change after a price update; a later sync can fill in unknown costs. A report may combine estimates made before and after price updates. - `estimated`: all selected usage has a price; - `partial`: ProfileDeck can estimate only part of the selected usage; @@ -72,6 +72,19 @@ ProfileDeck estimates each event from its exact model name and the [OpenAI Stand The report always keeps token totals and shows the known subtotal. Pricing coverage shows how much of the selected token usage could be priced. +If local records do not provide enough detail to apply a cache-write or long-context rate, ProfileDeck shows a partial estimate. + +The app checks for price-list updates at startup and at most once every 24 hours while running. The CLI checks an overdue list before `usage sync`; a failed check does not stop the sync. Manage this independently of app updates in **Settings → Usage prices** or with: + +```bash +profiledeck-cli usage pricing status +profiledeck-cli usage pricing check +profiledeck-cli usage pricing auto off +profiledeck-cli usage pricing auto on +``` + +The included price list works offline. `usage report` never checks the network. Only the price list is downloaded; local usage is not uploaded. + These estimates are not subscription billing, account limits, invoices, or ChatGPT balances. ProfileDeck does not contact a billing API when producing a usage report. [Codex limit checks](./profiles.md#check-limits-and-keep-a-login-active) are separate and never change or attribute usage reports. ## Privacy limits diff --git a/docs/grok-build/usage-cost.md b/docs/grok-build/usage-cost.md index 8f2633e..1909c66 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, 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. +ProfileDeck reads local Grok Build session records to show token usage, activity, 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 @@ -66,24 +66,15 @@ The default range is `7d`. Reports use your computer's local time zone and inclu ## Understand cost figures -ProfileDeck uses the short-context xAI Standard API-equivalent prices included with the installed version: +ProfileDeck uses a price list based on [xAI Standard API prices](https://docs.x.ai/developers/pricing), selecting the rate by model and event date. The list includes `grok-4.5`, `grok-4.6`, and `grok-4.7`. Grok Build's `grok-4.7-build` records use the standard `grok-4.7` rate for API-equivalent estimates. Fast variants, other internal `*-build` names, and mutable `*-latest` names remain unknown until their exact model mapping is verified. Session records do not show whether an individual request entered a long-context pricing tier, so estimates use the short-context rates. -| Model | Input | Cached input | Output | -| --- | ---: | ---: | ---: | -| `grok-4.5` | $2.00 / 1M tokens | $0.30 / 1M tokens | $6.00 / 1M tokens | -| `grok-4.5-build` | $2.00 / 1M tokens | $0.30 / 1M tokens | $6.00 / 1M tokens | -| `grok-4.5-latest` | $2.00 / 1M tokens | $0.30 / 1M tokens | $6.00 / 1M tokens | -| `grok-4.6` | $2.00 / 1M tokens | $0.50 / 1M tokens | $6.00 / 1M tokens | -| `grok-4.6-build` | $2.00 / 1M tokens | $0.50 / 1M tokens | $6.00 / 1M tokens | -| `grok-build-latest` | $2.00 / 1M tokens | $0.30 / 1M tokens | $6.00 / 1M tokens | - -The prices come from [xAI pricing](https://docs.x.ai/developers/pricing). [Grok Build uses Grok 4.6](https://docs.x.ai/build/overview), so ProfileDeck treats the `grok-4.6-build` identifier found in current session records as pricing-equivalent to Grok 4.6. Aggregated session records do not show whether an individual request entered a long-context pricing tier, so ProfileDeck does not apply the 2× long-context multiplier. - -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 session includes cache-creation tokens, ProfileDeck shows a partial estimate if that model has a verified short-context rate. 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. 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 or an event predating a verified price keeps its token totals and Grok-reported amount but has unknown API-equivalent cost. Existing estimates do not change after a price update; a later sync can fill in unknown costs. A report may combine estimates made before and after price updates. + +The app checks the price list at startup and at most once every 24 hours while running. The CLI checks an overdue list before `usage sync`; a failed check does not stop the sync. Manage this independently of app updates in **Settings → Usage prices** or with `profiledeck-cli usage pricing status`, `check`, and `auto on|off`. The included list works offline, and `usage report` never checks the network. 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/docs/reference/data-security.md b/docs/reference/data-security.md index 95501fe..5761f30 100644 --- a/docs/reference/data-security.md +++ b/docs/reference/data-security.md @@ -52,6 +52,7 @@ Backup lists and previews show only safe metadata. Keep encrypted backup files p Most ProfileDeck actions use local data only. - Usage sync and reports read local Codex or Grok Build session files and do not contact a billing service. +- Usage price checks download the public ProfileDeck price list from GitHub. The Desktop app checks at most once a day while running; `usage sync` checks first when due. No local usage is sent. Turn automatic checks off in **Settings → Usage prices** or with `profiledeck-cli usage pricing auto off`. - ChatGPT Codex limit checks contact Codex or OpenAI with the selected saved login. That login is never sent to a custom model-service URL from the saved Codex settings. - API Key limit checks send the saved API Key to `/v1/usage` on the custom Base URL configured by that Profile. ProfileDeck makes one request without redirects. An HTTP Base URL does not encrypt the API Key or response in transit. Limit results stay in memory and are not added to usage reports or application backups. - Grok Build credits checks use the current managed Profile and follow the installed Grok Build app's network and sign-in settings. Grok may renew its current sign-in during the check. ProfileDeck does not query inactive Profiles, and the result remains in memory instead of being added to the database, usage reports, or application backups. diff --git a/docs/zh/codex/usage-cost.md b/docs/zh/codex/usage-cost.md index 3360c8b..8a9d2db 100644 --- a/docs/zh/codex/usage-cost.md +++ b/docs/zh/codex/usage-cost.md @@ -64,7 +64,7 @@ profiledeck-cli usage report --range all ## 理解成本估算 -ProfileDeck 根据准确的模型名称,以及每条记录首次获得成本估算时所安装版本包含的 [OpenAI 标准 API 价格](https://developers.openai.com/api/docs/pricing)估算成本。安装新版本不会重新计算已有估算。 +ProfileDeck 根据 [OpenAI 标准 API 价格](https://developers.openai.com/api/docs/pricing)估算成本,按每条记录的准确模型名称和发生日期选择价格。早于已核实价格的记录,或模型名称为 `chat-latest` 等可变别名的记录,成本保持未知。价格更新后,已有估算不会改变;再次同步可补齐此前未知的成本。因此,一份报告可能包含价格更新前后的估算。 - `estimated`:所选用量都有可用价格; - `partial`:只能估算所选用量中的一部分; @@ -72,6 +72,19 @@ ProfileDeck 根据准确的模型名称,以及每条记录首次获得成本 报告始终保留令牌总量,并显示已知成本小计。定价覆盖率表示所选令牌用量中可估价的比例。 +如果本地记录缺少应用缓存写入或长上下文费率所需的详情,ProfileDeck 会显示部分估算。 + +应用启动时会检查价格更新,运行期间最多每 24 小时检查一次。CLI 在价格表需要更新时,会先检查再执行 `usage sync`;检查失败不会中断同步。你可以在 **设置 → 用量价格** 中单独管理自动检查,也可以使用: + +```bash +profiledeck-cli usage pricing status +profiledeck-cli usage pricing check +profiledeck-cli usage pricing auto off +profiledeck-cli usage pricing auto on +``` + +离线时可使用内置价格表。`usage report` 不会联网。ProfileDeck 只下载价格表,不会上传本地用量。 + 这些数值不是订阅账单、账号限额、发票或 ChatGPT 余额。生成用量报告时,ProfileDeck 不会请求计费 API。[Codex 限额查询](./profiles.md#检查限额并保持登录)是独立功能,不会改变用量报告,也不会为报告归属账号。 ## 隐私范围 diff --git a/docs/zh/grok-build/usage-cost.md b/docs/zh/grok-build/usage-cost.md index db9ebe4..fce64aa 100644 --- a/docs/zh/grok-build/usage-cost.md +++ b/docs/zh/grok-build/usage-cost.md @@ -1,6 +1,6 @@ # Grok Build 用量与成本 -ProfileDeck 读取本地 Grok Build 会话记录,展示令牌用量、活动情况和 API 等价成本估算。报告保持离线,也不会把活动归属到某个 Profile、已保存登录或账号。 +ProfileDeck 读取本地 Grok Build 会话记录,展示令牌用量、活动情况、API 等价成本估算和 Grok 上报成本。报告保持离线,也不会把活动归属到某个 Profile、已保存登录或账号。 ## 在桌面端同步 @@ -51,7 +51,7 @@ profiledeck-cli usage summary --provider grok-build profiledeck-cli usage summary --provider grok-build --json ``` -摘要包含事件数、输入和输出令牌、缓存输入、令牌总量、可用时的成本估算,以及成本未知的事件数。 +摘要包含事件数、输入和输出令牌、缓存输入、令牌总量、可用时的 API 等价估算和 Grok 上报成本,以及两种金额各自成本未知的事件数。 ## 查看报告 @@ -62,28 +62,23 @@ profiledeck-cli usage report --provider grok-build --range 30d --json profiledeck-cli usage report --provider grok-build --range all ``` -默认范围是 `7d`。报告使用电脑的本地时区,包含令牌总量、会话数、缓存命中率、已知成本、定价覆盖率、模型明细和同步状态。没有时间戳的记录会计入全量报告的总量和模型明细,其数量会单独显示,但不会出现在时间趋势中。 +默认范围是 `7d`。报告使用电脑的本地时区,包含令牌总量、会话数、缓存命中率、API 等价估算与 Grok 上报成本的小计及各自覆盖率、模型明细和同步状态。没有时间戳的记录会计入全量报告的总量和模型明细,其数量会单独显示,但不会出现在时间趋势中。 ## 理解成本估算 -ProfileDeck 使用当前安装版本内置的 xAI 短上下文标准 API 等价价格: +ProfileDeck 使用参考 [xAI 标准 API 价格](https://docs.x.ai/developers/pricing)的价格表,按模型和记录发生日期选择费率。Grok Build 的 `grok-4.7-build` 记录会按标准版 `grok-4.7` 费率计算 API 等价估算。Fast 变体、其他内部 `*-build` 标识和可变的 `*-latest` 别名,在无法核实其准确模型映射时仍保持未知。会话记录无法判断单次请求是否进入长上下文价格阶梯,因此估算使用短上下文费率。 -| 模型 | 输入 | 缓存输入 | 输出 | -| --- | ---: | ---: | ---: | -| `grok-4.5` | $2.00 / 100 万令牌 | $0.30 / 100 万令牌 | $6.00 / 100 万令牌 | -| `grok-4.5-build` | $2.00 / 100 万令牌 | $0.30 / 100 万令牌 | $6.00 / 100 万令牌 | -| `grok-4.5-latest` | $2.00 / 100 万令牌 | $0.30 / 100 万令牌 | $6.00 / 100 万令牌 | -| `grok-4.6` | $2.00 / 100 万令牌 | $0.50 / 100 万令牌 | $6.00 / 100 万令牌 | -| `grok-4.6-build` | $2.00 / 100 万令牌 | $0.50 / 100 万令牌 | $6.00 / 100 万令牌 | -| `grok-build-latest` | $2.00 / 100 万令牌 | $0.30 / 100 万令牌 | $6.00 / 100 万令牌 | +如果会话包含缓存创建令牌,且该模型有已核实的短上下文费率,ProfileDeck 会显示部分估算。 -价格来源为 [xAI 定价](https://docs.x.ai/developers/pricing)。[Grok Build 使用 Grok 4.6](https://docs.x.ai/build/overview),因此 ProfileDeck 将当前会话记录中的 `grok-4.6-build` 标识按 Grok 4.6 等价价格计算。聚合后的会话记录无法判断单次请求是否进入长上下文价格阶梯,因此 ProfileDeck 不会应用 2 倍的长上下文价格。 +ProfileDeck 还会显示 Grok 在已完成会话记录中上报的金额。如果部分调用没有上报金额,则显示已知金额的小计,并标记为部分上报。只有金额完整的记录才计入上报成本覆盖率;如果所选记录均无上报金额,则上报成本不可用。 -如果会话记录包含缓存创建令牌,ProfileDeck 仍会保留该事件,但会报告部分估算,因为当前用量事实只按输入、缓存输入和输出令牌计价。 +API 等价估算与 Grok 上报金额分开显示,不会相加。模型无法识别或记录早于已核实价格时,令牌用量和 Grok 上报金额仍会保留,API 等价成本则为未知。价格更新后,已有估算不会改变;再次同步可补齐此前未知的成本。因此,一份报告可能包含价格更新前后的估算。 -Grok Build 记录的金额不会作为账单数据导入。未识别模型仍会保留令牌总量,但成本未知。后续 ProfileDeck 版本即使调整内置价格,也不会重新计算已有估算;成本未知的事实可在模型被识别后获得估算。 +应用启动时会检查价格更新,运行期间最多每 24 小时检查一次。CLI 在价格表需要更新时,会先检查再执行 `usage sync`;检查失败不会中断同步。你可以在 **设置 → 用量价格** 中单独管理自动检查,也可以使用 `profiledeck-cli usage pricing status`、`check` 和 `auto on|off`。离线时可使用内置价格表,`usage report` 不会联网。 -这些估算不是发票、credits、配额或账号余额。同步或生成报告时,ProfileDeck 不会连接 xAI 或计费 API。 +Grok Build 的用量限额面板可能只覆盖进程启动或恢复运行之后的活动;ProfileDeck 报告则读取所选日期内本地会话历史中的已完成回合。因此,即使两者都来自同一会话,总量也可能不同。 + +这两种金额都不是发票、credits 余额、配额或实际账单费用。同步或生成报告时,ProfileDeck 不会连接 xAI 或计费 API。 ## 隐私范围 diff --git a/docs/zh/reference/data-security.md b/docs/zh/reference/data-security.md index 150f8e8..11c69e8 100644 --- a/docs/zh/reference/data-security.md +++ b/docs/zh/reference/data-security.md @@ -52,6 +52,7 @@ Claude Code 支持与 Claude Desktop 相互独立。ProfileDeck 不会读取或 ProfileDeck 的大部分操作只使用本地数据。 - 用量同步和报告读取本地 Codex 或 Grok Build 会话文件,不会请求计费服务。 +- 用量价格检查会从 GitHub 下载公开的 ProfileDeck 价格表。桌面端运行期间每天最多自动检查一次;`usage sync` 在到期时会先检查。不会上传本地用量。可在**设置 → 用量价格**中或通过 `profiledeck-cli usage pricing auto off` 关闭自动检查。 - ChatGPT Codex 限额查询会使用所选的已保存登录连接 Codex 或 OpenAI。该登录绝不会发送到已保存 Codex 设置中的自定义模型服务地址。 - API Key 限额查询会把已保存的 API Key 发送到该 Profile 自定义 Base URL 的 `/v1/usage`。ProfileDeck 只发送一次请求且不跟随重定向。HTTP Base URL 不会对传输中的 API Key 或响应加密。限额结果只保留在内存中,不会写入用量报告或应用备份。 - Grok Build credits 查询会使用当前受管 Profile,并遵循已安装 Grok Build 的网络和登录设置。查询期间,Grok 可能续期当前登录。ProfileDeck 不会查询非当前 Profile,结果只保留在内存中,不会写入数据库、用量报告或应用备份。 diff --git a/internal/app/application.go b/internal/app/application.go index 8691808..3ff2434 100644 --- a/internal/app/application.go +++ b/internal/app/application.go @@ -24,6 +24,7 @@ import ( grokconfig "github.com/strahe/profiledeck/internal/grokbuild/config" grokcoordination "github.com/strahe/profiledeck/internal/grokbuild/coordination" grokprofile "github.com/strahe/profiledeck/internal/grokbuild/profile" + "github.com/strahe/profiledeck/internal/pricing" "github.com/strahe/profiledeck/internal/profile" "github.com/strahe/profiledeck/internal/profiletarget" "github.com/strahe/profiledeck/internal/provider" @@ -69,6 +70,7 @@ type Application struct { switching *switching.Service doctor *doctor.Service usage *usage.Service + pricing *pricing.Service settings *settings.Service codex *codex.Service grokBuild *grokbuild.Service @@ -193,6 +195,7 @@ func NewWithDependencies(config Config, dependencies Dependencies) (*Application agyprofile.DeleteParticipant{}, claudeprofile.DeleteParticipant{}, ) + pricingService := pricing.NewService(stores) return &Application{ runtime: runtimeService, dataLease: dataLease, backups: backupService, bootstrap: bootstrap.NewService( @@ -206,8 +209,9 @@ func NewWithDependencies(config Config, dependencies Dependencies) (*Application profiles: profile.NewService(stores, switchingService, profileDeleteRegistry), targets: profileTargetService, switching: switchingService, doctor: doctorService, - usage: usage.NewService(stores, usageRegistry), settings: settings.NewService(stores), - codex: codexService, grokBuild: grokBuildService, + usage: usage.NewService(stores, usageRegistry, pricingService), pricing: pricingService, + settings: settings.NewService(stores), + codex: codexService, grokBuild: grokBuildService, antigravity: antigravityService, claudeCode: claudeCodeService, }, nil } @@ -247,6 +251,7 @@ func (application *Application) Targets() *profiletarget.Service { return appl func (application *Application) Switching() *switching.Service { return application.switching } func (application *Application) Doctor() *doctor.Service { return application.doctor } func (application *Application) Usage() *usage.Service { return application.usage } +func (application *Application) Pricing() *pricing.Service { return application.pricing } func (application *Application) Settings() *settings.Service { return application.settings } func (application *Application) Codex() *codex.Service { return application.codex } func (application *Application) GrokBuild() *grokbuild.Service { return application.grokBuild } diff --git a/internal/architecture/boundary_test.go b/internal/architecture/boundary_test.go index 4f9de56..d4efacd 100644 --- a/internal/architecture/boundary_test.go +++ b/internal/architecture/boundary_test.go @@ -63,7 +63,7 @@ func TestAppContainsOnlyCompositionAndBuildInfoAPI(t *testing.T) { } allowedApplicationMethods := map[string]struct{}{ "Runtime": {}, "Backups": {}, "Agents": {}, "Providers": {}, "Profiles": {}, "Targets": {}, "Switching": {}, - "Doctor": {}, "Usage": {}, "Settings": {}, "Codex": {}, "GrokBuild": {}, "Antigravity": {}, "ClaudeCode": {}, + "Doctor": {}, "Usage": {}, "Pricing": {}, "Settings": {}, "Codex": {}, "GrokBuild": {}, "Antigravity": {}, "ClaudeCode": {}, "Initialize": {}, "Close": {}, } err := walkProductionGo(root, func(path string, file *ast.File) error { diff --git a/internal/bootstrap/service_test.go b/internal/bootstrap/service_test.go index 55092a8..d7e0111 100644 --- a/internal/bootstrap/service_test.go +++ b/internal/bootstrap/service_test.go @@ -187,7 +187,7 @@ func TestInitializeBacksUpValidatedMarkerGapBeforeReapplyingMarker(t *testing.T) snapshot := inspectDatabaseSnapshot(t, runtimeService.Paths().Database) if len(snapshot.markers) != 0 || !snapshot.usageTable || snapshot.grokUsageTable || - snapshot.usageObservationTable || snapshot.reportedCostColumns || + snapshot.usageObservationTable || snapshot.reportedCostColumns || snapshot.pricingColumns || !snapshot.pathKeyIndex { t.Fatalf("database changed before backup: %#v", snapshot) } @@ -205,7 +205,7 @@ func TestInitializeBacksUpValidatedMarkerGapBeforeReapplyingMarker(t *testing.T) snapshot := inspectDatabaseSnapshot(t, runtimeService.Paths().Database) if len(snapshot.markers) != migrationCount || !snapshot.usageTable || !snapshot.grokUsageTable || - !snapshot.usageObservationTable || !snapshot.reportedCostColumns || + !snapshot.usageObservationTable || !snapshot.reportedCostColumns || !snapshot.pricingColumns || !snapshot.pathKeyIndex || snapshot.setting != `{"kept":true}` { t.Fatalf("database after upgrade = %#v", snapshot) @@ -293,7 +293,7 @@ func TestInitializeRejectsMarkerGapSchemaDriftBeforeBackup(t *testing.T) { } } -func TestInitializeBacksUpPreviousBaselineBeforeGrokBuildReportedCostMigration(t *testing.T) { +func TestInitializeBacksUpPreviousBaselineBeforePricingCatalogMigration(t *testing.T) { ctx := context.Background() runtimeService := newRuntimeService(t) createPreviousBaseline(t, ctx, runtimeService) @@ -306,7 +306,7 @@ func TestInitializeBacksUpPreviousBaselineBeforeGrokBuildReportedCostMigration(t snapshot := inspectDatabaseSnapshot(t, runtimeService.Paths().Database) if len(snapshot.markers) != len(storemigrations.Migrations.Sorted())-1 || !snapshot.usageTable || !snapshot.grokUsageTable || - !snapshot.usageObservationTable || snapshot.reportedCostColumns || + !snapshot.usageObservationTable || !snapshot.reportedCostColumns || snapshot.pricingColumns || !snapshot.pathKeyIndex || snapshot.setting != `{"kept":true}` { t.Fatalf("previous usage baseline changed before backup: %#v", snapshot) @@ -316,7 +316,7 @@ func TestInitializeBacksUpPreviousBaselineBeforeGrokBuildReportedCostMigration(t result, err := NewService(runtimeService, backups, nil).Initialize(ctx) if err != nil { - t.Fatalf("apply Grok Build reported cost migration: %v", err) + t.Fatalf("apply pricing catalog migration: %v", err) } if result.MigrationsApplied != 1 || backups.calls != 1 { t.Fatalf("upgrade result = %#v, backups = %d", result, backups.calls) @@ -324,7 +324,7 @@ func TestInitializeBacksUpPreviousBaselineBeforeGrokBuildReportedCostMigration(t snapshot := inspectDatabaseSnapshot(t, runtimeService.Paths().Database) if len(snapshot.markers) != len(storemigrations.Migrations.Sorted()) || !snapshot.usageTable || !snapshot.grokUsageTable || - !snapshot.usageObservationTable || !snapshot.reportedCostColumns || + !snapshot.usageObservationTable || !snapshot.reportedCostColumns || !snapshot.pricingColumns || !snapshot.pathKeyIndex || snapshot.setting != `{"kept":true}` { t.Fatalf("database after upgrade = %#v", snapshot) @@ -391,6 +391,7 @@ type databaseSnapshot struct { grokUsageTable bool usageObservationTable bool reportedCostColumns bool + pricingColumns bool pathKeyIndex bool setting string } @@ -433,8 +434,9 @@ 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_facts DROP COLUMN reported_cost_status`, - `ALTER TABLE usage_facts DROP COLUMN reported_cost_usd_ticks`, + `ALTER TABLE usage_facts DROP COLUMN cache_creation_input_tokens`, + `ALTER TABLE usage_facts DROP COLUMN cache_write_input_tokens`, + `ALTER TABLE usage_facts DROP COLUMN pricing_catalog_version`, ) execDatabaseStatements(t, runtimeService.Paths().Database, `DELETE FROM bun_migrations WHERE name = '`+registered[len(registered)-1].Name+`'`, @@ -444,6 +446,9 @@ 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 cache_creation_input_tokens`, + `ALTER TABLE usage_facts DROP COLUMN cache_write_input_tokens`, + `ALTER TABLE usage_facts DROP COLUMN pricing_catalog_version`, `ALTER TABLE usage_facts DROP COLUMN reported_cost_status`, `ALTER TABLE usage_facts DROP COLUMN reported_cost_usd_ticks`, `DROP TABLE usage_import_observations`, @@ -533,6 +538,14 @@ func inspectDatabaseSnapshot(t *testing.T, path string) databaseSnapshot { t.Fatal(err) } snapshot.reportedCostColumns = reportedCostColumnCount == 2 + var pricingColumnCount int + if err := db.QueryRow(` + SELECT COUNT(1) FROM pragma_table_info('usage_facts') + WHERE name IN ('pricing_catalog_version', 'cache_write_input_tokens', 'cache_creation_input_tokens') + `).Scan(&pricingColumnCount); err != nil { + t.Fatal(err) + } + snapshot.pricingColumns = pricingColumnCount == 3 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 fdf8669..b479b45 100644 --- a/internal/cli/command_test.go +++ b/internal/cli/command_test.go @@ -684,8 +684,8 @@ func TestUsageSyncCodexAndSummaryJSON(t *testing.T) { if summary.EventCount != 1 || summary.InputTokens != 10 || summary.CachedInputTokens != 2 || summary.OutputTokens != 3 || summary.TotalTokens != 13 { t.Fatalf("unexpected usage summary: %#v", summary) } - if summary.CostStatus != "estimated" || summary.EstimatedCostUSD == nil { - t.Fatalf("expected estimated cost summary, got %#v", summary) + if summary.CostStatus != "unknown" || summary.EstimatedCostUSD != nil { + t.Fatalf("expected undated usage cost to remain unknown, got %#v", summary) } reportOut, err := runCLI(t, "--config-dir", configDir, "usage", "report", "--json") @@ -719,6 +719,32 @@ func TestUsageSyncCodexAndSummaryJSON(t *testing.T) { } } +func TestUsagePricingStatusAndAutomaticSetting(t *testing.T) { + configDir := t.TempDir() + if _, err := runCLI(t, "--config-dir", configDir, "init"); err != nil { + t.Fatal(err) + } + read := func() map[string]any { + t.Helper() + output, err := runCLI(t, "--config-dir", configDir, "usage", "pricing", "status", "--json") + if err != nil { + t.Fatal(err) + } + var status map[string]any + decodeCLIJSON(t, []byte(output), &status) + return status + } + if status := read(); status["automatic"] != true || status["catalog_version"] != float64(1) { + t.Fatalf("initial price status = %#v", status) + } + if _, err := runCLI(t, "--config-dir", configDir, "usage", "pricing", "auto", "off"); err != nil { + t.Fatal(err) + } + if status := read(); status["automatic"] != false { + t.Fatalf("disabled price checks = %#v", status) + } +} + func TestUsageSyncGrokBuildUsesGlobalHomeAndOmitsFileIdentifiers(t *testing.T) { configDir := t.TempDir() grokHome := t.TempDir() @@ -774,7 +800,7 @@ func TestUsageSyncGrokBuildUsesGlobalHomeAndOmitsFileIdentifiers(t *testing.T) { if summary.ProviderID != grokconfig.ProviderID || summary.EventCount != 1 || summary.TotalTokens != 120 || - summary.CostStatus != "estimated" || + summary.CostStatus != "unknown" || summary.ReportedCostUSD == nil || *summary.ReportedCostUSD != "0.0000000999" || summary.ReportedCostStatus != "reported" { diff --git a/internal/cli/pricing.go b/internal/cli/pricing.go new file mode 100644 index 0000000..3ed4598 --- /dev/null +++ b/internal/cli/pricing.go @@ -0,0 +1,100 @@ +package cli + +import ( + "context" + "fmt" + "io" + "time" + + urfavecli "github.com/urfave/cli/v3" + + "github.com/strahe/profiledeck/internal/pricing" +) + +func newUsagePricingCommand() *urfavecli.Command { + return &urfavecli.Command{ + Name: "pricing", Usage: "Check prices used for local usage estimates", + Commands: []*urfavecli.Command{ + { + Name: "status", Usage: "Show price update status", Flags: []urfavecli.Flag{boolFlag(jsonFlagName, "Write JSON output")}, + Action: func(ctx context.Context, cmd *urfavecli.Command) error { + application, err := applicationFor(cmd) + if err != nil { + return err + } + status, err := application.Pricing().Status(ctx) + if err != nil { + return err + } + return writePricingStatus(outputWriter(cmd), status, cmd.Bool(jsonFlagName), "") + }, + }, + { + Name: "check", Usage: "Check for an updated price list", Flags: []urfavecli.Flag{boolFlag(jsonFlagName, "Write JSON output")}, + Action: func(ctx context.Context, cmd *urfavecli.Command) error { + application, err := applicationFor(cmd) + if err != nil { + return err + } + previous, err := application.Pricing().Status(ctx) + if err != nil { + return err + } + status, err := application.Pricing().Check(ctx, true) + if err != nil { + return err + } + message := "Prices are up to date." + if status.CatalogVersion > previous.CatalogVersion { + message = "Prices updated." + } + return writePricingStatus(outputWriter(cmd), status, cmd.Bool(jsonFlagName), message) + }, + }, + { + Name: "auto", Usage: "Turn automatic price checks on or off", ArgsUsage: "", + Action: func(ctx context.Context, cmd *urfavecli.Command) error { + value := cmd.Args().First() + if value != "on" && value != "off" { + return fmt.Errorf("choose on or off") + } + application, err := applicationFor(cmd) + if err != nil { + return err + } + status, err := application.Pricing().SetAutomatic(ctx, value == "on") + if err != nil { + return err + } + return writePricingStatus(outputWriter(cmd), status, false, "") + }, + }, + }, + } +} + +func writePricingStatus(w io.Writer, status pricing.Status, asJSON bool, checkMessage string) error { + if asJSON { + return writeJSON(w, status) + } + message := "Using the price list included with this version." + if status.LastUpdatedAtUnixMS > 0 { + message = "Prices last updated " + formatPricingTime(status.LastUpdatedAtUnixMS) + } + if checkMessage != "" { + message = checkMessage + } + if status.LastError != "" { + message = "Could not check prices. Usage estimates remain available; try again later." + } + _, err := fmt.Fprintf(w, "%s\nversion: %d\nautomatic checks: %t\nlast checked: %s\n", message, + status.CatalogVersion, status.Automatic, formatPricingTime(status.LastCheckedAtUnixMS)) + return err +} + +func formatPricingTime(value int64) string { + if value <= 0 { + return "never" + } + return time.UnixMilli(value).Local().Format(time.RFC3339) +} diff --git a/internal/cli/usage.go b/internal/cli/usage.go index ecfefd1..0485999 100644 --- a/internal/cli/usage.go +++ b/internal/cli/usage.go @@ -27,6 +27,7 @@ func newUsageCommand() *urfavecli.Command { newUsageSyncCommand(), newUsageSummaryCommand(), newUsageReportCommand(), + newUsagePricingCommand(), }, } } @@ -85,6 +86,7 @@ func newUsageSyncCodexCommand() *urfavecli.Command { if err != nil { return err } + _, _ = application.Pricing().Check(ctx, false) result, err := application.Usage().SyncCodex(ctx) if err != nil { return err @@ -110,6 +112,7 @@ func newUsageSyncGrokBuildCommand() *urfavecli.Command { if err != nil { return err } + _, _ = application.Pricing().Check(ctx, false) result, err := application.Usage().SyncGrokBuild(ctx) if err != nil { return err diff --git a/internal/pricing/catalog.go b/internal/pricing/catalog.go new file mode 100644 index 0000000..73e2a7f --- /dev/null +++ b/internal/pricing/catalog.go @@ -0,0 +1,286 @@ +package pricing + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net/url" + "regexp" + "sort" + "strings" + "time" + + embedded "github.com/strahe/profiledeck/pricing" +) + +const MaxCatalogBytes = 256 << 10 + +var modelIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,127}$`) + +type Rates struct { + Input string `json:"input"` + CachedInput *string `json:"cached_input"` + CacheWrite *string `json:"cache_write"` + Output string `json:"output"` +} + +type LongContext struct { + AboveInputTokens int64 `json:"above_input_tokens"` + Rates Rates `json:"rates"` +} + +type Entry struct { + Provider string `json:"provider"` + Model string `json:"model"` + Basis string `json:"basis"` + EffectiveFrom string `json:"effective_from"` + EffectiveUntil string `json:"effective_until,omitempty"` + SourceURL string `json:"source_url"` + VerifiedAt string `json:"verified_at"` + ShortContext Rates `json:"short_context"` + LongContext *LongContext `json:"long_context,omitempty"` +} + +type Catalog struct { + SchemaVersion int `json:"schema_version"` + CatalogVersion int64 `json:"catalog_version"` + Entries []Entry `json:"entries"` +} + +type TokenRates struct { + Input int64 + CachedInput *int64 + CacheWrite *int64 + Output int64 +} + +type Selected struct { + Rates TokenRates + Long *TokenRates + Above int64 + Basis string + Source string + Version int64 +} + +type EffectivePeriod struct { + FromUnixMS int64 + UntilUnixMS int64 +} + +func (catalog Catalog) EffectivePeriods(provider, model string) []EffectivePeriod { + if strings.Contains(model, "latest") { + return nil + } + var periods []EffectivePeriod + for _, entry := range catalog.Entries { + if entry.Provider != provider || entry.Model != model { + continue + } + from, _ := time.Parse("2006-01-02", entry.EffectiveFrom) + period := EffectivePeriod{FromUnixMS: from.UnixMilli(), UntilUnixMS: math.MaxInt64} + if entry.EffectiveUntil != "" { + until, _ := time.Parse("2006-01-02", entry.EffectiveUntil) + period.UntilUnixMS = until.UnixMilli() + } + periods = append(periods, period) + } + return periods +} + +func Embedded() Catalog { + catalog, err := Parse(embedded.CatalogJSON) + if err != nil { + panic(err) + } + return catalog +} + +func Parse(data []byte) (Catalog, error) { + if len(data) == 0 || len(data) > MaxCatalogBytes { + return Catalog{}, errors.New("pricing catalog size is invalid") + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var catalog Catalog + if err := decoder.Decode(&catalog); err != nil { + return Catalog{}, errors.New("pricing catalog JSON is invalid") + } + if decoder.Decode(new(any)) != io.EOF { + return Catalog{}, errors.New("pricing catalog has trailing data") + } + if catalog.SchemaVersion != 1 || catalog.CatalogVersion <= 0 || len(catalog.Entries) == 0 || len(catalog.Entries) > 2_000 { + return Catalog{}, errors.New("pricing catalog version or entries are invalid") + } + byModel := make(map[string][]Entry) + for _, entry := range catalog.Entries { + if err := validateEntry(entry); err != nil { + return Catalog{}, err + } + key := entry.Provider + "\x00" + entry.Model + byModel[key] = append(byModel[key], entry) + } + for _, entries := range byModel { + sort.Slice(entries, func(i, j int) bool { return entries[i].EffectiveFrom < entries[j].EffectiveFrom }) + for i := 1; i < len(entries); i++ { + if entries[i-1].EffectiveUntil == "" || entries[i-1].EffectiveUntil > entries[i].EffectiveFrom { + return Catalog{}, errors.New("pricing periods overlap") + } + } + } + return catalog, nil +} + +func validateEntry(entry Entry) error { + if !modelIDPattern.MatchString(entry.Model) || strings.Contains(entry.Model, "latest") || + (entry.Provider != "codex" && entry.Provider != "grok-build" && entry.Provider != "claude-code") || + (entry.Provider == "grok-build" && (strings.Contains(entry.Model, "-build") || strings.Contains(entry.Model, "fast"))) { + return errors.New("pricing model is invalid") + } + wantBasis, wantHost := "openai-standard-api", "developers.openai.com" + switch entry.Provider { + case "grok-build": + wantBasis, wantHost = "xai-standard-api-short-context", "docs.x.ai" + case "claude-code": + wantBasis, wantHost = "anthropic-standard-api", "platform.claude.com" + } + parsed, err := url.Parse(entry.SourceURL) + if err != nil || parsed.Scheme != "https" || parsed.Hostname() != wantHost || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || entry.Basis != wantBasis { + return errors.New("pricing source is invalid") + } + from, err := time.Parse("2006-01-02", entry.EffectiveFrom) + if err != nil { + return errors.New("pricing start date is invalid") + } + if entry.EffectiveUntil != "" { + until, err := time.Parse("2006-01-02", entry.EffectiveUntil) + if err != nil || !until.After(from) { + return errors.New("pricing end date is invalid") + } + } + verified, err := time.Parse("2006-01-02", entry.VerifiedAt) + if err != nil || verified.Before(from) { + return errors.New("pricing verification date is invalid") + } + if _, err := parseRates(entry.ShortContext); err != nil { + return err + } + if entry.LongContext != nil { + threshold := int64(272_000) + if entry.Provider == "grok-build" { + threshold = 199_999 + } + if entry.LongContext.AboveInputTokens != threshold { + return errors.New("pricing context threshold is invalid") + } + if _, err := parseRates(entry.LongContext.Rates); err != nil { + return err + } + } + return nil +} + +func parseRates(rates Rates) (TokenRates, error) { + input, err := decimalMicros(rates.Input) + if err != nil { + return TokenRates{}, err + } + output, err := decimalMicros(rates.Output) + if err != nil { + return TokenRates{}, err + } + selected := TokenRates{Input: input, Output: output} + if rates.CachedInput != nil { + value, err := decimalMicros(*rates.CachedInput) + if err != nil { + return TokenRates{}, err + } + selected.CachedInput = &value + } + if rates.CacheWrite != nil { + value, err := decimalMicros(*rates.CacheWrite) + if err != nil { + return TokenRates{}, err + } + selected.CacheWrite = &value + } + return selected, nil +} + +func decimalMicros(value string) (int64, error) { + if value == "" || strings.Trim(value, "0123456789.") != "" || strings.Count(value, ".") > 1 { + return 0, errors.New("pricing rate is invalid") + } + parts := strings.SplitN(value, ".", 2) + if parts[0] == "" { + parts[0] = "0" + } + if len(parts) == 2 && (parts[1] == "" || len(parts[1]) > 6) { + return 0, errors.New("pricing rate precision is invalid") + } + var whole, fraction int64 + for _, digit := range parts[0] { + whole = whole*10 + int64(digit-'0') + if whole > 1_000 { + return 0, errors.New("pricing rate exceeds limit") + } + } + if len(parts) == 2 { + for _, digit := range parts[1] { + fraction = fraction*10 + int64(digit-'0') + } + for i := len(parts[1]); i < 6; i++ { + fraction *= 10 + } + } + amount := whole*1_000_000 + fraction + if amount <= 0 || amount > math.MaxInt32*1_000 { + return 0, errors.New("pricing rate is out of range") + } + return amount, nil +} + +func (catalog Catalog) Select(provider, model string, occurredAtUnixMS int64) (Selected, bool) { + if occurredAtUnixMS <= 0 || strings.Contains(model, "latest") { + return Selected{}, false + } + day := time.UnixMilli(occurredAtUnixMS).UTC().Format("2006-01-02") + for _, entry := range catalog.Entries { + if entry.Provider != provider || entry.Model != model || day < entry.EffectiveFrom || entry.EffectiveUntil != "" && day >= entry.EffectiveUntil { + continue + } + rates, err := parseRates(entry.ShortContext) + if err != nil { + return Selected{}, false + } + result := Selected{Rates: rates, Basis: entry.Basis, Source: entry.SourceURL, Version: catalog.CatalogVersion} + if entry.LongContext != nil { + long, err := parseRates(entry.LongContext.Rates) + if err != nil { + return Selected{}, false + } + result.Long, result.Above = &long, entry.LongContext.AboveInputTokens + } + return result, true + } + return Selected{}, false +} + +func (catalog Catalog) Supports(provider, model string) bool { + for _, entry := range catalog.Entries { + if entry.Provider == provider && entry.Model == model { + return true + } + } + return false +} + +func (catalog Catalog) ValidateCompatibleVersion(current int64) error { + if catalog.CatalogVersion < current { + return fmt.Errorf("pricing catalog version is older than active version") + } + return nil +} diff --git a/internal/pricing/catalog_test.go b/internal/pricing/catalog_test.go new file mode 100644 index 0000000..5bfa251 --- /dev/null +++ b/internal/pricing/catalog_test.go @@ -0,0 +1,88 @@ +package pricing + +import ( + "encoding/json" + "testing" + "time" + + embedded "github.com/strahe/profiledeck/pricing" +) + +func TestEmbeddedCatalogSelectsDatedExactModels(t *testing.T) { + catalog, err := Parse(embedded.CatalogJSON) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + provider, model, date string + want bool + }{ + {"codex", "gpt-6-sol", "2026-09-21", false}, + {"codex", "gpt-6-sol", "2026-09-22", true}, + {"codex", "chat-latest", "2026-09-23", false}, + {"grok-build", "grok-4.7", "2026-09-21", true}, + {"grok-build", "grok-4.7-build", "2026-09-23", false}, + {"grok-build", "grok-4.7-build-fast", "2026-09-23", false}, + {"claude-code", "claude-opus-5-5", "2026-09-21", false}, + {"claude-code", "claude-opus-5-5", "2026-09-22", true}, + } { + at, _ := time.Parse("2006-01-02", test.date) + _, ok := catalog.Select(test.provider, test.model, at.UnixMilli()) + if ok != test.want { + t.Fatalf("%s %s on %s: selected=%t", test.provider, test.model, test.date, ok) + } + } +} + +func TestCatalogKeepsHistoricalPriceBoundary(t *testing.T) { + catalog := Embedded() + for _, test := range []struct { + date string + input int64 + }{ + {"2026-07-08", 0}, + {"2026-08-20", 5_000_000}, + {"2026-08-21", 4_000_000}, + } { + at, _ := time.Parse("2006-01-02", test.date) + selected, ok := catalog.Select("codex", "gpt-5.6-sol", at.UnixMilli()) + if ok != (test.input != 0) || ok && selected.Rates.Input != test.input { + t.Fatalf("historical rate on %s = %+v, selected=%t", test.date, selected.Rates, ok) + } + } +} + +func TestCatalogRejectsOverlappingPeriodsAndVersionRollback(t *testing.T) { + catalog := Embedded() + duplicate := catalog.Entries[0] + catalog.Entries = append(catalog.Entries, duplicate) + data, _ := json.Marshal(catalog) + if _, err := Parse(data); err == nil { + t.Fatal("overlapping price periods accepted") + } + if err := catalog.ValidateCompatibleVersion(2); err == nil { + t.Fatal("older catalog accepted") + } +} + +func TestCatalogRejectsUnknownFieldsAndUnsafeSource(t *testing.T) { + data := append([]byte(nil), embedded.CatalogJSON...) + var catalog Catalog + if err := json.Unmarshal(data, &catalog); err != nil { + t.Fatal(err) + } + catalog.Entries[0].SourceURL = "https://example.com/pricing" + changed, _ := json.Marshal(catalog) + if _, err := Parse(changed); err == nil { + t.Fatal("untrusted price source accepted") + } + if _, err := Parse([]byte(`{"schema_version":1,"catalog_version":1,"entries":[],"extra":true}`)); err == nil { + t.Fatal("unknown catalog field accepted") + } + catalog = Embedded() + catalog.Entries = append(catalog.Entries, Entry{Provider: "grok-build", Model: "grok-4.7-build", Basis: "xai-standard-api-short-context", EffectiveFrom: "2026-09-23", SourceURL: "https://docs.x.ai/developers/pricing", VerifiedAt: "2026-09-23", ShortContext: Rates{Input: "2", Output: "6"}}) + changed, _ = json.Marshal(catalog) + if _, err := Parse(changed); err == nil { + t.Fatal("unverified Grok Build model mapping accepted") + } +} diff --git a/internal/pricing/service.go b/internal/pricing/service.go new file mode 100644 index 0000000..c97f68e --- /dev/null +++ b/internal/pricing/service.go @@ -0,0 +1,270 @@ +package pricing + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/strahe/profiledeck/internal/store" +) + +const ( + CatalogURL = "https://raw.githubusercontent.com/strahe/profiledeck/main/pricing/catalog.json" + stateKey = "pricing.catalog.state" + checkEvery = 24 * time.Hour +) + +type Status struct { + Automatic bool `json:"automatic"` + CatalogVersion int64 `json:"catalog_version"` + LastCheckedAtUnixMS int64 `json:"last_checked_at_unix_ms"` + LastUpdatedAtUnixMS int64 `json:"last_updated_at_unix_ms"` + LastError string `json:"last_error,omitempty"` +} + +type state struct { + Automatic *bool `json:"automatic,omitempty"` + Catalog json.RawMessage `json:"catalog,omitempty"` + LastCheckedAtUnixMS int64 `json:"last_checked_at_unix_ms"` + LastUpdatedAtUnixMS int64 `json:"last_updated_at_unix_ms"` + LastError string `json:"last_error,omitempty"` +} + +type Service struct { + stores store.Factory + client *http.Client + mu sync.Mutex +} + +func NewService(stores store.Factory) *Service { + return &Service{stores: stores, client: &http.Client{ + Timeout: 3 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("pricing redirect rejected") }, + }} +} + +func (service *Service) Snapshot(ctx context.Context) (Catalog, error) { + db, err := service.stores.OpenHealthy(ctx, true) + if err != nil { + return Catalog{}, err + } + defer db.Close() + saved, err := readState(ctx, db) + if err != nil { + return Catalog{}, err + } + return catalogFromState(saved) +} + +func (service *Service) SnapshotInStore(ctx context.Context, db *store.Store) (Catalog, error) { + saved, err := readState(ctx, db) + if err != nil { + return Catalog{}, err + } + return catalogFromState(saved) +} + +func (service *Service) Status(ctx context.Context) (Status, error) { + db, err := service.stores.OpenHealthy(ctx, true) + if err != nil { + return Status{}, err + } + defer db.Close() + saved, err := readState(ctx, db) + if err != nil { + return Status{}, err + } + return statusFromState(saved) +} + +func (service *Service) SetAutomatic(ctx context.Context, enabled bool) (Status, error) { + service.mu.Lock() + defer service.mu.Unlock() + db, err := service.stores.OpenHealthy(ctx, false) + if err != nil { + return Status{}, err + } + defer db.Close() + var result Status + err = db.WithTransaction(ctx, func(tx *store.Store) error { + if err := tx.ReserveSettingForWrite(ctx, stateKey); err != nil { + return err + } + saved, err := readState(ctx, tx) + if err != nil { + return err + } + saved.Automatic = &enabled + if err := writeState(ctx, tx, saved); err != nil { + return err + } + result, err = statusFromState(saved) + return err + }) + return result, err +} + +func (service *Service) Check(ctx context.Context, force bool) (Status, error) { + service.mu.Lock() + defer service.mu.Unlock() + db, err := service.stores.OpenHealthy(ctx, true) + if err != nil { + return Status{}, err + } + saved, err := readState(ctx, db) + closeErr := db.Close() + if err != nil { + return Status{}, err + } + if closeErr != nil { + return Status{}, closeErr + } + if !force && (!automatic(saved) || time.Since(time.UnixMilli(saved.LastCheckedAtUnixMS)) < checkEvery) { + return statusFromState(saved) + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, CatalogURL, nil) + if err != nil { + return Status{}, err + } + response, fetchErr := service.client.Do(request) + errorCode := "unavailable" + var fetched Catalog + var raw []byte + if fetchErr == nil { + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + fetchErr = errors.New("pricing feed unavailable") + } else { + raw, fetchErr = io.ReadAll(io.LimitReader(response.Body, MaxCatalogBytes+1)) + if fetchErr == nil { + fetched, fetchErr = Parse(raw) + errorCode = "invalid" + } + } + } + writable, err := service.stores.OpenHealthy(ctx, false) + if err != nil { + return Status{}, err + } + defer writable.Close() + var result Status + err = writable.WithTransaction(ctx, func(tx *store.Store) error { + if err := tx.ReserveSettingForWrite(ctx, stateKey); err != nil { + return err + } + current, err := readState(ctx, tx) + if err != nil { + return err + } + active, err := catalogFromState(current) + if err != nil { + return err + } + minimumVersion := active.CatalogVersion + if len(current.Catalog) != 0 { + if _, err := Parse(current.Catalog); err != nil { + var savedVersion struct { + CatalogVersion int64 `json:"catalog_version"` + } + if json.Unmarshal(current.Catalog, &savedVersion) != nil || savedVersion.CatalogVersion <= 0 { + minimumVersion = 0 + } else if savedVersion.CatalogVersion > minimumVersion { + minimumVersion = savedVersion.CatalogVersion + } + } + } + current.LastCheckedAtUnixMS = time.Now().UnixMilli() + if fetchErr != nil { + current.LastError = errorCode + } else if minimumVersion == 0 || fetched.CatalogVersion < minimumVersion || + fetched.CatalogVersion == active.CatalogVersion && !sameCatalog(fetched, active) { + current.LastError = "invalid" + } else { + current.LastError = "" + if fetched.CatalogVersion > active.CatalogVersion { + current.Catalog = raw + current.LastUpdatedAtUnixMS = current.LastCheckedAtUnixMS + } + } + if err := writeState(ctx, tx, current); err != nil { + return err + } + result, err = statusFromState(current) + return err + }) + return result, err +} + +func sameCatalog(a, b Catalog) bool { + left, _ := json.Marshal(a) + right, _ := json.Marshal(b) + return string(left) == string(right) +} + +func automatic(saved state) bool { return saved.Automatic == nil || *saved.Automatic } + +func catalogFromState(saved state) (Catalog, error) { + bundled := Embedded() + if len(saved.Catalog) == 0 { + return bundled, nil + } + accepted, err := Parse(saved.Catalog) + if err != nil { + return bundled, nil + } + if bundled.CatalogVersion > accepted.CatalogVersion { + return bundled, nil + } + return accepted, nil +} + +func statusFromState(saved state) (Status, error) { + catalog, err := catalogFromState(saved) + if err != nil { + return Status{}, err + } + lastError := saved.LastError + if len(saved.Catalog) != 0 { + if _, err := Parse(saved.Catalog); err != nil { + lastError = "invalid" + } + } + return Status{ + Automatic: automatic(saved), CatalogVersion: catalog.CatalogVersion, + LastCheckedAtUnixMS: saved.LastCheckedAtUnixMS, + LastUpdatedAtUnixMS: saved.LastUpdatedAtUnixMS, LastError: lastError, + }, nil +} + +func readState(ctx context.Context, db *store.Store) (state, error) { + setting, err := db.GetSetting(ctx, stateKey) + if errors.Is(err, store.ErrNotFound) { + return state{}, nil + } + if err != nil { + return state{}, err + } + var saved state + if err := json.Unmarshal([]byte(setting.ValueJSON), &saved); err != nil { + return state{}, err + } + if saved.LastCheckedAtUnixMS < 0 || saved.LastUpdatedAtUnixMS < 0 || + (saved.LastError != "" && saved.LastError != "unavailable" && saved.LastError != "invalid") { + return state{}, errors.New("pricing state is invalid") + } + return saved, nil +} + +func writeState(ctx context.Context, db *store.Store, saved state) error { + data, err := json.Marshal(saved) + if err != nil { + return err + } + _, err = db.UpsertSetting(ctx, store.UpsertSettingParams{Key: stateKey, ValueJSON: strings.TrimSpace(string(data))}) + return err +} diff --git a/internal/pricing/service_test.go b/internal/pricing/service_test.go new file mode 100644 index 0000000..707470a --- /dev/null +++ b/internal/pricing/service_test.go @@ -0,0 +1,166 @@ +package pricing + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "path/filepath" + "testing" + "time" + + "github.com/strahe/profiledeck/internal/store" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) } + +func pricingTestService(t *testing.T) *Service { + t.Helper() + ctx := context.Background() + factory := store.NewFactory(filepath.Join(t.TempDir(), "profiledeck.db")) + db, err := factory.Open(ctx, false) + if err != nil { + t.Fatal(err) + } + if _, err := db.Migrate(ctx); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + return NewService(factory) +} + +func TestPricingCheckAcceptsNewCatalogAndKeepsItOnInvalidFeed(t *testing.T) { + ctx := context.Background() + service := pricingTestService(t) + catalog := Embedded() + catalog.CatalogVersion++ + data, err := json.Marshal(catalog) + if err != nil { + t.Fatal(err) + } + service.client.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.String() != CatalogURL { + t.Fatalf("unexpected pricing URL: %s", req.URL) + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(data)), Header: make(http.Header)}, nil + }) + status, err := service.Check(ctx, true) + if err != nil || status.CatalogVersion != 2 || status.LastError != "" { + t.Fatalf("accepted status = %+v, err = %v", status, err) + } + service.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString("{}")), Header: make(http.Header)}, nil + }) + status, err = service.Check(ctx, true) + if err != nil || status.CatalogVersion != 2 || status.LastError != "invalid" { + t.Fatalf("rejected status = %+v, err = %v", status, err) + } + snapshot, err := service.Snapshot(ctx) + if err != nil || snapshot.CatalogVersion != 2 { + t.Fatalf("retained snapshot = %+v, err = %v", snapshot, err) + } +} + +func TestPricingAutomaticCheckCanBeDisabled(t *testing.T) { + ctx := context.Background() + service := pricingTestService(t) + if _, err := service.SetAutomatic(ctx, false); err != nil { + t.Fatal(err) + } + service.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("disabled automatic check made a request") + return nil, nil + }) + status, err := service.Check(ctx, false) + if err != nil || status.Automatic || status.LastCheckedAtUnixMS != 0 { + t.Fatalf("status = %+v, err = %v", status, err) + } + service.client.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + <-req.Context().Done() + return nil, req.Context().Err() + }) + requestCtx, cancel := context.WithTimeout(ctx, 10*time.Millisecond) + defer cancel() + _, _ = service.Check(requestCtx, true) + if snapshot, err := service.Snapshot(ctx); err != nil || snapshot.CatalogVersion != 1 { + t.Fatalf("fallback after timeout = %+v, err = %v", snapshot, err) + } +} + +func TestPricingCheckRejectsRollbackAndChangedSameVersion(t *testing.T) { + ctx := context.Background() + service := pricingTestService(t) + catalog := Embedded() + catalog.CatalogVersion = 2 + feed, err := json.Marshal(catalog) + if err != nil { + t.Fatal(err) + } + service.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(feed)), Header: make(http.Header)}, nil + }) + if status, err := service.Check(ctx, true); err != nil || status.CatalogVersion != 2 { + t.Fatalf("initial update = %+v, err = %v", status, err) + } + feed = marshalCatalog(t, Embedded()) + if status, err := service.Check(ctx, true); err != nil || status.CatalogVersion != 2 || status.LastError != "invalid" { + t.Fatalf("rollback = %+v, err = %v", status, err) + } + catalog.Entries[0].ShortContext.Input = "999" + feed = marshalCatalog(t, catalog) + if status, err := service.Check(ctx, true); err != nil || status.CatalogVersion != 2 || status.LastError != "invalid" { + t.Fatalf("same-version modification = %+v, err = %v", status, err) + } +} + +func TestPricingUnreadableSavedCatalogKeepsSyncAvailableAndCanRecover(t *testing.T) { + ctx := context.Background() + service := pricingTestService(t) + db, err := service.stores.OpenHealthy(ctx, false) + if err != nil { + t.Fatal(err) + } + corrupt := state{Catalog: json.RawMessage(`{"catalog_version":2,"entries":"unreadable"}`)} + if err := writeState(ctx, db, corrupt); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + if catalog, err := service.Snapshot(ctx); err != nil || catalog.CatalogVersion != Embedded().CatalogVersion { + t.Fatalf("offline catalog = %+v, err = %v", catalog, err) + } + if status, err := service.SetAutomatic(ctx, false); err != nil || status.Automatic || status.LastError != "invalid" { + t.Fatalf("setting with unreadable catalog = %+v, err = %v", status, err) + } + feed := marshalCatalog(t, Embedded()) + service.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(feed)), Header: make(http.Header)}, nil + }) + if status, err := service.Check(ctx, true); err != nil || status.LastError != "invalid" { + t.Fatalf("older feed replaced unreadable accepted version: %+v, err = %v", status, err) + } + version2 := Embedded() + version2.CatalogVersion = 2 + feed = marshalCatalog(t, version2) + service.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(feed)), Header: make(http.Header)}, nil + }) + if status, err := service.Check(ctx, true); err != nil || status.CatalogVersion != 2 || status.LastError != "" || status.Automatic { + t.Fatalf("recovered status = %+v, err = %v", status, err) + } +} + +func marshalCatalog(t *testing.T, catalog Catalog) []byte { + t.Helper() + data, err := json.Marshal(catalog) + if err != nil { + t.Fatal(err) + } + return data +} diff --git a/internal/store/integrity.go b/internal/store/integrity.go index 0cd3634..75d8f62 100644 --- a/internal/store/integrity.go +++ b/internal/store/integrity.go @@ -294,7 +294,10 @@ var schemaContracts = func() []schemaContract { reportedCost.tableSpecs, grokBuildReportedCostFactTableSpec, ) - return []schemaContract{stable, grokBuild, incremental, observationParserRevision, reportedCost} + pricingCatalog := reportedCost + pricingCatalog.migrationKey = "pricing_catalog" + pricingCatalog.tableSpecs = replaceTableSpec(pricingCatalog.tableSpecs, pricingCatalogFactTableSpec) + return []schemaContract{stable, grokBuild, incremental, observationParserRevision, reportedCost, pricingCatalog} }() func jsonObjectExpression(column string) string { diff --git a/internal/store/migrations/202609230001_pricing_catalog.go b/internal/store/migrations/202609230001_pricing_catalog.go new file mode 100644 index 0000000..b3fb8c5 --- /dev/null +++ b/internal/store/migrations/202609230001_pricing_catalog.go @@ -0,0 +1,38 @@ +package migrations + +import ( + "context" + + "github.com/uptrace/bun" +) + +func init() { + Migrations.MustRegister(upPricingCatalog, downPricingCatalog) +} + +func upPricingCatalog(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", "pricing_catalog_version", + "INTEGER CHECK (pricing_catalog_version IS NULL OR pricing_catalog_version > 0)"); err != nil { + return err + } + if err := addUsageColumnIfMissing(ctx, tx, "usage_facts", "cache_write_input_tokens", + "INTEGER CHECK (cache_write_input_tokens IS NULL OR cache_write_input_tokens >= 0)"); err != nil { + return err + } + return addUsageColumnIfMissing(ctx, tx, "usage_facts", "cache_creation_input_tokens", + "INTEGER CHECK (cache_creation_input_tokens IS NULL OR cache_creation_input_tokens >= 0)") + }) +} + +func downPricingCatalog(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", "cache_creation_input_tokens"); err != nil { + return err + } + if err := dropUsageColumnIfExists(ctx, tx, "usage_facts", "cache_write_input_tokens"); err != nil { + return err + } + return dropUsageColumnIfExists(ctx, tx, "usage_facts", "pricing_catalog_version") + }) +} diff --git a/internal/store/schema_contract.go b/internal/store/schema_contract.go index b560081..f534f77 100644 --- a/internal/store/schema_contract.go +++ b/internal/store/schema_contract.go @@ -483,6 +483,21 @@ var grokBuildReportedCostFactTableSpec = func() tableSpec { return spec }() +var pricingCatalogFactTableSpec = func() tableSpec { + spec := copyTableSpec(grokBuildReportedCostFactTableSpec) + spec.columns = append(spec.columns, + columnSpec{name: "pricing_catalog_version", columnType: "INTEGER"}, + columnSpec{name: "cache_write_input_tokens", columnType: "INTEGER"}, + columnSpec{name: "cache_creation_input_tokens", columnType: "INTEGER"}, + ) + spec.checks = append(spec.checks, + "CHECK (pricing_catalog_version IS NULL OR pricing_catalog_version > 0)", + "CHECK (cache_write_input_tokens IS NULL OR cache_write_input_tokens >= 0)", + "CHECK (cache_creation_input_tokens IS NULL OR cache_creation_input_tokens >= 0)", + ) + return spec +}() + func stableBaselineTableSpec(name string) tableSpec { for _, spec := range stableBaselineTableSpecs { if spec.name == name { diff --git a/internal/store/store.go b/internal/store/store.go index 6e317a3..e8a7e07 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1210,6 +1210,12 @@ func (s *Store) GetSetting(ctx context.Context, key string) (Setting, error) { return setting, err } +func (s *Store) ReserveSettingForWrite(ctx context.Context, key string) error { + // Acquire SQLite's writer before reading a setting that will be replaced. + _, err := s.executor().ExecContext(ctx, `UPDATE settings SET value_json = value_json WHERE key = ?`, strings.TrimSpace(key)) + return err +} + func (s *Store) ListSettingsByPrefix(ctx context.Context, prefix string) ([]Setting, error) { prefix = strings.TrimSpace(prefix) rows, err := s.executor().QueryContext(ctx, ` diff --git a/internal/store/store_test.go b/internal/store/store_test.go index aea5877..f1d6ed3 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -254,7 +254,7 @@ 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"} { + for _, column := range []string{"cache_creation_input_tokens", "cache_write_input_tokens", "pricing_catalog_version", "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) } diff --git a/internal/store/usage.go b/internal/store/usage.go index 807c225..6c31db7 100644 --- a/internal/store/usage.go +++ b/internal/store/usage.go @@ -130,19 +130,22 @@ 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 - ReportedCostUSDTicks *int64 - ReportedCostStatus UsageReportedCostStatus + 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 + PricingCatalogVersion *int64 + CacheWriteInputTokens *int64 + CacheCreationInputTokens *int64 } type UsageInsertResult struct { @@ -231,17 +234,21 @@ type UsageUnknownCostModel struct { } type UsageFactCostCandidate struct { - ID int64 - InputTokens int64 - CachedInputTokens int64 - OutputTokens int64 - TotalTokens int64 + ID int64 + OccurredAtUnixMS int64 + InputTokens int64 + CachedInputTokens int64 + CacheWriteInputTokens *int64 + CacheCreationInputTokens *int64 + OutputTokens int64 + TotalTokens int64 } type UpdateUsageFactCostParams struct { - ID int64 - EstimatedCostMicros int64 - CostStatus UsageCostStatus + ID int64 + EstimatedCostMicros int64 + CostStatus UsageCostStatus + PricingCatalogVersion *int64 } type usageFactSessionPolicy int @@ -438,8 +445,9 @@ 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, reported_cost_usd_ticks, reported_cost_status - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + estimated_cost_micros, cost_status, reported_cost_usd_ticks, reported_cost_status, + pricing_catalog_version, cache_write_input_tokens, cache_creation_input_tokens + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(event_key) DO NOTHING `) if err != nil { @@ -459,7 +467,7 @@ func (s *Store) insertUsageFactsWithSessionPolicy( costUpgradeStmt, err := s.executor().PrepareContext(ctx, ` UPDATE usage_facts - SET estimated_cost_micros = ?, cost_status = ? + SET estimated_cost_micros = ?, cost_status = ?, pricing_catalog_version = ? WHERE id = ? AND cost_status = ? `) if err != nil { @@ -520,6 +528,9 @@ func (s *Store) insertUsageFactsWithSessionPolicy( status, reportedCost, reportedStatus, + fact.PricingCatalogVersion, + fact.CacheWriteInputTokens, + fact.CacheCreationInputTokens, ) if err != nil { return UsageInsertResult{}, err @@ -608,6 +619,7 @@ func (s *Store) insertUsageFactsWithSessionPolicy( ctx, cost, status, + fact.PricingCatalogVersion, existingID, UsageCostStatusUnknown, ); err != nil { @@ -654,6 +666,11 @@ func validateUsageFact(fact CreateUsageFactParams) error { fact.OutputTokens < 0 || fact.TotalTokens < 0 { return errors.New("usage fact is invalid") } + if fact.PricingCatalogVersion != nil && *fact.PricingCatalogVersion <= 0 || + fact.CacheWriteInputTokens != nil && (*fact.CacheWriteInputTokens < 0 || *fact.CacheWriteInputTokens > fact.InputTokens) || + fact.CacheCreationInputTokens != nil && (*fact.CacheCreationInputTokens < 0 || *fact.CacheCreationInputTokens > fact.InputTokens) { + return errors.New("usage fact pricing is invalid") + } if fact.SessionKey != "" && !validUsageSessionKey(fact.SessionKey) { return errors.New("usage session key is invalid") } @@ -1063,6 +1080,22 @@ func (s *Store) ListUnknownUsageCostModels(ctx context.Context, providerID strin return models, rows.Err() } +func (s *Store) HasUnknownUsageFactCostInPeriod(ctx context.Context, sourceID, modelID, fromUnixMS, untilUnixMS int64) (bool, error) { + if sourceID <= 0 || modelID <= 0 || untilUnixMS <= fromUnixMS { + return false, errors.New("usage cost period query is invalid") + } + var found int + err := s.executor().QueryRowContext(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM usage_facts INDEXED BY idx_usage_facts_source_cost_model_id + WHERE source_id = ? AND cost_status = ? AND model_id = ? + AND occurred_at_unix_ms >= ? AND occurred_at_unix_ms < ? + LIMIT 1 + ) + `, sourceID, UsageCostStatusUnknown, modelID, fromUnixMS, untilUnixMS).Scan(&found) + return found != 0, err +} + // ListUnknownUsageFactCostCandidates pages facts for one model dimension so // unsupported models never cause every historical fact to be read again. func (s *Store) ListUnknownUsageFactCostCandidates( @@ -1079,7 +1112,8 @@ func (s *Store) ListUnknownUsageFactCostCandidates( } rows, err := s.executor().QueryContext(ctx, ` - SELECT f.id, f.input_tokens, f.cached_input_tokens, f.output_tokens, f.total_tokens + SELECT f.id, f.occurred_at_unix_ms, f.input_tokens, f.cached_input_tokens, + f.cache_write_input_tokens, f.cache_creation_input_tokens, f.output_tokens, f.total_tokens FROM usage_facts f INDEXED BY idx_usage_facts_source_cost_model_id JOIN usage_sources s ON s.id = f.source_id WHERE s.provider_id = ? AND f.source_id = ? AND f.cost_status = ? @@ -1097,8 +1131,11 @@ func (s *Store) ListUnknownUsageFactCostCandidates( var candidate UsageFactCostCandidate if err := rows.Scan( &candidate.ID, + &candidate.OccurredAtUnixMS, &candidate.InputTokens, &candidate.CachedInputTokens, + &candidate.CacheWriteInputTokens, + &candidate.CacheCreationInputTokens, &candidate.OutputTokens, &candidate.TotalTokens, ); err != nil { @@ -1128,7 +1165,7 @@ func (s *Store) updateUnknownUsageFactCosts(ctx context.Context, providerID stri } stmt, err := s.executor().PrepareContext(ctx, ` UPDATE usage_facts - SET estimated_cost_micros = ?, cost_status = ? + SET estimated_cost_micros = ?, cost_status = ?, pricing_catalog_version = ? WHERE id = ? AND cost_status = ? AND source_id IN ( SELECT id FROM usage_sources WHERE provider_id = ? ) @@ -1143,7 +1180,7 @@ func (s *Store) updateUnknownUsageFactCosts(ctx context.Context, providerID stri if item.ID <= 0 || item.EstimatedCostMicros < 0 || !item.CostStatus.valid() || item.CostStatus == UsageCostStatusUnknown { return 0, errors.New("usage fact cost update is invalid") } - result, err := stmt.ExecContext(ctx, item.EstimatedCostMicros, item.CostStatus, item.ID, UsageCostStatusUnknown, providerID) + result, err := stmt.ExecContext(ctx, item.EstimatedCostMicros, item.CostStatus, item.PricingCatalogVersion, item.ID, UsageCostStatusUnknown, providerID) if err != nil { return 0, err } diff --git a/internal/store/usage_report.go b/internal/store/usage_report.go index a17bbe4..a6de343 100644 --- a/internal/store/usage_report.go +++ b/internal/store/usage_report.go @@ -77,11 +77,12 @@ type UsageImportSummary struct { } type UsageReportSnapshot struct { - Sources []string - Summary UsageAggregate - Trend []UsageTrendAggregate - Models []UsageModelAggregate - ImportSummary UsageImportSummary + Sources []string + Summary UsageAggregate + Trend []UsageTrendAggregate + Models []UsageModelAggregate + ImportSummary UsageImportSummary + PricingVersionCount int64 } type usageProviderSources struct { @@ -197,15 +198,35 @@ func (s *Store) usageReport(ctx context.Context, query UsageReportQuery) (UsageR if err != nil { return UsageReportSnapshot{}, err } + versionCount, err := s.queryUsagePricingVersionCount(ctx, sources.IDs, query.StartUnixMS, query.EndUnixMS) + if err != nil { + return UsageReportSnapshot{}, err + } return UsageReportSnapshot{ - Sources: sources.FactSources, - Summary: summary, - Trend: trend, - Models: models, - ImportSummary: importSummary, + Sources: sources.FactSources, + Summary: summary, + Trend: trend, + Models: models, + ImportSummary: importSummary, + PricingVersionCount: versionCount, }, nil } +func (s *Store) queryUsagePricingVersionCount(ctx context.Context, sourceIDs []int64, start *int64, end int64) (int64, error) { + where, args := usageSourceIDWhere("f", sourceIDs) + where += " AND f.cost_status <> ?" + args = append(args, UsageCostStatusUnknown) + if start != nil { + where += " AND f.occurred_at_unix_ms >= ? AND f.occurred_at_unix_ms < ?" + args = append(args, *start, end) + } + var count int64 + err := s.executor().QueryRowContext(ctx, + "SELECT COUNT(DISTINCT COALESCE(f.pricing_catalog_version, 0)) FROM usage_facts f WHERE "+where, + args...).Scan(&count) + return count, err +} + func validateUsageReportQuery(query UsageReportQuery) error { if strings.TrimSpace(query.ProviderID) == "" { return errors.New("usage provider id is required") diff --git a/internal/usage/codex.go b/internal/usage/codex.go index e0f387c..72550c2 100644 --- a/internal/usage/codex.go +++ b/internal/usage/codex.go @@ -159,7 +159,7 @@ func newCodexParserState(file SourceFile) codexParserState { } } -func (state *codexParserState) parseLine(line []byte) (*Event, bool, bool) { +func (state *codexParserState) parseLineAt(ctx context.Context, line []byte) (*Event, bool, bool) { var payload any decoder := json.NewDecoder(bytes.NewReader(line)) decoder.UseNumber() @@ -206,18 +206,34 @@ func (state *codexParserState) parseLine(line []byte) (*Event, bool, bool) { state.UsageOrdinals[state.CurrentSessionKey]++ usageOrdinal := state.UsageOrdinals[state.CurrentSessionKey] - costMicros, costStatus := EstimateCostMicros(state.ModelForPricing, delta) + occurredAt := occurredAtUnixMS(object) + var cacheWrite, perCallInput *int64 + if lastObject, ok := usageObjectFromKnownPaths(object, "last_token_usage"); ok { + last, valid := tokenCountsFromUsageObject(lastObject) + if valid && last.InputTokens == delta.InputTokens && last.CachedInputTokens == delta.CachedInputTokens && last.OutputTokens == delta.OutputTokens { + input := last.InputTokens + perCallInput = &input + if count, present := int64Field(lastObject, "cache_write_input_tokens"); present && count >= 0 { + cacheWrite = &count + } + } + } + costMicros, costStatus, catalogVersion := estimateCostAt( + pricingSnapshot(ctx), ProviderCodex, state.ModelForPricing, occurredAt, delta, cacheWrite, perCallInput, + ) event := Event{ - EventKey: EventID(ProviderCodex, SourceCodexSessionJSONL, usageOrdinal, state.CurrentSessionKey, state.ModelForStorage, delta), - SessionID: state.CurrentSessionKey, - Model: state.ModelForStorage, - OccurredAtUnixMS: occurredAtUnixMS(object), - InputTokens: delta.InputTokens, - CachedInputTokens: delta.CachedInputTokens, - OutputTokens: delta.OutputTokens, - TotalTokens: delta.TotalTokens, - EstimatedCostMicros: costMicros, - CostStatus: costStatus, + EventKey: EventID(ProviderCodex, SourceCodexSessionJSONL, usageOrdinal, state.CurrentSessionKey, state.ModelForStorage, delta), + SessionID: state.CurrentSessionKey, + Model: state.ModelForStorage, + OccurredAtUnixMS: occurredAt, + InputTokens: delta.InputTokens, + CachedInputTokens: delta.CachedInputTokens, + OutputTokens: delta.OutputTokens, + TotalTokens: delta.TotalTokens, + EstimatedCostMicros: costMicros, + CostStatus: costStatus, + PricingCatalogVersion: catalogVersion, + CacheWriteInputTokens: cacheWrite, } return &event, false, false } @@ -258,7 +274,7 @@ func parseCodexSessionFile(ctx context.Context, file SourceFile, maxLineBytes in continue } - event, invalid, unsupported := state.parseLine(line) + event, invalid, unsupported := state.parseLineAt(ctx, line) if invalid { result.InvalidLines++ continue diff --git a/internal/usage/codex_sync.go b/internal/usage/codex_sync.go index b98880e..bc848f8 100644 --- a/internal/usage/codex_sync.go +++ b/internal/usage/codex_sync.go @@ -10,6 +10,7 @@ import ( "github.com/strahe/profiledeck/internal/apperror" codexconfig "github.com/strahe/profiledeck/internal/codex/config" codexpreset "github.com/strahe/profiledeck/internal/codex/preset" + "github.com/strahe/profiledeck/internal/pricing" "github.com/strahe/profiledeck/internal/store" ) @@ -312,7 +313,11 @@ func (integration codexIntegration) preflight( } cursors := codexCursorMap(cursorRows) observations := observationMap(observationRows) - priceBackfill := hasSupportedUnknownUsageModel(unknownModels, codexPriceCatalog.Supports) + catalog := pricingSnapshot(ctx) + priceBackfill, err := hasPriceableUnknownUsageModel(ctx, db, unknownModels, catalog, ProviderCodex) + if err != nil { + return nil, nil, nil, false, UsageSyncResult{}, false, err + } work := source.SyncGeneration != source.CompletedGeneration || priceBackfill discovered := make(map[store.UsageKey]struct{}, len(files)) for _, file := range files { @@ -453,16 +458,19 @@ func observationMap(rows []store.UsageImportObservation) map[store.UsageKey]stor return result } -func hasSupportedUnknownUsageModel( - models []store.UsageUnknownCostModel, - supports func(string) bool, -) bool { +func hasPriceableUnknownUsageModel( + ctx context.Context, db *store.Store, models []store.UsageUnknownCostModel, + catalog pricing.Catalog, providerID string, +) (bool, error) { for _, model := range models { - if supports(model.Model) { - return true + for _, period := range catalog.EffectivePeriods(providerID, pricingCatalogModelID(providerID, model.Model)) { + found, err := db.HasUnknownUsageFactCostInPeriod(ctx, model.SourceID, model.ModelID, period.FromUnixMS, period.UntilUnixMS) + if err != nil || found { + return found, err + } } } - return false + return false, nil } func newUsageObservation( @@ -593,31 +601,44 @@ 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, - ReportedCostUSDTicks: event.ReportedCostUSDTicks, - ReportedCostStatus: event.ReportedCostStatus, + 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, + PricingCatalogVersion: event.PricingCatalogVersion, + CacheWriteInputTokens: event.CacheWriteInputTokens, + CacheCreationInputTokens: event.CacheCreationInputTokens, }) } return facts } func backfillPartialUsageCosts(ctx context.Context, db *store.Store) error { + catalog := pricingSnapshot(ctx) return backfillUnknownUsageCosts( ctx, db, ProviderCodex, - codexPriceCatalog.Supports, - EstimateCostMicros, + catalog, + func(model string, candidate store.UsageFactCostCandidate) (*int64, store.UsageCostStatus, *int64) { + var perCallInput *int64 + if candidate.CacheWriteInputTokens != nil { + perCallInput = &candidate.InputTokens + } + return estimateCostAt(catalog, ProviderCodex, model, candidate.OccurredAtUnixMS, TokenCounts{ + InputTokens: candidate.InputTokens, CachedInputTokens: candidate.CachedInputTokens, + OutputTokens: candidate.OutputTokens, TotalTokens: candidate.TotalTokens, + }, candidate.CacheWriteInputTokens, perCallInput) + }, ) } @@ -625,8 +646,8 @@ func backfillUnknownUsageCosts( ctx context.Context, db *store.Store, providerID string, - supports func(string) bool, - estimate func(string, TokenCounts) (*int64, store.UsageCostStatus), + catalog pricing.Catalog, + estimate func(string, store.UsageFactCostCandidate) (*int64, store.UsageCostStatus, *int64), ) error { const batchSize = 256 models, err := db.ListUnknownUsageCostModels(ctx, providerID) @@ -634,7 +655,11 @@ func backfillUnknownUsageCosts( return err } for _, model := range models { - if !supports(model.Model) { + priceable, err := hasPriceableUnknownUsageModel(ctx, db, []store.UsageUnknownCostModel{model}, catalog, providerID) + if err != nil { + return err + } + if !priceable { continue } var afterID int64 @@ -655,19 +680,15 @@ func backfillUnknownUsageCosts( } updates := make([]store.UpdateUsageFactCostParams, 0, len(candidates)) for _, candidate := range candidates { - cost, status := estimate(model.Model, TokenCounts{ - InputTokens: candidate.InputTokens, - CachedInputTokens: candidate.CachedInputTokens, - OutputTokens: candidate.OutputTokens, - TotalTokens: candidate.TotalTokens, - }) + cost, status, version := estimate(model.Model, candidate) if cost == nil || status == CostStatusUnknown { continue } updates = append(updates, store.UpdateUsageFactCostParams{ - ID: candidate.ID, - EstimatedCostMicros: *cost, - CostStatus: status, + ID: candidate.ID, + EstimatedCostMicros: *cost, + CostStatus: status, + PricingCatalogVersion: version, }) } // Only unknown facts are eligible, so concurrent import/backfill runs are diff --git a/internal/usage/codex_test.go b/internal/usage/codex_test.go index f9e5a57..6bce292 100644 --- a/internal/usage/codex_test.go +++ b/internal/usage/codex_test.go @@ -41,8 +41,8 @@ func TestParseCodexSessionFileComputesCumulativeDeltas(t *testing.T) { writeTestFile(t, path, strings.Join([]string{ `{"type":"session_meta","session_id":"session-1"}`, `{"type":"turn_context","model":"gpt-5.3-codex"}`, - `{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":100,"cached_input_tokens":20,"output_tokens":10,"total_tokens":110},"prompt":"do not store me","content":"do not store me"}},"timestamp":"2026-07-06T00:00:00Z"}`, - `{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":150,"cached_input_tokens":25,"output_tokens":15,"total_tokens":165}}},"timestamp":"2026-07-06T00:01:00Z"}`, + `{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":100,"cached_input_tokens":20,"output_tokens":10,"total_tokens":110},"prompt":"do not store me","content":"do not store me"}},"timestamp":"2026-09-12T00:00:00Z"}`, + `{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":150,"cached_input_tokens":25,"output_tokens":15,"total_tokens":165}}},"timestamp":"2026-09-12T00:01:00Z"}`, }, "\n")) sourceKey, err := SourceKey(path) @@ -82,14 +82,14 @@ func TestParseCodexSessionFileStoresCacheWriteModelsAsPartial(t *testing.T) { want int64 }{ {model: "gpt-6-astra", want: 59_100_000}, - {model: "gpt-5.6-sol", want: 34_550_000}, + {model: "gpt-5.6-sol", want: 23_640_000}, } { t.Run(test.model, func(t *testing.T) { path := filepath.Join(t.TempDir(), "session.jsonl") writeTestFile(t, path, strings.Join([]string{ `{"type":"session_meta","session_id":"session-1"}`, `{"type":"turn_context","model":"` + test.model + `"}`, - `{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1000000,"cached_input_tokens":100000,"output_tokens":1000000,"total_tokens":2000000}}}}`, + `{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1000000,"cached_input_tokens":100000,"output_tokens":1000000,"total_tokens":2000000}}},"timestamp":"2026-09-23T00:00:00Z"}`, }, "\n")) sourceKey, err := SourceKey(path) @@ -114,6 +114,49 @@ func TestParseCodexSessionFileStoresCacheWriteModelsAsPartial(t *testing.T) { } } +func TestParseCodexSessionFilePricesMatchedCacheWriteAndLongContext(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + writeTestFile(t, path, strings.Join([]string{ + `{"type":"session_meta","session_id":"session-1"}`, + `{"type":"turn_context","model":"gpt-6-astra"}`, + `{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":300000,"cached_input_tokens":50000,"output_tokens":10000,"total_tokens":310000}},"last_token_usage":{"input_tokens":300000,"cached_input_tokens":50000,"cache_write_input_tokens":25000,"output_tokens":10000}},"timestamp":"2026-09-23T00:00:00Z"}`, + }, "\n")) + sourceKey, err := SourceKey(path) + if err != nil { + t.Fatal(err) + } + parsed, err := ParseCodexSessionFile(SourceFile{Path: path, SourceKey: sourceKey}) + if err != nil || len(parsed.Events) != 1 { + t.Fatalf("matched call = %#v, err = %v", parsed, err) + } + event := parsed.Events[0] + if event.CostStatus != CostStatusEstimated || event.EstimatedCostMicros == nil || *event.EstimatedCostMicros != 5_975_000 || + event.CacheWriteInputTokens == nil || *event.CacheWriteInputTokens != 25_000 || event.PricingCatalogVersion == nil || *event.PricingCatalogVersion != 1 { + t.Fatalf("matched cache write and long context = %#v", event) + } +} + +func TestParseCodexSessionFileDoesNotUseUnmatchedCallRates(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + writeTestFile(t, path, strings.Join([]string{ + `{"type":"session_meta","session_id":"session-1"}`, + `{"type":"turn_context","model":"gpt-6-astra"}`, + `{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":300000,"cached_input_tokens":50000,"output_tokens":10000,"total_tokens":310000}},"last_token_usage":{"input_tokens":299999,"cached_input_tokens":50000,"cache_write_input_tokens":25000,"output_tokens":10000}},"timestamp":"2026-09-23T00:00:00Z"}`, + }, "\n")) + sourceKey, err := SourceKey(path) + if err != nil { + t.Fatal(err) + } + parsed, err := ParseCodexSessionFile(SourceFile{Path: path, SourceKey: sourceKey}) + if err != nil || len(parsed.Events) != 1 { + t.Fatalf("unmatched call = %#v, err = %v", parsed, err) + } + event := parsed.Events[0] + if event.CostStatus != CostStatusPartial || event.EstimatedCostMicros == nil || *event.EstimatedCostMicros != 3_050_000 || event.CacheWriteInputTokens != nil { + t.Fatalf("unmatched call used cache-write or long-context pricing: %#v", event) + } +} + func TestParseCodexSessionFileDoesNotResetWhenCachedCumulativeDecreases(t *testing.T) { path := filepath.Join(t.TempDir(), "session.jsonl") writeTestFile(t, path, strings.Join([]string{ diff --git a/internal/usage/grok_build.go b/internal/usage/grok_build.go index b3f0f09..5e5e924 100644 --- a/internal/usage/grok_build.go +++ b/internal/usage/grok_build.go @@ -199,7 +199,7 @@ func parseGrokBuildSessionFileWithOpenHook( if len(line) == 0 { continue } - events, unsupported, err := parseGrokBuildSessionLine(line) + events, unsupported, err := parseGrokBuildSessionLineAt(ctx, line) if err != nil { return FileParseResult{}, err } @@ -239,6 +239,10 @@ type grokBuildParamsClassifier struct { } func parseGrokBuildSessionLine(line []byte) ([]Event, bool, error) { + return parseGrokBuildSessionLineAt(context.Background(), line) +} + +func parseGrokBuildSessionLineAt(ctx context.Context, line []byte) ([]Event, bool, error) { var classifier grokBuildEnvelopeClassifier if err := decodeJSON(line, &classifier, false); err != nil { return nil, false, errors.New("session file contains a malformed record") @@ -270,7 +274,7 @@ func parseGrokBuildSessionLine(line []byte) ([]Event, bool, error) { if err := decodeJSON(line, &envelope, false); err != nil { return nil, false, errors.New("terminal update has an unsupported structure") } - return grokBuildEventsFromTerminal(envelope) + return grokBuildEventsFromTerminal(ctx, envelope) } type grokBuildTerminalEnvelope struct { @@ -387,6 +391,7 @@ type grokBuildUsageModel struct { } func grokBuildEventsFromTerminal( + ctx context.Context, envelope grokBuildTerminalEnvelope, ) ([]Event, bool, error) { if envelope.Method != grokBuildSessionUpdateMethod || @@ -433,6 +438,7 @@ func grokBuildEventsFromTerminal( return nil, false, errors.New("terminal usage contains an invalid model") } row := usage.ModelUsage.values[rawModel] + cacheCreation := int64(row.CacheCreationTokens.value) if row.InputTokens.value == 0 && row.OutputTokens.value == 0 && row.CachedReadTokens.value == 0 && row.CacheCreationTokens.value == 0 { continue @@ -443,24 +449,28 @@ func grokBuildEventsFromTerminal( OutputTokens: int64(row.OutputTokens.value), TotalTokens: int64(row.TotalTokens.value), } - cost, status := EstimateGrokBuildCostMicros(model, tokens) + cost, status, catalogVersion := estimateCostAt( + pricingSnapshot(ctx), grokconfig.ProviderID, model, occurredAt, tokens, nil, nil, + ) 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, - ReportedCostUSDTicks: reportedCost, - ReportedCostStatus: reportedStatus, + 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, + PricingCatalogVersion: catalogVersion, + ReportedCostUSDTicks: reportedCost, + ReportedCostStatus: reportedStatus, + CacheCreationInputTokens: &cacheCreation, }) } if len(events) == 0 { diff --git a/internal/usage/grok_build_sync.go b/internal/usage/grok_build_sync.go index b547895..52e83f6 100644 --- a/internal/usage/grok_build_sync.go +++ b/internal/usage/grok_build_sync.go @@ -232,12 +232,22 @@ func (integration grokBuildIntegration) Sync( return SyncOutcome{}, err } if options.ProvisionMode == SyncProvisionProvider || priceBackfill { + catalog := pricingSnapshot(ctx) if err := backfillUnknownUsageCosts( ctx, db, grokconfig.ProviderID, - grokBuildPriceCatalog.Supports, - EstimateGrokBuildCostMicros, + catalog, + func(model string, candidate store.UsageFactCostCandidate) (*int64, store.UsageCostStatus, *int64) { + cost, status, version := estimateCostAt(catalog, grokconfig.ProviderID, model, candidate.OccurredAtUnixMS, TokenCounts{ + InputTokens: candidate.InputTokens, CachedInputTokens: candidate.CachedInputTokens, + OutputTokens: candidate.OutputTokens, TotalTokens: candidate.TotalTokens, + }, nil, nil) + if cost != nil && (candidate.CacheCreationInputTokens == nil || *candidate.CacheCreationInputTokens > 0) { + status = CostStatusPartial + } + return cost, status, version + }, ); err != nil { return SyncOutcome{}, apperror.Wrap( apperror.UsageImportFailed, @@ -360,7 +370,11 @@ func (integration grokBuildIntegration) preflight( } cursors := grokBuildCursorMap(cursorRows) observations := observationMap(observationRows) - priceBackfill := hasSupportedUnknownUsageModel(unknownModels, grokBuildPriceCatalog.Supports) + catalog := pricingSnapshot(ctx) + priceBackfill, err := hasPriceableUnknownUsageModel(ctx, db, unknownModels, catalog, grokconfig.ProviderID) + if err != nil { + return nil, nil, nil, false, UsageSyncResult{}, false, err + } work := source.SyncGeneration != source.CompletedGeneration || priceBackfill discovered := make(map[store.UsageKey]struct{}, len(files)) for _, file := range files { diff --git a/internal/usage/grok_build_sync_test.go b/internal/usage/grok_build_sync_test.go index aa1e1f2..3c9179f 100644 --- a/internal/usage/grok_build_sync_test.go +++ b/internal/usage/grok_build_sync_test.go @@ -10,11 +10,13 @@ import ( "strings" "sync" "testing" + "time" "github.com/strahe/profiledeck/internal/apperror" "github.com/strahe/profiledeck/internal/bootstrap" grokconfig "github.com/strahe/profiledeck/internal/grokbuild/config" grokpreset "github.com/strahe/profiledeck/internal/grokbuild/preset" + "github.com/strahe/profiledeck/internal/pricing" profilesruntime "github.com/strahe/profiledeck/internal/runtime" "github.com/strahe/profiledeck/internal/store" ) @@ -87,8 +89,8 @@ func TestUsageSyncGrokBuildImportsIncrementallyAcrossRestart(t *testing.T) { summary.CachedInputTokens != 50 || summary.OutputTokens != 25 || summary.TotalTokens != 175 || - summary.CostStatus != CostStatusEstimated.String() || - summary.EstimatedCostUSD == nil { + summary.CostStatus != CostStatusUnknown.String() || + summary.EstimatedCostUSD != nil { t.Fatalf("summary = %#v", summary) } @@ -130,8 +132,8 @@ func TestUsageSyncGrokBuildImportsCurrentSessionFormat(t *testing.T) { Range: UsageRangeAll, }) 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.CostStatus != CostStatusUnknown.String() || + report.Summary.KnownEstimatedCostUSD != "0.000000" || report.Summary.UnknownCostEventCount != 1 || report.Summary.KnownReportedCostUSD != "0.0000012345" || report.Summary.ReportedCostStatus != ReportedCostStatusReported.String() || report.Summary.ReportedCostEventCount != 1 || report.Summary.ReportedCostCoverage != 1 { @@ -435,6 +437,7 @@ func TestBackgroundGrokBuildSyncKeepsCursorCreatedAfterPreflight(t *testing.T) { func TestUsageSyncGrokBuildBackfillsNewlyRecognizedUnknownModels(t *testing.T) { ctx := context.Background() + reportedTicks := int64(20_000_000_000) environment := newGrokBuildUsageTestEnvironment(t, t.TempDir(), t.TempDir()) if _, err := bootstrap.NewService(environment.runtime, nil, nil).Initialize(ctx); err != nil { t.Fatalf("initialize runtime: %v", err) @@ -461,13 +464,16 @@ func TestUsageSyncGrokBuildBackfillsNewlyRecognizedUnknownModels(t *testing.T) { SourceID: source.ID, Generation: source.SyncGeneration, Facts: []store.CreateUsageFactParams{{ - EventKey: usageTestEventKey("grok-4.6-build-historical"), - SourceID: source.ID, - ModelKey: "grok-4.6-build", - InputTokens: 1_000_000, - OutputTokens: 1_000_000, - TotalTokens: 2_000_000, - CostStatus: store.UsageCostStatusUnknown, + EventKey: usageTestEventKey("grok-4.7-build-historical"), + SourceID: source.ID, + ModelKey: "grok-4.7-build", + OccurredAtUnixMS: time.Date(2026, time.September, 23, 0, 0, 0, 0, time.UTC).UnixMilli(), + InputTokens: 1_000_000, + OutputTokens: 1_000_000, + TotalTokens: 2_000_000, + CostStatus: store.UsageCostStatusUnknown, + ReportedCostUSDTicks: &reportedTicks, + ReportedCostStatus: store.UsageReportedCostStatusReported, }}, }); err != nil { _ = db.Close() @@ -480,16 +486,115 @@ func TestUsageSyncGrokBuildBackfillsNewlyRecognizedUnknownModels(t *testing.T) { if _, err := environment.service.SyncGrokBuild(ctx); err != nil { t.Fatalf("backfill Grok Build usage price: %v", err) } - summary, err := environment.service.Summary(ctx, UsageSummaryRequest{ - ProviderID: grokconfig.ProviderID, - }) + report, err := environment.service.Report(ctx, UsageReportRequest{ProviderID: grokconfig.ProviderID, Range: UsageRangeAll}) if err != nil { - t.Fatalf("read backfilled summary: %v", err) + t.Fatalf("read backfilled report: %v", err) + } + if report.Summary.CostStatus != CostStatusPartial.String() || + report.Summary.KnownEstimatedCostUSD != "8.000000" || report.Summary.PartialCostEventCount != 1 || + report.Summary.KnownReportedCostUSD != "2.0000000000" || len(report.Models) != 1 || + report.Models[0].Model != "grok-4.7-build" { + t.Fatalf("backfilled report = %#v", report.Summary) + } +} + +func TestUsageSyncGrokBuildPricesStandard47Session(t *testing.T) { + ctx := context.Background() + grokHome := t.TempDir() + environment := newGrokBuildUsageTestEnvironment(t, t.TempDir(), grokHome) + if _, err := bootstrap.NewService(environment.runtime, nil, nil).Initialize(ctx); err != nil { + t.Fatal(err) + } + writeGrokBuildUsageFixture(t, grokHome, "workspace-47", "session-47", syntheticGrokBuildUsageLine( + "session-47", "prompt-47", "grok-4.7-build", + uint64(time.Date(2026, time.September, 23, 0, 0, 0, 0, time.UTC).Unix()), + TokenCounts{InputTokens: 1_000_000, OutputTokens: 1_000_000, TotalTokens: 2_000_000}, + )) + if result, err := environment.service.SyncGrokBuild(ctx); err != nil || result.ImportedEvents != 1 { + t.Fatalf("sync standard 4.7 session = %+v, err=%v", result, err) + } + report, err := environment.service.Report(ctx, UsageReportRequest{ProviderID: grokconfig.ProviderID, Range: UsageRangeAll}) + if err != nil || report.Summary.CostStatus != CostStatusEstimated.String() || + report.Summary.KnownEstimatedCostUSD != "8.000000" || len(report.Models) != 1 || + report.Models[0].Model != "grok-4.7-build" { + t.Fatalf("standard 4.7 report = %+v, err=%v", report, err) + } +} + +func TestGrokBuildBackfillUsesEachUnknownRecordDate(t *testing.T) { + ctx := context.Background() + environment := newGrokBuildUsageTestEnvironment(t, t.TempDir(), t.TempDir()) + if _, err := bootstrap.NewService(environment.runtime, nil, nil).Initialize(ctx); err != nil { + t.Fatal(err) + } + if _, err := environment.service.SyncGrokBuild(ctx); err != nil { + t.Fatal(err) + } + db, err := environment.runtime.StoreFactory().OpenHealthy(ctx, false) + if err != nil { + t.Fatal(err) + } + defer db.Close() + source, err := db.BeginUsageSync(ctx, grokconfig.ProviderID, SourceGrokBuildSessionJSONL, GrokBuildUsageIdentityRevision) + if err != nil { + t.Fatal(err) + } + cacheCreation := int64(50) + makeFact := func(key, day string) store.CreateUsageFactParams { + date, parseErr := time.Parse("2006-01-02", day) + if parseErr != nil { + t.Fatal(parseErr) + } + return store.CreateUsageFactParams{ + EventKey: usageTestEventKey(key), SourceID: source.ID, ModelKey: "grok-4.6", + OccurredAtUnixMS: date.UnixMilli(), InputTokens: 1_000_000, OutputTokens: 1_000_000, + TotalTokens: 2_000_000, CostStatus: store.UsageCostStatusUnknown, + CacheCreationInputTokens: &cacheCreation, + } + } + if _, err := db.InsertUsageFacts(ctx, store.InsertUsageFactsParams{ + SourceID: source.ID, Generation: source.SyncGeneration, + Facts: []store.CreateUsageFactParams{ + makeFact("priceable-earlier", "2026-09-23"), makeFact("unpriced-later", "2026-09-24"), + }, + }); err != nil { + t.Fatal(err) + } + catalog := bundledPricingCatalog + catalog.Entries = append([]pricing.Entry(nil), catalog.Entries...) + for i := range catalog.Entries { + if catalog.Entries[i].Provider == grokconfig.ProviderID && catalog.Entries[i].Model == "grok-4.6" { + catalog.Entries[i].EffectiveUntil = "2026-09-24" + } } - if summary.CostStatus != CostStatusEstimated.String() || - summary.EstimatedCostUSD == nil || - *summary.EstimatedCostUSD != "8.000000" { - t.Fatalf("backfilled summary = %#v", summary) + models, err := db.ListUnknownUsageCostModels(ctx, grokconfig.ProviderID) + if err != nil { + t.Fatal(err) + } + priceable, err := hasPriceableUnknownUsageModel(ctx, db, models, catalog, grokconfig.ProviderID) + if err != nil || !priceable { + t.Fatalf("earlier record was skipped: priceable=%t err=%v", priceable, err) + } + if err := backfillUnknownUsageCosts(ctx, db, grokconfig.ProviderID, catalog, + func(model string, candidate store.UsageFactCostCandidate) (*int64, store.UsageCostStatus, *int64) { + cost, status, version := estimateCostAt(catalog, grokconfig.ProviderID, model, candidate.OccurredAtUnixMS, TokenCounts{ + InputTokens: candidate.InputTokens, CachedInputTokens: candidate.CachedInputTokens, + OutputTokens: candidate.OutputTokens, TotalTokens: candidate.TotalTokens, + }, nil, nil) + if cost != nil && candidate.CacheCreationInputTokens != nil && *candidate.CacheCreationInputTokens > 0 { + status = CostStatusPartial + } + return cost, status, version + }); err != nil { + t.Fatal(err) + } + candidates, err := db.ListUnknownUsageFactCostCandidates(ctx, grokconfig.ProviderID, models[0].SourceID, models[0].ModelID, 0, 10) + if err != nil || len(candidates) != 1 || candidates[0].OccurredAtUnixMS != time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC).UnixMilli() { + t.Fatalf("remaining unknown records = %+v, err=%v", candidates, err) + } + report, err := environment.service.Report(ctx, UsageReportRequest{ProviderID: grokconfig.ProviderID, Range: UsageRangeAll}) + if err != nil || report.Summary.PartialCostEventCount != 1 || report.Summary.UnknownCostEventCount != 1 || report.Summary.KnownEstimatedCostUSD != "8.000000" { + t.Fatalf("price boundary report = %+v, err=%v", report.Summary, err) } } diff --git a/internal/usage/grok_build_test.go b/internal/usage/grok_build_test.go index b4e4830..d99a2fe 100644 --- a/internal/usage/grok_build_test.go +++ b/internal/usage/grok_build_test.go @@ -10,6 +10,7 @@ import ( "runtime" "strings" "testing" + "time" ) func TestParseGrokBuildSyntheticFixture(t *testing.T) { @@ -41,8 +42,7 @@ func TestParseGrokBuildSyntheticFixture(t *testing.T) { strings.Contains(first.SessionID, "synthetic-session") { t.Fatalf("session identifier was not irreversibly derived: %q", first.SessionID) } - if first.EstimatedCostMicros == nil || *first.EstimatedCostMicros != 252 || - first.CostStatus != CostStatusEstimated { + if first.EstimatedCostMicros != nil || first.CostStatus != CostStatusUnknown { t.Fatalf("first event cost = %#v", first) } if first.ReportedCostUSDTicks == nil || *first.ReportedCostUSDTicks != 999 || @@ -52,7 +52,7 @@ func TestParseGrokBuildSyntheticFixture(t *testing.T) { known, unknown := result.Events[1], result.Events[2] if known.Model != "grok-4.5" || known.OccurredAtUnixMS != 0 || - known.EstimatedCostMicros == nil || *known.EstimatedCostMicros != 504 { + known.EstimatedCostMicros != nil || known.CostStatus != CostStatusUnknown { t.Fatalf("known undated event = %#v", known) } if unknown.Model != "synthetic-unknown-model" || @@ -102,8 +102,9 @@ func TestParseGrokBuildCurrentSessionFixture(t *testing.T) { event := result.Events[0] 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.TotalTokens != 1_100 || event.EstimatedCostMicros != nil || + event.CostStatus != CostStatusUnknown || + event.CacheCreationInputTokens == nil || *event.CacheCreationInputTokens != 50 || event.ReportedCostUSDTicks == nil || *event.ReportedCostUSDTicks != 12_345 || event.ReportedCostStatus != ReportedCostStatusReported { t.Fatalf("current Grok Build event = %#v", event) @@ -251,26 +252,26 @@ func TestEstimateGrokBuildCostUsesShortContextStandardPrices(t *testing.T) { OutputTokens: 1_000_000, TotalTokens: 2_000_000, } - for _, model := range []string{ - "grok-4.5", - "grok-4.5-build", - "grok-4.5-latest", - "grok-build-latest", - } { - cost, status := EstimateGrokBuildCostMicros(model, tokens) - if status != CostStatusEstimated || cost == nil || *cost != 7_830_000 { + if cost, status := EstimateGrokBuildCostMicros("grok-4.5", tokens); status != CostStatusEstimated || cost == nil || *cost != 7_830_000 { + t.Fatalf("grok-4.5 cost = %v, status = %v", cost, status) + } + for _, model := range []string{"grok-4.5-build", "grok-4.5-latest", "grok-build-latest", "grok-4.6-build", "grok-4.7-fast", "grok-4.7-build-fast", "grok-unverified"} { + if cost, status := EstimateGrokBuildCostMicros(model, tokens); cost != nil || status != CostStatusUnknown { t.Fatalf("%s cost = %v, status = %v", model, cost, status) } } - if cost, status := EstimateGrokBuildCostMicros("grok-unverified", tokens); cost != nil || - status != CostStatusUnknown { - t.Fatalf("unverified model cost = %v, status = %v", cost, status) + if cost, status := EstimateGrokBuildCostMicros("grok-4.6", tokens); status != CostStatusEstimated || cost == nil || *cost != 7_850_000 { + t.Fatalf("grok-4.6 cost = %v, status = %v", cost, status) } - for _, model := range []string{"grok-4.6", "grok-4.6-build"} { - cost, status := EstimateGrokBuildCostMicros(model, tokens) - if status != CostStatusEstimated || cost == nil || *cost != 7_850_000 { - t.Fatalf("%s cost = %v, status = %v", model, cost, status) - } + if cost, status := EstimateGrokBuildCostMicros("grok-4.7", tokens); status != CostStatusEstimated || cost == nil || *cost != 7_850_000 { + t.Fatalf("grok-4.7 cost = %v, status = %v", cost, status) + } + if cost, status := EstimateGrokBuildCostMicros("grok-4.7-build", tokens); status != CostStatusEstimated || cost == nil || *cost != 7_850_000 { + t.Fatalf("standard Grok Build 4.7 cost = %v, status = %v", cost, status) + } + beforeLaunch := time.Date(2026, time.September, 20, 23, 59, 59, 0, time.UTC).UnixMilli() + if cost, status, _ := estimateCostAt(bundledPricingCatalog, "grok-build", "grok-4.7-build", beforeLaunch, tokens, nil, nil); cost != nil || status != CostStatusUnknown { + t.Fatalf("pre-launch Grok Build 4.7 cost = %v, status = %v", cost, status) } if cost, status := EstimateGrokBuildCostMicros("grok-4.5", TokenCounts{ InputTokens: math.MaxInt64, diff --git a/internal/usage/incremental_reader.go b/internal/usage/incremental_reader.go index 49440aa..cb09aa4 100644 --- a/internal/usage/incremental_reader.go +++ b/internal/usage/incremental_reader.go @@ -134,7 +134,7 @@ func parseCodexCheckpointFile( boundary.appendSuffix(boundarySuffix, consumed) continue } - event, invalid, unsupported := state.parseLine(line) + event, invalid, unsupported := state.parseLineAt(ctx, line) if invalid && !terminated { break } @@ -228,7 +228,7 @@ func parseGrokBuildCheckpointFile( boundary.appendSuffix(boundarySuffix, consumed) continue } - events, unsupported, parseErr := parseGrokBuildSessionLine(line) + events, unsupported, parseErr := parseGrokBuildSessionLineAt(ctx, line) if parseErr != nil && !terminated { break } diff --git a/internal/usage/report_service.go b/internal/usage/report_service.go index c8b2cb9..3be304f 100644 --- a/internal/usage/report_service.go +++ b/internal/usage/report_service.go @@ -79,6 +79,8 @@ type UsagePricingInfo struct { SourceURL string `json:"source_url"` VerifiedAt string `json:"verified_at"` HistoricalRepricing bool `json:"historical_repricing"` + CatalogVersion int64 `json:"catalog_version"` + MultipleVersions bool `json:"multiple_versions"` } // Codex session logs do not prove which stored credential served a request; @@ -121,6 +123,7 @@ func (service *Service) usageReportAt(ctx context.Context, req UsageReportReques var resolved ResolvedRange var snapshot store.UsageReportSnapshot + pricingInfo := integration.PricingInfo() err = db.WithTransaction(ctx, func(txStore *store.Store) error { earliest, err := txStore.EarliestDatedUsageUnixMS(ctx, providerID) if err != nil { @@ -143,7 +146,22 @@ func (service *Service) usageReportAt(ctx context.Context, req UsageReportReques EndUnixMS: resolved.EndUnixMS, Buckets: buckets, }) - return err + if err != nil { + return err + } + if service.pricing != nil { + catalog, priceErr := service.pricing.SnapshotInStore(ctx, txStore) + if priceErr == nil { + pricingInfo.CatalogVersion = catalog.CatalogVersion + for _, entry := range catalog.Entries { + if entry.Provider == providerID && entry.VerifiedAt > pricingInfo.VerifiedAt { + pricingInfo.VerifiedAt = entry.VerifiedAt + pricingInfo.SourceURL = entry.SourceURL + } + } + } + } + return nil }) if err != nil { return UsageReportResult{}, apperror.Wrap(apperror.StoreStatusFailed, "failed to read usage report", err) @@ -171,8 +189,9 @@ func (service *Service) usageReportAt(ctx context.Context, req UsageReportReques InvalidLines: snapshot.ImportSummary.InvalidLines, UnsupportedLines: snapshot.ImportSummary.UnsupportedLines, }, - Pricing: integration.PricingInfo(), + Pricing: pricingInfo, } + result.Pricing.MultipleVersions = snapshot.PricingVersionCount > 1 result.Trend = make([]UsageTrendPoint, 0, len(snapshot.Trend)) for _, point := range snapshot.Trend { bucket := resolved.Buckets[point.BucketIndex] diff --git a/internal/usage/report_service_test.go b/internal/usage/report_service_test.go index 6a3f3ae..52bf7c4 100644 --- a/internal/usage/report_service_test.go +++ b/internal/usage/report_service_test.go @@ -48,6 +48,9 @@ func TestUsageReportRangesUndatedAndPartialPricing(t *testing.T) { for index := range events { events[index].SourceID = source.ID } + firstVersion, secondVersion := int64(1), int64(2) + events[0].PricingCatalogVersion = &firstVersion + events[2].PricingCatalogVersion = &secondVersion if result, insertErr := db.InsertUsageFacts(ctx, store.InsertUsageFactsParams{ SourceID: source.ID, Generation: source.SyncGeneration, Facts: events, }); insertErr != nil || result.Inserted != len(events) { @@ -105,6 +108,9 @@ func TestUsageReportRangesUndatedAndPartialPricing(t *testing.T) { if all.Summary.TotalTokens != 252 || all.Summary.SessionCount != 5 || all.Models[0].Summary.UndatedEventCount != 1 { t.Fatalf("expected undated usage in all-time totals and models, got %#v", all) } + if !all.Pricing.MultipleVersions || week.Pricing.MultipleVersions { + t.Fatalf("pricing version mix was not scoped to selected records: all=%#v week=%#v", all.Pricing, week.Pricing) + } } func TestUsageReportTodayUsesDSTBoundaries(t *testing.T) { diff --git a/internal/usage/service.go b/internal/usage/service.go index 6148e99..e9bd333 100644 --- a/internal/usage/service.go +++ b/internal/usage/service.go @@ -8,6 +8,7 @@ import ( "github.com/strahe/profiledeck/internal/apperror" grokconfig "github.com/strahe/profiledeck/internal/grokbuild/config" + "github.com/strahe/profiledeck/internal/pricing" "github.com/strahe/profiledeck/internal/store" "github.com/strahe/profiledeck/internal/validate" ) @@ -15,11 +16,16 @@ import ( type Service struct { stores store.Factory registry Registry + pricing *pricing.Service syncMu sync.Mutex } -func NewService(stores store.Factory, registry Registry) *Service { - return &Service{stores: stores, registry: registry} +func NewService(stores store.Factory, registry Registry, priceServices ...*pricing.Service) *Service { + service := &Service{stores: stores, registry: registry} + if len(priceServices) > 0 { + service.pricing = priceServices[0] + } + return service } type UsageImportError struct { @@ -91,6 +97,13 @@ func (service *Service) sync( return SyncOutcome{}, usageSyncError(providerID, err) } defer releaseWork() + if service.pricing != nil { + catalog, err := service.pricing.Snapshot(workCtx) + if err != nil { + return SyncOutcome{}, usageSyncError(providerID, err) + } + workCtx = withPricingSnapshot(workCtx, catalog) + } outcome, err := integration.Sync(workCtx, service.stores, options) if options.ProvisionMode == SyncExistingProvider && errors.Is(err, store.ErrUsageProviderMissing) { diff --git a/internal/usage/service_test.go b/internal/usage/service_test.go index cb0bc5d..1c13951 100644 --- a/internal/usage/service_test.go +++ b/internal/usage/service_test.go @@ -346,8 +346,8 @@ func TestUsageSyncCodexImportsAndSkipsUnchangedFiles(t *testing.T) { writeAppUsageFixture(t, codexDir, strings.Join([]string{ `{"type":"session_meta","session_id":"session-1"}`, `{"type":"turn_context","model":"gpt-5.3-codex"}`, - `{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":100,"cached_input_tokens":20,"output_tokens":10,"total_tokens":110}}}}`, - `{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":150,"cached_input_tokens":25,"output_tokens":15,"total_tokens":165}}}}`, + `{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":100,"cached_input_tokens":20,"output_tokens":10,"total_tokens":110}}},"timestamp":"2026-09-12T00:00:00Z"}`, + `{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":150,"cached_input_tokens":25,"output_tokens":15,"total_tokens":165}}},"timestamp":"2026-09-12T00:01:00Z"}`, }, "\n")) if _, err := bootstrap.NewService(newUsageTestEnvironment(t, configDir, "").runtime, nil, nil).Initialize(ctx); err != nil { @@ -1307,14 +1307,19 @@ func TestUsageSyncCodexConcurrentRunsRemainIdempotentWhenOneRunIsSuperseded(t *t } func TestUsageSyncCodexBackfillsRecognizedPartialCost(t *testing.T) { + cacheWrite := int64(100_000) for _, test := range []struct { - name string - model string - eventKey string - wantCost string + name string + model string + eventKey string + wantCost string + cacheWrite *int64 + wantStatus string + wantPartialCount int64 }{ - {name: "gpt-5.6-sol", model: "GPT-5.6-SOL", eventKey: "existing-gpt-5.6", wantCost: "34.550000"}, - {name: "gpt-6-astra", model: "gpt-6-astra", eventKey: "existing-gpt-6-astra", wantCost: "59.100000"}, + {name: "gpt-5.6-sol", model: "GPT-5.6-SOL", eventKey: "existing-gpt-5.6", wantCost: "23.640000", wantStatus: "partial", wantPartialCount: 1}, + {name: "gpt-6-astra", model: "gpt-6-astra", eventKey: "existing-gpt-6-astra", wantCost: "59.100000", wantStatus: "partial", wantPartialCount: 1}, + {name: "gpt-6-astra known long context", model: "gpt-6-astra", eventKey: "existing-gpt-6-astra-long", wantCost: "93.700000", cacheWrite: &cacheWrite, wantStatus: "estimated"}, } { t.Run(test.name, func(t *testing.T) { ctx := context.Background() @@ -1342,7 +1347,7 @@ func TestUsageSyncCodexBackfillsRecognizedPartialCost(t *testing.T) { EventKey: usageTestEventKey(test.eventKey), SourceID: source.ID, SessionKey: "session", ModelKey: test.model, OccurredAtUnixMS: time.Now().UnixMilli(), InputTokens: 1_000_000, CachedInputTokens: 100_000, OutputTokens: 1_000_000, TotalTokens: 2_000_000, - CostStatus: store.UsageCostStatusUnknown, + CostStatus: store.UsageCostStatusUnknown, CacheWriteInputTokens: test.cacheWrite, }}, }); err != nil || result.Inserted != 1 { _ = db.Close() @@ -1359,24 +1364,27 @@ func TestUsageSyncCodexBackfillsRecognizedPartialCost(t *testing.T) { if err != nil { t.Fatalf("expected usage report, got %v", err) } - if report.Summary.KnownEstimatedCostUSD != test.wantCost || report.Summary.CostStatus != "partial" || - report.Summary.PartialCostEventCount != 1 || report.Summary.UnknownCostEventCount != 0 || report.Summary.PricingCoverage != 1 { + if report.Summary.KnownEstimatedCostUSD != test.wantCost || report.Summary.CostStatus != test.wantStatus || + report.Summary.PartialCostEventCount != test.wantPartialCount || report.Summary.UnknownCostEventCount != 0 || report.Summary.PricingCoverage != 1 { t.Fatalf("unexpected %s partial pricing summary: %#v", test.model, report.Summary) } if len(report.Models) != 1 || report.Models[0].Model != test.model || report.Models[0].Summary.KnownEstimatedCostUSD != test.wantCost { t.Fatalf("unexpected %s model summary: %#v", test.model, report.Models) } if len(report.Trend) != 1 || report.Trend[0].Summary.KnownEstimatedCostUSD != test.wantCost || - report.Trend[0].Summary.CostStatus != "partial" || report.Trend[0].Summary.PartialCostEventCount != 1 { + report.Trend[0].Summary.CostStatus != test.wantStatus || report.Trend[0].Summary.PartialCostEventCount != test.wantPartialCount { t.Fatalf("expected %s base cost in the trend bucket, got %#v", test.model, report.Trend) } legacy, err := newUsageTestEnvironment(t, configDir, "").service.Summary(ctx, UsageSummaryRequest{ProviderID: "codex"}) if err != nil { t.Fatalf("expected legacy usage summary, got %v", err) } - if legacy.CostStatus != "unknown" || legacy.EstimatedCostUSD != nil || legacy.UnknownCostEventCount != 1 { + if test.wantStatus == "partial" && (legacy.CostStatus != "unknown" || legacy.EstimatedCostUSD != nil || legacy.UnknownCostEventCount != 1) { t.Fatalf("expected legacy summary to preserve its conservative contract, got %#v", legacy) } + if test.wantStatus == "estimated" && (legacy.CostStatus != "estimated" || legacy.EstimatedCostUSD == nil || *legacy.EstimatedCostUSD != test.wantCost) { + t.Fatalf("expected complete legacy estimate, got %#v", legacy) + } }) } } diff --git a/internal/usage/usage.go b/internal/usage/usage.go index 433ec7a..427be51 100644 --- a/internal/usage/usage.go +++ b/internal/usage/usage.go @@ -1,6 +1,7 @@ package usage import ( + "context" "crypto/sha256" "encoding/binary" "fmt" @@ -9,7 +10,9 @@ import ( "path/filepath" "runtime" "strings" + "time" + pricing "github.com/strahe/profiledeck/internal/pricing" "github.com/strahe/profiledeck/internal/store" ) @@ -43,18 +46,21 @@ 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 - ReportedCostUSDTicks *int64 - ReportedCostStatus store.UsageReportedCostStatus + 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 + PricingCatalogVersion *int64 + CacheWriteInputTokens *int64 + CacheCreationInputTokens *int64 } type SourceFile struct { @@ -74,149 +80,112 @@ type FileParseResult struct { UnsupportedLines int64 } -type Price struct { - InputMicrosPerMillion int64 - CachedInputMicrosPerMillion *int64 - CacheWriteMicrosPerMillion *int64 - OutputMicrosPerMillion int64 -} +var bundledPricingCatalog = pricing.Embedded() + +type pricingContextKey struct{} -// PriceCatalog owns a provider-scoped, immutable price snapshot. Callers can -// estimate against it but cannot mutate or inspect its backing map. -type PriceCatalog struct { - prices map[string]Price +func withPricingSnapshot(ctx context.Context, catalog pricing.Catalog) context.Context { + return context.WithValue(ctx, pricingContextKey{}, catalog) } -func newPriceCatalog(prices map[string]Price) PriceCatalog { - copied := make(map[string]Price, len(prices)) - for model, modelPrice := range prices { - copied[pricingModelID(model)] = modelPrice +func pricingSnapshot(ctx context.Context) pricing.Catalog { + if catalog, ok := ctx.Value(pricingContextKey{}).(pricing.Catalog); ok { + return catalog } - return PriceCatalog{prices: copied} + return bundledPricingCatalog } -// Static price source: OpenAI API pricing, accessed 2026-09-12. -// These local estimates use Standard API prices. For models with multiple -// context tiers, the table uses the short-context rate until Codex logs expose -// enough billing context to select batch, flex, priority, or long-context rates. -// GPT-5.6 and GPT-6 Astra logs do not expose cache-write tokens, so their stored -// amount is the verifiable input/cache-read/output subtotal and remains -// explicitly partial. -var codexPriceCatalog = newPriceCatalog(map[string]Price{ - "gpt-6-astra": priceWithCacheWrite(10_000_000, 1_000_000, 12_500_000, 50_000_000), - "gpt-5.6-sol": priceWithCacheWrite(5_000_000, 500_000, 6_250_000, 30_000_000), - "gpt-5.6-terra": priceWithCacheWrite(2_500_000, 250_000, 3_125_000, 15_000_000), - "gpt-5.6-luna": priceWithCacheWrite(1_000_000, 100_000, 1_250_000, 6_000_000), - "gpt-5.5": price(5_000_000, 500_000, 30_000_000), - "gpt-5.5-pro": priceWithoutCached(30_000_000, 180_000_000), - "gpt-5.4": price(2_500_000, 250_000, 15_000_000), - "gpt-5.4-mini": price(750_000, 75_000, 4_500_000), - "gpt-5.4-nano": price(200_000, 20_000, 1_250_000), - "gpt-5.4-pro": priceWithoutCached(30_000_000, 180_000_000), - "chat-latest": price(5_000_000, 500_000, 30_000_000), - "gpt-5.3-codex": price(1_750_000, 175_000, 14_000_000), - "gpt-5.2": price(1_750_000, 175_000, 14_000_000), - "gpt-5.2-pro": priceWithoutCached(21_000_000, 168_000_000), - "gpt-5.1": price(1_250_000, 125_000, 10_000_000), - "gpt-5": price(1_250_000, 125_000, 10_000_000), - "gpt-5-mini": price(250_000, 25_000, 2_000_000), - "gpt-5-nano": price(50_000, 5_000, 400_000), - "gpt-5-pro": priceWithoutCached(15_000_000, 120_000_000), - "gpt-4.1": price(2_000_000, 500_000, 8_000_000), - "gpt-4.1-mini": price(400_000, 100_000, 1_600_000), - "gpt-4.1-nano": price(100_000, 25_000, 400_000), -}) - -// Static price source: xAI pricing documentation, accessed 2026-09-12. -// Aggregated Grok Build records do not identify long-context calls, so -// estimates intentionally use only the short-context Standard API tier. -// Grok Build session records may emit model-specific -build identifiers. -var grokBuildPriceCatalog = newPriceCatalog(map[string]Price{ - "grok-4.5": price(2_000_000, 300_000, 6_000_000), - "grok-4.5-build": price(2_000_000, 300_000, 6_000_000), - "grok-4.5-latest": price(2_000_000, 300_000, 6_000_000), - "grok-4.6": price(2_000_000, 500_000, 6_000_000), - "grok-4.6-build": price(2_000_000, 500_000, 6_000_000), - "grok-build-latest": price(2_000_000, 300_000, 6_000_000), -}) - func EstimateCostMicros(model string, tokens TokenCounts) (*int64, store.UsageCostStatus) { - return codexPriceCatalog.EstimateCostMicros(model, tokens) + cost, status, _ := estimateCostAt(bundledPricingCatalog, ProviderCodex, model, time.Now().UnixMilli(), tokens, nil, nil) + return cost, status } func EstimateGrokBuildCostMicros(model string, tokens TokenCounts) (*int64, store.UsageCostStatus) { - return grokBuildPriceCatalog.EstimateCostMicros(model, tokens) -} - -func (catalog PriceCatalog) Supports(model string) bool { - _, ok := catalog.prices[pricingModelID(model)] - return ok + cost, status, _ := estimateCostAt(bundledPricingCatalog, "grok-build", model, time.Now().UnixMilli(), tokens, nil, nil) + return cost, status } -func (catalog PriceCatalog) EstimateCostMicros( - model string, - tokens TokenCounts, -) (*int64, store.UsageCostStatus) { - price, ok := catalog.prices[pricingModelID(model)] - if !ok || tokens.CachedInputTokens > tokens.InputTokens { - return nil, CostStatusUnknown +func estimateCostAt(catalog pricing.Catalog, provider, model string, occurredAt int64, + tokens TokenCounts, cacheWrite, perCallInput *int64, +) (*int64, store.UsageCostStatus, *int64) { + selected, ok := catalog.Select(provider, pricingCatalogModelID(provider, model), occurredAt) + if !ok || tokens.InputTokens < 0 || tokens.CachedInputTokens < 0 || + tokens.CachedInputTokens > tokens.InputTokens || tokens.OutputTokens < 0 { + return nil, CostStatusUnknown, nil } - if tokens.CachedInputTokens > 0 && price.CachedInputMicrosPerMillion == nil { - return nil, CostStatusUnknown + rates := selected.Rates + partial := false + if selected.Long != nil && provider == ProviderCodex { + if perCallInput == nil { + partial = true + } else if *perCallInput > selected.Above { + rates = *selected.Long + } } - - uncachedInputTokens := tokens.InputTokens - tokens.CachedInputTokens - cost, ok := roundedTokenCostMicrosSafe(uncachedInputTokens, price.InputMicrosPerMillion) + if tokens.CachedInputTokens > 0 && rates.CachedInput == nil { + return nil, CostStatusUnknown, nil + } + fresh := tokens.InputTokens - tokens.CachedInputTokens + if cacheWrite != nil { + if *cacheWrite < 0 || *cacheWrite > fresh { + return nil, CostStatusUnknown, nil + } + fresh -= *cacheWrite + } else if rates.CacheWrite != nil { + partial = true + } + cost, ok := roundedTokenCostMicrosSafe(fresh, rates.Input) if !ok { - return nil, CostStatusUnknown + return nil, CostStatusUnknown, nil } - if price.CachedInputMicrosPerMillion != nil { - cachedCost, ok := roundedTokenCostMicrosSafe(tokens.CachedInputTokens, *price.CachedInputMicrosPerMillion) - if !ok { - return nil, CostStatusUnknown + if rates.CachedInput != nil { + cached, valid := roundedTokenCostMicrosSafe(tokens.CachedInputTokens, *rates.CachedInput) + if !valid { + return nil, CostStatusUnknown, nil } - cost, ok = addCostMicros(cost, cachedCost) + cost, ok = addCostMicros(cost, cached) if !ok { - return nil, CostStatusUnknown + return nil, CostStatusUnknown, nil } } - outputCost, ok := roundedTokenCostMicrosSafe(tokens.OutputTokens, price.OutputMicrosPerMillion) - if !ok { - return nil, CostStatusUnknown + if cacheWrite != nil && *cacheWrite > 0 { + if rates.CacheWrite == nil { + partial = true + } else { + writeCost, valid := roundedTokenCostMicrosSafe(*cacheWrite, *rates.CacheWrite) + if !valid { + return nil, CostStatusUnknown, nil + } + cost, ok = addCostMicros(cost, writeCost) + if !ok { + return nil, CostStatusUnknown, nil + } + } } - cost, ok = addCostMicros(cost, outputCost) + output, ok := roundedTokenCostMicrosSafe(tokens.OutputTokens, rates.Output) if !ok { - return nil, CostStatusUnknown + return nil, CostStatusUnknown, nil } - if price.CacheWriteMicrosPerMillion != nil { - return &cost, CostStatusPartial + cost, ok = addCostMicros(cost, output) + if !ok { + return nil, CostStatusUnknown, nil } - return &cost, CostStatusEstimated -} - -func pricingModelID(value string) string { - return strings.ToLower(strings.TrimSpace(value)) -} - -func price(inputMicrosPerMillion, cachedInputMicrosPerMillion, outputMicrosPerMillion int64) Price { - return Price{ - InputMicrosPerMillion: inputMicrosPerMillion, - CachedInputMicrosPerMillion: &cachedInputMicrosPerMillion, - OutputMicrosPerMillion: outputMicrosPerMillion, + version := selected.Version + if partial { + return &cost, CostStatusPartial, &version } + return &cost, CostStatusEstimated, &version } -func priceWithCacheWrite(inputMicrosPerMillion, cachedInputMicrosPerMillion, cacheWriteMicrosPerMillion, outputMicrosPerMillion int64) Price { - price := price(inputMicrosPerMillion, cachedInputMicrosPerMillion, outputMicrosPerMillion) - price.CacheWriteMicrosPerMillion = &cacheWriteMicrosPerMillion - return price -} +func pricingModelID(value string) string { return strings.ToLower(strings.TrimSpace(value)) } -func priceWithoutCached(inputMicrosPerMillion, outputMicrosPerMillion int64) Price { - return Price{ - InputMicrosPerMillion: inputMicrosPerMillion, - OutputMicrosPerMillion: outputMicrosPerMillion, +func pricingCatalogModelID(provider, model string) string { + model = pricingModelID(model) + // Grok Build's standard 4.7 session label is distinct from its Fast variant. + if provider == "grok-build" && model == "grok-4.7-build" { + return "grok-4.7" } + return model } func SourceKey(path string) (store.UsageKey, error) { diff --git a/internal/usage/usage_test.go b/internal/usage/usage_test.go index 69b1c93..6b8a952 100644 --- a/internal/usage/usage_test.go +++ b/internal/usage/usage_test.go @@ -43,7 +43,6 @@ func TestEstimateCostMicrosCoversCurrentOpenAIAndCodexModels(t *testing.T) { {model: "gpt-5.4", want: 17_275_000}, {model: "gpt-5.4-mini", want: 5_182_500}, {model: "gpt-5.4-nano", want: 1_432_000}, - {model: "chat-latest", want: 34_550_000}, {model: "gpt-5.3-codex", want: 15_592_500}, {model: "gpt-5.2", want: 15_592_500}, {model: "gpt-5.1", want: 11_137_500}, @@ -80,9 +79,9 @@ func TestEstimateCostMicrosReportsCacheWriteModelsAsPartial(t *testing.T) { want int64 }{ {model: "gpt-6-astra", want: 59_100_000}, - {model: "gpt-5.6-sol", want: 34_550_000}, - {model: "gpt-5.6-terra", want: 17_275_000}, - {model: "gpt-5.6-luna", want: 6_910_000}, + {model: "gpt-5.6-sol", want: 23_640_000}, + {model: "gpt-5.6-terra", want: 13_820_000}, + {model: "gpt-5.6-luna", want: 1_382_000}, } for _, tt := range tests { t.Run(tt.model, func(t *testing.T) { @@ -96,7 +95,7 @@ func TestEstimateCostMicrosReportsCacheWriteModelsAsPartial(t *testing.T) { func TestEstimateCostMicrosDoesNotGuessModelAliases(t *testing.T) { tokens := TokenCounts{InputTokens: 10, CachedInputTokens: 2, OutputTokens: 3, TotalTokens: 13} - for _, model := range []string{"openai/gpt-5.3-codex", "gpt-5.3-codex-2026-07-06"} { + for _, model := range []string{"openai/gpt-5.3-codex", "gpt-5.3-codex-2026-07-06", "chat-latest"} { cost, status := EstimateCostMicros(model, tokens) if cost != nil || status != CostStatusUnknown { t.Fatalf("expected unlisted model %q to remain unknown, status=%q cost=%v", model, status, cost) diff --git a/pricing/catalog.json b/pricing/catalog.json new file mode 100644 index 0000000..668637f --- /dev/null +++ b/pricing/catalog.json @@ -0,0 +1,537 @@ +{ + "schema_version": 1, + "catalog_version": 1, + "entries": [ + { + "provider": "codex", + "model": "gpt-5.5", + "basis": "openai-standard-api", + "effective_from": "2026-09-12", + "source_url": "https://developers.openai.com/api/docs/pricing", + "verified_at": "2026-09-12", + "short_context": { + "input": "5", + "cached_input": ".5", + "cache_write": null, + "output": "30" + } + }, + { + "provider": "codex", + "model": "gpt-5.5-pro", + "basis": "openai-standard-api", + "effective_from": "2026-09-12", + "source_url": "https://developers.openai.com/api/docs/pricing", + "verified_at": "2026-09-12", + "short_context": { + "input": "30", + "cached_input": null, + "cache_write": null, + "output": "180" + } + }, + { + "provider": "codex", + "model": "gpt-5.4", + "basis": "openai-standard-api", + "effective_from": "2026-09-12", + "source_url": "https://developers.openai.com/api/docs/pricing", + "verified_at": "2026-09-12", + "short_context": { + "input": "2.5", + "cached_input": ".25", + "cache_write": null, + "output": "15" + } + }, + { + "provider": "codex", + "model": "gpt-5.4-mini", + "basis": "openai-standard-api", + "effective_from": "2026-09-12", + "source_url": "https://developers.openai.com/api/docs/pricing", + "verified_at": "2026-09-12", + "short_context": { + "input": ".75", + "cached_input": ".075", + "cache_write": null, + "output": "4.5" + } + }, + { + "provider": "codex", + "model": "gpt-5.4-nano", + "basis": "openai-standard-api", + "effective_from": "2026-09-12", + "source_url": "https://developers.openai.com/api/docs/pricing", + "verified_at": "2026-09-12", + "short_context": { + "input": ".2", + "cached_input": ".02", + "cache_write": null, + "output": "1.25" + } + }, + { + "provider": "codex", + "model": "gpt-5.4-pro", + "basis": "openai-standard-api", + "effective_from": "2026-09-12", + "source_url": "https://developers.openai.com/api/docs/pricing", + "verified_at": "2026-09-12", + "short_context": { + "input": "30", + "cached_input": null, + "cache_write": null, + "output": "180" + } + }, + { + "provider": "codex", + "model": "gpt-5.3-codex", + "basis": "openai-standard-api", + "effective_from": "2026-09-12", + "source_url": "https://developers.openai.com/api/docs/pricing", + "verified_at": "2026-09-12", + "short_context": { + "input": "1.75", + "cached_input": ".175", + "cache_write": null, + "output": "14" + } + }, + { + "provider": "codex", + "model": "gpt-5.2", + "basis": "openai-standard-api", + "effective_from": "2026-09-12", + "source_url": "https://developers.openai.com/api/docs/pricing", + "verified_at": "2026-09-12", + "short_context": { + "input": "1.75", + "cached_input": ".175", + "cache_write": null, + "output": "14" + } + }, + { + "provider": "codex", + "model": "gpt-5.2-pro", + "basis": "openai-standard-api", + "effective_from": "2026-09-12", + "source_url": "https://developers.openai.com/api/docs/pricing", + "verified_at": "2026-09-12", + "short_context": { + "input": "21", + "cached_input": null, + "cache_write": null, + "output": "168" + } + }, + { + "provider": "codex", + "model": "gpt-5.1", + "basis": "openai-standard-api", + "effective_from": "2026-09-12", + "source_url": "https://developers.openai.com/api/docs/pricing", + "verified_at": "2026-09-12", + "short_context": { + "input": "1.25", + "cached_input": ".125", + "cache_write": null, + "output": "10" + } + }, + { + "provider": "codex", + "model": "gpt-5", + "basis": "openai-standard-api", + "effective_from": "2026-09-12", + "source_url": "https://developers.openai.com/api/docs/pricing", + "verified_at": "2026-09-12", + "short_context": { + "input": "1.25", + "cached_input": ".125", + "cache_write": null, + "output": "10" + } + }, + { + "provider": "codex", + "model": "gpt-5-mini", + "basis": "openai-standard-api", + "effective_from": "2026-09-12", + "source_url": "https://developers.openai.com/api/docs/pricing", + "verified_at": "2026-09-12", + "short_context": { + "input": ".25", + "cached_input": ".025", + "cache_write": null, + "output": "2" + } + }, + { + "provider": "codex", + "model": "gpt-5-nano", + "basis": "openai-standard-api", + "effective_from": "2026-09-12", + "source_url": "https://developers.openai.com/api/docs/pricing", + "verified_at": "2026-09-12", + "short_context": { + "input": ".05", + "cached_input": ".005", + "cache_write": null, + "output": ".4" + } + }, + { + "provider": "codex", + "model": "gpt-5-pro", + "basis": "openai-standard-api", + "effective_from": "2026-09-12", + "source_url": "https://developers.openai.com/api/docs/pricing", + "verified_at": "2026-09-12", + "short_context": { + "input": "15", + "cached_input": null, + "cache_write": null, + "output": "120" + } + }, + { + "provider": "codex", + "model": "gpt-4.1", + "basis": "openai-standard-api", + "effective_from": "2026-09-12", + "source_url": "https://developers.openai.com/api/docs/pricing", + "verified_at": "2026-09-12", + "short_context": { + "input": "2", + "cached_input": ".5", + "cache_write": null, + "output": "8" + } + }, + { + "provider": "codex", + "model": "gpt-4.1-mini", + "basis": "openai-standard-api", + "effective_from": "2026-09-12", + "source_url": "https://developers.openai.com/api/docs/pricing", + "verified_at": "2026-09-12", + "short_context": { + "input": ".4", + "cached_input": ".1", + "cache_write": null, + "output": "1.6" + } + }, + { + "provider": "codex", + "model": "gpt-4.1-nano", + "basis": "openai-standard-api", + "effective_from": "2026-09-12", + "source_url": "https://developers.openai.com/api/docs/pricing", + "verified_at": "2026-09-12", + "short_context": { + "input": ".1", + "cached_input": ".025", + "cache_write": null, + "output": ".4" + } + }, + { + "provider": "codex", + "model": "gpt-5.6-sol", + "basis": "openai-standard-api", + "effective_from": "2026-07-09", + "source_url": "https://developers.openai.com/api/docs/changelog", + "verified_at": "2026-09-23", + "short_context": { + "input": "5", + "cached_input": ".5", + "cache_write": "6.25", + "output": "30" + }, + "effective_until": "2026-08-21", + "long_context": { + "above_input_tokens": 272000, + "rates": { + "input": "10", + "cached_input": "1", + "cache_write": "12.5", + "output": "45" + } + } + }, + { + "provider": "codex", + "model": "gpt-5.6-sol", + "basis": "openai-standard-api", + "effective_from": "2026-08-21", + "source_url": "https://developers.openai.com/api/docs/pricing", + "verified_at": "2026-09-23", + "short_context": { + "input": "4", + "cached_input": ".4", + "cache_write": "5", + "output": "20" + }, + "long_context": { + "above_input_tokens": 272000, + "rates": { + "input": "8", + "cached_input": ".8", + "cache_write": "10", + "output": "30" + } + } + }, + { + "provider": "codex", + "model": "gpt-5.6-terra", + "basis": "openai-standard-api", + "effective_from": "2026-07-09", + "source_url": "https://developers.openai.com/api/docs/changelog", + "verified_at": "2026-09-23", + "short_context": { + "input": "2.5", + "cached_input": ".25", + "cache_write": "3.125", + "output": "15" + }, + "effective_until": "2026-07-30", + "long_context": { + "above_input_tokens": 272000, + "rates": { + "input": "5", + "cached_input": ".5", + "cache_write": "6.25", + "output": "22.5" + } + } + }, + { + "provider": "codex", + "model": "gpt-5.6-terra", + "basis": "openai-standard-api", + "effective_from": "2026-07-30", + "source_url": "https://developers.openai.com/api/docs/pricing", + "verified_at": "2026-09-23", + "short_context": { + "input": "2", + "cached_input": ".2", + "cache_write": "2.5", + "output": "12" + }, + "long_context": { + "above_input_tokens": 272000, + "rates": { + "input": "4", + "cached_input": ".4", + "cache_write": "5", + "output": "18" + } + } + }, + { + "provider": "codex", + "model": "gpt-5.6-luna", + "basis": "openai-standard-api", + "effective_from": "2026-07-09", + "source_url": "https://developers.openai.com/api/docs/changelog", + "verified_at": "2026-09-23", + "short_context": { + "input": "1", + "cached_input": ".1", + "cache_write": "1.25", + "output": "6" + }, + "effective_until": "2026-07-30", + "long_context": { + "above_input_tokens": 272000, + "rates": { + "input": "2", + "cached_input": ".2", + "cache_write": "2.5", + "output": "9" + } + } + }, + { + "provider": "codex", + "model": "gpt-5.6-luna", + "basis": "openai-standard-api", + "effective_from": "2026-07-30", + "source_url": "https://developers.openai.com/api/docs/pricing", + "verified_at": "2026-09-23", + "short_context": { + "input": ".2", + "cached_input": ".02", + "cache_write": ".25", + "output": "1.2" + }, + "long_context": { + "above_input_tokens": 272000, + "rates": { + "input": ".4", + "cached_input": ".04", + "cache_write": ".5", + "output": "1.8" + } + } + }, + { + "provider": "codex", + "model": "gpt-6-astra", + "basis": "openai-standard-api", + "effective_from": "2026-09-03", + "source_url": "https://developers.openai.com/api/docs/pricing", + "verified_at": "2026-09-23", + "short_context": { + "input": "10", + "cached_input": "1", + "cache_write": "12.5", + "output": "50" + }, + "long_context": { + "above_input_tokens": 272000, + "rates": { + "input": "20", + "cached_input": "2", + "cache_write": "25", + "output": "75" + } + } + }, + { + "provider": "codex", + "model": "gpt-6-sol", + "basis": "openai-standard-api", + "effective_from": "2026-09-22", + "source_url": "https://developers.openai.com/api/docs/pricing", + "verified_at": "2026-09-23", + "short_context": { + "input": "2", + "cached_input": ".2", + "cache_write": "2.5", + "output": "10" + }, + "long_context": { + "above_input_tokens": 272000, + "rates": { + "input": "4", + "cached_input": ".4", + "cache_write": "5", + "output": "15" + } + } + }, + { + "provider": "codex", + "model": "gpt-6-luna", + "basis": "openai-standard-api", + "effective_from": "2026-09-22", + "source_url": "https://developers.openai.com/api/docs/pricing", + "verified_at": "2026-09-23", + "short_context": { + "input": ".1", + "cached_input": ".01", + "cache_write": ".125", + "output": ".5" + }, + "long_context": { + "above_input_tokens": 272000, + "rates": { + "input": ".2", + "cached_input": ".02", + "cache_write": ".25", + "output": ".75" + } + } + }, + { + "provider": "grok-build", + "model": "grok-4.5", + "basis": "xai-standard-api-short-context", + "effective_from": "2026-09-12", + "source_url": "https://docs.x.ai/developers/pricing", + "verified_at": "2026-09-12", + "short_context": { + "input": "2", + "cached_input": ".3", + "cache_write": null, + "output": "6" + }, + "long_context": { + "above_input_tokens": 199999, + "rates": { + "input": "4", + "cached_input": ".6", + "cache_write": null, + "output": "12" + } + } + }, + { + "provider": "grok-build", + "model": "grok-4.6", + "basis": "xai-standard-api-short-context", + "effective_from": "2026-09-12", + "source_url": "https://docs.x.ai/developers/pricing", + "verified_at": "2026-09-12", + "short_context": { + "input": "2", + "cached_input": ".5", + "cache_write": null, + "output": "6" + }, + "long_context": { + "above_input_tokens": 199999, + "rates": { + "input": "4", + "cached_input": "1", + "cache_write": null, + "output": "12" + } + } + }, + { + "provider": "grok-build", + "model": "grok-4.7", + "basis": "xai-standard-api-short-context", + "effective_from": "2026-09-21", + "source_url": "https://docs.x.ai/developers/pricing", + "verified_at": "2026-09-23", + "short_context": { + "input": "2", + "cached_input": ".5", + "cache_write": null, + "output": "6" + }, + "long_context": { + "above_input_tokens": 199999, + "rates": { + "input": "4", + "cached_input": "1", + "cache_write": null, + "output": "12" + } + } + }, + { + "provider": "claude-code", + "model": "claude-opus-5-5", + "basis": "anthropic-standard-api", + "effective_from": "2026-09-22", + "source_url": "https://platform.claude.com/docs/en/models/opus-5-5/overview", + "verified_at": "2026-09-23", + "short_context": { + "input": "4", + "cached_input": ".2", + "cache_write": "5", + "output": "20" + } + } + ] +} diff --git a/pricing/embed.go b/pricing/embed.go new file mode 100644 index 0000000..350b48e --- /dev/null +++ b/pricing/embed.go @@ -0,0 +1,6 @@ +package pricing + +import _ "embed" + +//go:embed catalog.json +var CatalogJSON []byte