From 71fd67ab7795cf35a47038ef7accf51f499bd82f Mon Sep 17 00:00:00 2001 From: Resham Joshi <65915470+iamtoruk@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:15:35 -0700 Subject: [PATCH] fix(warp): price conversations from total_billed_cost_in_cents Warp 0.2026.10 removed total_provider_cost_in_cents and now persists total_billed_cost_in_cents. Read it after total_charged_usage, as Warp does, so conversations without streamed charges keep their billed dollars instead of the credits estimate. Bump the warp parse version and DAILY_CACHE_VERSION to 77 (76 on a Node without zstd, so it re-derives too and a zstd Node still adopts its file). --- src/daily-cache.ts | 16 ++++++++---- src/providers/warp.ts | 15 ++++++++--- src/session-cache.ts | 4 ++- tests/providers/warp.test.ts | 37 +++++++++++++++++++++++++++ tests/warp-billing-cost-cache.test.ts | 2 +- 5 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/daily-cache.ts b/src/daily-cache.ts index 5fc9943e1..30944e607 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -366,11 +366,17 @@ import type { DateRange, ProjectSummary } from './types.js' // Codex moves cold sessions into, which were skipped, so days settled after // that move miss their Codex usage. Calls and cost only rise, so no // PENDING_REDERIVE_PROVIDER_VERSIONS entry is needed. A Node without zstd -// cannot read them and stays at 75, so its cache never claims v76 and a later -// Node upgrade still adopts and re-derives it. The next bump must retire this -// split: raise the Node floor to 22.15 and drop the fallback, or give the -// no-zstd runtime a generation of its own just below the new version. -export const DAILY_CACHE_VERSION = zstdSupported ? 76 : 75 +// cannot read them and stayed at 75, so its cache never claimed v76 and a +// later Node upgrade still adopts and re-derives it. +// v77: Warp conversations that carry only total_billed_cost_in_cents are +// priced from it instead of the credits estimate. Calls are unchanged, so no +// PENDING_REDERIVE_PROVIDER_VERSIONS entry is needed. A Node without zstd +// takes 76, so it re-derives for this fix too and a zstd Node still adopts +// and re-derives its file. This is the last bump that can keep the split: a +// no-zstd Node adopts any file at its own number, and after this release zstd +// Nodes will have written v77 files next to it. Raise the Node floor to 22.15 +// and drop the fallback before the next bump. +export const DAILY_CACHE_VERSION = zstdSupported ? 77 : 76 const MIN_SUPPORTED_VERSION = 28 /// Providers whose per-day CALL COUNT means something different at diff --git a/src/providers/warp.ts b/src/providers/warp.ts index 9f3200721..081d876fa 100644 --- a/src/providers/warp.ts +++ b/src/providers/warp.ts @@ -88,6 +88,9 @@ type WarpConversationData = { total_provider_cost_in_cents?: number // Cumulative per-category charged usage across the conversation, in cents. total_charged_usage?: WarpChargedUsageTotals + // Server snapshot of what the customer was billed, in US cents. Replaced + // total_provider_cost_in_cents in Warp 0.2026.10 (warpdotdev/warp#16327). + total_billed_cost_in_cents?: number // Credits Warp deducted for the conversation (always populated today). credits_spent?: number } @@ -216,8 +219,9 @@ function extractTokenBudget(rawConversationData: string): { tokenBudget: number; // least authoritative: // (a) total_provider_cost_in_cents — server-authoritative USD (exact) // (b) sum of total_charged_usage — server per-category USD (exact) -// (c) credits_spent × CREDIT_USD_RATE — credits at published rate (heuristic) -// (d) null → caller falls back to the token × LiteLLM estimate (floor) +// (c) total_billed_cost_in_cents — server billed snapshot USD (exact) +// (d) credits_spent × CREDIT_USD_RATE — credits at published rate (heuristic) +// (e) null → caller falls back to the token × LiteLLM estimate (floor) // A value of 0/absent at a rung is treated as "not recorded here" and defers to // the next rung, so a zero never masks a real cost recorded elsewhere. function conversationCostUsd(rawConversationData: string): WarpCost | null { @@ -244,6 +248,9 @@ function conversationCostUsd(rawConversationData: string): WarpCost | null { if (cents > 0) return { usd: cents / 100, estimated: false } } + const billedCents = safeNumber(meta.total_billed_cost_in_cents) + if (billedCents > 0) return { usd: billedCents / 100, estimated: false } + const credits = safeNumber(meta.credits_spent) if (credits > 0) return { usd: credits * CREDIT_USD_RATE, estimated: true } @@ -491,8 +498,8 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars webSearchRequests: 0, costUSD: allocatedCosts ? (allocatedCosts[index] ?? 0) : calculateCost(model, inputTokens, 0, 0, 0, 0), costIsEstimated: conversationCost ? conversationCost.estimated : true, - // Rungs (a)-(c) are Warp's own billing record; keep them through - // the cache. Rung (d) is the token floor, which stays re-priceable. + // Rungs (a)-(d) are Warp's own billing record; keep them through + // the cache. Rung (e) is the token floor, which stays re-priceable. ...(conversationCost ? { costFromBilling: true } : {}), tools: exchangeTools.tools, bashCommands: exchangeTools.bashCommands, diff --git a/src/session-cache.ts b/src/session-cache.ts index 65c65ba4f..08051906a 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -612,7 +612,9 @@ export const PROVIDER_PARSE_VERSIONS: Record = { // cached before this hold costUSD: undefined and are re-priced from the // token floor on every read, so they must re-parse once for the real dollars // to land. - warp: 'worktree-project-grouping-v1-est-cost-billing-cost-v1', + // billed-cost-v1: total_billed_cost_in_cents prices conversations that carry + // no provider cost or charged usage. + warp: 'worktree-project-grouping-v1-est-cost-billing-cost-v1-billed-cost-v1', // cache-read-v1-est-cost: gen_metadata and RPC usage now carry cache-read // tokens, fields 9/10 read as thinking/response (they were swapped), and // placeholder-only models are priced and flagged costIsEstimated. diff --git a/tests/providers/warp.test.ts b/tests/providers/warp.test.ts index 6264e21ef..b50f3e0e1 100644 --- a/tests/providers/warp.test.ts +++ b/tests/providers/warp.test.ts @@ -469,4 +469,41 @@ skipUnlessSqlite('warp provider', () => { expect(totalCost).toBeCloseTo(0.05, 6) expect(calls[0]!.costIsEstimated).toBe(false) }) + + it.each([ + ['only total_billed_cost_in_cents', { total_billed_cost_in_cents: 3.5 }, 0.035], + ['only legacy total_provider_cost_in_cents', { total_provider_cost_in_cents: 2.5 }, 0.025], + ['both cost fields, legacy provider cost first', { total_provider_cost_in_cents: 2.5, total_billed_cost_in_cents: 3.5 }, 0.025], + ['charged usage ahead of the billed snapshot', { total_billed_cost_in_cents: 3.5, total_charged_usage: { input_cost_in_cents: 4 } }, 0.04], + ])('prices from %s', async (_label, costFields, expectedUsd) => { + const dbPath = createWarpDb(tmpDir) + withTestDb(dbPath, (db) => { + insertConversation(db, 'conv-billed', { + conversation_usage_metadata: { + ...costFields, + credits_spent: 1.44165, + token_usage: [ + { + model_id: 'Claude Haiku 4.5', + warp_tokens: 51385, + byok_tokens: 0, + warp_token_usage_by_category: { primary_agent: 51385 }, + byok_token_usage_by_category: {}, + }, + ], + }, + }) + insertQuery(db, { + exchangeId: 'ex-1', + conversationId: 'conv-billed', + startTs: '2026-10-18 10:00:00.000000', + input: JSON.stringify([{ Query: { text: 'do the thing' } }]), + }) + }) + + const calls = await collectCalls(dbPath, 'conv-billed') + expect(calls).toHaveLength(1) + expect(calls[0]!.costUSD).toBeCloseTo(expectedUsd, 6) + expect(calls[0]!.costIsEstimated).toBe(false) + }) }) diff --git a/tests/warp-billing-cost-cache.test.ts b/tests/warp-billing-cost-cache.test.ts index e4de7a1d8..452030e0a 100644 --- a/tests/warp-billing-cost-cache.test.ts +++ b/tests/warp-billing-cost-cache.test.ts @@ -100,7 +100,7 @@ itUnlessNoSqlite('prices a billing-backed Warp conversation the same cold and wa const warm = await parseAllSessions(undefined, 'warp') const warmCost = warm.reduce((sum, p) => sum + p.totalCostUSD, 0) - // Billing rungs (a) and (c) plus the token floor for the conversation that + // Billing rungs (a) and (d) plus the token floor for the conversation that // has no billing blob. Before the fix both runs reported 3 x TOKEN_FLOOR. expect(coldCost).toBeCloseTo(CREDIT_COST + PROVIDER_COST + TOKEN_FLOOR, 10) expect(warmCost).toBeCloseTo(coldCost, 10)