mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
fix: preserve historical adapter token layouts and currency counts
Recognize the persisted provider-qualified OpenCode/Pi model convention without reinterpreting legacy Codex counters. Match production USD headline event counts in Storybook while retaining other currencies in reports and the timeline. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
8442ad1c41
commit
e1db48f754
5 files changed
+36
-4
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+10
-2
@@ -180,8 +180,9 @@ function readRunCostUsd(payload: Record<string, unknown> | 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<string, unknown> | null | undefined): number {
|
||||
const count = (...keys: string[]) => {
|
||||
for (const key of keys) {
|
||||
@@ -193,8 +194,15 @@ export function visibleRunTokenTotal(usage: Record<string, unknown> | 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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user