Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
99 changes: 99 additions & 0 deletions src/commands/usage.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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`));
}
10 changes: 10 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,16 @@ program
await doctorCommand(opts);
});

program
.command('usage')
.description('Account ledger from the gateway, reconciled against the local journal')
.option('--days <n>', '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 <address>')
.description('Make a discovered Solana wallet the active one (backs up the current session)')
Expand Down
14 changes: 14 additions & 0 deletions src/payments/price-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
213 changes: 213 additions & 0 deletions src/payments/usage.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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<UsagePage | null> {
const key = loadApiKey();
if (!key) return null;

const rows: UsageRow[] = [];
const unavailableDays = new Set<string>();
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<string, unknown>;
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<string, unknown>;
} catch {
return null;
}

for (const raw of Array.isArray(body.data) ? body.data : []) {
const row = toRow(raw as Record<string, unknown>);
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<string, number>();
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 };
}
Loading
Loading