From b827836835324e12089ff32b98a8d2bf34001b66 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 2 Oct 2026 16:39:07 -0700 Subject: [PATCH] fix: checkpoint adapter usage and preserve unknown prices Co-Authored-By: Paperclip --- .../src/acpx-engine/execute.test.ts | 2 + .../adapter-utils/src/acpx-engine/execute.ts | 6 ++ packages/adapter-utils/src/billing.test.ts | 10 ++++ packages/adapter-utils/src/billing.ts | 12 +++- packages/adapter-utils/src/index.ts | 1 + packages/adapter-utils/src/types.ts | 35 ++++++++++- .../adapter-utils/src/usage-checkpoint.ts | 59 +++++++++++++++++++ .../claude-local/src/server/execute.ts | 26 +++++++- .../claude-local/src/server/parse.test.ts | 14 +++++ .../adapters/claude-local/src/server/parse.ts | 33 ++++++++++- .../codex-local/src/server/execute.ts | 14 ++++- .../codex-local/src/server/parse.test.ts | 4 +- .../adapters/codex-local/src/server/parse.ts | 3 +- .../cursor-local/src/server/execute.test.ts | 5 +- .../cursor-local/src/server/execute.ts | 17 +++++- .../adapters/cursor-local/src/server/parse.ts | 16 +++-- .../gemini-local/src/server/execute.ts | 14 ++++- .../gemini-local/src/server/parse.test.ts | 16 ++++- .../adapters/gemini-local/src/server/parse.ts | 34 +++++------ .../adapters/kimi-local/src/server/execute.ts | 7 +++ .../opencode-local/src/server/execute.ts | 15 ++++- .../opencode-local/src/server/parse.test.ts | 9 +++ .../opencode-local/src/server/parse.ts | 10 ++-- .../adapters/pi-local/src/server/execute.ts | 8 ++- .../pi-local/src/server/parse.test.ts | 9 +++ .../adapters/pi-local/src/server/parse.ts | 24 ++++---- .../adapter-cost-failure-paths.test.ts | 53 +++++++++++++++++ .../src/__tests__/codex-local-adapter.test.ts | 2 +- .../__tests__/cursor-local-adapter.test.ts | 9 +++ .../__tests__/gemini-local-adapter.test.ts | 2 +- 30 files changed, 411 insertions(+), 58 deletions(-) create mode 100644 packages/adapter-utils/src/usage-checkpoint.ts create mode 100644 server/src/__tests__/adapter-cost-failure-paths.test.ts diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index ffc724b4d9f..8eb21ecca13 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -7119,6 +7119,7 @@ describe("ACPX engine sandbox bridge run-disposition seam (fail-closed)", () => // The lost channel overrides the nominally completed terminal to a failure. expect(result.exitCode).not.toBe(0); expect(result.errorCode).toBe("duplex_channel_lost"); + expect(result.usageComplete).toBe(false); // The message carries only the typed loss reason, not raw provider text. expect(result.errorMessage).toContain("provider_exit"); expect(result.resultJson).toMatchObject({ status: "failed" }); @@ -7138,6 +7139,7 @@ describe("ACPX engine sandbox bridge run-disposition seam (fail-closed)", () => expect(result.exitCode).toBe(0); expect(result.errorCode ?? null).toBeNull(); + expect(result.usageComplete).toBe(true); // The atomic settle step marked the orderly completion for the // success-eligible terminal. expect(fake.settleRunDisposition).toHaveBeenCalledTimes(1); diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index a40d3a36f30..a9507912f13 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -4906,6 +4906,9 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { if (event.type === "status" && event.tag === "usage_update") { eventBreakdown = event.breakdown ?? eventBreakdown; eventCostUsd = usdCostAmount(event.cost) ?? eventCostUsd; + const checkpoint = summarizeAcpxTurnUsage({ preStatus: preTurnStatus, postStatus: null, eventBreakdown, eventCostUsd }); + await ctx.onUsage?.({ ...billingFields, usage: checkpoint.usage ?? undefined, costUsd: checkpoint.costUsd, + model: prepared.requestedModel, usageBasis: "per_run", complete: false }); } await emitRuntimeEvent(ctx, event, toolTitles, prepared.coalescePlaceholderToolUpdates); } @@ -4992,6 +4995,8 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { eventBreakdown, eventCostUsd, }); + await ctx.onUsage?.({ ...billingFields, usage: turnUsage.usage ?? undefined, costUsd: turnUsage.costUsd, + model: prepared.requestedModel, usageBasis: "per_run", complete: !channelLost }); const failedTurn = terminal.status === "failed" || terminal.status === "cancelled" || timedOut; // ACPX can defer session/load until runTurn. Forget an unavailable // session so the next bounded turn receives the full task conversation. @@ -5077,6 +5082,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { model: prepared.requestedModel || null, ...(turnUsage.usage ? { usage: turnUsage.usage, usageBasis: "per_run" as const } : {}), costUsd: turnUsage.costUsd, + usageComplete: !channelLost, resultJson: { status: channelLost ? "failed" : terminal.status, ...activityDiagnostics, diff --git a/packages/adapter-utils/src/billing.test.ts b/packages/adapter-utils/src/billing.test.ts index 1b85340f8f7..76e147c0deb 100644 --- a/packages/adapter-utils/src/billing.test.ts +++ b/packages/adapter-utils/src/billing.test.ts @@ -25,4 +25,14 @@ describe("inferOpenAiCompatibleBiller", () => { ), ).toBe("openai"); }); + + it.each(["https://proxy.example/v1", "https://openrouter.ai.example/v1", "https://example.org/openrouter.ai", "http://api.openai.com/v1", "invalid"])("does not apply direct provider prices to %s", (baseUrl) => { + expect(inferOpenAiCompatibleBiller({ OPENAI_BASE_URL: baseUrl }, "openai")).toBe("unknown"); + }); + + it("recognizes each supported base URL override", () => { + expect(inferOpenAiCompatibleBiller({ OPENAI_API_BASE: "https://proxy.example" })).toBe("unknown"); + expect(inferOpenAiCompatibleBiller({ OPENAI_API_BASE_URL: "https://proxy.example" })).toBe("unknown"); + expect(inferOpenAiCompatibleBiller({}, null)).toBeNull(); + }); }); diff --git a/packages/adapter-utils/src/billing.ts b/packages/adapter-utils/src/billing.ts index 3ecd8ec3594..b57ab8666ad 100644 --- a/packages/adapter-utils/src/billing.ts +++ b/packages/adapter-utils/src/billing.ts @@ -14,7 +14,17 @@ export function inferOpenAiCompatibleBiller( readEnv(env, "OPENAI_BASE_URL") ?? readEnv(env, "OPENAI_API_BASE") ?? readEnv(env, "OPENAI_API_BASE_URL"); - if (baseUrl && /openrouter\.ai/i.test(baseUrl)) return "openrouter"; + if (baseUrl) { + try { + const url = new URL(baseUrl); + if (url.protocol === "https:" && url.hostname === "openrouter.ai") return "openrouter"; + // An OpenAI-compatible endpoint does not imply OpenAI prices. Do not + // assign direct-provider estimates to a proxy or an unknown endpoint. + if (url.protocol !== "https:" || url.hostname !== "api.openai.com") return "unknown"; + } catch { + return "unknown"; + } + } return fallback; } diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index eb02fb4efc4..c14eebfa25e 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -2,6 +2,7 @@ export type { AdapterAgent, AdapterRuntime, UsageSummary, + AdapterUsageCheckpoint, AdapterBillingType, AdapterRuntimeServiceReport, AdapterExecutionResult, diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index c40da05f918..d8107d3224c 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -30,9 +30,32 @@ export interface AdapterRuntime { // --------------------------------------------------------------------------- export interface UsageSummary { + /** Uncached input plus cache writes; excludes cache reads. Total tokens are input + cachedInput + output. */ inputTokens: number; outputTokens: number; cachedInputTokens?: number; + /** Subset of inputTokens, not an additional token count. */ + cacheWriteTokens?: number; +} + +/** Accounting-only snapshot. Never include prompt, response, or credentials. */ +export interface AdapterUsageCheckpoint { + attemptId?: string; + usage?: UsageSummary; + usageByModel?: Array<{ model: string; usage: UsageSummary; costUsd: number }>; + usageBasis?: "per_run" | "session_cumulative" | null; + provider?: string | null; + biller?: string | null; + model?: string | null; + billingType?: AdapterBillingType | null; + costUsd?: number | null; + costUsdExact?: string | null; + costStatus?: "reported" | "estimated" | "unpriced"; + pricingContext?: { serviceTier?: string; contextTier?: "short" | "long" }; + pricingProvenance?: { source: "provider_reported" | "provider_invoice" | "operator" | "rate_card" | "unknown"; version?: string; evidence?: string; inputCentsPerMillion?: string; cachedInputCentsPerMillion?: string; cacheWriteCentsPerMillion?: string; outputCentsPerMillion?: string; serviceTier?: string; contextTier?: "short" | "long" }; + cacheAdjustedCostUsd?: number | null; + providerRequestId?: string | null; + complete: boolean; } export type AdapterBillingType = @@ -92,11 +115,14 @@ export interface AdapterExecutionResult { retryNotBefore?: string | null; errorMeta?: Record; usage?: UsageSummary; + /** Complete per-model receipts, when supplied by the runtime. Their sums must match the run totals. */ + usageByModel?: Array<{ model: string; usage: UsageSummary; costUsd: number }>; /** * How `usage` totals are scoped. "per_run" means the tokens cover only this * execution; "session_cumulative" means they are running totals for the * persisted session, and the server must delta consecutive runs. Absent - * means unknown — the server applies its legacy session-delta heuristic. + * defaults to per-run; the server must never infer cumulative usage from a + * reused session ID. */ usageBasis?: "per_run" | "session_cumulative" | null; /** @@ -110,6 +136,12 @@ export interface AdapterExecutionResult { model?: string | null; billingType?: AdapterBillingType | null; costUsd?: number | null; + costUsdExact?: string | null; + costStatus?: "reported" | "estimated" | "unpriced"; + pricingContext?: { serviceTier?: string; contextTier?: "short" | "long" }; + pricingProvenance?: { source: "provider_reported" | "provider_invoice" | "operator" | "rate_card" | "unknown"; version?: string; evidence?: string; inputCentsPerMillion?: string; cachedInputCentsPerMillion?: string; cacheWriteCentsPerMillion?: string; outputCentsPerMillion?: string; serviceTier?: string; contextTier?: "short" | "long" }; + providerRequestId?: string | null; + usageComplete?: boolean; /** * Provider-billed cost after prompt-cache discounts. Adapters should set * this when they expose it separately; otherwise the server treats a @@ -229,6 +261,7 @@ export interface AdapterExecutionContext { onLog: (stream: "stdout" | "stderr", chunk: string) => Promise; onMeta?: (meta: AdapterInvocationMeta) => Promise; onEvent?: (event: AdapterRuntimeEvent) => Promise; + onUsage?: (receipt: AdapterUsageCheckpoint) => Promise; onRuntimeProgress?: RuntimeStatusSink; /** * Reports that execution has crossed the adapter's dispatch boundary. diff --git a/packages/adapter-utils/src/usage-checkpoint.ts b/packages/adapter-utils/src/usage-checkpoint.ts new file mode 100644 index 00000000000..39b05c68a5f --- /dev/null +++ b/packages/adapter-utils/src/usage-checkpoint.ts @@ -0,0 +1,59 @@ +import type { AdapterUsageCheckpoint } from "./types.js"; + +/** Project accounting protocol fields before retaining a stream snapshot. + * Content-bearing strings are never retained in the accounting buffer. */ +const fields = new Set([ + "type", "subtype", "role", "id", "session_id", "sessionID", "thread_id", "model", "modelID", "providerID", + "usage", "usageMetadata", "modelUsage", "message", "part", "info", "stats", "tokens", "cost", "total", + "input", "output", "cache", "read", "write", "input_tokens", "output_tokens", "cached_input_tokens", + "cache_read_input_tokens", "cache_creation_input_tokens", "inputTokens", "outputTokens", "cacheReadInputTokens", + "cacheCreationInputTokens", "cachedInputTokens", "promptTokenCount", "candidatesTokenCount", "cachedContentTokenCount", + "reasoning", "cacheRead", "cacheWrite", "prompt", "candidates", "totalTokenCount", "total_tokens", "toolUsePromptTokenCount", "messages", + "thoughtsTokenCount", "inputTokens", "totalTokens", "cached", "thoughts", "total_cost_usd", "cost_usd", "costUSD", +]); +const stringFields = new Set(["type", "subtype", "role", "id", "session_id", "sessionID", "thread_id", "model", "modelID", "providerID"]); +function project(value: unknown, depth = 0, field = ""): unknown { + if (depth > 8) return undefined; + if (typeof value === "number" || typeof value === "boolean" || value === null) return value; + if (typeof value === "string") return stringFields.has(field) ? value.slice(0, 250) : undefined; + if (Array.isArray(value)) return value.slice(0, 500).map(item => project(item, depth + 1)); + if (!value || typeof value !== "object") return undefined; + return Object.fromEntries(Object.entries(value).filter(([key]) => fields.has(key)) + .map(([key, entry]) => [key, key === "modelUsage" && entry && typeof entry === "object" + ? Object.fromEntries(Object.entries(entry).map(([model, usage]) => [model.slice(0, 250), project(usage, depth + 1)])) + : project(entry, depth + 1, key)])); +} + +/** A fresh instance belongs to one CLI attempt. Retries must not concatenate + * cumulative usage from different attempts. Callback durability is awaited + * before ordinary log publication. */ +export function createUsageCheckpointLog( + onLog: (stream: "stdout" | "stderr", chunk: string) => Promise, + onUsage: ((receipt: AdapterUsageCheckpoint) => Promise) | undefined, + parse: (stdout: string) => AdapterUsageCheckpoint | null, +) { + if (!onUsage) return onLog; + const attemptId = randomUUID(); + let remainder = "", accounting = "", previous = ""; + return async (stream: "stdout" | "stderr", chunk: string) => { + if (stream === "stdout") { + remainder += chunk; + const lines = remainder.split("\n"); remainder = lines.pop() ?? ""; + if (remainder.length > 8 * 1024 * 1024) throw new Error("Accounting protocol line exceeds 8 MiB"); + for (const line of lines) { + let raw: unknown; + try { raw = JSON.parse(line); } catch { continue; } + const compact = JSON.stringify(project(raw)); + if (!compact || !/usage|tokens|cost|"result"|"turn.completed"|"agent_end"/.test(compact)) continue; + accounting += compact + "\n"; + if (accounting.length > 8 * 1024 * 1024) throw new Error("Accounting checkpoint history exceeds 8 MiB"); + const receipt = parse(accounting); + if (!receipt) continue; + const serialized = JSON.stringify(receipt); + if (serialized !== previous) { await onUsage({ ...receipt, attemptId }); previous = serialized; } + } + } + await onLog(stream, chunk); + }; +} +import { randomUUID } from "node:crypto"; diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 114cad4fa43..378917ecadb 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -1,4 +1,5 @@ import { createProviderStoppedBoundary } from "@paperclipai/adapter-utils/provider-stopped-boundary"; +import { createUsageCheckpointLog } from "@paperclipai/adapter-utils/usage-checkpoint"; import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -63,6 +64,7 @@ import { } from "@paperclipai/adapter-utils/local-process-sandbox"; import { claudeModelUsageTotals, + claudeModelReceipts, parseClaudeStreamJson, describeClaudeFailure, detectClaudeLoginRequired, @@ -971,7 +973,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + const parsed = parseClaudeStreamJson(stdout); + return { usage: parsed.usage ?? undefined, usageBasis: "per_run", costUsd: parsed.costUsd, + provider: "anthropic", biller: isBedrockAuth(effectiveEnv) ? "aws_bedrock" : "anthropic", + billingType, model: parsed.model || model, complete: parsed.resultJson !== null }; + }), runLogTail: paperclipBridge?.runLogTail, settleRunDisposition: paperclipBridge?.settleRunDisposition, terminalResultCleanup: { @@ -1012,6 +1019,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise 1 ? "mixed" : parsedStream.model || asString(parsed.model, model), billingType, costUsd: parsedStream.costUsd, resultJson: mergedResultJson, diff --git a/packages/adapters/claude-local/src/server/parse.test.ts b/packages/adapters/claude-local/src/server/parse.test.ts index 5b291d4056e..b84651f051d 100644 --- a/packages/adapters/claude-local/src/server/parse.test.ts +++ b/packages/adapters/claude-local/src/server/parse.test.ts @@ -567,3 +567,17 @@ describe("parseClaudeStreamJson usage extraction", () => { expect(parsed.usageBasis).toBe("per_run"); }); }); + +describe("interrupted Claude accounting", () => { + it("retains partial usage and replaces repeated message snapshots", () => { + const events = [ + { type: "assistant", message: { id: "m1", usage: { input_tokens: 10, cache_creation_input_tokens: 20, cache_read_input_tokens: 40, output_tokens: 3 } } }, + { type: "assistant", message: { id: "m1", usage: { input_tokens: 10, cache_creation_input_tokens: 20, cache_read_input_tokens: 40, output_tokens: 7 } } }, + { type: "assistant", message: { id: "m2", usage: { input_tokens: 5, output_tokens: 2 } } }, + ]; + const parsed = parseClaudeStreamJson(events.map((event) => JSON.stringify(event)).join("\n")); + expect(parsed.usage).toEqual({ inputTokens: 35, cachedInputTokens: 40, outputTokens: 9 }); + expect(parsed.usageBasis).toBe("per_run"); + expect(parsed.costUsd).toBeNull(); + }); +}); diff --git a/packages/adapters/claude-local/src/server/parse.ts b/packages/adapters/claude-local/src/server/parse.ts index 3e636ac2cfa..86955687cc4 100644 --- a/packages/adapters/claude-local/src/server/parse.ts +++ b/packages/adapters/claude-local/src/server/parse.ts @@ -54,11 +54,25 @@ export function claudeModelUsageTotals(modelUsage: unknown): UsageSummary | null return { inputTokens, outputTokens, cachedInputTokens }; } +export function claudeModelReceipts(modelUsage: unknown) { + const entries = Object.entries(parseObject(modelUsage)); + if (!entries.length) return undefined; + const receipts = entries.map(([model, raw]) => { + const entry = parseObject(raw); + const usage = claudeModelUsageTotals({ [model]: entry }); + const costUsd = entry.costUSD; + return usage && typeof costUsd === "number" && Number.isFinite(costUsd) && costUsd >= 0 ? { model, usage, costUsd } : null; + }); + return receipts.every((part) => part !== null) ? receipts : undefined; +} + export function parseClaudeStreamJson(stdout: string) { let sessionId: string | null = null; let model = ""; let finalResult: Record | null = null; const assistantTexts: string[] = []; + const messageUsage = new Map(); + let anonymousMessage = 0; for (const rawLine of stdout.split(/\r?\n/)) { const line = rawLine.trim(); @@ -76,6 +90,16 @@ export function parseClaudeStreamJson(stdout: string) { if (type === "assistant") { sessionId = asString(event.session_id, sessionId ?? "") || sessionId; const message = parseObject(event.message); + const observed = parseObject(message.usage); + if (Object.keys(observed).length > 0) { + const key = asString(message.id, "") || `anonymous:${anonymousMessage++}`; + const previous = messageUsage.get(key); + messageUsage.set(key, { + inputTokens: Math.max(previous?.inputTokens ?? 0, asNumber(observed.input_tokens, 0) + asNumber(observed.cache_creation_input_tokens, 0)), + cachedInputTokens: Math.max(previous?.cachedInputTokens ?? 0, asNumber(observed.cache_read_input_tokens, 0)), + outputTokens: Math.max(previous?.outputTokens ?? 0, asNumber(observed.output_tokens, 0)), + }); + } const content = Array.isArray(message.content) ? message.content : []; for (const entry of content) { if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue; @@ -99,8 +123,11 @@ export function parseClaudeStreamJson(stdout: string) { sessionId, model, costUsd: null as number | null, - usage: null as UsageSummary | null, - usageBasis: null as "per_run" | null, + usage: messageUsage.size ? [...messageUsage.values()].reduce((total, part) => ({ + inputTokens: total.inputTokens + part.inputTokens, outputTokens: total.outputTokens + part.outputTokens, + cachedInputTokens: (total.cachedInputTokens ?? 0) + (part.cachedInputTokens ?? 0), + }), { inputTokens: 0, outputTokens: 0, cachedInputTokens: 0 }) : null, + usageBasis: "per_run" as const, summary: assistantTexts.join("\n\n").trim(), resultJson: null as Record | null, }; @@ -109,7 +136,7 @@ export function parseClaudeStreamJson(stdout: string) { const modelUsageTotals = claudeModelUsageTotals(finalResult.modelUsage); const usageObj = parseObject(finalResult.usage); const usage: UsageSummary = modelUsageTotals ?? { - inputTokens: asNumber(usageObj.input_tokens, 0), + inputTokens: asNumber(usageObj.input_tokens, 0) + asNumber(usageObj.cache_creation_input_tokens, 0), cachedInputTokens: asNumber(usageObj.cache_read_input_tokens, 0), outputTokens: asNumber(usageObj.output_tokens, 0), }; diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index fe2fa38b588..a302bdbba86 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -1,4 +1,5 @@ import { createProviderStoppedBoundary } from "@paperclipai/adapter-utils/provider-stopped-boundary"; +import { createUsageCheckpointLog } from "@paperclipai/adapter-utils/usage-checkpoint"; import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -1318,6 +1319,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + const parsed = parseCodexJsonl(stdout); + return { usage: parsed.usage, usageBasis: "per_run", provider: "openai", biller: resolveCodexBiller(effectiveEnv, billingType), billingType, model, costUsd: null, complete: parsed.sawProtocolTerminalEvent }; + }); try { const proc = await runAdapterExecutionTargetProcess(runId, runtimeExecutionTarget, command, args, { onProcessStopped: providerStop.beginInvocation(), @@ -1331,7 +1336,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { monitor?.noteOutputChunk(stream, chunk); if (stream === "stdout") { - await onLog(stream, chunk); + await accountingLog(stream, chunk); return; } const cleaned = stripCodexRolloutNoise(chunk); @@ -1423,6 +1428,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise { sessionId: "thread_123", summary: "Recovered response", usage: { - inputTokens: 10, + inputTokens: 8, cachedInputTokens: 2, outputTokens: 4, }, @@ -64,7 +64,7 @@ describe("parseCodexJsonl", () => { sessionId: "thread_123", summary: "Fixed the issue and verified the targeted tests pass.", usage: { - inputTokens: 10, + inputTokens: 8, cachedInputTokens: 2, outputTokens: 4, }, diff --git a/packages/adapters/codex-local/src/server/parse.ts b/packages/adapters/codex-local/src/server/parse.ts index 7698977f409..d0bdfad092b 100644 --- a/packages/adapters/codex-local/src/server/parse.ts +++ b/packages/adapters/codex-local/src/server/parse.ts @@ -89,7 +89,8 @@ export function parseCodexJsonl(stdout: string) { return { sessionId, summary: finalMessage?.trim() ?? "", - usage, + // Codex includes cache hits in input_tokens; Paperclip stores them separately. + usage: { ...usage, inputTokens: Math.max(0, usage.inputTokens - usage.cachedInputTokens) }, usageBasis: "per_run" as const, errorMessage, sawProtocolEvent, diff --git a/packages/adapters/cursor-local/src/server/execute.test.ts b/packages/adapters/cursor-local/src/server/execute.test.ts index 913d34cdc40..6aea2414d3d 100644 --- a/packages/adapters/cursor-local/src/server/execute.test.ts +++ b/packages/adapters/cursor-local/src/server/execute.test.ts @@ -288,7 +288,7 @@ printf '%s\\n' '{"type":"result","subtype":"success","session_id":"cursor-sessio finalPreparedCommand = preferredCommandPath; const runtimeEnv = { ...input.env, - PATH: `${path.join(systemHomeDir, ".local", "bin")}${path.delimiter}${input.env.PATH}`, + PATH: `${path.join(systemHomeDir, ".local", "bin")}${path.delimiter}${input.env.PATH ?? process.env.PATH ?? "/usr/bin:/bin"}`, }; await fs.mkdir(path.dirname(preferredCommandPath), { recursive: true }); await fs.writeFile(preferredCommandPath, preferredAgentScript); @@ -312,13 +312,14 @@ printf '%s\\n' '{"type":"result","subtype":"success","session_id":"cursor-sessio // shell seam faithful to that protocol instead of returning empty stdout // for every shell command. const runner = { - execute: async (input: { command: string; args?: string[]; env?: Record }) => { + execute: async (input: { command: string; args?: string[]; env?: Record; stdin?: string }) => { runnerState.commands.push(input.command); // Exercise actual bounded file reads during managed-home restoration; // reporting empty success for every shell command hides missing bytes. return runChildProcess(`cursor-fresh-lease-${runnerState.commands.length}`, input.command, input.args ?? [], { cwd: remoteWorkspace, env: { ...input.env, PATH: `${input.env?.PATH ?? ""}:/usr/bin:/bin` }, + stdin: input.stdin, timeoutSec: 30, graceSec: 5, onLog: async () => {}, diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index cfaa9949a9e..55c5c0c5e37 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -1,4 +1,5 @@ import { createProviderStoppedBoundary } from "@paperclipai/adapter-utils/provider-stopped-boundary"; +import { createUsageCheckpointLog } from "@paperclipai/adapter-utils/usage-checkpoint"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -626,11 +627,16 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + const parsed = parseCursorJsonl(stdout); + const provider = resolveProviderFromModel(model); + return { usage: parsed.usage, costUsd: parsed.costUsd, usageBasis: "per_run", provider, biller: resolveCursorBiller(effectiveEnv, billingType, provider), billingType, model, complete: false }; + }); let stdoutLineBuffer = ""; const emitNormalizedStdoutLine = async (rawLine: string) => { const normalized = normalizeCursorStreamLine(rawLine); if (!normalized.line) return; - await onLog(normalized.stream ?? "stdout", `${normalized.line}\n`); + await accountingLog(normalized.stream ?? "stdout", `${normalized.line}\n`); }; const flushStdoutChunk = async (chunk: string, finalize = false) => { const combined = `${stdoutLineBuffer}${chunk}`; @@ -698,6 +704,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + if (typeof value === "number" && Number.isFinite(value) && value >= 0) { + totalCostUsd += value; + reportedCost = true; + } else missingCost = true; + }; const usage = { inputTokens: 0, cachedInputTokens: 0, @@ -102,7 +110,7 @@ export function parseCursorJsonl(stdout: string) { usageObj.output_tokens, asNumber(usageObj.outputTokens, 0), ); - totalCostUsd += asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, 0))); + addCost(event.total_cost_usd ?? event.cost_usd ?? event.cost); const isError = event.is_error === true || asString(event.subtype, "").toLowerCase() === "error"; const resultText = asString(event.result, "").trim(); @@ -143,10 +151,10 @@ export function parseCursorJsonl(stdout: string) { const part = parseObject(event.part); const tokens = parseObject(part.tokens); const cache = parseObject(tokens.cache); - usage.inputTokens += asNumber(tokens.input, 0); + usage.inputTokens += asNumber(tokens.input, 0) + asNumber(cache.write, 0); usage.cachedInputTokens += asNumber(cache.read, 0); usage.outputTokens += asNumber(tokens.output, 0); - totalCostUsd += asNumber(part.cost, 0); + addCost(part.cost); continue; } } @@ -155,7 +163,7 @@ export function parseCursorJsonl(stdout: string) { sessionId, summary: messages.join("\n\n").trim(), usage, - costUsd: totalCostUsd > 0 ? totalCostUsd : null, + costUsd: reportedCost && !missingCost ? totalCostUsd : null, errorMessage, }; } diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index 720c384d433..1ba97244a2d 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -1,4 +1,5 @@ import { createProviderStoppedBoundary } from "@paperclipai/adapter-utils/provider-stopped-boundary"; +import { createUsageCheckpointLog } from "@paperclipai/adapter-utils/usage-checkpoint"; import fs from "node:fs/promises"; import type { Dirent } from "node:fs"; import os from "node:os"; @@ -650,7 +651,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + const parsed = parseGeminiJsonl(stdout); + return { usage: parsed.usage, costUsd: parsed.costUsd, usageBasis: "per_run", provider: "google", biller: "google", billingType, model, complete: parsed.resultEvent !== null }; + }), runLogTail: paperclipBridge?.runLogTail, settleRunDisposition: paperclipBridge?.settleRunDisposition, }); @@ -687,6 +691,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise { expect(result.summary).toBe("hello."); expect(result.sessionId).toBe("session-abc"); expect(result.errorMessage).toBeNull(); - expect(result.usage.inputTokens).toBe(9095); - expect(result.usage.outputTokens).toBe(29); + expect(result.usage.inputTokens).toBe(963); + expect(result.usage.outputTokens).toBe(373); expect(result.usage.cachedInputTokens).toBe(8132); }); @@ -213,3 +213,15 @@ describe("isGeminiTransientNetworkError", () => { ).toBe(false); }); }); + + +describe("Gemini accounting semantics", () => { + it("keeps cache reads disjoint and includes thinking tokens in API metadata", () => { + const parsed = parseGeminiJsonl(JSON.stringify({ type: "result", usageMetadata: { promptTokenCount: 100, cachedContentTokenCount: 80, candidatesTokenCount: 10, thoughtsTokenCount: 5, totalTokenCount: 115 } })); + expect(parsed.usage).toEqual({ inputTokens: 20, cachedInputTokens: 80, outputTokens: 15 }); + }); + it("preserves explicit zero and distinguishes absent prices", () => { + expect(parseGeminiJsonl(JSON.stringify({ type: "result", total_cost_usd: 0 })).costUsd).toBe(0); + expect(parseGeminiJsonl(JSON.stringify({ type: "result" })).costUsd).toBeNull(); + }); +}); diff --git a/packages/adapters/gemini-local/src/server/parse.ts b/packages/adapters/gemini-local/src/server/parse.ts index 008f6522d05..a2f68326cc1 100644 --- a/packages/adapters/gemini-local/src/server/parse.ts +++ b/packages/adapters/gemini-local/src/server/parse.ts @@ -58,21 +58,19 @@ function accumulateUsage( const usageMetadata = parseObject(usage.usageMetadata); const source = Object.keys(usageMetadata).length > 0 ? usageMetadata : usage; - target.inputTokens += asNumber( - source.input_tokens, - asNumber(source.inputTokens, asNumber(source.promptTokenCount, 0)), - ); - target.cachedInputTokens += asNumber( - source.cached_input_tokens, - asNumber( - source.cachedInputTokens, - asNumber(source.cachedContentTokenCount, asNumber(source.cached, 0)), - ), - ); - target.outputTokens += asNumber( - source.output_tokens, - asNumber(source.outputTokens, asNumber(source.candidatesTokenCount, 0)), - ); + // Gemini CLI's input_tokens is the full prompt; input is uncached input. + // https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/output/stream-json-formatter.ts + const cached = asNumber(source.cached_input_tokens, + asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount, asNumber(source.cached, 0)))); + const prompt = asNumber(source.input_tokens, asNumber(source.inputTokens, asNumber(source.promptTokenCount, 0))); + const toolInput = asNumber(source.toolUsePromptTokenCount, 0); + target.inputTokens += Math.max(0, asNumber(source.input, prompt - cached)) + toolInput; + target.cachedInputTokens += cached; + // The streaming CLI omits a separate thought count, but includes it in total_tokens. + const response = asNumber(source.output_tokens, asNumber(source.outputTokens, asNumber(source.candidatesTokenCount, 0))); + const total = asNumber(source.total_tokens, asNumber(source.totalTokenCount, 0)); + target.outputTokens += Math.max(response + asNumber(source.thoughtsTokenCount, 0), total - prompt - toolInput); + } export function parseGeminiJsonl(stdout: string) { @@ -145,7 +143,8 @@ export function parseGeminiJsonl(stdout: string) { asString(event.text, "").trim() || asString(event.response, "").trim(); if (resultText && messages.length === 0) messages.push(resultText); - costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd; + const reportedCost = [event.total_cost_usd, event.cost_usd, event.cost].find((value) => typeof value === "number" && Number.isFinite(value) && value >= 0); + if (typeof reportedCost === "number") costUsd = reportedCost; const status = asString(event.status, "").toLowerCase(); const isError = event.is_error === true || @@ -183,7 +182,8 @@ export function parseGeminiJsonl(stdout: string) { if (type === "step_finish" || event.usage || event.usageMetadata) { accumulateUsage(usage, event.usage ?? event.usageMetadata); - costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd; + const reportedCost = [event.total_cost_usd, event.cost_usd, event.cost].find((value) => typeof value === "number" && Number.isFinite(value) && value >= 0); + if (typeof reportedCost === "number") costUsd = reportedCost; continue; } } diff --git a/packages/adapters/kimi-local/src/server/execute.ts b/packages/adapters/kimi-local/src/server/execute.ts index b02ca0c02da..f60fd8d5993 100644 --- a/packages/adapters/kimi-local/src/server/execute.ts +++ b/packages/adapters/kimi-local/src/server/execute.ts @@ -648,6 +648,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + const parsed = parseOpenCodeJsonl(stdout); + const provider = parseModelProvider(model || null); + return { usage: parsed.usage, costUsd: parsed.costUsd, usageBasis: "per_run", provider, biller: resolveOpenCodeBiller(runtimeEnv, provider), billingType: "unknown", model, complete: false }; + }), runLogTail: paperclipBridge?.runLogTail, settleRunDisposition: paperclipBridge?.settleRunDisposition, }); @@ -678,6 +683,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise { expect(isOpenCodeUnknownSessionError("all good", "")).toBe(false); }); }); + +describe("OpenCode price availability", () => { + it("distinguishes missing cost from reported zero and counts cache writes", () => { + const event = { type: "step_finish", part: { tokens: { input: 10, output: 3, cache: { read: 100, write: 20 } } } }; + expect(parseOpenCodeJsonl(JSON.stringify(event))).toMatchObject({ costUsd: null, usage: { inputTokens: 30, cachedInputTokens: 100, outputTokens: 3 } }); + expect(parseOpenCodeJsonl(JSON.stringify({ ...event, part: { ...event.part, cost: 0 } })).costUsd).toBe(0); + expect(parseOpenCodeJsonl([event, { ...event, part: { ...event.part, cost: 1 } }].map((row) => JSON.stringify(row)).join("\n")).costUsd).toBeNull(); + }); +}); diff --git a/packages/adapters/opencode-local/src/server/parse.ts b/packages/adapters/opencode-local/src/server/parse.ts index ee7fd41279b..ee02f3c4211 100644 --- a/packages/adapters/opencode-local/src/server/parse.ts +++ b/packages/adapters/opencode-local/src/server/parse.ts @@ -29,7 +29,8 @@ export function parseOpenCodeJsonl(stdout: string) { cachedInputTokens: 0, outputTokens: 0, }; - let costUsd = 0; + let costUsd: number | null = null; + let missingCost = false; for (const rawLine of stdout.split(/\r?\n/)) { const line = rawLine.trim(); @@ -54,10 +55,11 @@ export function parseOpenCodeJsonl(stdout: string) { const part = parseObject(event.part); const tokens = parseObject(part.tokens); const cache = parseObject(tokens.cache); - usage.inputTokens += asNumber(tokens.input, 0); + usage.inputTokens += asNumber(tokens.input, 0) + asNumber(cache.write, 0); usage.cachedInputTokens += asNumber(cache.read, 0); usage.outputTokens += asNumber(tokens.output, 0) + asNumber(tokens.reasoning, 0); - costUsd += asNumber(part.cost, 0); + if (typeof part.cost === "number" && Number.isFinite(part.cost) && part.cost >= 0) costUsd = (costUsd ?? 0) + part.cost; + else missingCost = true; continue; } @@ -82,7 +84,7 @@ export function parseOpenCodeJsonl(stdout: string) { sessionId, summary: messages.join("\n\n").trim(), usage, - costUsd, + costUsd: missingCost ? null : costUsd, errorMessage: errors.length > 0 ? errors.join("\n") : null, toolErrors, }; diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index 3149642b323..19ed60ae277 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -1,4 +1,5 @@ import { createProviderStoppedBoundary } from "@paperclipai/adapter-utils/provider-stopped-boundary"; +import { createUsageCheckpointLog } from "@paperclipai/adapter-utils/usage-checkpoint"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -744,7 +745,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + const parsed = parsePiJsonl(stdout); + return { usage: parsed.usage, costUsd: parsed.usage.costUsd, usageBasis: "per_run", provider, biller: resolvePiBiller(runtimeEnv, provider), billingType: "unknown", model, complete: false }; + }), runLogTail: paperclipBridge?.runLogTail, settleRunDisposition: paperclipBridge?.settleRunDisposition, }); @@ -774,6 +778,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { .toEqual(["Pi provider request failed."]); }); }); + +describe("Pi price availability", () => { + it("retains unpriced usage and counts cache writes without treating a missing price as free", () => { + const event = { type: "turn_end", message: { role: "assistant", content: [], usage: { input: 10, output: 3, cacheRead: 100, cacheWrite: 20 } } }; + expect(parsePiJsonl(JSON.stringify(event)).usage).toEqual({ inputTokens: 30, cachedInputTokens: 100, outputTokens: 3, costUsd: null }); + event.message.usage = { ...event.message.usage, cost: { total: 0 } } as typeof event.message.usage; + expect(parsePiJsonl(JSON.stringify(event)).usage.costUsd).toBe(0); + }); +}); diff --git a/packages/adapters/pi-local/src/server/parse.ts b/packages/adapters/pi-local/src/server/parse.ts index b4d769e1596..406623c075e 100644 --- a/packages/adapters/pi-local/src/server/parse.ts +++ b/packages/adapters/pi-local/src/server/parse.ts @@ -8,7 +8,7 @@ interface ParsedPiOutput { inputTokens: number; outputTokens: number; cachedInputTokens: number; - costUsd: number; + costUsd: number | null; }; finalMessage: string | null; toolCalls: Array<{ toolCallId: string; toolName: string; args: unknown; result: string | null; isError: boolean }>; @@ -37,12 +37,17 @@ export function parsePiJsonl(stdout: string): ParsedPiOutput { inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, - costUsd: 0, + costUsd: null, }, finalMessage: null, toolCalls: [], }; + let missingCost = false; + function addCost(value: unknown) { + if (typeof value === "number" && Number.isFinite(value) && value >= 0) result.usage.costUsd = (result.usage.costUsd ?? 0) + value; + else missingCost = true; + } let currentToolCall: { toolCallId: string; toolName: string; args: unknown } | null = null; for (const rawLine of stdout.split(/\r?\n/)) { @@ -117,15 +122,13 @@ export function parsePiJsonl(stdout: string): ParsedPiOutput { // Extract usage and cost from assistant message const usage = asRecord(message.usage); if (usage) { - result.usage.inputTokens += asNumber(usage.input, 0); + result.usage.inputTokens += asNumber(usage.input, 0) + asNumber(usage.cacheWrite, 0); result.usage.outputTokens += asNumber(usage.output, 0); result.usage.cachedInputTokens += asNumber(usage.cacheRead, 0); // Pi stores cost in usage.cost.total (and broken down in usage.cost.input, etc.) const cost = asRecord(usage.cost); - if (cost) { - result.usage.costUsd += asNumber(cost.total, 0); - } + addCost(cost?.total); } } @@ -213,21 +216,18 @@ export function parsePiJsonl(stdout: string): ParsedPiOutput { const usage = asRecord(event.usage); if (usage) { // Support both Pi format (input/output/cacheRead) and generic format (inputTokens/outputTokens/cachedInputTokens) - result.usage.inputTokens += asNumber(usage.inputTokens ?? usage.input, 0); + result.usage.inputTokens += asNumber(usage.inputTokens ?? usage.input, 0) + asNumber(usage.cacheWrite, 0); result.usage.outputTokens += asNumber(usage.outputTokens ?? usage.output, 0); result.usage.cachedInputTokens += asNumber(usage.cachedInputTokens ?? usage.cacheRead, 0); // Cost may be in usage.costUsd (direct) or usage.cost.total (Pi format) const cost = asRecord(usage.cost); - if (cost) { - result.usage.costUsd += asNumber(cost.total ?? usage.costUsd, 0); - } else { - result.usage.costUsd += asNumber(usage.costUsd, 0); - } + addCost(cost?.total ?? usage.costUsd); } } } + if (missingCost) result.usage.costUsd = null; return result; } diff --git a/server/src/__tests__/adapter-cost-failure-paths.test.ts b/server/src/__tests__/adapter-cost-failure-paths.test.ts new file mode 100644 index 00000000000..6355240efbb --- /dev/null +++ b/server/src/__tests__/adapter-cost-failure-paths.test.ts @@ -0,0 +1,53 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; + +const processResult = vi.hoisted(() => vi.fn()); +vi.mock("@paperclipai/adapter-utils/execution-target", async (original) => ({ + ...await original(), + runAdapterExecutionTargetProcess: processResult, +})); + +type Execute = (context: AdapterExecutionContext) => Promise; +const cases: Array<{ name: string; execute: () => Promise; event: unknown; tokens?: { inputTokens: number; outputTokens: number; cachedInputTokens: number }; price: number | null }> = [ + { name: "codex", execute: async () => (await import("../../../packages/adapters/codex-local/src/server/execute.js")).execute, + event: { type: "turn.completed", usage: { input_tokens: 120, cached_input_tokens: 100, output_tokens: 10 } }, tokens: { inputTokens: 20, cachedInputTokens: 100, outputTokens: 10 }, price: null }, + { name: "claude", execute: async () => (await import("../../../packages/adapters/claude-local/src/server/execute.js")).execute, + event: { type: "assistant", message: { id: "m1", usage: { input_tokens: 20, cache_read_input_tokens: 100, output_tokens: 10 }, content: [] } }, tokens: { inputTokens: 20, cachedInputTokens: 100, outputTokens: 10 }, price: null }, + { name: "opencode", execute: async () => (await import("../../../packages/adapters/opencode-local/src/server/execute.js")).execute, + event: { type: "step_finish", part: { tokens: { input: 20, output: 10, cache: { read: 100 } }, cost: 0.004 } }, tokens: { inputTokens: 20, cachedInputTokens: 100, outputTokens: 10 }, price: 0.004 }, + { name: "pi", execute: async () => (await import("../../../packages/adapters/pi-local/src/server/execute.js")).execute, + event: { type: "turn_end", message: { role: "assistant", content: [], usage: { input: 20, output: 10, cacheRead: 100, cost: { total: 0.004 } } } }, tokens: { inputTokens: 20, cachedInputTokens: 100, outputTokens: 10 }, price: 0.004 }, + { name: "cursor", execute: async () => (await import("../../../packages/adapters/cursor-local/src/server/execute.js")).execute, + event: { type: "result", usage: { input_tokens: 20, cache_read_input_tokens: 100, output_tokens: 10 }, total_cost_usd: 0.004 }, tokens: { inputTokens: 20, cachedInputTokens: 100, outputTokens: 10 }, price: 0.004 }, + { name: "gemini", execute: async () => (await import("../../../packages/adapters/gemini-local/src/server/execute.js")).execute, + event: { type: "result", usage: { input_tokens: 120, cached_input_tokens: 100, output_tokens: 10 }, total_cost_usd: 0.004 }, tokens: { inputTokens: 20, cachedInputTokens: 100, outputTokens: 10 }, price: 0.004 }, + { name: "kimi", execute: async () => (await import("../../../packages/adapters/kimi-local/src/server/execute.js")).execute, + event: { type: "assistant", content: "partial work" }, price: null }, +]; + +describe("CLI adapter accounting on timeout", () => { + const directories: string[] = []; + afterEach(async () => { vi.clearAllMocks(); for (const dir of directories.splice(0)) await rm(dir, { recursive: true, force: true }); }); + it.each(cases)("$name retains observed accounting when the process times out", async (fixture) => { + const dir = await mkdtemp(join(tmpdir(), "paperclip-accounting-adapter-")); directories.push(dir); + const command = join(dir, "runtime"); await writeFile(command, "#!/bin/sh\nprintf 'openai test\\n'\n", { mode: 0o755 }); + processResult.mockResolvedValue({ exitCode: null, signal: "SIGTERM", timedOut: true, stdout: JSON.stringify(fixture.event), stderr: "", pid: 123, startedAt: new Date().toISOString() }); + const execute = await fixture.execute(); + const result = await execute({ + runId: "test-run", agent: { id: "test-agent", companyId: "test-company", name: "Accounting", adapterType: `${fixture.name}_local`, adapterConfig: {} }, + runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { engine: "cli", command, cwd: dir, model: ["opencode", "pi"].includes(fixture.name) ? "openai/test" : fixture.name === "cursor" ? "gpt-5" : "test", paperclipRuntimeSkills: [], + env: { OPENAI_API_KEY: "test-placeholder", ANTHROPIC_API_KEY: "test-placeholder", GEMINI_API_KEY: "test-placeholder", MOONSHOT_API_KEY: "test-placeholder", OPENCODE_ALLOW_ALL_MODELS: "1", CLAUDE_CONFIG_DIR: dir } }, + context: {}, onLog: async () => {}, + }); + expect(result.timedOut).toBe(true); + expect(result.usageBasis).toBe("per_run"); + expect(result.provider).toBeTruthy(); + expect(result.billingType).toBeTruthy(); + if (fixture.tokens) expect(result.usage).toMatchObject(fixture.tokens); + expect(result.costUsd).toBe(fixture.price); + }); +}); diff --git a/server/src/__tests__/codex-local-adapter.test.ts b/server/src/__tests__/codex-local-adapter.test.ts index 84c806cd7a0..d1eb49ff817 100644 --- a/server/src/__tests__/codex-local-adapter.test.ts +++ b/server/src/__tests__/codex-local-adapter.test.ts @@ -16,7 +16,7 @@ describe("codex_local parser", () => { expect(parsed.sessionId).toBe("thread-123"); expect(parsed.summary).toBe("hello"); expect(parsed.usage).toEqual({ - inputTokens: 10, + inputTokens: 8, cachedInputTokens: 2, outputTokens: 4, }); diff --git a/server/src/__tests__/cursor-local-adapter.test.ts b/server/src/__tests__/cursor-local-adapter.test.ts index a1949af19c3..808acad3ba8 100644 --- a/server/src/__tests__/cursor-local-adapter.test.ts +++ b/server/src/__tests__/cursor-local-adapter.test.ts @@ -403,3 +403,12 @@ describe("cursor cli formatter", () => { } }); }); + + +describe("Cursor price completeness", () => { + it("preserves reported zero and keeps a partially priced stream unpriced", () => { + expect(parseCursorJsonl(JSON.stringify({ type: "result", cost_usd: 0 })).costUsd).toBe(0); + expect(parseCursorJsonl(JSON.stringify({ type: "result" })).costUsd).toBeNull(); + expect(parseCursorJsonl([JSON.stringify({ type: "step_finish", part: { cost: 1 } }), JSON.stringify({ type: "step_finish", part: {} })].join("\n")).costUsd).toBeNull(); + }); +}); diff --git a/server/src/__tests__/gemini-local-adapter.test.ts b/server/src/__tests__/gemini-local-adapter.test.ts index bc2d4643d8b..860605516ce 100644 --- a/server/src/__tests__/gemini-local-adapter.test.ts +++ b/server/src/__tests__/gemini-local-adapter.test.ts @@ -34,7 +34,7 @@ describe("gemini_local parser", () => { expect(parsed.sessionId).toBe("gemini-session-1"); expect(parsed.summary).toBe("hello"); expect(parsed.usage).toEqual({ - inputTokens: 12, + inputTokens: 9, cachedInputTokens: 3, outputTokens: 7, });