fix(cursor): period-aligned lookback; add Vercel AI Gateway provider (#432)

Opt-in Vercel AI Gateway provider (AI_GATEWAY_API_KEY/VERCEL_OIDC_TOKEN), inert without a key. Cursor lookback now period-aligned with a 6-month floor. Gateway fetch hardened on merge with an 8s timeout and try/catch fallback.
This commit is contained in:
Gaurav Sisodia 2026-06-09 14:01:43 -07:00 committed by GitHub
parent 35a8518518
commit a2e18d79e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 352 additions and 18 deletions

View file

@ -123,6 +123,7 @@ Arrow keys switch between Today, 7 Days, 30 Days, Month, and 6 Months (use `--fr
| <img src="assets/providers/crush.png" width="28" /> | Crush | Yes | [crush.md](docs/providers/crush.md) |
| | Warp | Yes | [warp.md](docs/providers/warp.md) |
| | Mux (coder) | Yes | [mux.md](docs/providers/mux.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).
@ -132,6 +133,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.

View file

@ -40,6 +40,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

View file

@ -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:<day>:<model>`.
## 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.

View file

@ -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<ParsedProviderCall[] | null> {
export async function readCachedResults(
dbPath: string,
requestedFloor: string,
): Promise<ParsedProviderCall[] | null> {
try {
const fp = await getDbFingerprint(dbPath)
if (!fp) return null
@ -47,7 +51,13 @@ export async function readCachedResults(dbPath: string): Promise<ParsedProviderC
const raw = await readFile(getCachePath(), 'utf-8')
const cache = JSON.parse(raw) as ResultCache
if (cache.version === CURSOR_CACHE_VERSION && cache.dbMtimeMs === fp.mtimeMs && cache.dbSizeBytes === fp.size) {
if (
cache.version === CURSOR_CACHE_VERSION &&
cache.dbMtimeMs === fp.mtimeMs &&
cache.dbSizeBytes === fp.size &&
typeof cache.lookbackFloor === 'string' &&
cache.lookbackFloor <= requestedFloor
) {
return cache.calls
}
return null
@ -56,7 +66,11 @@ export async function readCachedResults(dbPath: string): Promise<ParsedProviderC
}
}
export async function writeCachedResults(dbPath: string, calls: ParsedProviderCall[]): Promise<void> {
export async function writeCachedResults(
dbPath: string,
calls: ParsedProviderCall[],
lookbackFloor: string,
): Promise<void> {
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,
}

View file

@ -1971,6 +1971,7 @@ async function parseProviderSources(
const parser = provider.createSessionParser(
{ path: source.path, project: source.project, provider: providerName },
parserDedup,
dateRange,
)
try {

View file

@ -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<string, string> = {
@ -384,13 +400,14 @@ function takeUserMessage(queues: Map<string, UserMessageQueue>, conversationId:
return msg
}
function parseBubbles(db: SqliteDatabase, seenKeys: Set<string>): { calls: ParsedProviderCall[] } {
function parseBubbles(
db: SqliteDatabase,
seenKeys: Set<string>,
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<string>, dbPath: string)
return { calls: results }
}
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
function createParser(
source: SessionSource,
seenKeys: Set<string>,
dateRange?: DateRange,
): SessionParser {
const timeFloor = getCursorTimeFloor(dateRange)
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
if (!isSqliteAvailable()) {
@ -699,7 +722,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): 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<string>): 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<string>()
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<string>): SessionParser {
return createParser(source, seenKeys)
createSessionParser(source: SessionSource, seenKeys: Set<string>, dateRange?: DateRange): SessionParser {
return createParser(source, seenKeys, dateRange)
},
}
}

View file

@ -101,6 +101,21 @@ let cursorAgentLoadAttempted = false
let crushProvider: Provider | null = null
let crushLoadAttempted = false
let vercelGatewayProvider: Provider | null = null
let vercelGatewayLoadAttempted = false
async function loadVercelGateway(): Promise<Provider | null> {
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<Provider | null> {
if (opencodeLoadAttempted) return opencodeProvider
opencodeLoadAttempted = true
@ -140,7 +155,9 @@ async function loadCrush(): Promise<Provider | null> {
const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, devin, droid, gemini, ibmBob, kiloCode, kiro, kimi, mistralVibe, mux, openclaw, pi, omp, qwen, rooCode]
export async function getAllProviders(): Promise<Provider[]> {
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)
@ -150,6 +167,7 @@ export async function getAllProviders(): Promise<Provider[]> {
if (cursorAgent) all.push(cursorAgent)
if (crush) all.push(crush)
if (warp) all.push(warp)
if (vercelGw) all.push(vercelGw)
return all
}
@ -201,5 +219,9 @@ export async function getProvider(name: string): Promise<Provider | undefined> {
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)
}

View file

@ -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<SessionSource[]>
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser
createSessionParser(source: SessionSource, seenKeys: Set<string>, dateRange?: DateRange): SessionParser
}

View file

@ -0,0 +1,150 @@
import type { DateRange } from '../types.js'
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
import { fetchWithTimeout } from '../fetch-utils.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<ReportRow[]> {
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',
})
try {
const res = await fetchWithTimeout(`${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 ?? []
} catch (err) {
process.stderr.write(
`codeburn: Vercel AI Gateway report unreachable (${err instanceof Error ? err.message : String(err)}).\n`,
)
return []
}
}
function createParser(
source: SessionSource,
seenKeys: Set<string>,
dateRange?: DateRange,
): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
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<SessionSource[]> {
if (!getVercelGatewayApiKey()) return []
return [{
path: 'vercel-ai-gateway:report',
project: 'Vercel AI Gateway',
provider: 'vercel-gateway',
}]
},
createSessionParser(
source: SessionSource,
seenKeys: Set<string>,
dateRange?: DateRange,
): SessionParser {
return createParser(source, seenKeys, dateRange)
},
}

View file

@ -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()
})
})

View file

@ -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<string>()
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')
})
})