From 3856902a3c483753dc0e52e778ddea4dc3516982 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 7 Oct 2026 07:55:41 -0500 Subject: [PATCH] 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 --- doc/SPEC-implementation.md | 11 ++++++ .../backends/harness-driver-backend.test.ts | 21 ++++++++++ .../src/backends/harness-driver-backend.ts | 5 ++- .../src/native-session-runtime.test.ts | 11 ++++-- .../src/native-session-runtime.ts | 39 ++++++++++++++++++- server/src/services/anthropic-pricing.test.ts | 29 ++++++++++++++ server/src/services/anthropic-pricing.ts | 31 +++++++++++++++ .../native-completion-feedback.test.ts | 26 +++++++++++++ .../native-completion-feedback.ts | 29 ++++++++------ .../native-session-executor.test.ts | 17 ++++++++ server/src/services/usage-receipts.ts | 5 ++- 11 files changed, 206 insertions(+), 18 deletions(-) create mode 100644 server/src/services/anthropic-pricing.test.ts create mode 100644 server/src/services/anthropic-pricing.ts diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 5fc5c9a361d..cd56f4460ba 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -1527,6 +1527,17 @@ for contracts, recovery behavior, Storybook, and acceptance workflows. Board may override by raising budget or explicitly resuming agent. +Native runs retain final usage receipts during a bounded accounting-only drain +after a governed wait cancels provider work. This does not accept late messages, +tool calls, or new completion proposals. Missing or incomplete receipts continue +to block budget admission. + +Complete direct Anthropic API receipts for `claude-sonnet-5` can use a versioned +list-price estimate when the provider supplies no run price. The receipt records +the rates and assumptions. Aggregate cache writes use the one-hour rate because +their TTL is unknown. Estimates are not invoices. Other models, billers, unknown +billing types, and incomplete receipts remain unpriced. + ## 13.3 Cost Event Ingestion `POST /companies/:companyId/cost-events` body: diff --git a/packages/paperclip-runner/src/backends/harness-driver-backend.test.ts b/packages/paperclip-runner/src/backends/harness-driver-backend.test.ts index d550d594d64..6d6cc07ec33 100644 --- a/packages/paperclip-runner/src/backends/harness-driver-backend.test.ts +++ b/packages/paperclip-runner/src/backends/harness-driver-backend.test.ts @@ -974,6 +974,27 @@ describe("HarnessDriverBackend", () => { await expect(iterator.next()).rejects.toThrow("provider transport lost after resolution"); }); + it("retains final usage after cancellation without accepting late output", async () => { + class AccountingAfterCancelSession extends FakeHarnessSession { + async interrupt() {} + override async *events() { + yield prpEvent(1, "item.completed", { kind: "agentMessage", text: "late answer" }); + yield prpEvent(2, "item.completed", { kind: "usage", usage: { runDelta: { inputTokens: 7, outputTokens: 3 }, runDeltaComplete: true } }); + yield prpEvent(3, "turn.completed", { status: "completed" }); + } + } + const backend = new HarnessDriverBackend({ ...driver, async openSession() { return new AccountingAfterCancelSession(); } }); + const session = await backend.openSession({ + identity: { runId: "run-1", sessionId: "session-1", companyId: "company-1", issueId: "issue-1", agentId: "agent-1" }, workingDirectory: "/workspace", + }); + await session.cancel({ reason: "durable governed wait", signal: new AbortController().signal }).cleanup; + const events = []; + for await (const event of session.events()) events.push(event); + expect(events.filter(event => event.payload.kind === "agentMessage")).toHaveLength(0); + expect(events.find(event => event.payload.kind === "usage")?.payload.usage).toMatchObject({ runDelta: { inputTokens: 7, outputTokens: 3 } }); + expect(events.some(event => event.eventType === "turn.cancelled")).toBe(true); + }); + it("does not start provider work when Stop arrives before the first turn", async () => { const provider = new FakeHarnessSession(); const start = vi.spyOn(provider, "startTurn"); diff --git a/packages/paperclip-runner/src/backends/harness-driver-backend.ts b/packages/paperclip-runner/src/backends/harness-driver-backend.ts index bf5496c097e..e5c072d0085 100644 --- a/packages/paperclip-runner/src/backends/harness-driver-backend.ts +++ b/packages/paperclip-runner/src/backends/harness-driver-backend.ts @@ -549,7 +549,10 @@ class HarnessNativeSession implements NativeSession { (event.eventType === "run.terminal" && ["failed", "cancelled"].includes(String(event.payload.runTerminalState))) || (event.eventType === "item.completed" && event.payload.kind === "interrupt_acknowledgement"); - if (this.#explicitlyCancelled && !isCancellationEvent) continue; + // Accounting for work already performed survives cancellation. It grants + // no tool, message, semantic-result, or continuation authority. + const isUsageReceipt = event.eventType === "item.completed" && event.payload.kind === "usage"; + if (this.#explicitlyCancelled && !isCancellationEvent && !isUsageReceipt) continue; sourceInstanceId = event.sourceInstanceId; lastSourceSequence = Math.max(lastSourceSequence, event.sourceSeq); if (event.eventType === "runtime_request.created") { diff --git a/packages/paperclip-runner/src/native-session-runtime.test.ts b/packages/paperclip-runner/src/native-session-runtime.test.ts index db0344becc7..1d6aa5a0c0f 100644 --- a/packages/paperclip-runner/src/native-session-runtime.test.ts +++ b/packages/paperclip-runner/src/native-session-runtime.test.ts @@ -7641,7 +7641,7 @@ describe("executeNativeSession recovery", () => { ]); }); - it("parks a provider turn immediately after a durable governed wait appears", async () => { + it("parks immediately but retains shutdown usage before finalizing a governed wait", async () => { const yielded: PrpStructuredRunResult = { schema: "paperclip.run_result.v1", reportedWorkDisposition: "yielded", @@ -7681,8 +7681,8 @@ describe("executeNativeSession recovery", () => { turnId: "turn-waiting", }; const turnInterrupted: PrpEvent = { - ...controlEvent(2, "turn.interrupted", { reason: "governed_wait" }), - sourceEventId: "provider-recovery:2", + ...controlEvent(3, "turn.interrupted", { reason: "governed_wait" }), + sourceEventId: "provider-recovery:3", sourceInstanceId: "provider-recovery", sourceKind: "provider", turnId: "turn-waiting", @@ -7711,6 +7711,8 @@ describe("executeNativeSession recovery", () => { async *events() { yield itemCompleted; await cancelled; + yield { ...itemCompleted, sourceSeq: 2, sourceEventId: "provider-recovery:2", + payload: { kind: "usage", usage: { runDelta: { inputTokens: 7, outputTokens: 3 }, runDeltaComplete: true } } }; yield turnInterrupted; }, async startTurn() { @@ -7789,6 +7791,7 @@ describe("executeNativeSession recovery", () => { }); expect(cancel).toHaveBeenCalledOnce(); + expect(events.find(event => event.payload.kind === "usage")?.payload.usage).toMatchObject({ runDelta: { inputTokens: 7, outputTokens: 3 } }); expect(completed).toMatchObject({ result: yielded, terminal: { @@ -7799,6 +7802,8 @@ describe("executeNativeSession recovery", () => { }); expect(events.map((event) => event.eventType)).toEqual([ "item.completed", + "item.completed", + "turn.interrupted", "run.result.accepted", "run.terminal", ]); diff --git a/packages/paperclip-runner/src/native-session-runtime.ts b/packages/paperclip-runner/src/native-session-runtime.ts index df65c4450ab..93e0d81545f 100644 --- a/packages/paperclip-runner/src/native-session-runtime.ts +++ b/packages/paperclip-runner/src/native-session-runtime.ts @@ -922,7 +922,7 @@ async function consumeTurn( let latestSessionGoal: HarnessThreadGoal | null = null; let resultSource: "semantic_result" | "governed_wait" | null = null; let providerFailure: NativeProviderTerminalFailure | null = null; - const settleDurableResult = ( + const settleDurableResult = async ( event: PrpEvent, result: PrpStructuredRunResult, reason: string, @@ -940,6 +940,43 @@ async function consumeTurn( void cleanup .catch(() => quarantineSession("governed_cleanup_failed")) .finally(() => governedCleanupOperations.delete(cleanup)); + // Cancellation revokes provider work immediately, but shutdown can still + // supply its final usage. Keep a bounded accounting-only drain before + // acknowledging the run; otherwise the next wake sees permanent debt. + // Never let a stuck stream or cleanup revoke the durable governed wait. + const drainAbort = new AbortController(); + const stopDrain = () => drainAbort.abort(appendAbort.signal.reason); + appendAbort.signal.addEventListener("abort", stopDrain, { once: true }); + const drainMs = Math.min(5_000, timeoutMs > 0 ? Math.max(1, Math.floor(timeoutMs / 4)) : 5_000); + let drainTimer: ReturnType | undefined; + const drained = (async () => { + while (!drainAbort.signal.aborted) { + const next = await eventIterator.next(); + if (next.done || drainAbort.signal.aborted) return; + const trailing = next.value; + const terminal = isTurnTerminal(trailing); + const usage = trailing.eventType === "item.completed" && trailing.payload.kind === "usage"; + if (trailing.turnId === event.turnId && (usage || terminal)) { + const receipt = await controlPlane.appendEvent(trailing, { signal: drainAbort.signal }); + if (drainAbort.signal.aborted) return; + eventCount += receipt.disposition === "committed" ? 1 : 0; + highestContiguousSourceSeq = Math.max(highestContiguousSourceSeq, receipt.highestContiguousSourceSeq); + if (terminal) return; + } + } + })(); + await Promise.race([ + drained.catch(() => quarantineSession("governed_accounting_drain_failed")), + new Promise(resolve => { drainTimer = setTimeout(() => { + drainAbort.abort(new Error("governed accounting drain timed out")); + quarantineSession("governed_accounting_drain_timed_out"); + resolve(); + }, drainMs); }), + ]).finally(() => { + clearTimeout(drainTimer); + appendAbort.signal.removeEventListener("abort", stopDrain); + drainAbort.abort(); + }); return { event, eventCount, diff --git a/server/src/services/anthropic-pricing.test.ts b/server/src/services/anthropic-pricing.test.ts new file mode 100644 index 00000000000..760b18d2eef --- /dev/null +++ b/server/src/services/anthropic-pricing.test.ts @@ -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[])("preserves incomplete, incompatible or already priced receipts: %j", override => { + const original = { ...receipt, ...override }; + expect(priceAnthropicReceipt(original)).toBe(original); + }); +}); diff --git a/server/src/services/anthropic-pricing.ts b/server/src/services/anthropic-pricing.ts new file mode 100644 index 00000000000..6a9d3cfdae3 --- /dev/null +++ b/server/src/services/anthropic-pricing.ts @@ -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", + }, + }; +} diff --git a/server/src/services/native-runtime/native-completion-feedback.test.ts b/server/src/services/native-runtime/native-completion-feedback.test.ts index e52702dd173..7d043c93208 100644 --- a/server/src/services/native-runtime/native-completion-feedback.test.ts +++ b/server/src/services/native-runtime/native-completion-feedback.test.ts @@ -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, diff --git a/server/src/services/native-runtime/native-completion-feedback.ts b/server/src/services/native-runtime/native-completion-feedback.ts index 92df948c82c..de374de1166 100644 --- a/server/src/services/native-runtime/native-completion-feedback.ts +++ b/server/src/services/native-runtime/native-completion-feedback.ts @@ -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; diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index ed6f95e1e65..8b838e3fd30 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -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 }) => { diff --git a/server/src/services/usage-receipts.ts b/server/src/services/usage-receipts.ts index 7d02f0c047c..3606cd3a0ce 100644 --- a/server/src/services/usage-receipts.ts +++ b/server/src/services/usage-receipts.ts @@ -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",