mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
fix(native): preserve accounting and settled approval continuations
Retain shutdown usage during bounded governed-wait cleanup. Estimate complete direct Claude API receipts with explicit rate provenance. Reject stale approval waits bound to a resolved tool action across final dispositions. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
b315580640
commit
3856902a3c
11 files changed
+206
-18
No files matched your search
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AdapterUsageCheckpoint } from "@paperclipai/adapter-utils";
|
||||
import { priceAnthropicReceipt } from "./anthropic-pricing.js";
|
||||
|
||||
const receipt: AdapterUsageCheckpoint = { complete: true, usageBasis: "per_run", provider: "anthropic", biller: "anthropic",
|
||||
billingType: "metered_api", model: "claude-sonnet-5", costUsd: null, costStatus: "unpriced",
|
||||
usage: { inputTokens: 22462, cachedInputTokens: 208218, cacheWriteTokens: 22450, outputTokens: 2450 } };
|
||||
describe("complete Anthropic receipt pricing", () => {
|
||||
it("prices complete counters conservatively and labels their provenance as an estimate", () => {
|
||||
const priced = priceAnthropicReceipt(receipt);
|
||||
expect(priced.costUsdExact).toBe("0.155967600");
|
||||
expect(priced.costStatus).toBe("estimated");
|
||||
expect(priced.pricingProvenance).toMatchObject({ source: "rate_card", version: "anthropic-standard-2026-10-07" });
|
||||
expect(priced.pricingProvenance?.evidence).toContain("one-hour upper rate");
|
||||
expect(priced.usage).toBe(receipt.usage);
|
||||
expect(receipt.costStatus).toBe("unpriced");
|
||||
});
|
||||
it.each([
|
||||
{ complete: false }, { billingType: "unknown" }, { billingType: "subscription_included" },
|
||||
{ provider: "unknown" }, { biller: "aws_bedrock" }, { model: "claude-sonnet-latest" },
|
||||
{ usageBasis: "session_cumulative" }, { costUsd: 0.12 }, { costUsdExact: "0.12" }, { cacheAdjustedCostUsd: 0.12 },
|
||||
{ pricingContext: { serviceTier: "priority" } }, { usageByModel: [{ model: "other", usage: { inputTokens: 1, outputTokens: 1 }, costUsd: 1 }] },
|
||||
{ usage: undefined }, { usage: { inputTokens: 1, outputTokens: 1 } },
|
||||
{ usage: { inputTokens: 1, outputTokens: 1, cacheWriteTokens: 2, cachedInputTokens: 0 } },
|
||||
] as Partial<AdapterUsageCheckpoint>[])("preserves incomplete, incompatible or already priced receipts: %j", override => {
|
||||
const original = { ...receipt, ...override };
|
||||
expect(priceAnthropicReceipt(original)).toBe(original);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { centsToUsd, unitsToCents, usdToUnits } from "@paperclipai/shared";
|
||||
import type { AdapterUsageCheckpoint } from "@paperclipai/adapter-utils";
|
||||
|
||||
// Direct Anthropic API list prices, verified 2026-10-07:
|
||||
// https://platform.claude.com/docs/en/about-claude/pricing
|
||||
// Keep this bounded to the qualified model; aliases and other billers stay unknown.
|
||||
const RATES = ["2", "0.2", "4", "10"] as const;
|
||||
|
||||
/** A complete token receipt can receive an explicit estimate, never an invoice
|
||||
* claim. ACP aggregates cache writes without TTL; conservatively price them at
|
||||
* the documented one-hour rate rather than silently assume five-minute writes. */
|
||||
export function priceAnthropicReceipt(receipt: AdapterUsageCheckpoint): AdapterUsageCheckpoint {
|
||||
if (!receipt.complete || !receipt.usage || receipt.usageBasis !== "per_run"
|
||||
|| receipt.provider !== "anthropic" || receipt.biller !== "anthropic"
|
||||
|| !["api", "metered_api"].includes(receipt.billingType ?? "")
|
||||
|| receipt.model !== "claude-sonnet-5" || receipt.usageByModel?.length
|
||||
|| receipt.costUsd != null || receipt.costUsdExact != null || receipt.cacheAdjustedCostUsd != null
|
||||
|| (receipt.pricingContext?.serviceTier && !["standard", "default"].includes(receipt.pricingContext.serviceTier))) return receipt;
|
||||
const usage = receipt.usage;
|
||||
if (usage.cacheWriteTokens === undefined || usage.cachedInputTokens === undefined) return receipt;
|
||||
const counts = [usage.inputTokens - usage.cacheWriteTokens, usage.cachedInputTokens, usage.cacheWriteTokens, usage.outputTokens];
|
||||
if (counts.some(count => !Number.isSafeInteger(count) || count < 0)) return receipt;
|
||||
const numerator = counts.reduce((total, count, index) => total + BigInt(count) * usdToUnits(RATES[index]), 0n);
|
||||
return { ...receipt, costUsdExact: centsToUsd(unitsToCents((numerator + 500_000n) / 1_000_000n)), costStatus: "estimated",
|
||||
pricingProvenance: {
|
||||
source: "rate_card", version: "anthropic-standard-2026-10-07",
|
||||
evidence: "https://platform.claude.com/docs/en/about-claude/pricing; standard global API pricing assumed; cache-write TTL unavailable, one-hour upper rate used; not an invoice",
|
||||
inputCentsPerMillion: "200", cachedInputCentsPerMillion: "20", cacheWriteCentsPerMillion: "400", outputCentsPerMillion: "1000", serviceTier: "standard",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -54,6 +54,32 @@ describe("native final-response feedback", () => {
|
||||
await db.update(issueThreadInteractions).set({ sourceRunId: null }).where(eq(issueThreadInteractions.id, interactionId));
|
||||
await expect(nativeCompletionFeedback(db, value.runId, waiting)).resolves.toContain("Completion report accepted");
|
||||
});
|
||||
it.each(["blocked", "needs_review", "yielded"] as const)("rejects a %s approval report bound to an already declined invocation", async (disposition) => {
|
||||
const value = await fixture(), interactionId = randomUUID(), invocationId = randomUUID();
|
||||
await db.insert(issueThreadInteractions).values({ id: interactionId, companyId: value.companyId, issueId: value.issueId,
|
||||
sourceRunId: value.runId, createdByAgentId: value.agentId, kind: "request_confirmation", status: "rejected",
|
||||
continuationPolicy: "wake_assignee", result: { version: 1, outcome: "rejected" },
|
||||
payload: { version: 1, prompt: "Approve service read", toolAction: { version: 1, actionRequestId: randomUUID(), invocationId, toolName: "pages.read", toolDisplayName: "Read pages", connectionId: null, applicationId: null, appDisplayName: null, risk: "read", previewMarkdown: "Read pages", argumentsSummaryJson: "{}", argumentsHash: "test-hash", expiresAt: "2026-10-07T12:00:00Z" } },
|
||||
});
|
||||
// The provider can name its exact invocation in the summary without citing
|
||||
// an interaction evidence ref, as in the retained OpenCode failure.
|
||||
const report: PrpStructuredRunResult = { ...done, reportedWorkDisposition: disposition,
|
||||
summary: disposition === "blocked" ? "Waiting on the page-service approval." : `Waiting on the page-service approval (invocationId ${invocationId}).`,
|
||||
...(disposition === "blocked" ? { blocker: { reasonCode: "approval_required", owner: { kind: "user" as const, name: "Release Owner" }, scope: "task_wide" as const, unblockAction: `Resolve invocation ${invocationId}` } } : {}),
|
||||
completionClaim: { ...done.completionClaim, objectiveSatisfied: false, remainingWork: [{ description: "Wait for the decision", blocksCompletion: true }] },
|
||||
attentionRequests: [{ kind: "approval", ownerClass: "human", summary: "Approve the page-service call" }],
|
||||
...(disposition === "yielded" ? { continuation: { kind: "response_wake" as const, idempotencyKey: "wait", summary: "Wait for approval" } } : {}),
|
||||
};
|
||||
await expect(nativeCompletionFeedback(db, value.runId, report)).rejects.toThrow("already resolved");
|
||||
// Completed-action evidence is not the target of an independent review.
|
||||
if (disposition !== "yielded") await expect(nativeCompletionFeedback(db, value.runId, {
|
||||
...report, blocker: undefined, summary: "Review a separate deliverable", evidence: [{ ref: `interaction:${interactionId}` }],
|
||||
})).resolves.toContain("report accepted");
|
||||
// Neither a similar description nor another run's card is identity proof.
|
||||
await expect(nativeCompletionFeedback(db, value.runId, { ...report, blocker: undefined, summary: "Wait for a separate approval" })).resolves.toContain("report accepted");
|
||||
await db.update(issueThreadInteractions).set({ sourceRunId: null }).where(eq(issueThreadInteractions.id, interactionId));
|
||||
await expect(nativeCompletionFeedback(db, value.runId, report)).resolves.toContain("report accepted");
|
||||
});
|
||||
it.each(["accepted", "rejected"] as const)("allows a new question after a %s tool approval cited as completed evidence", async (status) => {
|
||||
const value = await fixture(), interactionId = randomUUID(), questionId = randomUUID();
|
||||
await db.insert(issueThreadInteractions).values({ id: interactionId, companyId: value.companyId, issueId: value.issueId,
|
||||
|
||||
@@ -168,21 +168,28 @@ export async function nativeCompletionFeedback(
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]),
|
||||
]);
|
||||
// A response wake for this run's tool action already has a durable approval
|
||||
// surface. Reject a repeated approval request before it becomes a new review.
|
||||
// General evidence can also cite completed actions while waiting on something
|
||||
// else, so a resolved card alone is not a stale wait target.
|
||||
if (result.reportedWorkDisposition === "yielded" && result.continuation?.kind === "response_wake") {
|
||||
const referencedIds = result.evidence.flatMap(({ ref }) => {
|
||||
const match = typeof ref === "string" ? /^interaction:([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i.exec(ref) : null;
|
||||
return match ? [match[1]!] : [];
|
||||
});
|
||||
const referenced = referencedIds.length ? await db.select().from(issueThreadInteractions).where(and(
|
||||
// Match an existing action by its exact durable identity, regardless of how
|
||||
// the provider labels its final disposition. A summary can cite the invocation
|
||||
// ID instead of an interaction evidence ref; prose similarity is not identity.
|
||||
if (signals.actionableAttentionRequests.some(request => request.kind === "approval" && request.ownerClass === "human")) {
|
||||
const approvalTarget = {
|
||||
summary: result.summary,
|
||||
attentionRequests: signals.actionableAttentionRequests.filter(request => request.kind === "approval" && request.ownerClass === "human"),
|
||||
blocker: result.blocker,
|
||||
continuation: result.continuation,
|
||||
// The existing response-wake contract also names its card through evidence.
|
||||
evidence: result.reportedWorkDisposition === "yielded" && result.continuation?.kind === "response_wake" ? result.evidence : [],
|
||||
};
|
||||
const referencedIds = new Set(JSON.stringify(approvalTarget).match(/\b[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\b/gi)?.map(id => id.toLowerCase()) ?? []);
|
||||
const cards = referencedIds.size ? await db.select().from(issueThreadInteractions).where(and(
|
||||
eq(issueThreadInteractions.companyId, run.companyId), eq(issueThreadInteractions.issueId, issue.id),
|
||||
eq(issueThreadInteractions.sourceRunId, run.id), eq(issueThreadInteractions.createdByAgentId, run.agentId),
|
||||
eq(issueThreadInteractions.kind, "request_confirmation"), eq(issueThreadInteractions.continuationPolicy, "wake_assignee"),
|
||||
inArray(issueThreadInteractions.id, referencedIds),
|
||||
)) : [];
|
||||
const referenced = cards.filter(card => {
|
||||
const action = record(record(card.payload).toolAction);
|
||||
return [card.id, action.actionRequestId, action.invocationId].some(id => typeof id === "string" && referencedIds.has(id.toLowerCase()));
|
||||
});
|
||||
for (const card of referenced) {
|
||||
const action = record(record(card.payload).toolAction);
|
||||
if (action.version !== 1 || typeof action.actionRequestId !== "string") continue;
|
||||
|
||||
@@ -5239,6 +5239,23 @@ describe("native startup restart detachment", () => {
|
||||
});
|
||||
|
||||
describe("native terminal-turn accounting", () => {
|
||||
it.each([false, true])("prices only complete direct Claude API turn accounting (%s)", async complete => {
|
||||
const { rehydrateRunnerdUsageNotification } = await import("@paperclipai/paperclip-runner/live");
|
||||
const { priceAnthropicReceipt } = await import("../anthropic-pricing.js");
|
||||
const onUsage = vi.fn(async (_receipt: import("@paperclipai/adapter-utils").AdapterUsageCheckpoint) => {});
|
||||
const counts = { inputTokens: 12, outputTokens: 4, cacheReadTokens: 30, cacheWriteTokens: 20, providerCostUsd: 0 };
|
||||
state.execute.mockReset().mockImplementationOnce(async () => {
|
||||
const notification = rehydrateRunnerdUsageNotification({ provider: "acpx", cumulative: { ...counts, providerCostUsd: 10 }, runDelta: counts, runDeltaAvailable: complete }, "session", "turn");
|
||||
await accountingEvents.committed!({ eventType: "item.completed", turnId: "turn", payload: { kind: "usage", usage: notification.tokenUsage } } as unknown as PrpEvent);
|
||||
await accountingEvents.committed!({ eventType: "turn.interrupted", turnId: "turn", payload: {} } as unknown as PrpEvent);
|
||||
return { result: { summary: "Waiting for approval" }, terminal: { runTerminalState: "succeeded" }, turnId: "turn", normalizedSessionId: "session", providerSessionId: null, driverKind: "acpx_runtime", driverVersion: "1", nativeEventCount: 2, highestContiguousSourceSeq: 2, usage: null };
|
||||
});
|
||||
await executePaperclipNativeSession({ db: leaseDb(), execution: { ...execution, provider: { kind: "acpx", agent: "claude", model: "claude-sonnet-5" } } as NativeExecutionInput, runnerInstanceId: "runner", runnerEnvironment: { ANTHROPIC_API_KEY: "fixture" }, onUsage });
|
||||
const receipt = onUsage.mock.calls.at(-1)![0];
|
||||
const priced = priceAnthropicReceipt(receipt);
|
||||
if (complete) expect(priced).toMatchObject({ complete: true, costStatus: "estimated", costUsdExact: "0.000150000" });
|
||||
else expect(priced).toMatchObject({ complete: false, costStatus: "unpriced", costUsd: null });
|
||||
});
|
||||
it.each(["claude", "codex"].flatMap(agent => [false, true].flatMap(restart =>
|
||||
[false, true].flatMap(partialFirst => [false, true].map(zeroFirst => ({ agent, restart, partialFirst, zeroFirst }))),
|
||||
)))("accumulates ACPX turns for $agent (restart: $restart, partial: $partialFirst, zero first: $zeroFirst)", async ({ agent, restart, partialFirst, zeroFirst }) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { priceAnthropicReceipt } from "./anthropic-pricing.js";
|
||||
import { priceCodexReceipt } from "./codex-pricing.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
@@ -237,8 +238,8 @@ export async function createRunUsageRecorder(db: Db, input: { companyId: string;
|
||||
const attempt = parsed.attemptId ?? currentAttempt;
|
||||
if (!attempts.has(attempt)) currentAttempt = attempt;
|
||||
const previous = attempts.get(attempt);
|
||||
const priced = priceCodexReceipt({ ...parsed, usage: parsed.usage ?? previous?.usage,
|
||||
complete: parsed.complete && !(previous?.usage && !parsed.usage) });
|
||||
const priced = priceAnthropicReceipt(priceCodexReceipt({ ...parsed, usage: parsed.usage ?? previous?.usage,
|
||||
complete: parsed.complete && !(previous?.usage && !parsed.usage) }));
|
||||
priced.pricingProvenance ??= {
|
||||
source: priced.costUsd != null || priced.costUsdExact != null || priced.cacheAdjustedCostUsd != null ? "provider_reported" : "unknown",
|
||||
version: "accounting-receipt/v1",
|
||||
|
||||
Reference in new issue
Block a user