diff --git a/doc/COST-ACCOUNTING.md b/doc/COST-ACCOUNTING.md index 2d7d930cc6d..1413d13137b 100644 --- a/doc/COST-ACCOUNTING.md +++ b/doc/COST-ACCOUNTING.md @@ -44,7 +44,7 @@ Cost and finance endpoints default to the current UTC month through now. Explici Cost summaries expose `eventCount`, `estimatedEventCount`, `unpricedEventCount`, `pendingRunCount`, and `pricingComplete`. Estimated charges count toward budgets, while the UI distinguishes estimates from provider-reported dollars. Agent totals and expanded model rows show **Estimated** when every charge in that group is estimated, or **Partially estimated** for a mix. Groups with no estimates have no estimate badge. These labels follow the selected date range and use ledger-event counts, not run counts; they do not infer pricing status from the provider or model name. `pricingComplete` means no missing prices or pending receipts; it does not mean invoice reconciliation has occurred. The Costs page warns when totals are incomplete. Provider cards include cached tokens and calculate subscription shares using each token once. Reports normalize historical OpenAI events without a receipt fingerprint from inclusive input to exclusive input before grouping, so mixed historical/new totals count cache reads once across agents, models, providers, billers, projects, issues, and rolling windows. This read-time conversion preserves original ledger rows and charges; legacy Anthropic input already excludes cache reads. Provider spend is compared with the real company budget, not a fabricated proportional allocation. Run displays prefer cache-adjusted prices and preserve explicit zeroes. -Provider pace warnings project month-to-date spend across the UTC month and compare it with the same company cap displayed by the bar. Run-history totals preserve separate cached input for legacy Claude and Gemini snapshots while retaining inclusive legacy Codex totals. Storybook finance timelines use the production ordering (event time, then creation time, newest first) and request limits, including after importing older invoices. +Provider pace warnings project month-to-date spend across the UTC month and compare it with the same company cap displayed by the bar. Run-history totals preserve separate cached input for legacy Claude/Gemini snapshots and provider-qualified OpenCode/Pi model snapshots, while retaining inclusive legacy Codex totals. Storybook finance timelines use the production ordering (event time, then creation time, newest first) and request limits, including after importing older invoices. Its headline event count covers USD only; non-USD events remain in their own currency summaries and in the timeline. Gemini token normalization follows the [CLI stream statistics](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/output/stream-json-formatter.ts) and [API usage metadata](https://ai.google.dev/api/generate-content#UsageMetadata): cached prompt tokens are separated from input, and thinking tokens contribute to output. diff --git a/ui/src/lib/cost-reporting.test.ts b/ui/src/lib/cost-reporting.test.ts index f5a15e51579..642824df94e 100644 --- a/ui/src/lib/cost-reporting.test.ts +++ b/ui/src/lib/cost-reporting.test.ts @@ -32,6 +32,16 @@ describe("cost presentation", () => { expect(formatCents(123, "EUR")).toBe("€1.23"); }); + it.each(["openai", "openrouter", "opencode", "xai"])("recognizes saved OpenCode/Pi %s model layouts without changing legacy Codex", (provider) => { + const tokens = { inputTokens: 100, cachedInputTokens: 80, outputTokens: 10 }; + expect(visibleRunTokenTotal({ ...tokens, provider, model: `${provider}/vendor/model` })).toBe(190); + expect(visibleRunTokenTotal({ ...tokens, provider: "openai", model: "gpt-5" })).toBe(110); + expect(visibleRunTokenTotal({ ...tokens, provider: "openai", model: "different/model" })).toBe(110); + expect(visibleRunTokenTotal({ ...tokens, provider: "openai", model: "openai/" })).toBe(110); + expect(visibleRunTokenTotal({ ...tokens, provider: "unknown", model: "unknown/model" })).toBe(110); + expect(visibleRunTokenTotal({ ...tokens, inputTokens: 20, provider: "openai", model: "gpt-5", accountingReceiptId: "saved" })).toBe(110); + }); + it("starts month-to-date at the UTC boundary even before local midnight", () => { vi.useFakeTimers({ toFake: ["Date"] }); try { diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index d709364c246..8ad58a1f738 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -180,8 +180,9 @@ function readRunCostUsd(payload: Record | null): number | null } /** Receipt-backed runs and legacy Claude/Gemini snapshots store uncached input - * separately. Older Codex input already includes its cached tokens. Unknown - * legacy formats retain their original input-plus-output interpretation. */ + * separately. OpenCode/Pi persist their provider-qualified model IDs and also + * separate cache reads. Older Codex input already includes cached tokens. + * Unknown legacy formats retain their input-plus-output interpretation. */ export function visibleRunTokenTotal(usage: Record | null | undefined): number { const count = (...keys: string[]) => { for (const key of keys) { @@ -193,8 +194,15 @@ export function visibleRunTokenTotal(usage: Record | null | und const input = count("inputTokens", "input_tokens"); const output = count("outputTokens", "output_tokens"); const cached = count("cachedInputTokens", "cached_input_tokens", "cache_read_input_tokens"); + const provider = typeof usage?.provider === "string" ? usage.provider : ""; + const model = typeof usage?.model === "string" ? usage.model : ""; + // Use the saved model convention, not the agent's current adapter: an agent + // can change adapters after a historical run. Codex saves an unqualified ID. + const qualifiedModel = provider !== "" && provider !== "unknown" + && model.startsWith(`${provider}/`) && model.length > provider.length + 1; const inputExcludesCached = usage?.provider === "anthropic" || usage?.provider === "google" + || qualifiedModel || (typeof usage?.accountingReceiptId === "string" && usage.accountingReceiptId.length > 0); return input + output + (inputExcludesCached ? cached : 0); } diff --git a/ui/storybook/fixtures/costsFinance.test.ts b/ui/storybook/fixtures/costsFinance.test.ts index 7377bab4e6b..fcae9832d79 100644 --- a/ui/storybook/fixtures/costsFinance.test.ts +++ b/ui/storybook/fixtures/costsFinance.test.ts @@ -92,4 +92,18 @@ describe("Costs story finance actions", () => { } }); + it("counts only USD events in the headline while retaining every currency in reports and the timeline", async () => { + const fixture = createCostsFinanceFixtures("preview"); + await fixture("accounting/invoices", request("accounting/invoices", { biller: "Example", externalId: "eur-invoice", currency: "EUR", + lines: [{ externalId: "fee", kind: "fee", amountCents: "100", occurredAt: charge.occurredAt }] })); + expect(await (await fixture("costs/finance-summary", request("costs/finance-summary")))!.json()) + .toMatchObject({ currency: "USD", debitCents: 0, eventCount: 0, currencies: [{ currency: "EUR", debitCents: 100, eventCount: 1 }] }); + await fixture("finance-events", request("finance-events", charge)); + expect(await (await fixture("costs/finance-summary", request("costs/finance-summary")))!.json()) + .toMatchObject({ currency: "USD", debitCents: 125, eventCount: 1, currencies: [ + { currency: "EUR", debitCents: 100, eventCount: 1 }, { currency: "USD", debitCents: 125, eventCount: 1 }, + ] }); + expect(await (await fixture("costs/finance-events", request("costs/finance-events")))!.json()).toHaveLength(2); + }); + }); diff --git a/ui/storybook/fixtures/costsFinance.ts b/ui/storybook/fixtures/costsFinance.ts index faec984858a..9c349cdf0bf 100644 --- a/ui/storybook/fixtures/costsFinance.ts +++ b/ui/storybook/fixtures/costsFinance.ts @@ -37,7 +37,7 @@ export function createCostsFinanceFixtures(companyId: string) { const from = url.searchParams.get("from"); const to = url.searchParams.get("to"); const until = to ? new Date(/^\d{4}-\d{2}-\d{2}$/.test(to) ? `${to}T23:59:59.999Z` : to) : null; const visible = events.filter(row => (!from || row.occurredAt >= new Date(from)) && (!until || row.occurredAt <= until)); - if (resource === "costs/finance-summary") return Response.json({ companyId, ...summary(visible), eventCount: visible.length, currencies: [...new Set(visible.map(row => row.currency))].map(currency => summary(visible, currency)) }); + if (resource === "costs/finance-summary") return Response.json({ companyId, ...summary(visible), currencies: [...new Set(visible.map(row => row.currency))].sort().map(currency => summary(visible, currency)) }); if (resource === "costs/finance-events") { const rawLimit = url.searchParams.get("limit"); const limit = rawLimit == null || rawLimit === "" ? 100 : Number(rawLimit);