From 44869d8c620e25e59bcbc3dbeb9c8624591ac123 Mon Sep 17 00:00:00 2001 From: 1bcMax <195689928+1bcMax@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:49:51 -0500 Subject: [PATCH] feat(usage): reconcile against the account ledger row by row, not total by total MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Key-mode numbers in `franklin stats` are catalog estimates. The account gateway settles against a prepaid balance and returns no charge on the response, so Franklin books what a call SHOULD have cost and has never had a way to check it. GET /v1/usage reports what it DID cost. Every gateway response already carries x-blockrun-request-id and nothing read it. Franklin now records it on each usage row, so the two ledgers are joined per request. Comparing totals is a weaker check: two errors of opposite sign produce a total that matches and a ledger wrong in two places. Four properties of the feed turn a careless read into a confident wrong answer, and all four are load-bearing here: A `pending` row is usage whose charge does not exist yet and can still be repriced. Summing it as zero understates spend AND looks settled. An unrecognised cost_state is treated as pending, never priced — the safe error is "not known yet", not "settled at whatever came through". Zero-cost rows are included on purpose. "You were not charged" is an answer; an absent row is indistinguishable from one the client dropped. unavailable_days names days the gateway could not list. Swallowed, it makes two correct ledgers look like they disagree. kind says whether a row is checkable at all. A service charge is a per-call figure only the gateway holds, so a local estimate for one is a guess by construction; the output labels those rather than letting a mismatch read as a defect. It also names what it could not check. A charged request with no local row is real spend that never reached --max-spend. Local rows with no request id — wallet-mode, free-path, or recorded before this version — are counted and reported, because "0 mismatches" from a journal that could not be joined is a different statement from "0 mismatches". The cursor is opaque and is followed, never parsed. Six mutations, six caught: unknown state as priced, pending summed as settled, zero-cost rows filtered, unavailable_days swallowed, unjoinable rows counted as agreeing, cursor not followed. 761 local tests pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rm7cRGtC7wCofhYuHRo21h --- CHANGELOG.md | 24 ++++ src/commands/usage.ts | 99 ++++++++++++++++ src/index.ts | 10 ++ src/payments/price-catalog.ts | 14 +++ src/payments/usage.ts | 213 ++++++++++++++++++++++++++++++++++ src/stats/tracker.ts | 13 ++- src/tools/defillama.ts | 4 +- src/tools/prediction.ts | 4 +- src/tools/realface.ts | 4 +- src/tools/rpc.ts | 4 +- src/tools/surf.ts | 4 +- test/api-key.local.mjs | 116 +++++++++++++++++- 12 files changed, 494 insertions(+), 15 deletions(-) create mode 100644 src/commands/usage.ts create mode 100644 src/payments/usage.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a9b7335..80f3ae6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,30 @@ wallet" while account credits sat there. They read the credit balance now, and an ungated account or an unreachable gateway means no local ceiling rather than an invented one. +**`franklin usage` reconciles against the account ledger, row by row.** Key-mode +totals in `franklin stats` are catalog estimates — the gateway settles without +returning a charge, so Franklin books what a call SHOULD have cost. `GET +/v1/usage` reports what it DID cost, and every response already carries an +`x-blockrun-request-id` that Franklin now records, so the two are joined per +request instead of compared as totals. A total that matches can still be two +errors cancelling out. + +The feed has four properties that turn a careless read into a confident wrong +answer, and the command respects all four: a `pending` row is usage whose charge +does not exist yet and can still be repriced, so it is never summed as a settled +zero (an unrecognised state is treated as pending, not priced); zero-cost rows +are included on purpose, because "you were not charged" is an answer and an +absent row is indistinguishable from a dropped one; `unavailable_days` names +days the gateway could not list, and is reported as a short read rather than a +quiet period; and `kind` says whether a row is checkable locally at all, since a +`service` charge is a per-call figure only the gateway holds — a local estimate +for one is a guess by construction, and the output says so rather than letting +it read as a defect. + +It also names what it could NOT check: charged requests with no local row are +real spend that never reached `--max-spend`, and local rows carrying no request +id are reported rather than counted as agreeing. + **The agent was told the account host was an alias of the Base gateway.** It is not. `api.blockrun.ai` authenticates a bearer key and 401s without one; the wallet hosts answer a 402 challenge and ignore a key entirely. Calling them diff --git a/src/commands/usage.ts b/src/commands/usage.ts new file mode 100644 index 0000000..5738c70 --- /dev/null +++ b/src/commands/usage.ts @@ -0,0 +1,99 @@ +/** + * `franklin usage` — what the account actually charged, joined to what + * Franklin recorded. + * + * `franklin stats` reports Franklin's own tally, which in key mode is built + * from catalog prices because the account gateway settles without returning a + * charge. This command reports the gateway's ledger, which is authoritative, + * and lines the two up per request rather than comparing totals — a total that + * matches can still be two errors cancelling out. + */ + +import chalk from 'chalk'; +import { isKeyMode } from '../payments/auth-mode.js'; +import { fetchUsage, reconcile } from '../payments/usage.js'; +import { loadStats } from '../stats/tracker.js'; +import { DASHBOARD_URL } from '../config.js'; + +function usd(n: number): string { + return `$${n.toFixed(n < 0.01 && n > 0 ? 4 : 2)}`; +} + +export async function usageCommand(opts: { days?: string; json?: boolean } = {}): Promise { + if (!isKeyMode()) { + console.log('Account usage is a key-mode ledger. This session pays from a wallet —'); + console.log('its settled amounts are already exact, and `franklin stats` reports them.'); + return; + } + + const days = Math.max(1, Math.min(Number(opts.days) || 30, 365)); + const from = new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10); + + const ledger = await fetchUsage({ from }); + if (!ledger) { + console.log(chalk.yellow('Could not read the account ledger.')); + console.log(`Check your key, or view it at ${DASHBOARD_URL}/dashboard.`); + process.exitCode = 1; + return; + } + + const local = loadStats().history.map((h) => ({ requestId: h.requestId, costUsd: h.costUsd })); + const r = reconcile(ledger, local); + + if (opts.json) { + console.log(JSON.stringify(r, null, 2)); + return; + } + + const t = r.totals; + console.log(chalk.bold(`\nAccount ledger — last ${days} day(s)\n`)); + console.log(` Charged: ${chalk.green(usd(t.pricedUsd))} across ${t.pricedCount} request(s) ${chalk.dim('(BlockRun, authoritative)')}`); + if (t.pendingCount > 0) { + // Not settled zeros — these can still be priced. + console.log(` Pending: ${chalk.yellow(String(t.pendingCount))} request(s) with no charge yet ${chalk.dim('— not free, not final')}`); + } + if (t.freeCount > 0) console.log(` Free: ${t.freeCount} request(s) ${chalk.dim('(explicitly not charged)')}`); + console.log(chalk.dim(` Mix: ${t.chatCount} chat, ${t.serviceCount} service`)); + + if (r.unavailableDays.length > 0) { + // A hidden gap makes two correct ledgers look like they disagree. + console.log(chalk.yellow(`\n ${r.unavailableDays.length} day(s) could not be listed: ${r.unavailableDays.join(', ')}`)); + console.log(chalk.dim(' Totals above exclude them — this is a short read, not a quiet period.')); + } + + console.log(chalk.bold('\nAgainst Franklin\'s own journal\n')); + if (r.matched.length === 0 && r.missingLocally.length === 0) { + console.log(chalk.dim(' No ledger row could be joined yet. Request ids are recorded from now on,')); + console.log(chalk.dim(' so calls made before this version have nothing to match against.')); + } + + const off = r.matched + .filter((m) => Math.abs(m.deltaUsd) > 0.000_05) + .sort((a, b) => Math.abs(b.deltaUsd) - Math.abs(a.deltaUsd)); + + if (r.matched.length > 0) { + console.log(` Joined: ${r.matched.length} request(s); ${off.length} where Franklin's estimate differs`); + } + for (const m of off.slice(0, 10)) { + const sign = m.deltaUsd > 0 ? '+' : ''; + // A service charge is a per-call figure only the gateway holds, so a local + // estimate for one is a guess by construction — say so rather than letting + // it read as a defect. + const note = m.row.kind === 'service' ? chalk.dim(' (service — locally an estimate by construction)') : ''; + console.log(` ${m.row.endpoint.padEnd(30)} gateway ${usd(m.row.costUsd)} local ${usd(m.localUsd)} ${sign}${m.deltaUsd.toFixed(4)}${note}`); + } + + if (r.missingLocally.length > 0) { + const sum = r.missingLocally.reduce((a, b) => a + b.costUsd, 0); + console.log(chalk.yellow(`\n ${r.missingLocally.length} charged request(s) have no local row (${usd(sum)}).`)); + console.log(chalk.dim(' Franklin never counted these, so they never reached --max-spend either.')); + for (const row of r.missingLocally.slice(0, 5)) { + console.log(chalk.dim(` ${row.timestamp} ${row.endpoint} ${usd(row.costUsd)}`)); + } + } + + if (r.unjoinable > 0) { + console.log(chalk.dim(`\n ${r.unjoinable} local row(s) carry no request id and were not compared.`)); + } + console.log(chalk.dim(`\n Full activity: ${DASHBOARD_URL}/dashboard\n`)); +} diff --git a/src/index.ts b/src/index.ts index da0f429..a62650a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -301,6 +301,16 @@ program await doctorCommand(opts); }); +program + .command('usage') + .description('Account ledger from the gateway, reconciled against the local journal') + .option('--days ', 'Days to read (default 30, max 365)') + .option('--json', 'Machine-readable output') + .action(async (opts) => { + const { usageCommand } = await import('./commands/usage.js'); + await usageCommand(opts); + }); + program .command('wallet-adopt
') .description('Make a discovered Solana wallet the active one (backs up the current session)') diff --git a/src/payments/price-catalog.ts b/src/payments/price-catalog.ts index 2cc0a93..80c9a77 100644 --- a/src/payments/price-catalog.ts +++ b/src/payments/price-catalog.ts @@ -407,6 +407,20 @@ export function chargeFromResponse(res: HeaderBag): number | null { return usdHeader(res, 'x-blockrun-cost-usd'); } +/** + * The gateway's id for this request, from `x-blockrun-request-id`. + * + * Recorded locally so `franklin usage` can join Franklin's journal to the + * account ledger row by row. Comparing totals is not the same check: two + * errors of opposite sign produce a total that matches and a ledger that is + * wrong in two places. + */ +export function requestIdFromResponse(res: HeaderBag): string | null { + const raw = res.headers?.get?.('x-blockrun-request-id'); + const id = typeof raw === 'string' ? raw.trim() : ''; + return id.length > 0 && id.length <= 128 ? id : null; +} + /** * Credit left on the account after this call, from * `x-blockrun-credit-remaining-usd`. Absent on ungated accounts, which have no diff --git a/src/payments/usage.ts b/src/payments/usage.ts new file mode 100644 index 0000000..5dfe93e --- /dev/null +++ b/src/payments/usage.ts @@ -0,0 +1,213 @@ +/** + * The gateway's own per-request ledger, and how Franklin reconciles to it. + * + * Franklin's local numbers in key mode are catalog estimates: the account + * gateway settles against a prepaid balance and returns no charge on the + * response, so `franklin stats` reports what a call SHOULD have cost. This + * module fetches what it DID cost. + * + * `GET /v1/usage` returns one row per request with a `request_id` that matches + * the `x-blockrun-request-id` header the gateway already sends, so the join is + * line-by-line rather than a comparison of two totals — a total that matches + * can still be two errors cancelling out. + * + * Four properties of this feed change how it must be read, and getting any of + * them wrong produces a confident wrong answer: + * + * `cost_state: 'pending'` means the usage exists and its charge does not yet. + * Those rows can still be repriced. Summing them as zero understates spend + * and, worse, looks settled. + * + * Zero-cost rows are INCLUDED on purpose. "You were not charged for this" is + * an answer; a row that is simply absent is indistinguishable from one the + * client dropped. Never filter them. + * + * `unavailable_days` names days the gateway could not list rather than + * silently returning a short page. Swallowing it makes two correct ledgers + * look like they disagree. + * + * `kind` says whether a row is checkable locally at all. A `chat` charge can + * be rebuilt from tokens and a published rate; a `service` charge is a + * per-call figure only the gateway holds, so a local estimate for one is a + * guess by construction and a mismatch is not evidence of a bug. + * + * The cursor is opaque. Do not parse it, and do not construct one. + */ + +import { KEY_API_URL, USER_AGENT } from '../config.js'; +import { loadApiKey } from './auth-mode.js'; + +export type CostState = 'priced' | 'pending' | 'free'; +export type UsageKind = 'chat' | 'service'; + +export interface UsageRow { + requestId: string; + timestamp: string; + endpoint: string; + model: string | null; + kind: UsageKind; + inputTokens: number; + outputTokens: number; + costUsd: number; + costState: CostState; + status: number; +} + +export interface UsagePage { + rows: UsageRow[]; + /** Days the gateway could not list. Surface these; never treat as empty. */ + unavailableDays: string[]; +} + +/** Max rows the endpoint will return per page. */ +const MAX_LIMIT = 500; +/** Stop paging rather than walk an unbounded history. */ +const MAX_PAGES = 40; + +function toRow(raw: Record): UsageRow | null { + const requestId = typeof raw.request_id === 'string' ? raw.request_id : ''; + if (!requestId) return null; // without the join key the row is not usable here + const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0); + const state = raw.cost_state; + return { + requestId, + timestamp: typeof raw.timestamp === 'string' ? raw.timestamp : '', + endpoint: typeof raw.endpoint === 'string' ? raw.endpoint : '', + model: typeof raw.model === 'string' ? raw.model : null, + kind: raw.kind === 'chat' ? 'chat' : 'service', + inputTokens: num(raw.input_tokens), + outputTokens: num(raw.output_tokens), + costUsd: num(raw.cost_usd), + // An unrecognised state is treated as pending, not priced: the safe error + // is "we do not know yet", never "settled at whatever number came through". + costState: state === 'priced' || state === 'free' ? state : 'pending', + status: num(raw.status), + }; +} + +/** + * Fetch usage rows, following the cursor. Returns null when there is no key or + * the gateway is unreachable — callers show what they do know rather than + * inventing a ledger. + */ +export async function fetchUsage(opts: { + from?: string; + to?: string; + limit?: number; + timeoutMs?: number; +} = {}): Promise { + const key = loadApiKey(); + if (!key) return null; + + const rows: UsageRow[] = []; + const unavailableDays = new Set(); + let cursor: string | null = null; + + for (let page = 0; page < MAX_PAGES; page++) { + const url = new URL(`${KEY_API_URL}/v1/usage`); + if (opts.from) url.searchParams.set('from', opts.from); + if (opts.to) url.searchParams.set('to', opts.to); + url.searchParams.set('limit', String(Math.min(opts.limit ?? MAX_LIMIT, MAX_LIMIT))); + if (cursor) url.searchParams.set('cursor', cursor); + + let body: Record; + try { + const res = await fetch(url, { + headers: { Authorization: `Bearer ${key}`, 'User-Agent': USER_AGENT }, + signal: AbortSignal.timeout(opts.timeoutMs ?? 20_000), + }); + if (!res.ok) return null; + body = (await res.json()) as Record; + } catch { + return null; + } + + for (const raw of Array.isArray(body.data) ? body.data : []) { + const row = toRow(raw as Record); + if (row) rows.push(row); + } + for (const d of Array.isArray(body.unavailable_days) ? body.unavailable_days : []) { + if (typeof d === 'string') unavailableDays.add(d); + } + + cursor = typeof body.next_cursor === 'string' && body.next_cursor ? body.next_cursor : null; + if (!cursor) break; + } + + return { rows, unavailableDays: [...unavailableDays] }; +} + +export interface UsageTotals { + /** Rows whose charge has settled. This is the number you can trust. */ + pricedUsd: number; + pricedCount: number; + /** Usage that exists with no charge yet. Not a zero — it can still be priced. */ + pendingCount: number; + /** Explicitly not chargeable. Counted, never hidden. */ + freeCount: number; + chatCount: number; + serviceCount: number; +} + +export function summarize(rows: readonly UsageRow[]): UsageTotals { + const t: UsageTotals = { + pricedUsd: 0, pricedCount: 0, pendingCount: 0, freeCount: 0, chatCount: 0, serviceCount: 0, + }; + for (const r of rows) { + if (r.costState === 'priced') { t.pricedUsd += r.costUsd; t.pricedCount++; } + else if (r.costState === 'pending') t.pendingCount++; + else t.freeCount++; + if (r.kind === 'chat') t.chatCount++; else t.serviceCount++; + } + return t; +} + +export interface Reconciliation { + /** Ledger rows Franklin also has locally, and whether the numbers agree. */ + matched: Array<{ row: UsageRow; localUsd: number; deltaUsd: number }>; + /** Charged by the gateway with no local row. Franklin under-counted. */ + missingLocally: UsageRow[]; + /** Rows Franklin recorded that carry no id to join on. */ + unjoinable: number; + totals: UsageTotals; + unavailableDays: string[]; +} + +/** + * Join the gateway ledger to Franklin's own journal on `request_id`. + * + * Only `priced` rows are compared. A `pending` row has no charge to disagree + * with yet, and a `free` row's zero is an answer rather than a discrepancy. + * + * `unjoinable` counts local rows with no id — wallet-mode calls, free-path + * calls, and anything recorded before the id was captured. They are reported + * rather than hidden, because "0 mismatches" from a journal that could not be + * joined is not the same statement as "0 mismatches". + */ +export function reconcile( + ledger: UsagePage, + local: ReadonlyArray<{ requestId?: string; costUsd: number }>, +): Reconciliation { + const byId = new Map(); + let unjoinable = 0; + for (const r of local) { + if (r.requestId) byId.set(r.requestId, (byId.get(r.requestId) ?? 0) + r.costUsd); + else unjoinable++; + } + + const matched: Reconciliation['matched'] = []; + const missingLocally: UsageRow[] = []; + for (const row of ledger.rows) { + if (row.costState !== 'priced') continue; + const localUsd = byId.get(row.requestId); + if (localUsd === undefined) { + // A charge with no local row: real spend Franklin never counted, so it + // never reached --max-spend either. + if (row.costUsd > 0) missingLocally.push(row); + continue; + } + matched.push({ row, localUsd, deltaUsd: localUsd - row.costUsd }); + } + + return { matched, missingLocally, unjoinable, totals: summarize(ledger.rows), unavailableDays: ledger.unavailableDays }; +} diff --git a/src/stats/tracker.ts b/src/stats/tracker.ts index bc619c8..ef63039 100644 --- a/src/stats/tracker.ts +++ b/src/stats/tracker.ts @@ -78,6 +78,12 @@ export interface UsageRecord { costUsd: number; latencyMs: number; fallback?: boolean; // true if this request used fallback + /** + * The gateway's `x-blockrun-request-id` for this call, when it sent one. + * The join key `franklin usage` uses to line this row up against the + * account ledger. Absent on wallet-mode and free-path calls. + */ + requestId?: string; } export interface ModelStats { @@ -249,7 +255,9 @@ export function recordUsage( * key-mode row is priced locally. Surfaced by `franklin stats` so the ledger * never claims more precision than it has. */ - estimated: boolean = isKeyMode() + estimated: boolean = isKeyMode(), + /** Gateway request id, from `requestIdFromResponse`. Enables reconciliation. */ + requestId?: string | null ): void { // Count real spend BEFORE the test/audit gates — the --max-spend ceiling must // see every paid tool call even when history persistence is suppressed. @@ -312,6 +320,9 @@ export function recordUsage( costUsd, latencyMs, fallback, + // Omit rather than store undefined: history is JSON round-tripped, and an + // explicit `requestId: undefined` survives as a key with no value. + ...(requestId ? { requestId } : {}), }); scheduleSave(); diff --git a/src/tools/defillama.ts b/src/tools/defillama.ts index ad6a109..325a207 100644 --- a/src/tools/defillama.ts +++ b/src/tools/defillama.ts @@ -28,7 +28,7 @@ import { } from '@blockrun/llm'; import type { CapabilityHandler, CapabilityResult, ExecutionScope } from '../agent/types.js'; import { loadChain, VERSION} from '../config.js'; -import { chargeFromResponse, resolveCharge } from '../payments/price-catalog.js'; +import { chargeFromResponse, requestIdFromResponse, resolveCharge } from '../payments/price-catalog.js'; import { gatewayBase, gatewayHeaders } from '../payments/auth-mode.js'; import { logger } from '../logger.js'; import { recordUsage } from '../stats/tracker.js'; @@ -85,7 +85,7 @@ async function getWithPayment(path: string, ctx: ExecutionScope): Promise // See rpc.ts — `paidUsd` is 0 whenever no 402 happened, which is every // call in API-key mode. const charge = resolveCharge({ apiPath: endpoint, chargedUsd: chargeFromResponse(response), settledUsd: paidUsd }); - try { recordUsage(`DeFiLlama:${path}`, 0, 0, charge.usd, Date.now() - startedAt, false, charge.estimated); } catch { /* best-effort */ } + try { recordUsage(`DeFiLlama:${path}`, 0, 0, charge.usd, Date.now() - startedAt, false, charge.estimated, requestIdFromResponse(response)); } catch { /* best-effort */ } return (await response.json()) as T; } finally { clearTimeout(timeout); diff --git a/src/tools/prediction.ts b/src/tools/prediction.ts index 6f1a885..bce2457 100644 --- a/src/tools/prediction.ts +++ b/src/tools/prediction.ts @@ -48,7 +48,7 @@ import { } from '@blockrun/llm'; import type { CapabilityHandler, CapabilityResult, ExecutionScope } from '../agent/types.js'; import { loadChain, VERSION} from '../config.js'; -import { chargeFromResponse, resolveCharge } from '../payments/price-catalog.js'; +import { chargeFromResponse, requestIdFromResponse, resolveCharge } from '../payments/price-catalog.js'; import { gatewayBase, gatewayHeaders } from '../payments/auth-mode.js'; import { logger } from '../logger.js'; import { recordFetch } from '../trading/providers/telemetry.js'; @@ -151,7 +151,7 @@ async function getWithPayment(path: string, query: Record assert.ok(token, 'a gateway outage must not block a call the gateway would accept'); walletReservation.release(token); } finally { - globalThis.fetch = originalFetch; + globalThis.fetch = realFetch; walletReservation._resetForTests(); clean(); } @@ -806,6 +806,114 @@ test('no mode is told the account host is an alias of a wallet host', async () = auth.resetPayModeCache(); }); +// ── /v1/usage reconciliation ────────────────────────────────────────────── +// The four properties that turn a careless read of this feed into a confident +// wrong answer: pending is not a settled zero, zero-cost rows are real answers, +// unavailable_days is a short read, and only chat is locally checkable. + +const usageRow = (o) => ({ + request_id: o.id, timestamp: '2026-09-05T12:00:00Z', endpoint: o.endpoint ?? '/v1/exa/search', + model: o.model ?? null, kind: o.kind ?? 'service', input_tokens: 0, output_tokens: 0, + cost_usd: o.cost ?? 0.01, cost_state: o.state ?? 'priced', status: 200, +}); + +const realFetch = globalThis.fetch; + +function mockUsage(pages) { + let i = 0; + globalThis.fetch = async (url) => { + const u = String(url instanceof Request ? url.url : url); + assert.match(u, /^https:\/\/api\.blockrun\.ai\/v1\/usage/, 'usage reads the account host only'); + return new Response(JSON.stringify(pages[i++]), { status: 200, headers: { 'content-type': 'application/json' } }); + }; +} + +test('usage follows the cursor and never parses it', async () => { + clean(); process.env.BLOCKRUN_API_KEY = VALID_KEY; auth.resetPayModeCache(); + const { fetchUsage } = await import('../dist/payments/usage.js'); + mockUsage([ + { object: 'list', data: [usageRow({ id: 'a' })], next_cursor: 'OPAQUE//token==', unavailable_days: [] }, + { object: 'list', data: [usageRow({ id: 'b' })], next_cursor: null, unavailable_days: [] }, + ]); + const page = await fetchUsage({}); + globalThis.fetch = realFetch; + assert.deepEqual(page.rows.map((r) => r.requestId), ['a', 'b'], 'both pages are read'); + clean(); +}); + +test('pending is not a settled zero, and free rows are counted not hidden', async () => { + const { summarize } = await import('../dist/payments/usage.js'); + const { fetchUsage } = await import('../dist/payments/usage.js'); + clean(); process.env.BLOCKRUN_API_KEY = VALID_KEY; auth.resetPayModeCache(); + mockUsage([{ object: 'list', next_cursor: null, unavailable_days: [], data: [ + usageRow({ id: 'p', cost: 0.02, state: 'priced' }), + usageRow({ id: 'q', cost: 0, state: 'pending' }), + usageRow({ id: 'f', cost: 0, state: 'free' }), + usageRow({ id: 'x', cost: 0.5, state: 'weird-new-value' }), + ] }]); + const page = await fetchUsage({}); + globalThis.fetch = realFetch; + + const t = summarize(page.rows); + assert.equal(t.pricedUsd, 0.02, 'only settled charges are summed'); + assert.equal(t.pendingCount, 2, 'an unrecognised state is pending, never priced'); + assert.equal(t.freeCount, 1, 'a free row is an answer and stays visible'); + assert.equal(page.rows.length, 4, 'zero-cost rows are never filtered out'); + clean(); +}); + +test('unavailable_days is surfaced rather than read as a quiet period', async () => { + clean(); process.env.BLOCKRUN_API_KEY = VALID_KEY; auth.resetPayModeCache(); + const { fetchUsage } = await import('../dist/payments/usage.js'); + mockUsage([{ object: 'list', data: [], next_cursor: null, unavailable_days: ['2026-09-01', '2026-09-02'] }]); + const page = await fetchUsage({}); + globalThis.fetch = realFetch; + assert.deepEqual(page.unavailableDays, ['2026-09-01', '2026-09-02']); + clean(); +}); + +test('reconcile joins on request_id and names what it could not check', async () => { + const { reconcile } = await import('../dist/payments/usage.js'); + const ledger = { + rows: [ + usageRow({ id: 'match', cost: 0.0075 }), + usageRow({ id: 'drift', cost: 0.0100 }), + usageRow({ id: 'orphan', cost: 0.0500 }), + usageRow({ id: 'later', cost: 0.02, state: 'pending' }), + ].map((r) => ({ + requestId: r.request_id, timestamp: r.timestamp, endpoint: r.endpoint, model: r.model, + kind: r.kind, inputTokens: 0, outputTokens: 0, costUsd: r.cost_usd, costState: r.cost_state, status: 200, + })), + unavailableDays: [], + }; + const local = [ + { requestId: 'match', costUsd: 0.0075 }, + { requestId: 'drift', costUsd: 0.0075 }, + { costUsd: 0.0075 }, // wallet-mode / pre-upgrade row: no id to join on + ]; + const r = reconcile(ledger, local); + + assert.equal(r.matched.length, 2); + assert.equal(r.matched.find((m) => m.row.requestId === 'match').deltaUsd, 0); + assert.ok(Math.abs(r.matched.find((m) => m.row.requestId === 'drift').deltaUsd + 0.0025) < 1e-9, + 'a local underestimate shows as a negative delta'); + assert.deepEqual(r.missingLocally.map((x) => x.requestId), ['orphan'], + 'a charge with no local row is real spend that never reached --max-spend'); + assert.equal(r.unjoinable, 1, 'rows with no id are reported, not silently counted as agreeing'); + // A pending ledger row has no charge to disagree with yet. + assert.ok(!r.matched.some((m) => m.row.requestId === 'later')); +}); + +test('the request id is captured into the local journal', async () => { + const { requestIdFromResponse } = await import('../dist/payments/price-catalog.js'); + const withId = new Response('{}', { headers: { 'x-blockrun-request-id': ' 326d2e86-abc ' } }); + assert.equal(requestIdFromResponse(withId), '326d2e86-abc', 'trimmed'); + assert.equal(requestIdFromResponse(new Response('{}')), null, 'absent header is null, not empty string'); + assert.equal(requestIdFromResponse(new Response('{}', { headers: { 'x-blockrun-request-id': ' ' } })), null); + assert.equal(requestIdFromResponse(new Response('{}', { headers: { 'x-blockrun-request-id': 'x'.repeat(200) } })), null, + 'an absurd value is refused rather than stored'); +}); + test('cleanup', () => { clean(); rmSync(TEST_HOME, { recursive: true, force: true }); @@ -830,7 +938,7 @@ for (const status of [401, 402, 404, 429, 500]) { assert.equal(calls[0].headers.get('authorization'), `Bearer ${VALID_KEY}`); assert.equal(auth.resolvePayMode().kind, 'key'); } finally { - globalThis.fetch = originalFetch; + globalThis.fetch = realFetch; clean(); } });