From 0413f6907ccc65e2c97a3ae964c0e067fbad9245 Mon Sep 17 00:00:00 2001 From: Gaurav Sisodia Date: Tue, 2 Jun 2026 23:29:32 -0700 Subject: [PATCH] fix(cursor): align SQLite lookback with report period Replace the fixed 35-day Cursor DB window with a period-aware floor (matching the CLI "all" cap of six months) and bump the results cache so wider ranges re-query correctly. Add a Vercel AI Gateway provider for Custom Reporting API usage when AI_GATEWAY_API_KEY or VERCEL_OIDC_TOKEN is set. Co-authored-by: Cursor --- README.md | 5 + docs/providers/README.md | 1 + docs/providers/vercel-gateway.md | 45 ++++++++ src/cursor-cache.ts | 23 +++- src/parser.ts | 1 + src/providers/cursor.ts | 43 ++++++-- src/providers/index.ts | 24 ++++- src/providers/types.ts | 4 +- src/providers/vercel-gateway.ts | 142 +++++++++++++++++++++++++ tests/providers/cursor.test.ts | 10 +- tests/providers/vercel-gateway.test.ts | 64 +++++++++++ 11 files changed, 344 insertions(+), 18 deletions(-) create mode 100644 docs/providers/vercel-gateway.md create mode 100644 src/providers/vercel-gateway.ts create mode 100644 tests/providers/vercel-gateway.test.ts diff --git a/README.md b/README.md index 7d70533c..5224013c 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ Arrow keys switch between Today, 7 Days, 30 Days, Month, and 6 Months (use `--fr | | Antigravity | Yes | [antigravity.md](docs/providers/antigravity.md) | | | Crush | Yes | [crush.md](docs/providers/crush.md) | | | Warp | Yes | [warp.md](docs/providers/warp.md) | +| | Vercel AI Gateway | Yes* | [vercel-gateway.md](docs/providers/vercel-gateway.md) | Each provider doc lists the exact data location, storage format, and known quirks. Linux and Windows paths are detected automatically. If a path has changed or is wrong, please [open an issue](https://github.com/getagentseal/codeburn/issues). @@ -130,6 +131,10 @@ The `--provider` flag filters any command to a single provider: `codeburn report ### Provider Notes +**Codex (OpenAI)** stores sessions at `~/.codex/sessions/` as JSONL rollout files. CodeBurn reads `token_count` events and attributes cost by project working directory. Use `codeburn report --provider codex` to view Codex only, or `codeburn` (all providers) to combine with Cursor and others. + +**Vercel AI Gateway** pulls usage from the [Vercel AI Gateway reporting API](https://vercel.com/docs/ai-gateway/capabilities/custom-reporting) (cloud, not local logs). Set `AI_GATEWAY_API_KEY` or `VERCEL_OIDC_TOKEN` (from `vercel env pull` / `vercel dev`). Requires a Vercel plan with Custom Reporting access. Example: `codeburn report --provider vercel-gateway -p month`. Without credentials, this provider is skipped silently in the combined dashboard. + **Cursor** reads token usage from its local SQLite database. Since Cursor's "Auto" mode hides the actual model used, costs are estimated using Sonnet pricing (labeled "Auto (Sonnet est.)" in the dashboard). The Cursor view shows a Languages panel instead of Core Tools/Shell/MCP panels, since Cursor does not log individual tool calls. First run on a large Cursor database may take up to a minute; results are cached and subsequent runs are instant. **Gemini CLI** stores sessions as single JSON files. Each session embeds real token counts (input, output, cached, thoughts) per message, so no estimation is needed. Gemini reports input tokens inclusive of cached; CodeBurn subtracts cached from input before pricing to avoid double charging. diff --git a/docs/providers/README.md b/docs/providers/README.md index 109c47a4..df36fe97 100644 --- a/docs/providers/README.md +++ b/docs/providers/README.md @@ -39,6 +39,7 @@ For the architectural picture, see `../architecture.md`. | [Goose](goose.md) | SQLite | `src/providers/goose.ts` | none | | [OpenCode](opencode.md) | SQLite | `src/providers/opencode.ts` | `tests/providers/opencode.test.ts` | | [Warp](warp.md) | SQLite | `src/providers/warp.ts` | `tests/providers/warp.test.ts` | +| [Vercel AI Gateway](vercel-gateway.md) | REST API | `src/providers/vercel-gateway.ts` | `tests/providers/vercel-gateway.test.ts` | ### Shared diff --git a/docs/providers/vercel-gateway.md b/docs/providers/vercel-gateway.md new file mode 100644 index 00000000..24df98c9 --- /dev/null +++ b/docs/providers/vercel-gateway.md @@ -0,0 +1,45 @@ +# Vercel AI Gateway + +Cloud usage for [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) via the reporting API. + +- **Source:** `src/providers/vercel-gateway.ts` +- **Loading:** lazy (`src/providers/index.ts`) +- **Test:** `tests/providers/vercel-gateway.test.ts` + +## Where it reads from + +Not local disk. CodeBurn calls: + +``` +GET https://ai-gateway.vercel.sh/v1/report?start_date=...&end_date=...&date_part=day&group_by=model +``` + +See [Custom Reporting](https://vercel.com/docs/ai-gateway/capabilities/custom-reporting). + +## Authentication + +Set one of: + +- `AI_GATEWAY_API_KEY` +- `VERCEL_OIDC_TOKEN` (from `vercel env pull` when using `vercel dev`) + +## Caching + +None. Each parse issues one API request for the requested date range. + +## Deduplication + +Per `vercel-gateway::`. + +## Quirks + +- Requires Pro/Enterprise Custom Reporting on your Vercel account. +- Data can lag by a few minutes after requests complete. +- Rows are daily aggregates per model, not per chat session. +- `total_cost` is used as `costUSD`; token fields map directly when present. + +## When fixing a bug here + +1. Confirm env vars are set in the same shell running `codeburn`. +2. Reproduce with `codeburn report --provider vercel-gateway -p week --format json`. +3. Compare totals to the Vercel dashboard AI Gateway usage view. diff --git a/src/cursor-cache.ts b/src/cursor-cache.ts index 390dcfa2..7d84d262 100644 --- a/src/cursor-cache.ts +++ b/src/cursor-cache.ts @@ -11,12 +11,13 @@ import type { ParsedProviderCall } from './providers/types.js' // router relies on those composer ids to bucket calls per project. // Version 2 caches contain `sessionId: 'unknown'` for every call and would // route everything to the orphan project, so we invalidate them. -const CURSOR_CACHE_VERSION = 3 +const CURSOR_CACHE_VERSION = 4 type ResultCache = { version?: number dbMtimeMs: number dbSizeBytes: number + lookbackFloor: string calls: ParsedProviderCall[] } @@ -39,7 +40,10 @@ async function getDbFingerprint(dbPath: string): Promise<{ mtimeMs: number; size } } -export async function readCachedResults(dbPath: string): Promise { +export async function readCachedResults( + dbPath: string, + requestedFloor: string, +): Promise { try { const fp = await getDbFingerprint(dbPath) if (!fp) return null @@ -47,7 +51,13 @@ export async function readCachedResults(dbPath: string): Promise { +export async function writeCachedResults( + dbPath: string, + calls: ParsedProviderCall[], + lookbackFloor: string, +): Promise { const fp = await getDbFingerprint(dbPath) if (!fp) return @@ -66,6 +80,7 @@ export async function writeCachedResults(dbPath: string, calls: ParsedProviderCa version: CURSOR_CACHE_VERSION, dbMtimeMs: fp.mtimeMs, dbSizeBytes: fp.size, + lookbackFloor, calls, } diff --git a/src/parser.ts b/src/parser.ts index d0131330..3ee6d4c9 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1883,6 +1883,7 @@ async function parseProviderSources( const parser = provider.createSessionParser( { path: source.path, project: source.project, provider: providerName }, parserDedup, + dateRange, ) try { diff --git a/src/providers/cursor.ts b/src/providers/cursor.ts index ebd7f918..21cdce47 100644 --- a/src/providers/cursor.ts +++ b/src/providers/cursor.ts @@ -5,8 +5,24 @@ import { homedir } from 'os' import { calculateCost } from '../models.js' import { readCachedResults, writeCachedResults } from '../cursor-cache.js' import { isSqliteAvailable, getSqliteLoadError, openDatabase, blobToText, type SqliteDatabase } from '../sqlite.js' +import type { DateRange } from '../types.js' import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' +/** Matches cli-date.ts "all" period cap (6 months). */ +const CURSOR_MAX_LOOKBACK_MONTHS = 6 + +export function getCursorTimeFloor(dateRange?: DateRange): string { + const now = new Date() + const maxStart = new Date( + now.getFullYear(), + now.getMonth() - CURSOR_MAX_LOOKBACK_MONTHS, + now.getDate(), + ) + const start = dateRange?.start ?? maxStart + const effective = start < maxStart ? maxStart : start + return effective.toISOString() +} + const CURSOR_COST_MODEL = 'claude-sonnet-4-5' const modelDisplayNames: Record = { @@ -384,13 +400,14 @@ function takeUserMessage(queues: Map, conversationId: return msg } -function parseBubbles(db: SqliteDatabase, seenKeys: Set): { calls: ParsedProviderCall[] } { +function parseBubbles( + db: SqliteDatabase, + seenKeys: Set, + timeFloor: string, +): { calls: ParsedProviderCall[] } { const results: ParsedProviderCall[] = [] let skipped = 0 - const LOOKBACK_DAYS = 180 - const timeFloor = new Date(Date.now() - LOOKBACK_DAYS * 24 * 60 * 60 * 1000).toISOString() - // Hard cap on rows to scan. The BUBBLE_QUERY_SINCE filter relies on // json_extract over the value BLOB, which SQLite cannot serve from an // index — every row is JSON-decoded. Multi-GB Cursor DBs (power users, @@ -660,7 +677,13 @@ function parseAgentKv(db: SqliteDatabase, seenKeys: Set, dbPath: string) return { calls: results } } -function createParser(source: SessionSource, seenKeys: Set): SessionParser { +function createParser( + source: SessionSource, + seenKeys: Set, + dateRange?: DateRange, +): SessionParser { + const timeFloor = getCursorTimeFloor(dateRange) + return { async *parse(): AsyncGenerator { if (!isSqliteAvailable()) { @@ -699,7 +722,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars // sources reuse one parsed bubble set per CLI run. Filtering happens // post-cache so each source emits only its own composers. let allCalls: ParsedProviderCall[] | null = null - const cached = await readCachedResults(dbPath) + const cached = await readCachedResults(dbPath, timeFloor) if (cached) { allCalls = cached } else { @@ -719,10 +742,10 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars // seenKeys is not mutated by calls that the workspace filter is // about to drop. Cross-source dedup happens at yield time. const localSeen = new Set() - const { calls: bubbleCalls } = parseBubbles(db, localSeen) + const { calls: bubbleCalls } = parseBubbles(db, localSeen, timeFloor) const { calls: agentKvCalls } = parseAgentKv(db, localSeen, dbPath) allCalls = [...bubbleCalls, ...agentKvCalls] - await writeCachedResults(dbPath, allCalls) + await writeCachedResults(dbPath, allCalls, timeFloor) } finally { db.close() } @@ -784,8 +807,8 @@ export function createCursorProvider(dbPathOverride?: string): Provider { return sources }, - createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { - return createParser(source, seenKeys) + createSessionParser(source: SessionSource, seenKeys: Set, dateRange?: DateRange): SessionParser { + return createParser(source, seenKeys, dateRange) }, } } diff --git a/src/providers/index.ts b/src/providers/index.ts index b6c15621..d4aa717a 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -99,6 +99,21 @@ let cursorAgentLoadAttempted = false let crushProvider: Provider | null = null let crushLoadAttempted = false +let vercelGatewayProvider: Provider | null = null +let vercelGatewayLoadAttempted = false + +async function loadVercelGateway(): Promise { + if (vercelGatewayLoadAttempted) return vercelGatewayProvider + vercelGatewayLoadAttempted = true + try { + const { vercelGateway } = await import('./vercel-gateway.js') + vercelGatewayProvider = vercelGateway + return vercelGateway + } catch { + return null + } +} + async function loadOpenCode(): Promise { if (opencodeLoadAttempted) return opencodeProvider opencodeLoadAttempted = true @@ -138,7 +153,9 @@ async function loadCrush(): Promise { const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, droid, gemini, ibmBob, kiloCode, kiro, kimi, mistralVibe, openclaw, pi, omp, qwen, rooCode] export async function getAllProviders(): Promise { - const [ag, forge, gs, cursor, opencode, cursorAgent, crush, warp] = await Promise.all([loadAntigravity(), loadForge(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush(), loadWarp()]) + const [ag, forge, gs, cursor, opencode, cursorAgent, crush, warp, vercelGw] = await Promise.all([ + loadAntigravity(), loadForge(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush(), loadWarp(), loadVercelGateway(), + ]) const all = [...coreProviders] if (ag) all.push(ag) if (forge) all.push(forge) @@ -148,6 +165,7 @@ export async function getAllProviders(): Promise { if (cursorAgent) all.push(cursorAgent) if (crush) all.push(crush) if (warp) all.push(warp) + if (vercelGw) all.push(vercelGw) return all } @@ -199,5 +217,9 @@ export async function getProvider(name: string): Promise { const w = await loadWarp() return w ?? undefined } + if (name === 'vercel-gateway') { + const vg = await loadVercelGateway() + return vg ?? undefined + } return coreProviders.find(p => p.name === name) } diff --git a/src/providers/types.ts b/src/providers/types.ts index d6e3230e..6cc2c3af 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -1,4 +1,4 @@ -import type { ToolCall } from '../types.js' +import type { DateRange, ToolCall } from '../types.js' export type SessionSource = { path: string @@ -41,5 +41,5 @@ export type Provider = { modelDisplayName(model: string): string toolDisplayName(rawTool: string): string discoverSessions(): Promise - createSessionParser(source: SessionSource, seenKeys: Set): SessionParser + createSessionParser(source: SessionSource, seenKeys: Set, dateRange?: DateRange): SessionParser } diff --git a/src/providers/vercel-gateway.ts b/src/providers/vercel-gateway.ts new file mode 100644 index 00000000..6a5a4978 --- /dev/null +++ b/src/providers/vercel-gateway.ts @@ -0,0 +1,142 @@ +import type { DateRange } from '../types.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +const REPORT_URL = 'https://ai-gateway.vercel.sh/v1/report' + +type ReportRow = { + day?: string + model?: string + total_cost?: number + input_tokens?: number + output_tokens?: number + cached_input_tokens?: number + cache_creation_input_tokens?: number + reasoning_tokens?: number + request_count?: number +} + +export function getVercelGatewayApiKey(): string | null { + const key = process.env['AI_GATEWAY_API_KEY'] ?? process.env['VERCEL_OIDC_TOKEN'] + return key?.trim() ? key.trim() : null +} + +function formatUtcDate(d: Date): string { + const y = d.getUTCFullYear() + const m = String(d.getUTCMonth() + 1).padStart(2, '0') + const day = String(d.getUTCDate()).padStart(2, '0') + return `${y}-${m}-${day}` +} + +export async function fetchVercelGatewayReport( + dateRange: DateRange, +): Promise { + const key = getVercelGatewayApiKey() + if (!key) return [] + + const params = new URLSearchParams({ + start_date: formatUtcDate(dateRange.start), + end_date: formatUtcDate(dateRange.end), + date_part: 'day', + group_by: 'model', + }) + + const res = await fetch(`${REPORT_URL}?${params}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${key}`, + Accept: 'application/json', + }, + }) + + if (!res.ok) { + const detail = await res.text().catch(() => '') + process.stderr.write( + `codeburn: Vercel AI Gateway report failed (HTTP ${res.status}). ` + + 'Requires AI_GATEWAY_API_KEY or VERCEL_OIDC_TOKEN (Pro/Enterprise for /v1/report). ' + + `${detail.slice(0, 200)}\n`, + ) + return [] + } + + const body = (await res.json()) as { results?: ReportRow[] } + return body.results ?? [] +} + +function createParser( + source: SessionSource, + seenKeys: Set, + dateRange?: DateRange, +): SessionParser { + return { + async *parse(): AsyncGenerator { + if (!dateRange) return + + const rows = await fetchVercelGatewayReport(dateRange) + for (const row of rows) { + const day = row.day ?? '' + const model = row.model ?? 'unknown' + const costUSD = row.total_cost ?? 0 + const inputTokens = row.input_tokens ?? 0 + const outputTokens = row.output_tokens ?? 0 + if (costUSD === 0 && inputTokens === 0 && outputTokens === 0) continue + + const deduplicationKey = `vercel-gateway:${day}:${model}` + if (seenKeys.has(deduplicationKey)) continue + seenKeys.add(deduplicationKey) + + yield { + provider: 'vercel-gateway', + model, + inputTokens, + outputTokens, + cacheCreationInputTokens: row.cache_creation_input_tokens ?? 0, + cacheReadInputTokens: row.cached_input_tokens ?? 0, + cachedInputTokens: 0, + reasoningTokens: row.reasoning_tokens ?? 0, + webSearchRequests: 0, + costUSD, + tools: [], + bashCommands: [], + timestamp: day ? `${day}T12:00:00.000Z` : '', + speed: 'standard', + deduplicationKey, + userMessage: '', + sessionId: `${day}:${model}`, + project: source.project, + } + } + }, + } +} + +export const vercelGateway: Provider = { + name: 'vercel-gateway', + displayName: 'Vercel AI Gateway', + + modelDisplayName(model: string): string { + const slash = model.indexOf('/') + return slash >= 0 ? model.slice(slash + 1) : model + }, + + toolDisplayName(rawTool: string): string { + return rawTool + }, + + async discoverSessions(): Promise { + if (!getVercelGatewayApiKey()) return [] + + return [{ + path: 'vercel-ai-gateway:report', + project: 'Vercel AI Gateway', + provider: 'vercel-gateway', + }] + }, + + createSessionParser( + source: SessionSource, + seenKeys: Set, + dateRange?: DateRange, + ): SessionParser { + return createParser(source, seenKeys, dateRange) + }, +} diff --git a/tests/providers/cursor.test.ts b/tests/providers/cursor.test.ts index 29b8b1e5..867886a2 100644 --- a/tests/providers/cursor.test.ts +++ b/tests/providers/cursor.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest' import { getAllProviders } from '../../src/providers/index.js' +import { getCursorTimeFloor } from '../../src/providers/cursor.js' import type { Provider } from '../../src/providers/types.js' describe('cursor provider', () => { @@ -40,6 +41,13 @@ describe('cursor provider', () => { }) }) + describe('time floor', () => { + it('uses dateRange.start when within the six-month cap', () => { + const start = new Date(2026, 3, 1) + expect(getCursorTimeFloor({ start, end: new Date(2026, 5, 2) })).toBe(start.toISOString()) + }) + }) + describe('session discovery', () => { it('returns empty when sqlite is not available', async () => { const sessions = await cursorProvider.discoverSessions() @@ -71,7 +79,7 @@ describe('cursor sqlite adapter', () => { describe('cursor cache', () => { it('returns null when no cache exists', async () => { const { readCachedResults } = await import('../../src/cursor-cache.js') - const result = await readCachedResults('/nonexistent/path.db') + const result = await readCachedResults('/nonexistent/path.db', new Date(0).toISOString()) expect(result).toBeNull() }) }) diff --git a/tests/providers/vercel-gateway.test.ts b/tests/providers/vercel-gateway.test.ts new file mode 100644 index 00000000..1c2c1259 --- /dev/null +++ b/tests/providers/vercel-gateway.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { fetchVercelGatewayReport, vercelGateway } from '../../src/providers/vercel-gateway.js' + +describe('vercel-gateway provider', () => { + const originalFetch = globalThis.fetch + const originalKey = process.env.AI_GATEWAY_API_KEY + + beforeEach(() => { + process.env.AI_GATEWAY_API_KEY = 'test-key' + }) + + afterEach(() => { + globalThis.fetch = originalFetch + if (originalKey === undefined) delete process.env.AI_GATEWAY_API_KEY + else process.env.AI_GATEWAY_API_KEY = originalKey + vi.restoreAllMocks() + }) + + it('discovers a session when API key is set', async () => { + const sessions = await vercelGateway.discoverSessions() + expect(sessions).toHaveLength(1) + expect(sessions[0]?.provider).toBe('vercel-gateway') + }) + + it('returns empty discovery without API key', async () => { + delete process.env.AI_GATEWAY_API_KEY + delete process.env.VERCEL_OIDC_TOKEN + const sessions = await vercelGateway.discoverSessions() + expect(sessions).toEqual([]) + }) + + it('maps report rows to parsed calls', async () => { + globalThis.fetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ + results: [{ + day: '2026-06-01', + model: 'anthropic/claude-sonnet-4.6', + total_cost: 1.25, + input_tokens: 1000, + output_tokens: 200, + request_count: 3, + }], + }), + })) as typeof fetch + + const range = { + start: new Date('2026-06-01T00:00:00.000Z'), + end: new Date('2026-06-02T23:59:59.999Z'), + } + const rows = await fetchVercelGatewayReport(range) + expect(rows).toHaveLength(1) + + const source = { path: 'vercel-ai-gateway:report', project: 'Vercel AI Gateway', provider: 'vercel-gateway' } + const seen = new Set() + const calls = [] + for await (const call of vercelGateway.createSessionParser(source, seen, range).parse()) { + calls.push(call) + } + expect(calls).toHaveLength(1) + expect(calls[0]?.costUSD).toBe(1.25) + expect(calls[0]?.model).toBe('anthropic/claude-sonnet-4.6') + }) +})