diff --git a/src/parser.ts b/src/parser.ts index 80afbad..085f7d7 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1696,7 +1696,7 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall { webSearchRequests: call.webSearchRequests, cacheCreationOneHourTokens: 0, }, - costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity' || call.provider === 'devin') ? call.costUSD : undefined, + costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity' || call.provider === 'devin' || call.provider === 'vercel-gateway') ? call.costUSD : undefined, speed: call.speed, timestamp: call.timestamp, tools: call.tools, @@ -1931,6 +1931,16 @@ async function parseProviderSources( for (const source of sources) { allDiscoveredFiles.add(source.path) + + // Network providers (e.g. Vercel AI Gateway) have no on-disk file — their data + // comes from a live API fetch in createSessionParser. There's nothing to + // fingerprint or incrementally cache, so re-fetch every run with a synthetic + // fingerprint (mtime=now so the date-range filter below never excludes it). + if (provider.network) { + changedSources.push({ source, fp: { dev: 0, ino: 0, mtimeMs: Date.now(), sizeBytes: 0 } }) + continue + } + const fp = await fingerprintFile(source.path) if (!fp) continue diff --git a/src/providers/types.ts b/src/providers/types.ts index 6cc2c3a..3005408 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -38,6 +38,9 @@ export type ParsedProviderCall = { export type Provider = { name: string displayName: string + // Data comes from a live API fetch (no on-disk file). Such sources can't be + // fingerprinted or incrementally cached, so the parser re-fetches every run. + network?: boolean modelDisplayName(model: string): string toolDisplayName(rawTool: string): string discoverSessions(): Promise diff --git a/src/providers/vercel-gateway.ts b/src/providers/vercel-gateway.ts index aeb7112..9968d6d 100644 --- a/src/providers/vercel-gateway.ts +++ b/src/providers/vercel-gateway.ts @@ -120,6 +120,7 @@ function createParser( export const vercelGateway: Provider = { name: 'vercel-gateway', displayName: 'Vercel AI Gateway', + network: true, modelDisplayName(model: string): string { const slash = model.indexOf('/') diff --git a/tests/providers/vercel-gateway.test.ts b/tests/providers/vercel-gateway.test.ts index 1c2c125..856c675 100644 --- a/tests/providers/vercel-gateway.test.ts +++ b/tests/providers/vercel-gateway.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mkdtemp, rm } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' import { fetchVercelGatewayReport, vercelGateway } from '../../src/providers/vercel-gateway.js' +import { parseAllSessions, clearSessionCache } from '../../src/parser.js' describe('vercel-gateway provider', () => { const originalFetch = globalThis.fetch @@ -62,3 +66,52 @@ describe('vercel-gateway provider', () => { expect(calls[0]?.model).toBe('anthropic/claude-sonnet-4.6') }) }) + +describe('vercel-gateway end-to-end (parseAllSessions network path)', () => { + const originalFetch = globalThis.fetch + const originalKey = process.env.AI_GATEWAY_API_KEY + const originalCacheDir = process.env.CODEBURN_CACHE_DIR + let cacheDir: string + + beforeEach(async () => { + cacheDir = await mkdtemp(join(tmpdir(), 'cb-vercel-cache-')) + process.env.CODEBURN_CACHE_DIR = cacheDir + process.env.AI_GATEWAY_API_KEY = 'test-key' + clearSessionCache() + }) + + afterEach(async () => { + globalThis.fetch = originalFetch + if (originalKey === undefined) delete process.env.AI_GATEWAY_API_KEY + else process.env.AI_GATEWAY_API_KEY = originalKey + if (originalCacheDir === undefined) delete process.env.CODEBURN_CACHE_DIR + else process.env.CODEBURN_CACHE_DIR = originalCacheDir + clearSessionCache() + vi.restoreAllMocks() + await rm(cacheDir, { recursive: true, force: true }) + }) + + // Regression: the synthetic source path `vercel-ai-gateway:report` has no file + // on disk, so it was dropped by the fingerprintFile gate in parseProviderSources + // and the provider always reported $0. Network providers must survive that gate + // and contribute their fetched cost through the real aggregation pipeline. + it('network source survives the fingerprint gate and contributes cost', async () => { + globalThis.fetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ + results: [ + { day: '2026-06-01', model: 'openai/gpt-4o', total_cost: 12.34, input_tokens: 1000, output_tokens: 500, request_count: 3 }, + ], + }), + })) as typeof fetch + + const range = { + start: new Date('2026-05-01T00:00:00.000Z'), + end: new Date('2026-06-09T23:59:59.999Z'), + } + const projects = await parseAllSessions(range, 'vercel-gateway') + const total = projects.reduce((sum, p) => sum + p.totalCostUSD, 0) + + expect(total).toBeCloseTo(12.34, 2) + }) +})