fix(usage): keep a subscription's estimate out of the paid total

Refs #13276
This commit is contained in:
esengine committed 2026-10-18 10:32:04 -07:00
1 parent 78cb353fd8
commit d8abd5b5ce
13 files changed
+339 -39

No files matched your search

@@ -468,6 +468,9 @@ export const EN_SETTINGS: Record<string, string> = {
"来源必须是 http 或 https 链接,不能带用户名密码。": "The source must be an http or https link without a username or password.",
"核对日期不能晚于今天(按 UTC)。": "The check date cannot be later than today (UTC).",
"核对于 {date}(UTC)": "Checked on {date} (UTC)",
"等价估算": "API-priced estimate",
"按 API 价格估算的订阅用量,不是实际扣费": "Subscription use priced at API rates; not a charge",
"订阅用量按 API 价格估算,不是实际扣费,也不计入上面的实付": "Subscription use priced at API rates. It is not a charge and is not part of the paid figure above.",
"这个价格不在官方价目表里,选了人民币或美元显示时无法换算。": "This price is not in the vendor's price list, so it cannot be converted when a display currency is chosen.",
"缓存命中价是 0:命中的 token 会按免费计,费用会偏低。": "The cache-hit price is 0: cached tokens are counted as free, so cost reads too low.",
"{model} 恢复官方价": "{model} restore official price",
@@ -46,6 +46,7 @@ export function mockUsage(query: number | UsageQuery): UsageReport {
requests: Math.round(total / 20_000), turns: Math.round(total / 150_000),
cacheHit: Math.round(total * 0.92), cacheMiss: Math.round(total * 0.08),
cost: amount ? [{ amount, currency: "CNY" }] : undefined,
estimateCost: i >= count - 2 && total > 0 ? [{ amount: i === count - 1 ? "2.8" : "3.5", currency: "CNY" }] : undefined,
};
});
const tokens = daily.reduce((a, d) => a + d.total, 0);
@@ -56,6 +57,7 @@ export function mockUsage(query: number | UsageQuery): UsageReport {
cache_hit: daily.reduce((a, d) => a + d.cacheHit, 0),
cache_miss: daily.reduce((a, d) => a + d.cacheMiss, 0),
cost: [{ amount: "10.4882", currency: "CNY" }],
estimateCost: daily.some((d) => d.estimateCost) ? [{ amount: "6.3", currency: "CNY" }] : undefined,
active_days: daily.filter((d) => d.total > 0).length,
top_model: "deepseek/deepseek-flash", top_provider: "deepseek",
daily,
+5
View File
@@ -28,6 +28,8 @@ export interface UsageDay {
cacheHit: number;
cacheMiss: number;
cost?: Money[];
// A subscription's API-priced estimate for the day: not money billed, never added to cost.
estimateCost?: Money[];
}
export interface UsageModel {
@@ -52,6 +54,9 @@ export interface UsageReport {
cache_hit: number;
cache_miss: number;
cost?: Money[];
// What subscription-equivalent connections would have cost at API prices. It is
// an estimate of a plan's use, not a charge, and is kept out of cost.
estimateCost?: Money[];
// Set when any folded quote was priced from a fallback rate card. The
// total is then an estimate wearing a "paid" label unless the panel says so.
costEstimated?: boolean;
+4
View File
@@ -5464,6 +5464,10 @@
padding: 4px 10px; border-radius: var(--r-pill); background: var(--overlay);
color: var(--muted); font: var(--w-med) var(--fs-ui) var(--ui);
}
.uhero-est { display: flex; align-items: baseline; gap: 4px 12px; flex-wrap: wrap; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--hair) }
.uhero-estk { color: var(--muted); font: var(--w-med) var(--fs-meta) var(--ui); letter-spacing: var(--track-lead) }
.uhero-estv { font: var(--w-med) calc(20px * var(--fs-scale, 1))/1.2 var(--mono); font-variant-numeric: tabular-nums; color: var(--muted) }
.udaily-est { display: block; color: var(--faint); font-size: var(--fs-meta) }
.uhero-note { margin: 11px 0 0; max-width: 62ch; color: var(--muted); font: var(--w-reg) var(--fs-ui)/1.6 var(--ui) }
.utiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(142px, 1fr)); gap: 12px }
@@ -0,0 +1,63 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
import "./testkit";
import type { AgentPort, UsageReport } from "../port/port";
import { Usage } from "./Usage";
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
const day = (d: string, over: Partial<UsageReport["daily"][number]> = {}): UsageReport["daily"][number] => ({
day: d, total: 1_000, byModel: {}, byProvider: {}, requests: 3, turns: 2, cacheHit: 800, cacheMiss: 200, ...over,
});
const REPORT: UsageReport = {
from: "2026-08-10", to: "2026-08-12", tokens: 3_000, requests: 9, turns: 6, cache_hit: 2_400, cache_miss: 600,
cost: [{ amount: "4", currency: "CNY" }],
estimateCost: [{ amount: "10", currency: "CNY" }],
active_days: 3, top_model: "relay/a", top_provider: "relay", models: [], providers: [],
daily: [
day("2026-08-10", { cost: [{ amount: "4", currency: "CNY" }] }),
day("2026-08-11", { estimateCost: [{ amount: "7", currency: "CNY" }] }),
day("2026-08-12", { cost: undefined, estimateCost: [{ amount: "3", currency: "CNY" }] }),
],
};
async function open(report: UsageReport) {
const port = { usage: vi.fn().mockResolvedValue(report), providers: vi.fn().mockResolvedValue([]), models: vi.fn().mockResolvedValue([]) } as unknown as AgentPort;
const view = render(<Usage port={port} />);
await waitFor(() => expect(view.container.querySelector(".uhero")).toBeTruthy());
return view;
}
describe("a subscription's estimate on the usage page", () => {
it("is shown on its own line, labelled as an estimate, and never inside the paid total", async () => {
const { container } = await open(REPORT);
const paid = container.querySelector(".uhero-v")!;
expect(paid.textContent).toContain("4");
expect(paid.textContent).not.toContain("14");
expect(paid.textContent).not.toContain("10");
const est = container.querySelector(".uhero-est")!;
expect(est.textContent).toContain("10");
expect(est.textContent).toContain("等价估算");
});
it("sits beside the billed figure in a day's row, and a day with only an estimate shows no billed cost", async () => {
const { container } = await open(REPORT);
const row = (d: string) => container.querySelector<HTMLElement>(`tr[data-day="${d}"]`)!;
expect(within(row("2026-08-10")).queryByText(/等价估算/)).toBeNull();
expect(row("2026-08-11").querySelector(".udaily-est")?.textContent).toContain("7");
expect(row("2026-08-11").querySelectorAll("td")[2]!.textContent).toContain("—");
});
it("does not call an estimate-only day unpriced", async () => {
await open(REPORT);
expect(screen.queryByText(/没有成本记录/)).toBeNull();
});
it("shows nothing extra when there is no estimate", async () => {
const { container } = await open({ ...REPORT, estimateCost: undefined, daily: REPORT.daily.map((d) => ({ ...d, estimateCost: undefined })) });
expect(container.querySelector(".uhero-est")).toBeNull();
expect(container.querySelector(".udaily-est")).toBeNull();
});
});
+12 -2
View File
@@ -185,7 +185,10 @@ function DailyTable({ days }: { days: UsageDay[] }) {
<tr key={d.day} data-day={d.day}>
<td className="ud">{d.day}</td>
<td>{count(d.total)}</td>
<td>{moneyList(d.cost)}</td>
<td>
{moneyList(d.cost)}
{d.estimateCost?.length ? <span className="udaily-est" title={t("按 API 价格估算的订阅用量,不是实际扣费")}>{t("等价估算")} {moneyList(d.estimateCost)}</span> : null}
</td>
<td>{count(d.requests)}</td>
</tr>
))}
@@ -271,7 +274,7 @@ export function Usage({ port }: { port: AgentPort }) {
() => report?.daily.filter((d) => d.cost?.length) ?? [],
[report],
);
const unpricedDays = (report?.daily.filter((d) => d.total > 0 && !d.cost?.length) ?? []).length;
const unpricedDays = (report?.daily.filter((d) => d.total > 0 && !d.cost?.length && !d.estimateCost?.length) ?? []).length;
// Every currency the range touched, in the order the kernel sorted them.
const currencies = useMemo(() => {
const seen: string[] = [];
@@ -342,6 +345,13 @@ export function Usage({ port }: { port: AgentPort }) {
{pricedFrom && <span className="uhero-scope">{t("{day} 起 · {n} 天有记录", { day: pricedFrom.slice(5), n: priced.length })}</span>}
{report.costEstimated && <span className="uhero-scope">{t("含估算")}</span>}
</div>
{report.estimateCost?.length ? (
<div className="uhero-est">
<span className="uhero-estk">{t("等价估算")}</span>
<span className="uhero-estv">{moneyList(report.estimateCost)}</span>
<span className="uhero-scope">{t("订阅用量按 API 价格估算,不是实际扣费,也不计入上面的实付")}</span>
</div>
) : null}
<p className="uhero-note">
{t("该时间段送入的 {tok} 输入 tokens 中,{rate}% 命中前缀缓存 —— 命中部分按缓存价计费,较未命中低一个数量级。", {
tok: fmtTokens(input), rate: hitRate.toFixed(1),
+12
View File
@@ -61,6 +61,18 @@ A price you enter for a model can carry where it was read and when.
- The next start puts `source_url`, `checked` and `billing_mode` back from `provider-keys.json` beside the config.
- A price you changed does not inherit the old source, and a key you cleared in the editor is not put back.
## Usage totals: billed and estimated
A connection set to `billing_mode = "subscription_equivalent"` (a token plan, for example) is priced at API rates to show what the plan covered, but nothing is charged per call.
- The usage page's **paid** figure and each day's cost are billed cost only.
- The plan's figure is a separate **API-priced estimate**, per currency, shown on its own line and beside the day it belongs to. It is never added to billed cost, and a day with only an estimate shows no billed cost rather than zero.
- The kernel answers with `cost` (billed) and `estimateCost`; the second is absent when there is no estimate, so a client that does not know it sees what it saw before.
- The aggregate behind the page is a disposable cache, `usage-catalog/v6.sqlite`, rebuilt from the daily stats files when it is missing; until it is ready the page reads the files directly and shows the same numbers.
- A build older than this one uses its own `v5.sqlite` and, on start, removes the files of versions it does not open.
- Alternating between builds therefore rebuilds the cache each time.
- A rebuild read about 18,000 records (5.4 MB, 30 daily files) in 0.7 s on a laptop, roughly 26,000 records a second; it grows with the stats directory.
## Wallets and diagnostics
Wallets are never converted or cross-added. An explicit target uses the exact
+154
View File
@@ -0,0 +1,154 @@
package stats
import (
"context"
"reflect"
"testing"
"time"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/pricing"
"reasonix/internal/state/usagecatalog"
)
func modeRecords() (string, []map[string]any) {
return "2026-08-10", []map[string]any{
{"ts": "2026-08-10T01:00:00Z", "model": "relay/a", "source": "cli", "total": 100, "cost_amount": "1.50", "cost_currency": "CNY", "billing_mode": "payg"},
{"ts": "2026-08-10T02:00:00Z", "model": "relay/a", "source": "cli", "total": 100, "cost_amount": "2.50", "cost_currency": "CNY"},
{"ts": "2026-08-10T03:00:00Z", "model": "plan/b", "source": "cli", "total": 400, "cost_amount": "10", "cost_currency": "CNY", "billing_mode": "subscription_equivalent"},
}
}
func money(m []pricing.Money) map[string]string {
out := map[string]string{}
for _, e := range m {
out[e.Currency] = e.Amount
}
return out
}
// A plan's API-priced estimate is not money paid: it never shares a total with
// billed cost, even in the same currency.
func TestSubscriptionEstimateNeverJoinsTheBilledTotal(t *testing.T) {
dir := testenv.TempDir(t)
day, lines := modeRecords()
writeDay(t, dir, day, lines...)
got := queryAll(t, dir)
if !reflect.DeepEqual(money(got.Cost), map[string]string{"CNY": "4"}) {
t.Fatalf("billed total = %+v, want 4 CNY and nothing of the plan's estimate", got.Cost)
}
if !reflect.DeepEqual(money(got.EstimateCost), map[string]string{"CNY": "10"}) {
t.Fatalf("estimate = %+v, want 10 CNY on its own", got.EstimateCost)
}
var d DailyTokens
for _, row := range got.Daily {
if row.Day == day {
d = row
}
}
if !reflect.DeepEqual(money(d.Cost), map[string]string{"CNY": "4"}) || !reflect.DeepEqual(money(d.EstimateCost), map[string]string{"CNY": "10"}) {
t.Fatalf("day cost %+v / estimate %+v", d.Cost, d.EstimateCost)
}
}
// A day that only has an estimate reports no billed cost, not a zero one.
func TestAnEstimateOnlyDayHasNoBilledCost(t *testing.T) {
dir := testenv.TempDir(t)
writeDay(t, dir, "2026-08-11",
map[string]any{"ts": "2026-08-11T01:00:00Z", "model": "plan/b", "source": "cli", "total": 50, "cost_amount": "3", "cost_currency": "CNY", "billing_mode": "subscription_equivalent"})
got := queryAll(t, dir)
if len(got.Cost) != 0 || len(got.EstimateCost) != 1 {
t.Fatalf("cost %+v estimate %+v", got.Cost, got.EstimateCost)
}
}
// The rollup catalog and the JSONL read answer the same question the same way,
// so a rebuild that has not finished, or one that failed, changes nothing the
// page shows.
func TestTheCatalogAndTheJSONLReadAgreeOnTheSplit(t *testing.T) {
dir := testenv.TempDir(t)
day, lines := modeRecords()
writeDay(t, dir, day, lines...)
from, _ := time.Parse(dayLayout, "2026-08-01")
to, _ := time.Parse(dayLayout, "2026-08-31")
for _, currency := range []string{"", "CNY", "USD"} {
filter := SourceFilter{From: from, To: to, Currency: currency}
fromFiles, err := NewWriter(dir).Query(filter)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
catalog, err := usagecatalog.Open(ctx, testenv.TempDir(t)+"/usage.sqlite")
if err != nil {
t.Fatal(err)
}
if err := catalog.ReconcileDir(ctx, dir); err != nil {
t.Fatal(err)
}
manager := &usageManager{}
manager.catalog.Store(catalog)
w := NewWriter(dir)
w.usage = manager
fromCatalog, err := w.Query(filter)
if err != nil {
t.Fatal(err)
}
_ = catalog.Close(ctx)
if !reflect.DeepEqual(fromFiles, fromCatalog) {
t.Fatalf("currency %q: the two reads differ\nfiles: %+v\ncatalog: %+v", currency, fromFiles, fromCatalog)
}
if len(fromCatalog.EstimateCost) == 0 {
t.Fatalf("currency %q: the catalog read lost the estimate", currency)
}
}
}
func queryThrough(t *testing.T, dir string, catalog *usagecatalog.Catalog, filter SourceFilter) RangeStats {
t.Helper()
manager := &usageManager{}
manager.catalog.Store(catalog)
w := NewWriter(dir)
w.usage = manager
got, err := w.Query(filter)
if err != nil {
t.Fatal(err)
}
return got
}
// While the projection has not been built, or cannot be read, the page is
// answered from the stats files: slower, never empty and never different.
func TestAnUnbuiltOrUnreadableCatalogFallsBackToTheFilesWithTheSameAnswer(t *testing.T) {
dir := testenv.TempDir(t)
day, lines := modeRecords()
writeDay(t, dir, day, lines...)
from, _ := time.Parse(dayLayout, "2026-08-01")
to, _ := time.Parse(dayLayout, "2026-08-31")
filter := SourceFilter{From: from, To: to}
want, err := NewWriter(dir).Query(filter)
if err != nil || len(want.EstimateCost) == 0 {
t.Fatalf("the files answer %+v (%v)", want, err)
}
ctx := context.Background()
unbuilt, err := usagecatalog.Open(ctx, testenv.TempDir(t)+"/unbuilt.sqlite")
if err != nil {
t.Fatal(err)
}
defer unbuilt.Close(ctx)
if got := queryThrough(t, dir, unbuilt, filter); !reflect.DeepEqual(got, want) {
t.Fatalf("an unbuilt catalog changed the answer\nwant %+v\ngot %+v", want, got)
}
broken, err := usagecatalog.Open(ctx, testenv.TempDir(t)+"/broken.sqlite")
if err != nil {
t.Fatal(err)
}
if err := broken.ReconcileDir(ctx, dir); err != nil {
t.Fatal(err)
}
_ = broken.Close(ctx)
if got := queryThrough(t, dir, broken, filter); !reflect.DeepEqual(got, want) {
t.Fatalf("an unreadable catalog changed the answer\nwant %+v\ngot %+v", want, got)
}
}
+39 -16
View File
@@ -21,7 +21,10 @@ type DailyTokens struct {
Turns int `json:"turns"` // completed turns
CacheHit int64 `json:"cacheHit"` // cached input tokens that day
CacheMiss int64 `json:"cacheMiss"` // uncached input tokens that day
Cost []pricing.Money `json:"cost,omitempty"` // one entry per billing currency
Cost []pricing.Money `json:"cost,omitempty"` // billed, one entry per billing currency
// EstimateCost is what a plan's API-priced estimates add up to; it never
// shares a figure with Cost.
EstimateCost []pricing.Money `json:"estimateCost,omitempty"`
}
// ModelUsage is one model's aggregate within the range.
@@ -55,6 +58,10 @@ type RangeStats struct {
// Cost is one entry per billing currency: summing two would have to invent
// an exchange rate, and the rate a turn was billed at is not today's.
Cost []pricing.Money `json:"cost,omitempty"`
// EstimateCost is the API-priced estimate of what subscription-equivalent
// connections used, one entry per currency. It is not money paid and is kept
// out of Cost, so a plan's estimate never reads as a charge.
EstimateCost []pricing.Money `json:"estimateCost,omitempty"`
// CostEstimated marks a total that folded in at least one quote priced from
// a fallback rate card. A surface labelling the sum "paid" has to say so.
CostEstimated bool `json:"costEstimated,omitempty"`
@@ -125,11 +132,11 @@ func (w *Writer) queryJSONL(f SourceFilter) (RangeStats, error) {
}
modelTotals := map[string]int64{}
providerTotals := map[string]int64{}
totalCost := map[string]pricing.Amount{}
totalCost := newCostBooks()
active := map[string]bool{} // day -> active
for _, day := range days {
recs := recordsByDay[day]
dayCost := map[string]pricing.Amount{}
dayCost := newCostBooks()
dayTotals := map[string]int64{}
dayTurns := 0
dayRequests := 0
@@ -176,9 +183,10 @@ func (w *Writer) queryJSONL(f SourceFilter) (RangeStats, error) {
dayTotals[model] += t
}
if currency, amount := usageEntry(day, rec).CostIn(view); currency != "" {
dayCost[currency] += pricing.Amount(amount)
totalCost[currency] += pricing.Amount(amount)
out.CostEstimated = out.CostEstimated || rec.CostEstimated
mode := usagecatalog.CostMode(rec.BillingMode)
dayCost.add(mode, currency, pricing.Amount(amount))
totalCost.add(mode, currency, pricing.Amount(amount))
out.CostEstimated = out.CostEstimated || (mode == "" && rec.CostEstimated)
}
dayActive = dayActive || rec.Total > 0 || requests > 0
}
@@ -201,7 +209,7 @@ func (w *Writer) queryJSONL(f SourceFilter) (RangeStats, error) {
dayRequests, dayTurns, dayCacheHit, dayCacheMiss))
}
out.ActiveDays = len(active)
out.Cost = moneySorted(totalCost)
out.Cost, out.EstimateCost = moneySorted(totalCost.billed), moneySorted(totalCost.estimate)
out.Models = modelsSorted(modelTotals)
out.Providers = providersSorted(providerTotals)
if len(out.Models) > 0 {
@@ -230,10 +238,10 @@ func rangeStatsFromRollups(f SourceFilter, days []string, rows []usagecatalog.Ro
}
modelTotals := map[string]int64{}
providerTotals := map[string]int64{}
totalCost := map[string]pricing.Amount{}
totalCost := newCostBooks()
active := map[string]bool{}
for _, day := range days {
dayCost := map[string]pricing.Amount{}
dayCost := newCostBooks()
dayModels := map[string]int64{}
dayProviders := map[string]int64{}
dayRequests, dayTurns := int64(0), int64(0)
@@ -267,9 +275,9 @@ func rangeStatsFromRollups(f SourceFilter, days []string, rows []usagecatalog.Ro
if currency == "" || entry.Amount == 0 {
continue
}
dayCost[currency] += pricing.Amount(entry.Amount)
totalCost[currency] += pricing.Amount(entry.Amount)
out.CostEstimated = out.CostEstimated || entry.Estimated
dayCost.add(entry.Mode, currency, pricing.Amount(entry.Amount))
totalCost.add(entry.Mode, currency, pricing.Amount(entry.Amount))
out.CostEstimated = out.CostEstimated || (entry.Mode == "" && entry.Estimated)
}
if row.Total > 0 || row.Requests > 0 {
active[day] = true
@@ -277,10 +285,10 @@ func rangeStatsFromRollups(f SourceFilter, days []string, rows []usagecatalog.Ro
}
out.Daily = append(out.Daily, DailyTokens{Day: day, Total: int(sum(dayModels)), ByModel: dayModels, ByProvider: dayProviders,
Requests: int(dayRequests), Turns: int(dayTurns), CacheHit: dayCacheHit, CacheMiss: dayCacheMiss,
Cost: moneySorted(dayCost)})
Cost: moneySorted(dayCost.billed), EstimateCost: moneySorted(dayCost.estimate)})
}
out.ActiveDays = len(active)
out.Cost = moneySorted(totalCost)
out.Cost, out.EstimateCost = moneySorted(totalCost.billed), moneySorted(totalCost.estimate)
out.Models = modelsSorted(modelTotals)
out.Providers = providersSorted(providerTotals)
if len(out.Models) > 0 {
@@ -301,16 +309,31 @@ func rangeStatsFromRollups(f SourceFilter, days []string, rows []usagecatalog.Ro
}
// dailyRow assembles one day of the trend series.
func dailyRow(day string, byModel, byProvider map[string]int64, cost map[string]pricing.Amount,
func dailyRow(day string, byModel, byProvider map[string]int64, cost costBooks,
requests, turns int, cacheHit, cacheMiss int64,
) DailyTokens {
return DailyTokens{
Day: day, Total: int(sum(byModel)), ByModel: byModel, ByProvider: byProvider,
Requests: requests, Turns: turns, CacheHit: cacheHit, CacheMiss: cacheMiss,
Cost: moneySorted(cost),
Cost: moneySorted(cost.billed), EstimateCost: moneySorted(cost.estimate),
}
}
// costBooks tallies cost per currency, billed and estimated apart.
type costBooks struct{ billed, estimate map[string]pricing.Amount }
func newCostBooks() costBooks {
return costBooks{billed: map[string]pricing.Amount{}, estimate: map[string]pricing.Amount{}}
}
func (b costBooks) add(mode, currency string, amount pricing.Amount) {
if mode == usagecatalog.ModeEstimate {
b.estimate[currency] += amount
return
}
b.billed[currency] += amount
}
// moneySorted renders the tally in a stable currency order so a repeated query
// returns the same JSON.
func moneySorted(tally map[string]pricing.Amount) []pricing.Money {
+1 -1
View File
@@ -160,5 +160,5 @@ func usageEntry(day string, r record) usagecatalog.Entry {
return usagecatalog.Entry{Day: day, Source: r.Source, ModelRef: r.ModelRef, Provider: providerOf(r.ModelRef),
Prompt: r.Prompt, Completion: r.Completion, Reasoning: r.Reasoning, CacheHit: r.CacheHit,
CacheMiss: r.CacheMiss, Total: r.Total, Requests: r.Requests, Turns: turns}.WithCost(usagecatalog.Priced{
Amount: r.CostAmount, Currency: r.CostCurrency, CNY: r.ValuationCNY, USD: r.ValuationUSD, Estimated: r.CostEstimated})
Amount: r.CostAmount, Currency: r.CostCurrency, CNY: r.ValuationCNY, USD: r.ValuationUSD, Estimated: r.CostEstimated, BillingMode: r.BillingMode})
}
+23 -16
View File
@@ -25,7 +25,7 @@ import (
// SchemaVersion names the projection file, so a bump retires the old one
// rather than migrating it: every column here is replayable from the JSONL.
const SchemaVersion = 5
const SchemaVersion = 6
type AppendReceipt struct {
Path string
@@ -53,14 +53,20 @@ type Entry struct {
Cost int64
CostCurrency string
CostEstimated bool
Valuations map[string]int64 // vendor-table amounts by currency, fixed-point
// BillingMode is the mode the quote was made in; a plan's estimate is kept
// apart from billed cost.
BillingMode string
Valuations map[string]int64 // vendor-table amounts by currency, fixed-point
}
// CostAmount is one currency's total. Currencies never add, so they never share
// a column with each other or with the token counts.
type CostAmount struct {
Currency string
Amount int64
// Mode is "" for billed cost and ModeEstimate for a subscription-equivalent
// estimate; the two are never added.
Mode string
Amount int64
// Estimated is set when any row folded in here was priced from a fallback
// rate card rather than a provider-reported amount.
Estimated bool
@@ -165,7 +171,7 @@ CREATE TABLE usage_records(
prompt INTEGER NOT NULL,completion INTEGER NOT NULL,reasoning INTEGER NOT NULL,cache_hit INTEGER NOT NULL,
cache_miss INTEGER NOT NULL,total INTEGER NOT NULL,requests INTEGER NOT NULL,turns INTEGER NOT NULL,
cost INTEGER NOT NULL DEFAULT 0,cost_currency TEXT NOT NULL DEFAULT '',cost_estimated INTEGER NOT NULL DEFAULT 0,
valuation_cny INTEGER,valuation_usd INTEGER,
valuation_cny INTEGER,valuation_usd INTEGER,billing_mode TEXT NOT NULL DEFAULT '',
PRIMARY KEY(file_path,byte_offset)
);
CREATE TABLE usage_rollups(
@@ -176,8 +182,8 @@ CREATE TABLE usage_rollups(
);
CREATE TABLE usage_cost(
day TEXT NOT NULL,source TEXT NOT NULL,model_ref TEXT NOT NULL,view TEXT NOT NULL,
currency TEXT NOT NULL,cost INTEGER NOT NULL,estimated INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(day,source,model_ref,view,currency)
currency TEXT NOT NULL,mode TEXT NOT NULL DEFAULT '',cost INTEGER NOT NULL,estimated INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(day,source,model_ref,view,currency,mode)
);
CREATE INDEX idx_usage_rollups_range ON usage_rollups(day,source,model_ref);
`
@@ -376,10 +382,10 @@ func insertRecord(ctx context.Context, tx *sql.Tx, receipt AppendReceipt, entry
}
_, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO usage_records(file_path,byte_offset,byte_length,line_hash,day,source,
model_ref,provider,prompt,completion,reasoning,cache_hit,cache_miss,total,requests,turns,cost,cost_currency,cost_estimated,
valuation_cny,valuation_usd) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
valuation_cny,valuation_usd,billing_mode) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
receipt.Path, receipt.Offset, receipt.Length, receipt.LineHash, entry.Day, entry.Source, entry.ModelRef, entry.Provider,
entry.Prompt, entry.Completion, entry.Reasoning, entry.CacheHit, entry.CacheMiss, entry.Total, entry.Requests, entry.Turns,
entry.Cost, entry.CostCurrency, estimated, valuationArg(entry, "CNY"), valuationArg(entry, "USD"))
entry.Cost, entry.CostCurrency, estimated, valuationArg(entry, "CNY"), valuationArg(entry, "USD"), entry.BillingMode)
return err
}
@@ -405,10 +411,10 @@ func projectRecord(ctx context.Context, tx *sql.Tx, entry Entry) error {
}
for _, view := range costViews {
currency, amount := entry.CostIn(view)
if _, err := tx.ExecContext(ctx, `INSERT INTO usage_cost(day,source,model_ref,view,currency,cost,estimated)
VALUES(?,?,?,?,?,?,?) ON CONFLICT(day,source,model_ref,view,currency) DO UPDATE SET cost=cost+excluded.cost,
if _, err := tx.ExecContext(ctx, `INSERT INTO usage_cost(day,source,model_ref,view,currency,mode,cost,estimated)
VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(day,source,model_ref,view,currency,mode) DO UPDATE SET cost=cost+excluded.cost,
estimated=MAX(estimated,excluded.estimated)`,
entry.Day, entry.Source, entry.ModelRef, view, currency, amount, estimated); err != nil {
entry.Day, entry.Source, entry.ModelRef, view, currency, CostMode(entry.BillingMode), amount, estimated); err != nil {
return err
}
}
@@ -457,6 +463,7 @@ type rawRecord struct {
CostEstimated bool `json:"cost_estimated"`
ValuationCNY string `json:"valuation_cny"`
ValuationUSD string `json:"valuation_usd"`
BillingMode string `json:"billing_mode"`
}
func providerOf(model string) string {
@@ -474,7 +481,7 @@ func entryFromRaw(day string, raw rawRecord) Entry {
return Entry{Day: day, Source: raw.Source, ModelRef: raw.ModelRef, Provider: providerOf(raw.ModelRef), Prompt: raw.Prompt,
Completion: raw.Completion, Reasoning: raw.Reasoning, CacheHit: raw.CacheHit, CacheMiss: raw.CacheMiss,
Total: raw.Total, Requests: raw.Requests, Turns: turns}.WithCost(Priced{Amount: raw.CostAmount,
Currency: raw.CostCurrency, CNY: raw.ValuationCNY, USD: raw.ValuationUSD, Estimated: raw.CostEstimated})
Currency: raw.CostCurrency, CNY: raw.ValuationCNY, USD: raw.ValuationUSD, Estimated: raw.CostEstimated, BillingMode: raw.BillingMode})
}
// recordDays is every day the file's records were filed under, plus the day it
@@ -660,7 +667,7 @@ func (c *Catalog) attachCosts(ctx context.Context, rows []Rollup, where string,
if len(rows) == 0 {
return rows, nil
}
found, err := c.db.QueryContext(ctx, `SELECT day,source,model_ref,currency,cost,estimated
found, err := c.db.QueryContext(ctx, `SELECT day,source,model_ref,currency,mode,cost,estimated
FROM usage_cost WHERE `+where, args...)
if err != nil {
return nil, err
@@ -671,15 +678,15 @@ func (c *Catalog) attachCosts(ctx context.Context, rows []Rollup, where string,
at[costKey(row.Day, row.Source, row.ModelRef)] = i
}
for found.Next() {
var day, source, model, currency string
var day, source, model, currency, mode string
var amount int64
var estimated int
if err := found.Scan(&day, &source, &model, &currency, &amount, &estimated); err != nil {
if err := found.Scan(&day, &source, &model, &currency, &mode, &amount, &estimated); err != nil {
return nil, err
}
if i, ok := at[costKey(day, source, model)]; ok {
rows[i].Costs = append(rows[i].Costs,
CostAmount{Currency: currency, Amount: amount, Estimated: estimated != 0})
CostAmount{Currency: currency, Mode: mode, Amount: amount, Estimated: estimated != 0})
}
}
return rows, found.Err()
+19 -4
View File
@@ -23,6 +23,18 @@ func CostView(currency string) string {
return ""
}
// ModeEstimate is the cost mode of a subscription-equivalent quote: an
// API-priced estimate of what a plan covered, not money billed.
const ModeEstimate = "estimate"
// CostMode is the cost mode a billing mode files its quotes under: "" is billed.
func CostMode(billingMode string) string {
if billingMode == pricing.BillingModeSubscriptionEquivalent {
return ModeEstimate
}
return ""
}
// Priced is the quote a stats line was written with, spelled as the line
// spells it. CNY and USD are the vendor-table valuations, empty when none.
type Priced struct {
@@ -31,12 +43,14 @@ type Priced struct {
CNY string
USD string
Estimated bool
// BillingMode is the mode the quote was made in; empty reads as billed.
BillingMode string
}
// WithCost attaches a line's quote. An amount that does not parse is dropped
// rather than guessed: a missing total beats a wrong one.
func (e Entry) WithCost(p Priced) Entry {
e.Cost, e.CostCurrency, e.CostEstimated = 0, "", p.Estimated
e.Cost, e.CostCurrency, e.CostEstimated, e.BillingMode = 0, "", p.Estimated, p.BillingMode
if amount, ok := parseAmount(p.Amount); ok {
e.Cost, e.CostCurrency = amount, pricing.NormalizeCurrency(p.Currency)
}
@@ -86,7 +100,8 @@ func viewCostSelect(view string) string {
valuation = "valuation_" + strings.ToLower(view)
}
currency := "CASE WHEN " + valuation + " IS NULL THEN cost_currency ELSE '" + view + "' END"
return `INSERT INTO usage_cost(day,source,model_ref,view,currency,cost,estimated)
SELECT day,source,model_ref,'` + view + `',` + currency + `,SUM(COALESCE(` + valuation + `,cost)),MAX(cost_estimated)
FROM usage_records WHERE day=? AND cost_currency<>'' AND cost<>0 GROUP BY day,source,model_ref,` + currency
mode := "CASE WHEN billing_mode='" + pricing.BillingModeSubscriptionEquivalent + "' THEN '" + ModeEstimate + "' ELSE '' END"
return `INSERT INTO usage_cost(day,source,model_ref,view,currency,mode,cost,estimated)
SELECT day,source,model_ref,'` + view + `',` + currency + `,` + mode + `,SUM(COALESCE(` + valuation + `,cost)),MAX(cost_estimated)
FROM usage_records WHERE day=? AND cost_currency<>'' AND cost<>0 GROUP BY day,source,model_ref,` + currency + `,` + mode
}
@@ -0,0 +1,2 @@
- 用量页的「实付」不再包含订阅等价(Token Plan 之类)的估算;估算单独一行标为「等价估算」,每日明细里也并排显示。议题 #13276。
- The usage page's paid figure no longer includes a subscription-equivalent estimate; it is its own labelled line and sits beside each day. Issue #13276.