Files
PaperClipAI/tests/runner-e2e/anthropic-billing-comparison.test.ts
T
DottaandPaperclip 17f1e386e8 feat(evals): compare retained Anthropic billing exports (#17360)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Product E2E evaluations retain request costs and failed attempts in
a journal.
> - Provider exports can arrive later and contain costs at a wider time
scope.
> - Comparing those exports by hand makes repeated accounting work hard
to audit.
> - This change adds a read-only command for the observed Anthropic
export format.
> - It binds the comparison to the retained manifest, journal and exact
export bytes.
> - Operators can see the unmatched amount without assigning it to
canceled requests or releasing their holds.

## Linked Issues or Issue Description

Refs: #17009, #17354

**Problem or motivation**

A hosted campaign retained eight complete model receipts and two
canceled requests. A later provider export covered the campaign, but
comparing its tokens, rounded costs and the journal required a one-off
script. Repeated observations must not be counted twice or treated as
request-level billing proof.

**Proposed solution**

Add a private comparison command for one standard Claude Sonnet 5
campaign and one UTC date. Validate the selected workspace, key label,
model and pricing dimensions. Compare provider totals with one
authoritative observation per request. Retain source hashes and signed
differences. Keep provisional exports, partial receipts and missing
model identities explicit.

**Alternatives considered**

Manual comparisons remain possible but are hard to repeat. Importing a
day total as a request cost would invent attribution. This command
leaves journal and budget mutations to their existing owners.

**Roadmap alignment**

This is a focused operational tool for the existing Product E2E
accounting subsystem. It does not change the product budgeting or
agent-eval roadmap features. A targeted search found no duplicate
billing-comparison PR or issue.

## What Changed

- Add the provider-specific CSV parser, scope checks, token pricing
comparison and private CLI in the Product E2E accounting owner.
- Report complete and partial receipt coverage, source hashes, rounded
provider charges, separate non-model charges, and signed unmatched
amounts.
- Reject ambiguous scopes, unsupported price dimensions, duplicate rows,
contradictory token observations, changed journals and incomplete input.
- Write a new mode-0600 output without replacing existing files or
changing the journal.
- Document the supported export shape and the limits of comparison
evidence.

## Verification

- After rebasing onto master `e48f1867b3b45f74edca4645d640846bcafeee4c`,
104 focused and adjacent tests passed across comparison, ledger,
provider pricing, operating receipts and gateway behavior.
- `pnpm test:e2e:runner:typecheck`: passed after rebase.
- `git diff --check origin/master...HEAD`: passed.
- Ran the actual CLI on retained hosted-campaign CSV exports and
journal. It reproduced the prior manual calculation: provider-token
estimate `$0.428027200`, with `$0.006782000` unmatched to retained
request cost receipts. No provider or network call was made.
- The first retained-data run exposed an empty provider key-status field
and canceled receipts without a returned model identity. Those cases are
now covered by the fixtures; the rerun passed. No request identity or
final charge was invented.
- Regression tests first reproduced loss of token fields on unknown-cost
receipts (2 failed, 13 passed). The repair retains each known field
independently of cost and rejects conflicting equal-authority token
values.
- Full PR CI passed on `f74ef1c31391ee62b686fcf300601e9cf8e73a7e`,
including typecheck, tests, build and browser suites. Greptile and
Paperclip each reviewed that exact head at 5/5 with no remaining
actionable findings.

## Risks

- The initial supported schema is standard global Claude Sonnet 5 with
context buckets at or below 200k, one campaign and one UTC date. Other
provider shapes fail explicitly.
- Matching workspace/key labels does not prove exclusive key use or a
complete provider export. The output remains a comparison and cannot
release a reservation.
- Provider costs are rounded and can lag. The token estimate uses
retained prices and is kept separate from provider charges and non-model
costs.
- Config and exports are private inputs. Output includes hashes instead
of raw key labels. This file is not a public report projection.
- No runtime dependency, schedule, credential, paid execution, journal
format or admission rule changes.

## Model Used

OpenAI Codex, GPT-6 family. The exact deployment ID and context-window
size are not exposed in this session. Used reasoning, repository tools
and code execution. No paid evaluation model calls were made for this
change.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-23 13:21:46 -07:00

153 lines
11 KiB
TypeScript

import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import { billingCsv, compareAnthropicBilling, writeAnthropicBillingComparison, type AnthropicBillingComparisonConfig } from "./anthropic-billing-comparison.js";
import { openEvalCostLedger } from "./cost-ledger.js";
const roots: string[] = [];
afterEach(async () => { vi.restoreAllMocks(); await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
const usageHeader = "usage_date_utc,model_version,api_key,workspace,usage_type,context_window,usage_input_tokens_no_cache,usage_input_tokens_cache_write_5m,usage_input_tokens_cache_write_1h,usage_input_tokens_cache_read,usage_output_tokens,web_search_count,inference_geo,speed\n";
const usageRow = '2020-01-02 12:00,claude-sonnet-5,"key, label",workspace,standard,≤ 200k,20,100,0,30,8,0,global,standard\n';
const costHeader = "usage_date_utc,model,workspace,api_key,usage_type,context_window,token_type,cost_usd,list_price_usd,cost_type,inference_geo,speed,api_key_id,api_key_status\n";
const costRows = ["input_no_cache", "input_cache_read", "input_cache_write_5m", "input_cache_write_1h", "output"].map(token =>
`2020-01-02,Claude Sonnet 5,workspace,"key, label",message,≤ 200k,${token},0.00,0.00,token,global,standard,key-id,\n`).join("");
async function fixture() {
const root = await mkdtemp(path.join(tmpdir(), "billing-comparison-")); roots.push(root);
const config: AnthropicBillingComparisonConfig = { version: 1, manifest: path.join(root, "manifest.json"), ledger: path.join(root, "ledger.jsonl"),
usageExport: path.join(root, "usage.csv"), costExport: path.join(root, "cost.csv"), workspace: "workspace", apiKeyLabel: "key, label",
date: "2020-01-02", sourceDataAsOf: "2020-01-03T01:00:00Z" };
const limits = { campaignId: "comparison", maxUsd: "10", deadlineAt: "2099-01-01T00:00:00Z", maxRequests: 10, maxConcurrent: 1 };
const clock = vi.spyOn(Date, "now").mockReturnValue(Date.parse("2020-01-02T12:05:00Z"));
const ledger = await openEvalCostLedger(config.ledger, limits);
const observation = { observationId: "provider-1", requestId: "complete", provider: "anthropic", model: "claude-sonnet-5", source: "provider" as const,
currency: "USD" as const, route: "braintrust" as const, classification: "estimated" as const, pricingVersion: "fixture", costUsd: "0.000326",
tokens: { input: 10, cachedWrite: 100, cachedRead: 30, output: 5 }, final: true };
try {
await ledger.reserve("complete", { executionId: "cell", attemptId: "attempt", phase: "setup" }, "1");
await ledger.observe(observation);
await ledger.observe({ ...observation, observationId: "gateway-1", source: "gateway" });
await ledger.finish("complete", "succeeded");
await ledger.reserve("cancelled", { executionId: "cell", attemptId: "attempt", phase: "agent" }, "1");
await ledger.observe({ observationId: "cancelled-usage", requestId: "cancelled", provider: "anthropic",
source: "gateway", currency: "USD", route: "braintrust", classification: "unknown", final: false });
await ledger.finish("cancelled", "cancelled");
} finally { await ledger.close(); clock.mockRestore(); }
const price = { provider: "anthropic", model: "claude-sonnet-5", version: "fixture", sourceUrl: "https://example.test/prices",
inputUsdPerMillion: "2", cachedReadUsdPerMillion: "0.2", cacheWrite5mUsdPerMillion: "2.5", cacheWrite1hUsdPerMillion: "4", outputUsdPerMillion: "10" };
await Promise.all([
writeFile(config.manifest, JSON.stringify({ version: 1, limits, executionIds: ["cell"], models: [{ provider: "anthropic", price }] })),
writeFile(config.usageExport, usageHeader + usageRow), writeFile(config.costExport, costHeader + costRows),
]);
return { config, root, limits, observation };
}
it("compares provider usage once, keeps the cancelled residual explicit, and preserves the journal", async () => {
const { config, root } = await fixture(), before = await readFile(config.ledger);
const configFile = path.join(root, "config.json"), output = path.join(root, "comparison.json");
await writeFile(configFile, JSON.stringify(config));
const result = await writeAnthropicBillingComparison(configFile, output);
expect(result).toMatchObject({
provider: { tokenEstimateUsd: "0.000376000", roundedTokenCostUsd: "0.000000000", tokenEstimateConsistentWithRoundedCost: true },
retained: { requests: 2, completeRequests: 1, missingTokenRequests: 1, partialTokenRequests: 0, unconfirmedModelRequests: 1, knownUsd: "0.000326000",
observedTokens: { input: 10, cachedRead: 30, cachedWrite: 100, output: 5 } },
difference: { tokens: { input: 10, cachedRead: 0, cachedWrite: 0, output: 3 }, estimatedUsd: "0.000050000" },
invoiceReconciled: false, requestCostsAllocated: false, holdsReleased: false,
});
expect(result.sources).toHaveLength(4);
expect(result.sources.every(source => /^[a-f0-9]{64}$/.test(source.sha256))).toBe(true);
expect(await readFile(config.ledger)).toEqual(before);
expect((await stat(output)).mode & 0o777).toBe(0o600);
expect(await readFile(output, "utf8")).not.toContain("key, label");
await expect(writeAnthropicBillingComparison(configFile, output)).rejects.toThrow("EEXIST");
expect(await readFile(config.ledger)).toEqual(before);
});
it("retains partial tokens once and exposes a negative provider difference", async () => {
const { config, limits, observation } = await fixture();
const ledger = await openEvalCostLedger(config.ledger, limits);
try {
await ledger.observe({ ...observation, observationId: "partial", requestId: "cancelled", final: false,
costUsd: "0.000100", tokens: { input: 40, cachedRead: 0, cachedWrite: 0, output: 2 } });
} finally { await ledger.close(); }
expect(await compareAnthropicBilling(config)).toMatchObject({ retained: { partialTokenRequests: 1, missingTokenRequests: 0, knownUsd: "0.000426000" },
difference: { tokens: { input: -30, output: 1 }, estimatedUsd: "-0.000050000" } });
});
it.each([
[{ input: 40 }, { input: 0, cachedRead: 1, cachedWrite: 1, output: 1 }, 5],
[{ input: 40, cachedRead: 0, cachedWrite: 0, output: 2 }, { input: 0, cachedRead: 0, cachedWrite: 0, output: 0 }, 7],
])("preserves known token fields on unpriced partial receipts", async (tokens, missingFields, expectedOutput) => {
const { config, limits } = await fixture();
const ledger = await openEvalCostLedger(config.ledger, limits);
try {
await ledger.observe({ observationId: "unpriced-token-evidence", requestId: "cancelled", provider: "anthropic", source: "gateway",
currency: "USD", route: "braintrust", classification: "unknown", tokens, final: false });
} finally { await ledger.close(); }
expect(await compareAnthropicBilling(config)).toMatchObject({ retained: { partialTokenRequests: 1, missingTokenRequests: 0,
knownUsd: "0.000326000", observedTokens: { input: 50, output: expectedOutput }, missingTokenFields: missingFields },
difference: { tokens: { input: -30 } }, holdsReleased: false });
});
it("prefers final token evidence while preserving an unknown dollar cost", async () => {
const { config, limits } = await fixture();
const ledger = await openEvalCostLedger(config.ledger, limits);
const observation = { requestId: "cancelled", provider: "anthropic", model: "claude-sonnet-5", source: "gateway" as const,
currency: "USD" as const, route: "braintrust" as const, classification: "unknown" as const };
try {
await ledger.observe({ ...observation, observationId: "earlier", tokens: { input: 40, output: 1 }, final: false });
await ledger.observe({ ...observation, observationId: "final-unpriced", tokens: { input: 40, cachedRead: 0, cachedWrite: 0, output: 2 }, final: true });
} finally { await ledger.close(); }
expect(await compareAnthropicBilling(config)).toMatchObject({ retained: { completeRequests: 1, completeTokenRequests: 2,
partialTokenRequests: 0, missingTokenRequests: 0, knownUsd: "0.000326000", observedTokens: { input: 50, output: 7 },
missingTokenFields: { input: 0, cachedRead: 0, cachedWrite: 0, output: 0 } } });
});
it("rejects duplicate token observations that disagree despite equal cost", async () => {
const { config, limits, observation } = await fixture();
const ledger = await openEvalCostLedger(config.ledger, limits);
try { await ledger.observe({ ...observation, observationId: "contradiction", tokens: { ...observation.tokens, output: 6 } }); }
finally { await ledger.close(); }
await expect(compareAnthropicBilling(config)).rejects.toThrow("disagree on tokens");
});
it.each([
["mixed workspace", usageRow.replace(",workspace,", ",other,"), "different workspace"],
["batch price", usageRow.replace(",standard,≤", ",batch,≤"), "Unsupported usage"],
["long context", usageRow.replace("≤ 200k", "> 200k"), "pricing dimensions"],
["geography", usageRow.replace(",global,", ",us,"), "pricing dimensions"],
["negative tokens", usageRow.replace(",20,100,", ",-20,100,"), "token count"],
["duplicate row", usageRow + usageRow, "Duplicate usage"],
["different date", usageRow.replace("2020-01-02", "2020-01-01"), "date"],
])("rejects %s instead of silently filtering the export", async (_name, rows, error) => {
const { config } = await fixture(); await writeFile(config.usageExport, usageHeader + rows);
await expect(compareAnthropicBilling(config)).rejects.toThrow(error);
});
it("requires complete cost categories and preserves separate non-model costs", async () => {
const { config } = await fixture();
await writeFile(config.costExport, costHeader + costRows.split("\n").slice(0, 4).join("\n"));
await expect(compareAnthropicBilling(config)).rejects.toThrow("all five token categories");
await writeFile(config.costExport, costHeader + costRows + '2020-01-02,--,workspace,"key, label",--,--,--,0.25,0.25,web_search,--,--,key-id,\n');
expect(await compareAnthropicBilling(config)).toMatchObject({ provider: { roundedOtherCostUsd: "0.250000000", tokenEstimateUsd: "0.000376000" } });
});
it("rejects a future snapshot, changed campaign identity and incomplete journal", async () => {
const { config } = await fixture();
await expect(compareAnthropicBilling({ ...config, sourceDataAsOf: "2099-01-01T00:00:00Z" })).rejects.toThrow("reporting window");
const manifest = JSON.parse(await readFile(config.manifest, "utf8"));
manifest.limits.campaignId = "other"; await writeFile(config.manifest, JSON.stringify(manifest));
await expect(compareAnthropicBilling(config)).rejects.toThrow("campaign model");
manifest.limits.campaignId = "comparison"; await writeFile(config.manifest, JSON.stringify(manifest));
await writeFile(config.ledger, '{"partial":', { flag: "a" });
const before = await readFile(config.ledger);
await expect(compareAnthropicBilling(config)).rejects.toThrow("durable record");
expect(await readFile(config.ledger)).toEqual(before);
});
it("parses quoted commas, escaped quotes, newlines and CRLF while rejecting malformed CSV", () => {
expect(billingCsv('\uFEFFa,b\r\n"x,y","z""q"\r\n"line\nnext",end')).toEqual([{ a: "x,y", b: 'z"q' }, { a: "line\nnext", b: "end" }]);
for (const source of ['a,a\n1,2', 'a,b\n1', 'a,b\n"x"tail,y', 'a,b\n"unterminated,y']) expect(() => billingCsv(source)).toThrow();
});