mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
fix(usage): keep a subscription's estimate out of the paid total
Refs #13276
This commit is contained in:
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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, ¤cy, &amount, &estimated); err != nil {
|
||||
if err := found.Scan(&day, &source, &model, ¤cy, &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()
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user