mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02: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>
129 lines
9.6 KiB
TypeScript
129 lines
9.6 KiB
TypeScript
import { readEvalCostLedger, validEvalLedgerId } from "./cost-ledger.js";
|
|
import { usdToUnits } from "../../packages/shared/src/money.js";
|
|
import { html, isPublicReportTimestamp } from "./report-format.js";
|
|
|
|
const money = (n: bigint) => `${n / 1_000_000_000n}.${String(n % 1_000_000_000n).padStart(9, "0")}`;
|
|
interface EvalOperationsAvailableReport {
|
|
version: 1;
|
|
scope: "shared_gateway_ledger";
|
|
status?: never;
|
|
ledgerCampaignId: string;
|
|
journalHash: string;
|
|
records: number;
|
|
deadlineAt: string;
|
|
limitUsd: string;
|
|
knownUsd: string;
|
|
heldUsd: string;
|
|
committedUsd: string;
|
|
requests: number;
|
|
unresolvedRequests: number;
|
|
boundedUnresolvedRequests?: number;
|
|
conflictingRequests: number;
|
|
inflight: number;
|
|
breakdown: { reported: string; estimated: string; allocated: string; reconciled: string };
|
|
attempts: Array<{ executionId: string; attemptId: string; requests: number; unresolvedRequests: number; knownUsd: string; heldUsd: string }>;
|
|
}
|
|
export type EvalOperationsReport = EvalOperationsAvailableReport | {
|
|
version: 1; scope: "shared_gateway_ledger"; status: "unavailable";
|
|
};
|
|
|
|
export function validateEvalOperationsReport(report: EvalOperationsReport): void {
|
|
const fail = (): never => { throw new Error("Invalid retained eval operations report"); };
|
|
const fields = (value: unknown, allowed: string[]) => {
|
|
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some(key => !allowed.includes(key))) fail();
|
|
};
|
|
if (report?.status === "unavailable") {
|
|
fields(report, ["version", "scope", "status"]);
|
|
if (report.version !== 1 || report.scope !== "shared_gateway_ledger") fail();
|
|
return;
|
|
}
|
|
const dollars = (value: unknown): bigint => typeof value === "string" && /^(?:0|[1-9][0-9]*)\.[0-9]{9}$/.test(value) ? usdToUnits(value) : fail();
|
|
const count = (value: unknown) => { if (!Number.isSafeInteger(value) || Number(value) < 0) fail(); };
|
|
fields(report, ["version", "scope", "ledgerCampaignId", "journalHash", "records", "deadlineAt", "limitUsd", "knownUsd", "heldUsd", "committedUsd", "requests", "unresolvedRequests", "boundedUnresolvedRequests", "conflictingRequests", "inflight", "breakdown", "attempts"]);
|
|
if (report.version !== 1 || report.scope !== "shared_gateway_ledger" || !validEvalLedgerId(report.ledgerCampaignId)
|
|
|| !/^[a-f0-9]{64}$/.test(report.journalHash) || !isPublicReportTimestamp(report.deadlineAt) || !Array.isArray(report.attempts)) fail();
|
|
[report.records, report.requests, report.unresolvedRequests, report.conflictingRequests, report.inflight].forEach(count);
|
|
if (report.records < 1 || report.unresolvedRequests > report.requests || report.conflictingRequests > report.unresolvedRequests || report.inflight > report.requests) fail();
|
|
if (report.boundedUnresolvedRequests !== undefined) {
|
|
count(report.boundedUnresolvedRequests);
|
|
if (report.boundedUnresolvedRequests > report.unresolvedRequests - report.conflictingRequests) fail();
|
|
}
|
|
dollars(report.limitUsd);
|
|
const known = dollars(report.knownUsd), held = dollars(report.heldUsd);
|
|
if (known + held !== dollars(report.committedUsd)) fail();
|
|
fields(report.breakdown, ["reported", "estimated", "allocated", "reconciled"]);
|
|
if (["reported", "estimated", "allocated", "reconciled"].reduce((sum, key) => sum + dollars(report.breakdown[key as keyof typeof report.breakdown]), 0n) !== known) fail();
|
|
let requests = 0, unresolved = 0, rowKnown = 0n, rowHeld = 0n;
|
|
const seen = new Set<string>();
|
|
for (const attempt of report.attempts) {
|
|
fields(attempt, ["executionId", "attemptId", "requests", "unresolvedRequests", "knownUsd", "heldUsd"]);
|
|
if (!validEvalLedgerId(attempt.executionId) || !validEvalLedgerId(attempt.attemptId)) fail();
|
|
const key = JSON.stringify([attempt.executionId, attempt.attemptId]); if (seen.has(key)) fail(); seen.add(key);
|
|
count(attempt.requests); count(attempt.unresolvedRequests);
|
|
if (attempt.requests < 1 || attempt.unresolvedRequests > attempt.requests) fail();
|
|
requests += attempt.requests; unresolved += attempt.unresolvedRequests;
|
|
rowKnown += dollars(attempt.knownUsd); rowHeld += dollars(attempt.heldUsd);
|
|
}
|
|
if (requests !== report.requests || unresolved !== report.unresolvedRequests || rowKnown !== known || rowHeld !== held) fail();
|
|
}
|
|
|
|
export async function readEvalOperationsReport(file: string): Promise<EvalOperationsReport> {
|
|
try { return await readAvailableEvalOperationsReport(file); }
|
|
catch { return { version: 1, scope: "shared_gateway_ledger", status: "unavailable" }; }
|
|
}
|
|
|
|
async function readAvailableEvalOperationsReport(file: string): Promise<EvalOperationsAvailableReport> {
|
|
const loaded = await readEvalCostLedger(file), snapshot = loaded.state.snapshot();
|
|
const groups = new Map<string, EvalOperationsAvailableReport["attempts"][number]>();
|
|
for (const request of snapshot.requests) {
|
|
const key = JSON.stringify([request.executionId, request.attemptId]);
|
|
const group = groups.get(key) ?? { executionId: request.executionId, attemptId: request.attemptId,
|
|
requests: 0, unresolvedRequests: 0, knownUsd: money(0n), heldUsd: money(0n) };
|
|
const cost = usdToUnits(request.costUsd ?? "0"), reserved = usdToUnits(request.reservedUsd);
|
|
const held = (!request.finished || !request.receiptComplete) && reserved > cost ? reserved - cost : 0n;
|
|
group.requests++; if (!request.receiptComplete) group.unresolvedRequests++;
|
|
group.knownUsd = money(usdToUnits(group.knownUsd) + cost);
|
|
group.heldUsd = money(usdToUnits(group.heldUsd) + held);
|
|
groups.set(key, group);
|
|
}
|
|
const report: EvalOperationsAvailableReport = { version: 1, scope: "shared_gateway_ledger", ledgerCampaignId: snapshot.campaignId,
|
|
journalHash: loaded.previous, records: loaded.sequence, deadlineAt: new Date(loaded.state.limits.deadlineAt).toISOString(),
|
|
limitUsd: snapshot.limitUsd, knownUsd: snapshot.knownUsd, heldUsd: snapshot.heldUsd, committedUsd: snapshot.committedUsd,
|
|
requests: snapshot.requests.length, unresolvedRequests: snapshot.unknownRequests, boundedUnresolvedRequests: snapshot.boundedUnresolvedRequests, conflictingRequests: snapshot.conflictingRequests,
|
|
inflight: snapshot.inflight, breakdown: snapshot.breakdown as EvalOperationsAvailableReport["breakdown"], attempts: [...groups.values()] };
|
|
validateEvalOperationsReport(report);
|
|
return report;
|
|
}
|
|
|
|
export function evalAccountingStatus(report: EvalOperationsReport): "unavailable" | "blocked" | "provisional" | "receipts_complete" {
|
|
validateEvalOperationsReport(report);
|
|
if (report.status === "unavailable") return "unavailable";
|
|
if (report.conflictingRequests || report.inflight || usdToUnits(report.committedUsd) > usdToUnits(report.limitUsd)
|
|
|| report.unresolvedRequests !== (report.boundedUnresolvedRequests ?? 0)) return "blocked";
|
|
return report.unresolvedRequests ? "provisional" : "receipts_complete";
|
|
}
|
|
|
|
export function operationsReportText(report: EvalOperationsReport): string[] {
|
|
if (report.status === "unavailable") return [
|
|
"Request ledger accounting unavailable: the journal could not be read or validated. Request amounts, counts, and held reservations are unknown.",
|
|
"Retained test results are preserved. Recover and validate the original journal before resuming paid work; do not reset the allowance or treat missing amounts as zero.",
|
|
];
|
|
return [
|
|
`Accounting status: ${evalAccountingStatus(report)}. ${report.boundedUnresolvedRequests ?? 0} pending charges have reviewed bounds; ${report.unresolvedRequests - (report.boundedUnresolvedRequests ?? 0)} do not. Provisional completion preserves holds and requires later reconciliation; it does not establish invoice completeness.`,
|
|
`Shared request ledger ${report.ledgerCampaignId}: $${report.knownUsd} known subtotal; $${report.heldUsd} additional held reservations; $${report.committedUsd} committed against $${report.limitUsd}.`,
|
|
`Charges: $${report.breakdown.reconciled} reconciled; $${report.breakdown.reported} reported; $${report.breakdown.estimated} estimated; $${report.breakdown.allocated} allocated.`,
|
|
`${report.requests} recorded requests or overhead reservations; ${report.unresolvedRequests} unresolved; ${report.conflictingRequests} conflicting; ${report.inflight} in flight. Deadline ${report.deadlineAt}.`,
|
|
"This shared ledger includes earlier attempts and setup probes routed through the gateway, plus any declared campaign-overhead reservations. Do not add its model subtotal to heartbeat totals. Held reservations are not charges. Unrecorded infrastructure, storage, subscriptions and requests outside the gateway still need accounting; zero recorded requests does not prove zero cost.",
|
|
`Journal checkpoint: ${report.records} records; SHA-256 ${report.journalHash}.`,
|
|
];
|
|
}
|
|
|
|
export function renderEvalOperationsReport(report: EvalOperationsReport): string {
|
|
validateEvalOperationsReport(report);
|
|
if (report.status === "unavailable") return `<section aria-label="Request ledger accounting"><h2>Request ledger accounting unavailable</h2>${operationsReportText(report).map(line => `<p>${html(line)}</p>`).join("")}</section>`;
|
|
return `<section aria-label="Request ledger accounting"><h2>Request ledger accounting</h2>${operationsReportText(report).map(line => `<p>${html(line)}</p>`).join("")}
|
|
<table><thead><tr><th>Execution</th><th>Attempt or component</th><th>Requests / reservations</th><th>Known subtotal</th><th>Additional held</th><th>Unresolved</th></tr></thead><tbody>
|
|
${report.attempts.map(attempt => `<tr><td>${html(attempt.executionId)}</td><td>${html(attempt.attemptId)}</td><td>${attempt.requests}</td><td>$${html(attempt.knownUsd)}</td><td>$${html(attempt.heldUsd)}</td><td>${attempt.unresolvedRequests}</td></tr>`).join("")}
|
|
</tbody></table></section>`;
|
|
}
|