mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Product E2E campaigns include model, setup, storage and observability costs. > - Some billing windows close after the tests and reports finish. > - Requiring settled receipts for retained storage can block a good campaign for weeks. > - A reservation alone does not prove that a pending charge has an upper bound. > - This change permits provisional completion only with reviewed evidence for the exact cost configuration. > - Reports preserve unknown amounts and holds until later reconciliation. ## Linked Issues or Issue Description Refs: #17360, #17354 **What existing behavior does this improve?** The Product E2E scheduled accounting gate and its retained cost reports. **Current behavior** Every pending receipt fails finalization. A completed test can wait through the retained-storage billing window. The journal has dollar reservations but no explicit qualification of their bounds. **Proposed behavior** Permit provisional completion when all pending charges have reviewed full-lifecycle bounds, no requests remain in flight, no receipts conflict, and known costs plus held amounts fit the campaign limit. Keep all existing holds and receipt-finality rules. Missing evidence still fails. **Reason and benefit** An operator can distinguish delayed but bounded billing from unbounded or missing costs. The campaign can finish without claiming that its invoice is settled. **Breaking changes** The journal accepts optional bound metadata and an invalidation record. New reports add an optional bounded-pending count. Current readers retain old journals and reports without granting them bounds. Older code must not read new bound-bearing journals. The existing daily/weekly enable gates and budget amounts stay unchanged. **Additional context** This implements the operator-approved provisional-cost policy for the existing eval harness. It does not change product budgets or the agent-eval roadmap. A targeted GitHub search found no duplicate open PR. ## What Changed - Accounting owns a narrow evidence contract: a reviewed audit hash, exact configuration hash and full-lifecycle scope. - Gateway reservations retain that evidence before dispatch. Changed limits or rates fail before dispatch. Unexpected routing, model identity or excessive usage invalidates a bound durably. - Operating allowances can carry separately reviewed bounds for each cost component, including its full storage lifetime. No existing allowance becomes qualified by default. - Reports and Braintrust experiment metadata distinguish provisional accounting, complete receipts, blocked accounting and unavailable evidence. Public output omits private audit hashes. - Scheduled finalization allows qualified provisional costs and explicitly rejects spending overruns, including overruns with complete receipts. - Add regressions for legacy data, restarts, late reconciliation, conflicts, malformed evidence, routing uncertainty and exceeded limits. Document the evidence and migration boundaries. ## Verification - Full offline eval-support suite: 4,062 Vitest tests passed, two existing tests skipped; 137 Node tests passed. - `pnpm test:e2e:runner:typecheck`: passed. - `git diff --check`: passed. - Read the retained hosted qualification journal with the new policy. All six legacy pending charges remain unqualified. Known cost `$0.421245200`, held `$19.327680000`, and committed `$19.748925200` remain unchanged. The journal's SHA-256 is unchanged. The original failed workflow remains failed. - No paid models, network provider calls, receipt imports or reservation releases were used for this verification. - Rebased onto master `4d90634f74`; 142 targeted tests and typecheck passed. - Review reproduced a lost OpenAI input-total limit check when cache details were missing (1 failed, 47 passed). Fix `5fef9ba7c149951a51eae468614a4b89f0b3d6db` preserves unpriced provider usage bounds. Then 178 focused tests, full offline support and typecheck passed. - Current-head hosted CI passed: https://github.com/paperclipai/paperclip/actions/runs/40020746585 (attempt 2). Typecheck, build, all general/Runner/serialized tests and browser gates are green. - CI attempt 1 hit a PostgreSQL deadlock in unchanged chat-test cleanup; its skipped flag restoration caused a second assertion failure. One failed-jobs-only retry passed. Original failure evidence is preserved in attempt 1. No paid eval campaign was rerun. - Greptile reviewed exact head `5fef9ba7c149951a51eae468614a4b89f0b3d6db` at 5/5; its one finding is fixed and resolved. The optional Paperclip review is paused and supplied no assessment. ## Risks - Evidence hashes bind an audit to a configuration; they do not prove the audit is true. Operators must review real provider limits, all price tiers, full resource lifetimes and enforceable infrastructure limits before configuring bounds. - Provisional completion is not invoice settlement. Later billing can reveal overruns, which must be reconciled and inspected. The ledger cannot stop external billing. - Existing pilot journals, configured allowances and price cards have no new bound approval. This PR alone does not qualify or enable recurring paid work. - The gateway cannot qualify a canceled Braintrust request without route confirmation. It preserves that unknown hold. - No new dependency, secret, repository variable, workflow, schedule or cloud resource 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>
132 lines
8.4 KiB
TypeScript
132 lines
8.4 KiB
TypeScript
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, expect, it } from "vitest";
|
|
import { openEvalCostLedger, type EvalSpendLimits } from "./cost-ledger.js";
|
|
import { costBoundConfigurationSha256, reservationBound, type EvalCostBoundEvidence } from "./cost-bound-evidence.js";
|
|
import { EVAL_OVERHEAD_COMPONENTS } from "./operating-overhead.js";
|
|
import { evalAccountingStatus, readEvalOperationsReport, renderEvalOperationsReport, validateEvalOperationsReport } from "./operations-report.js";
|
|
import { scheduledCampaignFindings } from "./scheduled-finalization.js";
|
|
import type { RunnerE2ECampaign } from "./types.js";
|
|
|
|
const directories: string[] = [];
|
|
afterEach(async () => { for (const dir of directories.splice(0)) await rm(dir, { recursive: true, force: true }); });
|
|
const evidence: EvalCostBoundEvidence = { version: 1, scope: "full_lifecycle", evidenceSha256: "a".repeat(64), configurationSha256: costBoundConfigurationSha256({ fixture: true }) };
|
|
const identity = { executionId: "cell", attemptId: "one", phase: "agent" as const };
|
|
const bound = reservationBound("model_limits", evidence, { fixture: true });
|
|
async function fixture(overhead = false) {
|
|
const dir = await mkdtemp(path.join(tmpdir(), "provisional-accounting-")); directories.push(dir);
|
|
const file = path.join(dir, "ledger.jsonl");
|
|
const limits: EvalSpendLimits = { campaignId: "fixture", maxUsd: "20", maxRequests: 20, maxConcurrent: 2,
|
|
deadlineAt: "2099-01-01T00:00:00Z", maxAttemptsPerExecution: 1,
|
|
...(overhead ? { overhead: { version: 1 as const, reference: "fixture-audit", runner: "github-hosted" as const,
|
|
verifiedAt: new Date(Date.now() - 60_000).toISOString(), expiresAt: new Date(Date.now() + 86_400_000).toISOString(),
|
|
components: EVAL_OVERHEAD_COMPONENTS.map(component => ({ component, maxUsd: component === "shared_subscription" ? "0" : "1",
|
|
reference: `fixture-${component}`, provisionalBound: evidence })) } } : {}) };
|
|
if (limits.overhead) for (const component of limits.overhead.components) {
|
|
component.provisionalBound = { ...evidence, configurationSha256: costBoundConfigurationSha256({
|
|
reference: limits.overhead.reference, runner: limits.overhead.runner, verifiedAt: limits.overhead.verifiedAt, expiresAt: limits.overhead.expiresAt,
|
|
component: { component: component.component, maxUsd: component.maxUsd, reference: component.reference },
|
|
}) };
|
|
}
|
|
const ledger = await openEvalCostLedger(file, limits);
|
|
await ledger.admitAttempt(identity.executionId, identity.attemptId);
|
|
return { ledger, file, limits };
|
|
}
|
|
function accountingFindings(operations: Awaited<ReturnType<typeof readEvalOperationsReport>>) {
|
|
return scheduledCampaignFindings({ results: [], expected: [], operations } as unknown as RunnerE2ECampaign)
|
|
.filter(finding => finding.startsWith("accounting_"));
|
|
}
|
|
|
|
it("allows reviewed pending operating/model costs without changing holds or finality, including after restart", async () => {
|
|
const { ledger, file, limits } = await fixture(true);
|
|
await ledger.reserve("request", identity, "2", bound);
|
|
await ledger.observe({ observationId: "partial", requestId: "request", source: "provider", provider: "fixture", currency: "USD",
|
|
route: "direct", classification: "estimated", pricingVersion: "fixture", costUsd: "0.1", final: false });
|
|
await ledger.finish("request", "cancelled");
|
|
await ledger.close();
|
|
const original = await readFile(file, "utf8");
|
|
const report = await readEvalOperationsReport(file);
|
|
expect(report).toMatchObject({ unresolvedRequests: 5, boundedUnresolvedRequests: 5, knownUsd: "0.100000000", heldUsd: "4.900000000", committedUsd: "5.000000000" });
|
|
expect(evalAccountingStatus(report)).toBe("provisional");
|
|
expect(accountingFindings(report)).toEqual([]);
|
|
const rendered = renderEvalOperationsReport(report);
|
|
expect(rendered).toContain("Accounting status: provisional");
|
|
expect(rendered).toContain("requires later reconciliation");
|
|
expect(rendered).not.toContain(evidence.evidenceSha256);
|
|
const resumed = await openEvalCostLedger(file, limits);
|
|
try { expect(resumed.snapshot()).toMatchObject({ boundedUnresolvedRequests: 5, heldUsd: "4.900000000" }); }
|
|
finally { await resumed.close(); }
|
|
expect(await readFile(file, "utf8")).toBe(original);
|
|
});
|
|
|
|
it("keeps legacy reservations and legacy reports unqualified instead of inferring a bound from dollars", async () => {
|
|
const { ledger, file } = await fixture();
|
|
try {
|
|
await ledger.reserve("request", identity, "2");
|
|
await ledger.finish("request", "cancelled");
|
|
const report = await readEvalOperationsReport(file);
|
|
expect(evalAccountingStatus(report)).toBe("blocked");
|
|
expect(accountingFindings(report)).toEqual(["accounting_incomplete"]);
|
|
if (report.status === "unavailable") throw new Error("Missing fixture report");
|
|
delete report.boundedUnresolvedRequests;
|
|
validateEvalOperationsReport(report);
|
|
expect(evalAccountingStatus(report)).toBe("blocked");
|
|
expect(() => validateEvalOperationsReport({ ...report, boundedUnresolvedRequests: 2 })).toThrow();
|
|
} finally { await ledger.close(); }
|
|
});
|
|
|
|
it.each(["inflight", "invalidated", "partial-over-bound", "conflict", "over-budget"])("blocks %s even with reviewed bound evidence", async reason => {
|
|
const { ledger, file } = await fixture();
|
|
try {
|
|
await ledger.reserve("request", identity, "2", bound);
|
|
if (reason === "invalidated") await ledger.invalidateBound("request");
|
|
if (reason !== "inflight") await ledger.finish("request", "cancelled");
|
|
if (["partial-over-bound", "conflict", "over-budget"].includes(reason)) {
|
|
await ledger.observe({ observationId: "first", requestId: "request", source: "provider", provider: "fixture", currency: "USD", route: "direct",
|
|
classification: "reported", costUsd: reason === "over-budget" ? "21" : "3", final: reason !== "partial-over-bound" });
|
|
}
|
|
if (reason === "conflict") await ledger.observe({ observationId: "second", requestId: "request", source: "provider", provider: "fixture",
|
|
currency: "USD", route: "direct", classification: "reported", costUsd: "4", final: true });
|
|
const report = await readEvalOperationsReport(file);
|
|
expect(evalAccountingStatus(report)).toBe("blocked");
|
|
expect(accountingFindings(report)).toContain("accounting_incomplete");
|
|
if (reason === "over-budget") expect(accountingFindings(report)).toContain("accounting_over_budget");
|
|
expect(report).toMatchObject({ boundedUnresolvedRequests: 0 });
|
|
} finally { await ledger.close(); }
|
|
});
|
|
|
|
it("lets a later complete receipt settle the original reservation without treating the provisional hold as a charge", async () => {
|
|
const { ledger, file } = await fixture();
|
|
try {
|
|
await ledger.reserve("request", identity, "2", bound);
|
|
await ledger.finish("request", "cancelled");
|
|
expect(evalAccountingStatus(await readEvalOperationsReport(file))).toBe("provisional");
|
|
await ledger.observe({ observationId: "invoice", requestId: "request", source: "invoice", provider: "fixture", currency: "USD",
|
|
route: "direct", classification: "reconciled", costUsd: "0.2", final: true });
|
|
const report = await readEvalOperationsReport(file);
|
|
expect(evalAccountingStatus(report)).toBe("receipts_complete");
|
|
expect(report).toMatchObject({ boundedUnresolvedRequests: 0, knownUsd: "0.200000000", heldUsd: "0.000000000" });
|
|
} finally { await ledger.close(); }
|
|
});
|
|
|
|
it("rejects malformed or mismatched evidence before creating a reservation", async () => {
|
|
const { ledger } = await fixture();
|
|
try {
|
|
for (const invalid of [ { ...bound, calculationSha256: "missing" }, { ...bound, basis: "operating_allowance" },
|
|
{ ...bound, evidence: { ...evidence, scope: "current_day" } }, { ...bound, evidence: { ...evidence, evidenceSha256: "bad" } },
|
|
{ ...bound, evidence: { ...evidence, privateField: "not-allowed" } } ]) {
|
|
await expect(ledger.reserve("request", identity, "2", invalid as never)).rejects.toThrow();
|
|
}
|
|
expect(ledger.snapshot().requests).toHaveLength(0);
|
|
} finally { await ledger.close(); }
|
|
});
|
|
|
|
it("does not accept null as reviewed operating-bound evidence", async () => {
|
|
const { ledger, limits } = await fixture(true);
|
|
await ledger.close();
|
|
limits.overhead!.components[0]!.provisionalBound = null as never;
|
|
const directory = await mkdtemp(path.join(tmpdir(), "provisional-malformed-")); directories.push(directory);
|
|
await expect(openEvalCostLedger(path.join(directory, "ledger.jsonl"), limits)).rejects.toThrow("Invalid reviewed cost bound evidence");
|
|
});
|