mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-30 11:25:08 +00:00
Merge pull request #839 from getagentseal/phase8/stateful-antigravity
refactor(core): antigravity decode into core, stitching and caches host-side (phase 8, stateful tier)
This commit is contained in:
commit
be00eed4e9
10 changed files with 2792 additions and 624 deletions
|
|
@ -8,6 +8,24 @@ import https from 'https'
|
|||
|
||||
import { isSqliteAvailable, isSqliteBusyError, openDatabase } from '../sqlite.js'
|
||||
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
import type { DecodeContext } from '@codeburn/core'
|
||||
import {
|
||||
decodeAntigravityGenMetadata,
|
||||
decodeAntigravityGeneratorMetadata,
|
||||
decodeAntigravityStatusLine,
|
||||
parseAntigravityStatusLinePayload,
|
||||
extractAntigravityModelMap,
|
||||
extractAntigravityGeneratorMetadata,
|
||||
} from '@codeburn/core/providers/antigravity'
|
||||
import type {
|
||||
AntigravityDecodedCall,
|
||||
AntigravityGeneratorMetadata,
|
||||
AntigravityModelMap,
|
||||
} from '@codeburn/core/providers/antigravity'
|
||||
|
||||
// Moved into @codeburn/core (the decoders use them). Re-exported so existing
|
||||
// importers keep resolving them from this module.
|
||||
export { extractAntigravityModelMap, extractAntigravityGeneratorMetadata }
|
||||
|
||||
type AntigravityConversationRoot = {
|
||||
dir: string
|
||||
|
|
@ -61,74 +79,7 @@ type ServerCandidate = ServerInfo & {
|
|||
appDataDir?: 'antigravity' | 'antigravity-cli' | 'antigravity-ide'
|
||||
}
|
||||
|
||||
type ModelMap = Record<string, string>
|
||||
|
||||
type UsageEntry = {
|
||||
model: string
|
||||
inputTokens: string
|
||||
outputTokens: string
|
||||
thinkingOutputTokens?: string
|
||||
responseOutputTokens?: string
|
||||
apiProvider: string
|
||||
responseId?: string
|
||||
}
|
||||
|
||||
export type GeneratorMetadata = {
|
||||
stepIndices?: number[]
|
||||
chatModel?: {
|
||||
model: string
|
||||
usage: UsageEntry
|
||||
chatStartMetadata?: {
|
||||
createdAt?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ModelMapResponse = {
|
||||
models?: Record<string, { model?: string; displayName?: string }>
|
||||
response?: {
|
||||
models?: Record<string, { model?: string; displayName?: string }>
|
||||
}
|
||||
}
|
||||
|
||||
type GeneratorMetadataResponse = {
|
||||
generatorMetadata?: GeneratorMetadata[]
|
||||
response?: {
|
||||
generatorMetadata?: GeneratorMetadata[]
|
||||
}
|
||||
}
|
||||
|
||||
type StatusLineCurrentUsage = {
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
cache_creation_input_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
}
|
||||
|
||||
type StatusLinePayload = {
|
||||
conversation_id?: string
|
||||
session_id?: string
|
||||
model?: string | {
|
||||
id?: string
|
||||
display_name?: string
|
||||
}
|
||||
context_window?: {
|
||||
current_usage?: StatusLineCurrentUsage | null
|
||||
}
|
||||
}
|
||||
|
||||
type StatusLineEvent = {
|
||||
at: string
|
||||
conversationId: string
|
||||
sessionId?: string
|
||||
model: string
|
||||
usage: {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheCreationInputTokens: number
|
||||
cacheReadInputTokens: number
|
||||
}
|
||||
}
|
||||
type ModelMap = AntigravityModelMap
|
||||
|
||||
type CachedCascade = {
|
||||
mtimeMs: number
|
||||
|
|
@ -141,29 +92,11 @@ type AntigravityCache = {
|
|||
cascades: Record<string, CachedCascade>
|
||||
}
|
||||
|
||||
type ProtoField = {
|
||||
number: number
|
||||
wireType: number
|
||||
value?: bigint
|
||||
bytes?: Uint8Array
|
||||
}
|
||||
|
||||
type ProtoVarint = {
|
||||
value: bigint
|
||||
offset: number
|
||||
}
|
||||
|
||||
type AntigravityGenMetadataRow = {
|
||||
idx: number
|
||||
data: Uint8Array | string
|
||||
}
|
||||
|
||||
const cachedServers = new Map<string, ServerInfo | null>()
|
||||
const cachedModelMaps = new Map<string, ModelMap>()
|
||||
let memCache: AntigravityCache | null = null
|
||||
let cacheDirty = false
|
||||
let httpsAgent: https.Agent | undefined
|
||||
const protoTextDecoder = new TextDecoder('utf-8', { fatal: false })
|
||||
|
||||
const SERVER_PORT_FLAGS = ['https_server_port', 'extension_server_port', 'https-server-port', 'extension-server-port']
|
||||
const CSRF_TOKEN_FLAGS = ['csrf_token', 'extension_server_csrf_token', 'csrf-token', 'extension-server-csrf-token']
|
||||
|
|
@ -259,69 +192,6 @@ function parseAntigravityServerCandidates(lines: string[]): ServerCandidate[] {
|
|||
.filter((server): server is ServerCandidate => server !== null)
|
||||
}
|
||||
|
||||
// Antigravity's own model-map config sometimes hasn't caught up with a new
|
||||
// model yet, so both the config key and displayName can still be the raw
|
||||
// placeholder id (e.g. "MODEL_PLACEHOLDER_M26"). Falling through to that
|
||||
// value as the "canonical" model would leak an internal placeholder as a
|
||||
// model name; 'unknown' is what this file already uses when no model can be
|
||||
// resolved at all (see antigravitySqliteModel).
|
||||
const MODEL_PLACEHOLDER_PATTERN = /^MODEL_PLACEHOLDER_/
|
||||
|
||||
function dropPlaceholderModelId(model: string): string {
|
||||
return MODEL_PLACEHOLDER_PATTERN.test(model) ? 'unknown' : model
|
||||
}
|
||||
|
||||
function getCanonicalModelId(key: string, displayName?: string): string {
|
||||
if (displayName) {
|
||||
const lower = displayName.toLowerCase()
|
||||
if (lower.includes('3.5 flash')) {
|
||||
if (lower.includes('high')) return 'gemini-3.5-flash-high'
|
||||
if (lower.includes('medium')) return 'gemini-3.5-flash-medium'
|
||||
if (lower.includes('low')) return 'gemini-3.5-flash-low'
|
||||
return 'gemini-3.5-flash'
|
||||
}
|
||||
if (lower.includes('3.1 pro')) {
|
||||
if (lower.includes('high')) return 'gemini-3.1-pro-high'
|
||||
if (lower.includes('low')) return 'gemini-3.1-pro-low'
|
||||
return 'gemini-3.1-pro'
|
||||
}
|
||||
if (lower.includes('3.1 flash')) {
|
||||
if (lower.includes('image')) return 'gemini-3.1-flash-image'
|
||||
if (lower.includes('lite')) return 'gemini-3.1-flash-lite'
|
||||
return 'gemini-3.1-flash'
|
||||
}
|
||||
if (lower.includes('3 flash')) {
|
||||
return 'gemini-3-flash'
|
||||
}
|
||||
if (lower.includes('3 pro')) {
|
||||
return 'gemini-3-pro'
|
||||
}
|
||||
}
|
||||
return dropPlaceholderModelId(key)
|
||||
}
|
||||
|
||||
export function extractAntigravityModelMap(resp: unknown): ModelMap {
|
||||
if (!resp || typeof resp !== 'object') return {}
|
||||
const data = resp as ModelMapResponse
|
||||
const models = data.response?.models ?? data.models
|
||||
const map = new Map<string, string>()
|
||||
if (!models) return {}
|
||||
for (const [key, info] of Object.entries(models)) {
|
||||
if (info && typeof info === 'object' && typeof info.model === 'string') {
|
||||
const canonicalKey = getCanonicalModelId(key, info.displayName)
|
||||
map.set(info.model, canonicalKey)
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(map)
|
||||
}
|
||||
|
||||
export function extractAntigravityGeneratorMetadata(resp: unknown): GeneratorMetadata[] {
|
||||
if (!resp || typeof resp !== 'object') return []
|
||||
const data = resp as GeneratorMetadataResponse
|
||||
const metadata = data.response?.generatorMetadata ?? data.generatorMetadata
|
||||
return Array.isArray(metadata) ? metadata : []
|
||||
}
|
||||
|
||||
async function loadCache(): Promise<AntigravityCache> {
|
||||
if (memCache) return memCache
|
||||
try {
|
||||
|
|
@ -578,234 +448,40 @@ function normalizePricingModel(model: string): string {
|
|||
return PRICING_ALIASES[stripped] ?? stripped
|
||||
}
|
||||
|
||||
function readProtoVarint(data: Uint8Array, startOffset: number): ProtoVarint | null {
|
||||
let value = 0n
|
||||
let shift = 0n
|
||||
let offset = startOffset
|
||||
|
||||
while (offset < data.length) {
|
||||
const byte = BigInt(data[offset]!)
|
||||
offset += 1
|
||||
value |= (byte & 0x7fn) << shift
|
||||
if ((byte & 0x80n) === 0n) return { value, offset }
|
||||
shift += 7n
|
||||
if (shift > 70n) return null
|
||||
}
|
||||
|
||||
return null
|
||||
function decodeContext(sourceRef: string): DecodeContext {
|
||||
return { privacyKey: '', providerId: 'antigravity', sourceRef }
|
||||
}
|
||||
|
||||
function parseProtoFields(data: Uint8Array): ProtoField[] {
|
||||
const fields: ProtoField[] = []
|
||||
let offset = 0
|
||||
|
||||
while (offset < data.length) {
|
||||
const key = readProtoVarint(data, offset)
|
||||
if (!key) break
|
||||
offset = key.offset
|
||||
|
||||
const fieldNumber = Number(key.value >> 3n)
|
||||
const wireType = Number(key.value & 0x7n)
|
||||
if (!Number.isSafeInteger(fieldNumber) || fieldNumber <= 0) break
|
||||
|
||||
if (wireType === 0) {
|
||||
const value = readProtoVarint(data, offset)
|
||||
if (!value) break
|
||||
fields.push({ number: fieldNumber, wireType, value: value.value })
|
||||
offset = value.offset
|
||||
continue
|
||||
}
|
||||
|
||||
if (wireType === 1) {
|
||||
if (offset + 8 > data.length) break
|
||||
fields.push({ number: fieldNumber, wireType, bytes: data.subarray(offset, offset + 8) })
|
||||
offset += 8
|
||||
continue
|
||||
}
|
||||
|
||||
if (wireType === 2) {
|
||||
const length = readProtoVarint(data, offset)
|
||||
if (!length) break
|
||||
offset = length.offset
|
||||
const byteLength = Number(length.value)
|
||||
if (!Number.isSafeInteger(byteLength) || byteLength < 0 || offset + byteLength > data.length) break
|
||||
fields.push({ number: fieldNumber, wireType, bytes: data.subarray(offset, offset + byteLength) })
|
||||
offset += byteLength
|
||||
continue
|
||||
}
|
||||
|
||||
if (wireType === 5) {
|
||||
if (offset + 4 > data.length) break
|
||||
fields.push({ number: fieldNumber, wireType, bytes: data.subarray(offset, offset + 4) })
|
||||
offset += 4
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
function firstProtoField(fields: readonly ProtoField[], fieldNumber: number): ProtoField | undefined {
|
||||
return fields.find(field => field.number === fieldNumber)
|
||||
}
|
||||
|
||||
function protoFieldText(field: ProtoField | undefined): string | undefined {
|
||||
if (!field?.bytes || field.bytes.length === 0) return undefined
|
||||
const text = protoTextDecoder.decode(field.bytes)
|
||||
if (!text || /[\u0000-\u0008\u000E-\u001F\u007F\uFFFD]/.test(text)) return undefined
|
||||
return text
|
||||
}
|
||||
|
||||
function protoFieldPositiveInteger(field: ProtoField | undefined): number {
|
||||
if (field?.value === undefined) return 0
|
||||
const value = Number(field.value)
|
||||
return Number.isSafeInteger(value) && value > 0 ? value : 0
|
||||
}
|
||||
|
||||
function protoFieldBytes(field: ProtoField | undefined): Uint8Array | undefined {
|
||||
return field?.bytes
|
||||
}
|
||||
|
||||
function isAntigravityResponseId(value: string): boolean {
|
||||
return /^[^\s]+$/.test(value)
|
||||
}
|
||||
|
||||
function antigravitySqliteResponseId(usageFields: readonly ProtoField[], fallback: string): string {
|
||||
const responseId = protoFieldText(firstProtoField(usageFields, 11))
|
||||
return responseId && isAntigravityResponseId(responseId) ? responseId : fallback
|
||||
}
|
||||
|
||||
function genMetadataDataBytes(value: Uint8Array | string): Uint8Array {
|
||||
return typeof value === 'string'
|
||||
? new TextEncoder().encode(value)
|
||||
: value
|
||||
}
|
||||
|
||||
function antigravitySqliteMetadataAttributes(chatFields: readonly ProtoField[]): Map<string, string> {
|
||||
const attributes = new Map<string, string>()
|
||||
for (const field of chatFields) {
|
||||
if (field.number !== 20) continue
|
||||
const pairFields = parseProtoFields(protoFieldBytes(field) ?? new Uint8Array())
|
||||
const key = protoFieldText(firstProtoField(pairFields, 1))
|
||||
const value = protoFieldText(firstProtoField(pairFields, 2))
|
||||
if (key && value) attributes.set(key, value)
|
||||
}
|
||||
return attributes
|
||||
}
|
||||
|
||||
function antigravitySqliteModel(chatFields: readonly ProtoField[]): string {
|
||||
const attributes = antigravitySqliteMetadataAttributes(chatFields)
|
||||
const displayName = protoFieldText(firstProtoField(chatFields, 21))
|
||||
const rawModel = protoFieldText(firstProtoField(chatFields, 19))
|
||||
?? attributes.get('model_enum')
|
||||
?? displayName
|
||||
?? 'unknown'
|
||||
|
||||
return getCanonicalModelId(rawModel, displayName)
|
||||
}
|
||||
|
||||
// Decode a proto field that carries a time into an ISO-8601 string. Antigravity
|
||||
// may encode ChatStartMetadata.created_at as an ISO string, a Timestamp
|
||||
// submessage (seconds in field 1), or a bare unix varint. Returns '' when the
|
||||
// field is absent or unparseable so the caller can fall back.
|
||||
function protoTimestampToIso(field: ProtoField | undefined): string {
|
||||
if (!field) return ''
|
||||
const text = protoFieldText(field)
|
||||
if (text && !Number.isNaN(Date.parse(text))) return new Date(text).toISOString()
|
||||
if (field.bytes) {
|
||||
// google.protobuf.Timestamp submessage: seconds (#1), nanos (#2).
|
||||
const tsFields = parseProtoFields(field.bytes)
|
||||
const seconds = firstProtoField(tsFields, 1)?.value
|
||||
if (seconds !== undefined) {
|
||||
const nanos = firstProtoField(tsFields, 2)?.value ?? 0n
|
||||
const ms = Number(seconds) * 1000 + Math.floor(Number(nanos) / 1e6)
|
||||
if (Number.isSafeInteger(ms) && ms > 0) return new Date(ms).toISOString()
|
||||
}
|
||||
}
|
||||
if (field.value !== undefined) {
|
||||
const raw = Number(field.value)
|
||||
const ms = raw < 1e12 ? raw * 1000 : raw
|
||||
if (Number.isSafeInteger(ms) && ms > 0) return new Date(ms).toISOString()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// ChatStartMetadata lives at chatModel(#1).#9; its created_at is #4. Not every
|
||||
// gen_metadata row carries it, so this returns '' when missing.
|
||||
function antigravitySqliteCreatedAt(chatFields: readonly ProtoField[]): string {
|
||||
const metadataBytes = protoFieldBytes(firstProtoField(chatFields, 9))
|
||||
if (!metadataBytes) return ''
|
||||
return protoTimestampToIso(firstProtoField(parseProtoFields(metadataBytes), 4))
|
||||
}
|
||||
|
||||
function buildCallFromSqliteGenMetadataRow(cascadeId: string, row: AntigravityGenMetadataRow): ParsedProviderCall | null {
|
||||
const rootFields = parseProtoFields(genMetadataDataBytes(row.data))
|
||||
const chatFields = parseProtoFields(protoFieldBytes(firstProtoField(rootFields, 1)) ?? new Uint8Array())
|
||||
const usageFields = parseProtoFields(protoFieldBytes(firstProtoField(chatFields, 4)) ?? new Uint8Array())
|
||||
if (usageFields.length === 0) return null
|
||||
|
||||
const inputTokens = protoFieldPositiveInteger(firstProtoField(usageFields, 2))
|
||||
|| protoFieldPositiveInteger(firstProtoField(usageFields, 1))
|
||||
const totalOutputTokens = protoFieldPositiveInteger(firstProtoField(usageFields, 3))
|
||||
let responseTokens = protoFieldPositiveInteger(firstProtoField(usageFields, 9))
|
||||
let thinkingTokens = protoFieldPositiveInteger(firstProtoField(usageFields, 10))
|
||||
|
||||
if (responseTokens === 0 && thinkingTokens === 0) {
|
||||
responseTokens = totalOutputTokens
|
||||
} else if (totalOutputTokens > 0 && responseTokens + thinkingTokens !== totalOutputTokens) {
|
||||
const adjustedResponseTokens = totalOutputTokens - thinkingTokens
|
||||
if (adjustedResponseTokens >= 0) responseTokens = adjustedResponseTokens
|
||||
}
|
||||
|
||||
if (inputTokens === 0 && totalOutputTokens === 0) return null
|
||||
|
||||
const responseId = antigravitySqliteResponseId(usageFields, String(row.idx))
|
||||
const model = antigravitySqliteModel(chatFields)
|
||||
const pricingModel = normalizePricingModel(model)
|
||||
|
||||
// Exported so the RPC-arm golden can assert the exact emitted shape without a
|
||||
// live language server; every emit arm goes through this one mapper.
|
||||
export function toProviderCall(rich: AntigravityDecodedCall, project?: string): ParsedProviderCall {
|
||||
return {
|
||||
provider: 'antigravity',
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens: responseTokens,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: thinkingTokens,
|
||||
webSearchRequests: 0,
|
||||
model: rich.model,
|
||||
inputTokens: rich.inputTokens,
|
||||
outputTokens: rich.outputTokens,
|
||||
cacheCreationInputTokens: rich.cacheCreationInputTokens,
|
||||
cacheReadInputTokens: rich.cacheReadInputTokens,
|
||||
cachedInputTokens: rich.cachedInputTokens,
|
||||
reasoningTokens: rich.reasoningTokens,
|
||||
webSearchRequests: rich.webSearchRequests,
|
||||
// Whitelisted for cost persistence: the pass prices `pricingModel` (which
|
||||
// strips suffixes / applies aliases) and the result is stored verbatim, so
|
||||
// the display `model` never reaches the price table. reasoning is billed at
|
||||
// the output rate (outputTokens + reasoningTokens), matching the lift.
|
||||
costBasis: 'estimated',
|
||||
pricingModel,
|
||||
pricingModel: normalizePricingModel(rich.model),
|
||||
tools: [],
|
||||
bashCommands: [],
|
||||
timestamp: antigravitySqliteCreatedAt(chatFields),
|
||||
speed: 'standard',
|
||||
deduplicationKey: `antigravity:${cascadeId}:${responseId}`,
|
||||
timestamp: rich.timestamp,
|
||||
speed: rich.speed,
|
||||
deduplicationKey: rich.deduplicationKey,
|
||||
userMessage: '',
|
||||
sessionId: cascadeId,
|
||||
sessionId: rich.sessionId,
|
||||
...(project !== undefined ? { project } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function buildCallsFromSqliteGenMetadata(cascadeId: string, rows: AntigravityGenMetadataRow[]): ParsedProviderCall[] {
|
||||
const calls: ParsedProviderCall[] = []
|
||||
const seenResponseIds = new Set<string>()
|
||||
|
||||
for (const row of rows) {
|
||||
const call = buildCallFromSqliteGenMetadataRow(cascadeId, row)
|
||||
if (!call) continue
|
||||
if (seenResponseIds.has(call.deduplicationKey)) continue
|
||||
seenResponseIds.add(call.deduplicationKey)
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
return calls
|
||||
}
|
||||
|
||||
async function parseSqliteGenMetadataCalls(filePath: string, cascadeId: string): Promise<ParsedProviderCall[]> {
|
||||
if (!filePath.toLowerCase().endsWith('.db')) return []
|
||||
if (!isSqliteAvailable()) return []
|
||||
|
|
@ -813,8 +489,9 @@ async function parseSqliteGenMetadataCalls(filePath: string, cascadeId: string):
|
|||
let db: ReturnType<typeof openDatabase> | null = null
|
||||
try {
|
||||
db = openDatabase(filePath)
|
||||
const rows = db.query<AntigravityGenMetadataRow>('SELECT idx, data FROM gen_metadata ORDER BY idx')
|
||||
return buildCallsFromSqliteGenMetadata(cascadeId, rows)
|
||||
const rows = db.query<{ idx: number; data: Uint8Array | string }>('SELECT idx, data FROM gen_metadata ORDER BY idx')
|
||||
const { calls } = decodeAntigravityGenMetadata({ records: rows, context: decodeContext(filePath), cascadeId })
|
||||
return calls.map(c => toProviderCall(c))
|
||||
} catch (err) {
|
||||
// Let a transient lock propagate so the run retries this file on the next
|
||||
// refresh instead of treating it as empty (see parser.ts busy handling).
|
||||
|
|
@ -825,107 +502,10 @@ async function parseSqliteGenMetadataCalls(filePath: string, cascadeId: string):
|
|||
}
|
||||
}
|
||||
|
||||
function parseFiniteToken(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||
? Math.floor(value)
|
||||
: 0
|
||||
}
|
||||
|
||||
function usageSignature(event: StatusLineEvent): string {
|
||||
const u = event.usage
|
||||
return [
|
||||
event.model,
|
||||
u.inputTokens,
|
||||
u.outputTokens,
|
||||
u.cacheCreationInputTokens,
|
||||
u.cacheReadInputTokens,
|
||||
].join(':')
|
||||
}
|
||||
|
||||
function usageHasTokens(usage: StatusLineEvent['usage']): boolean {
|
||||
return (
|
||||
usage.inputTokens > 0 ||
|
||||
usage.outputTokens > 0 ||
|
||||
usage.cacheCreationInputTokens > 0 ||
|
||||
usage.cacheReadInputTokens > 0
|
||||
)
|
||||
}
|
||||
|
||||
function usageIsMonotonic(current: StatusLineEvent['usage'], previous: StatusLineEvent['usage']): boolean {
|
||||
return (
|
||||
current.inputTokens >= previous.inputTokens &&
|
||||
current.outputTokens >= previous.outputTokens &&
|
||||
current.cacheCreationInputTokens >= previous.cacheCreationInputTokens &&
|
||||
current.cacheReadInputTokens >= previous.cacheReadInputTokens
|
||||
)
|
||||
}
|
||||
|
||||
function usageDelta(current: StatusLineEvent['usage'], previous: StatusLineEvent['usage']): StatusLineEvent['usage'] {
|
||||
return {
|
||||
inputTokens: current.inputTokens - previous.inputTokens,
|
||||
outputTokens: current.outputTokens - previous.outputTokens,
|
||||
cacheCreationInputTokens: current.cacheCreationInputTokens - previous.cacheCreationInputTokens,
|
||||
cacheReadInputTokens: current.cacheReadInputTokens - previous.cacheReadInputTokens,
|
||||
}
|
||||
}
|
||||
|
||||
export function antigravityCascadeIdFromPath(path: string): string {
|
||||
return basename(path).replace(/\.(pb|db)$/i, '')
|
||||
}
|
||||
|
||||
function buildCallsFromGeneratorMetadata(
|
||||
cascadeId: string,
|
||||
metadata: GeneratorMetadata[],
|
||||
modelMap: ModelMap,
|
||||
): ParsedProviderCall[] {
|
||||
const results: ParsedProviderCall[] = []
|
||||
|
||||
for (let i = 0; i < metadata.length; i++) {
|
||||
const entry = metadata[i]!
|
||||
const usage = entry.chatModel?.usage
|
||||
if (!usage) continue
|
||||
|
||||
const inputTokens = parseInt(usage.inputTokens ?? '0', 10)
|
||||
const outputTokens = parseInt(usage.outputTokens ?? '0', 10)
|
||||
const thinkingTokens = parseInt(usage.thinkingOutputTokens ?? '0', 10)
|
||||
const responseTokens = parseInt(usage.responseOutputTokens ?? '0', 10)
|
||||
|
||||
if (inputTokens === 0 && outputTokens === 0) continue
|
||||
|
||||
const responseId = usage.responseId || String(i)
|
||||
const dedupKey = `antigravity:${cascadeId}:${responseId}`
|
||||
|
||||
const model = dropPlaceholderModelId(modelMap[usage.model] ?? usage.model)
|
||||
const pricingModel = normalizePricingModel(model)
|
||||
const timestamp = entry.chatModel?.chatStartMetadata?.createdAt ?? ''
|
||||
|
||||
results.push({
|
||||
provider: 'antigravity',
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens: responseTokens,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: thinkingTokens,
|
||||
webSearchRequests: 0,
|
||||
// See buildCallFromSqliteGenMetadataRow: pricing pass prices `pricingModel`
|
||||
// and the whitelist persists the result verbatim.
|
||||
costBasis: 'estimated',
|
||||
pricingModel,
|
||||
tools: [],
|
||||
bashCommands: [],
|
||||
timestamp,
|
||||
speed: 'standard',
|
||||
deduplicationKey: dedupKey,
|
||||
userMessage: '',
|
||||
sessionId: cascadeId,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
function isConversationFile(file: string, extensions: readonly string[]): boolean {
|
||||
const lowerFile = file.toLowerCase()
|
||||
return extensions.some(ext => lowerFile.endsWith(ext))
|
||||
|
|
@ -979,38 +559,8 @@ export async function discoverAntigravitySessionSources(
|
|||
return sources
|
||||
}
|
||||
|
||||
function parseStatusLinePayload(input: unknown): StatusLineEvent | null {
|
||||
if (!input || typeof input !== 'object') return null
|
||||
const payload = input as StatusLinePayload
|
||||
if (typeof payload.conversation_id !== 'string' || payload.conversation_id.length === 0) return null
|
||||
const usage = payload.context_window?.current_usage
|
||||
if (!usage) return null
|
||||
|
||||
const event: StatusLineEvent = {
|
||||
at: new Date().toISOString(),
|
||||
conversationId: payload.conversation_id,
|
||||
sessionId: typeof payload.session_id === 'string' ? payload.session_id : undefined,
|
||||
model: typeof payload.model === 'string'
|
||||
? payload.model
|
||||
: payload.model?.id ?? payload.model?.display_name ?? 'unknown',
|
||||
usage: {
|
||||
inputTokens: parseFiniteToken(usage.input_tokens),
|
||||
outputTokens: parseFiniteToken(usage.output_tokens),
|
||||
cacheCreationInputTokens: parseFiniteToken(usage.cache_creation_input_tokens),
|
||||
cacheReadInputTokens: parseFiniteToken(usage.cache_read_input_tokens),
|
||||
},
|
||||
}
|
||||
|
||||
const u = event.usage
|
||||
if (u.inputTokens === 0 && u.outputTokens === 0 && u.cacheCreationInputTokens === 0 && u.cacheReadInputTokens === 0) {
|
||||
return null
|
||||
}
|
||||
if (event.model === 'unknown') return null
|
||||
return event
|
||||
}
|
||||
|
||||
export async function recordAntigravityStatusLinePayload(input: unknown): Promise<boolean> {
|
||||
const event = parseStatusLinePayload(input)
|
||||
const event = parseAntigravityStatusLinePayload(input, new Date().toISOString())
|
||||
if (!event) return false
|
||||
|
||||
const path = getAntigravityStatusLineEventsPath()
|
||||
|
|
@ -1024,128 +574,14 @@ export async function recordAntigravityStatusLinePayload(input: unknown): Promis
|
|||
return true
|
||||
}
|
||||
|
||||
function parseStatusLineEvent(input: unknown): StatusLineEvent | null {
|
||||
if (!input || typeof input !== 'object') return null
|
||||
const event = input as StatusLineEvent
|
||||
if (typeof event.at !== 'string' || Number.isNaN(new Date(event.at).getTime())) return null
|
||||
if (typeof event.conversationId !== 'string' || event.conversationId.length === 0) return null
|
||||
if (typeof event.model !== 'string' || event.model.length === 0) return null
|
||||
if (!event.usage || typeof event.usage !== 'object') return null
|
||||
|
||||
const usage = {
|
||||
inputTokens: parseFiniteToken(event.usage.inputTokens),
|
||||
outputTokens: parseFiniteToken(event.usage.outputTokens),
|
||||
cacheCreationInputTokens: parseFiniteToken(event.usage.cacheCreationInputTokens),
|
||||
cacheReadInputTokens: parseFiniteToken(event.usage.cacheReadInputTokens),
|
||||
}
|
||||
|
||||
if (
|
||||
usage.inputTokens === 0 &&
|
||||
usage.outputTokens === 0 &&
|
||||
usage.cacheCreationInputTokens === 0 &&
|
||||
usage.cacheReadInputTokens === 0
|
||||
) return null
|
||||
|
||||
return {
|
||||
at: event.at,
|
||||
conversationId: event.conversationId,
|
||||
sessionId: typeof event.sessionId === 'string' ? event.sessionId : undefined,
|
||||
model: event.model,
|
||||
usage,
|
||||
}
|
||||
}
|
||||
|
||||
function hasRpcCacheForConversation(seenKeys: Set<string>, conversationId: string): boolean {
|
||||
const prefix = `antigravity:${conversationId}:`
|
||||
for (const key of seenKeys) {
|
||||
if (key.startsWith(prefix)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function parseStatusLineCalls(source: SessionSource, seenKeys: Set<string>): Promise<ParsedProviderCall[]> {
|
||||
const raw = await readFile(source.path, 'utf-8').catch(() => '')
|
||||
const runsByConversation = new Map<string, Array<{ event: StatusLineEvent; signature: string; count: number }>>()
|
||||
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
if (!line.trim()) continue
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(line)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const event = parseStatusLineEvent(parsed)
|
||||
if (!event) continue
|
||||
if (hasRpcCacheForConversation(seenKeys, event.conversationId)) continue
|
||||
|
||||
const signature = usageSignature(event)
|
||||
const runs = runsByConversation.get(event.conversationId) ?? []
|
||||
const lastRun = runs.at(-1)
|
||||
if (lastRun?.signature === signature) {
|
||||
lastRun.count += 1
|
||||
lastRun.event = event
|
||||
} else {
|
||||
runs.push({ event, signature, count: 1 })
|
||||
runsByConversation.set(event.conversationId, runs)
|
||||
}
|
||||
}
|
||||
|
||||
const results: ParsedProviderCall[] = []
|
||||
|
||||
for (const runs of runsByConversation.values()) {
|
||||
let turnIndex = 0
|
||||
let previousSnapshotUsage: StatusLineEvent['usage'] | null = null
|
||||
for (let i = 0; i < runs.length; i++) {
|
||||
const run = runs[i]!
|
||||
const isLastRun = i === runs.length - 1
|
||||
if (run.count === 1 && !isLastRun) continue
|
||||
|
||||
const event = run.event
|
||||
const signature = run.signature
|
||||
const billableUsage = previousSnapshotUsage && usageIsMonotonic(event.usage, previousSnapshotUsage)
|
||||
? usageDelta(event.usage, previousSnapshotUsage)
|
||||
: event.usage
|
||||
previousSnapshotUsage = event.usage
|
||||
if (!usageHasTokens(billableUsage)) continue
|
||||
|
||||
const dedupKey = `antigravity-statusline:${event.conversationId}:${turnIndex}:${signature}`
|
||||
turnIndex += 1
|
||||
if (seenKeys.has(dedupKey)) continue
|
||||
|
||||
const u = billableUsage
|
||||
|
||||
results.push({
|
||||
provider: 'antigravity',
|
||||
model: event.model,
|
||||
inputTokens: u.inputTokens,
|
||||
outputTokens: u.outputTokens,
|
||||
cacheCreationInputTokens: u.cacheCreationInputTokens,
|
||||
cacheReadInputTokens: u.cacheReadInputTokens,
|
||||
cachedInputTokens: 0,
|
||||
// StatusLine current_usage exposes aggregate output tokens, not a
|
||||
// separate thinking/response split. Preserve the exact total instead
|
||||
// of inventing a breakdown.
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
// Pass prices `pricingModel` (reasoningTokens is 0 here, so the output
|
||||
// basis is exactly u.outputTokens); whitelist persists the result.
|
||||
costBasis: 'estimated',
|
||||
pricingModel: normalizePricingModel(event.model),
|
||||
tools: [],
|
||||
bashCommands: [],
|
||||
timestamp: event.at,
|
||||
speed: 'standard',
|
||||
deduplicationKey: dedupKey,
|
||||
userMessage: '',
|
||||
sessionId: event.conversationId,
|
||||
project: source.project,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
const { calls } = decodeAntigravityStatusLine({
|
||||
records: raw.split(/\r?\n/),
|
||||
context: decodeContext(source.path),
|
||||
seenKeys,
|
||||
})
|
||||
return calls.map(c => toProviderCall(c, source.project))
|
||||
}
|
||||
|
||||
export function shouldReparseAntigravitySource(path: string, cachedTurnCount: number): boolean {
|
||||
|
|
@ -1163,7 +599,7 @@ async function findCascadeSource(cascadeId: string): Promise<SessionSource | nul
|
|||
}
|
||||
|
||||
export async function snapshotAntigravityStatusLinePayload(input: unknown): Promise<boolean> {
|
||||
const event = parseStatusLinePayload(input)
|
||||
const event = parseAntigravityStatusLinePayload(input, new Date().toISOString())
|
||||
if (!event) return false
|
||||
|
||||
const cascadeId = event.conversationId
|
||||
|
|
@ -1182,13 +618,18 @@ export async function snapshotAntigravityStatusLinePayload(input: unknown): Prom
|
|||
const server = await detectServer(antigravityAppDataDirFromSourcePath(source.path))
|
||||
if (!server) return false
|
||||
|
||||
let metadata: GeneratorMetadata[]
|
||||
let metadata: AntigravityGeneratorMetadata[]
|
||||
try {
|
||||
const modelMap = await getModelMap(server)
|
||||
metadata = extractAntigravityGeneratorMetadata(
|
||||
await rpc(server, 'GetCascadeTrajectoryGeneratorMetadata', { cascadeId }),
|
||||
)
|
||||
const snapshotCalls = buildCallsFromGeneratorMetadata(cascadeId, metadata, modelMap)
|
||||
const snapshotCalls = decodeAntigravityGeneratorMetadata({
|
||||
records: metadata,
|
||||
context: decodeContext(source.path),
|
||||
cascadeId,
|
||||
modelMap,
|
||||
}).calls.map(c => toProviderCall(c))
|
||||
assignStableTimestamps(snapshotCalls, cached?.calls, new Date(s.mtimeMs).toISOString())
|
||||
cache.cascades[cascadeId] = {
|
||||
mtimeMs: s.mtimeMs,
|
||||
|
|
@ -1253,13 +694,13 @@ function applyAntigravityProject(call: ParsedProviderCall, source: SessionSource
|
|||
call.project = source.project
|
||||
}
|
||||
|
||||
// gen_metadata rows and RPC entries without a real ChatStartMetadata.created_at
|
||||
// carry no per-call timestamp. Left empty, those calls are dropped by the
|
||||
// date-range filters in parser.ts (`if (!callTs) continue`), so each needs a
|
||||
// fallback. The fallback must be *stable* across file rewrites: the generic
|
||||
// session-cache persists whatever timestamp is emitted, and a non-durable
|
||||
// source is cleared and reparsed whenever its mtime changes, so stamping the
|
||||
// current mtime on every reparse would retro-date the whole session forward.
|
||||
// Sqlite rows and RPC entries without a real created_at timestamp carry no
|
||||
// per-call timestamp. Left empty, those calls are dropped by the date-range
|
||||
// filters in parser.ts (`if (!callTs) continue`), so each needs a fallback.
|
||||
// The fallback must be *stable* across file rewrites: the generic session-cache
|
||||
// persists whatever timestamp is emitted, and a non-durable source is cleared
|
||||
// and reparsed whenever its mtime changes, so stamping the current mtime on
|
||||
// every reparse would retro-date the whole session forward.
|
||||
//
|
||||
// assignStableTimestamps carries forward the timestamp already recorded for a
|
||||
// dedup key (its first-seen time, held in the durable Antigravity cache) and
|
||||
|
|
@ -1356,7 +797,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
|
||||
const modelMap = await getModelMap(server)
|
||||
|
||||
let metadata: GeneratorMetadata[]
|
||||
let metadata: AntigravityGeneratorMetadata[]
|
||||
try {
|
||||
metadata = extractAntigravityGeneratorMetadata(
|
||||
await rpc(server, 'GetCascadeTrajectoryGeneratorMetadata', { cascadeId }),
|
||||
|
|
@ -1373,7 +814,12 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
return
|
||||
}
|
||||
|
||||
const results = buildCallsFromGeneratorMetadata(cascadeId, metadata, modelMap)
|
||||
const results = decodeAntigravityGeneratorMetadata({
|
||||
records: metadata,
|
||||
context: decodeContext(source.path),
|
||||
cascadeId,
|
||||
modelMap,
|
||||
}).calls.map(c => toProviderCall(c))
|
||||
assignStableTimestamps(results, cached?.calls, fallbackTimestamp)
|
||||
for (const call of results) {
|
||||
applyAntigravityProject(call, source, projectPath)
|
||||
|
|
|
|||
1317
packages/cli/tests/providers/antigravity-golden.test.ts
Normal file
1317
packages/cli/tests/providers/antigravity-golden.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -154,6 +154,10 @@
|
|||
"./providers/opencode-session": {
|
||||
"types": "./dist/providers/opencode-session/index.d.ts",
|
||||
"import": "./dist/providers/opencode-session/index.js"
|
||||
},
|
||||
"./providers/antigravity": {
|
||||
"types": "./dist/providers/antigravity/index.d.ts",
|
||||
"import": "./dist/providers/antigravity/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
|
|
|
|||
583
packages/core/src/providers/antigravity/decode.ts
Normal file
583
packages/core/src/providers/antigravity/decode.ts
Normal file
|
|
@ -0,0 +1,583 @@
|
|||
// @codeburn/core Antigravity decoder: pure decode over three record shapes the
|
||||
// host hands it: sqlite gen_metadata rows, RPC generatorMetadata entries, and
|
||||
// statusline JSONL events. No fs / env / clock — the host owns discovery,
|
||||
// durable caching, project attribution, and pricing.
|
||||
|
||||
import type { DecodeContext } from '../../contracts.js'
|
||||
import type { RecordDiagnostic } from '../../diagnostics.js'
|
||||
import type {
|
||||
AntigravityDecodedCall,
|
||||
AntigravityGeneratorMetadata,
|
||||
AntigravityGeneratorMetadataResponse,
|
||||
AntigravityGenMetadataRow,
|
||||
AntigravityModelMap,
|
||||
AntigravityModelMapResponse,
|
||||
AntigravityStatusLineEvent,
|
||||
AntigravityStatusLinePayload,
|
||||
ProtoField,
|
||||
ProtoVarint,
|
||||
} from './types.js'
|
||||
|
||||
export type AntigravityGenMetadataDecodeInput = {
|
||||
records: unknown[]
|
||||
context: DecodeContext
|
||||
cascadeId: string
|
||||
}
|
||||
|
||||
export type AntigravityDecodeResult = {
|
||||
calls: AntigravityDecodedCall[]
|
||||
diagnostics: RecordDiagnostic[]
|
||||
}
|
||||
|
||||
export type AntigravityGeneratorMetadataDecodeInput = {
|
||||
records: unknown[]
|
||||
context: DecodeContext
|
||||
cascadeId: string
|
||||
modelMap: AntigravityModelMap
|
||||
}
|
||||
|
||||
export type AntigravityStatusLineDecodeInput = {
|
||||
records: unknown[]
|
||||
context: DecodeContext
|
||||
seenKeys: ReadonlySet<string>
|
||||
}
|
||||
|
||||
const protoTextDecoder = new TextDecoder('utf-8', { fatal: false })
|
||||
|
||||
function readProtoVarint(data: Uint8Array, startOffset: number): ProtoVarint | null {
|
||||
let value = 0n
|
||||
let shift = 0n
|
||||
let offset = startOffset
|
||||
|
||||
while (offset < data.length) {
|
||||
const byte = BigInt(data[offset]!)
|
||||
offset += 1
|
||||
value |= (byte & 0x7fn) << shift
|
||||
if ((byte & 0x80n) === 0n) return { value, offset }
|
||||
shift += 7n
|
||||
if (shift > 70n) return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function parseProtoFields(data: Uint8Array): ProtoField[] {
|
||||
const fields: ProtoField[] = []
|
||||
let offset = 0
|
||||
|
||||
while (offset < data.length) {
|
||||
const key = readProtoVarint(data, offset)
|
||||
if (!key) break
|
||||
offset = key.offset
|
||||
|
||||
const fieldNumber = Number(key.value >> 3n)
|
||||
const wireType = Number(key.value & 0x7n)
|
||||
if (!Number.isSafeInteger(fieldNumber) || fieldNumber <= 0) break
|
||||
|
||||
if (wireType === 0) {
|
||||
const value = readProtoVarint(data, offset)
|
||||
if (!value) break
|
||||
fields.push({ number: fieldNumber, wireType, value: value.value })
|
||||
offset = value.offset
|
||||
continue
|
||||
}
|
||||
|
||||
if (wireType === 1) {
|
||||
if (offset + 8 > data.length) break
|
||||
fields.push({ number: fieldNumber, wireType, bytes: data.subarray(offset, offset + 8) })
|
||||
offset += 8
|
||||
continue
|
||||
}
|
||||
|
||||
if (wireType === 2) {
|
||||
const length = readProtoVarint(data, offset)
|
||||
if (!length) break
|
||||
offset = length.offset
|
||||
const byteLength = Number(length.value)
|
||||
if (!Number.isSafeInteger(byteLength) || byteLength < 0 || offset + byteLength > data.length) break
|
||||
fields.push({ number: fieldNumber, wireType, bytes: data.subarray(offset, offset + byteLength) })
|
||||
offset += byteLength
|
||||
continue
|
||||
}
|
||||
|
||||
if (wireType === 5) {
|
||||
if (offset + 4 > data.length) break
|
||||
fields.push({ number: fieldNumber, wireType, bytes: data.subarray(offset, offset + 4) })
|
||||
offset += 4
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
function firstProtoField(fields: readonly ProtoField[], fieldNumber: number): ProtoField | undefined {
|
||||
return fields.find(field => field.number === fieldNumber)
|
||||
}
|
||||
|
||||
function protoFieldText(field: ProtoField | undefined): string | undefined {
|
||||
if (!field?.bytes || field.bytes.length === 0) return undefined
|
||||
const text = protoTextDecoder.decode(field.bytes)
|
||||
if (!text || /[\u0000-\u0008\u000E-\u001F\u007F\uFFFD]/.test(text)) return undefined
|
||||
return text
|
||||
}
|
||||
|
||||
function protoFieldPositiveInteger(field: ProtoField | undefined): number {
|
||||
if (field?.value === undefined) return 0
|
||||
const value = Number(field.value)
|
||||
return Number.isSafeInteger(value) && value > 0 ? value : 0
|
||||
}
|
||||
|
||||
function protoFieldBytes(field: ProtoField | undefined): Uint8Array | undefined {
|
||||
return field?.bytes
|
||||
}
|
||||
|
||||
// Antigravity's own model-map config sometimes hasn't caught up with a new
|
||||
// model yet, so both the config key and displayName can still be the raw
|
||||
// placeholder id (e.g. "MODEL_PLACEHOLDER_M26"). Falling through to that
|
||||
// value as the "canonical" model would leak an internal placeholder as a
|
||||
// model name; 'unknown' is what the CLI already uses when no model can be
|
||||
// resolved at all.
|
||||
const MODEL_PLACEHOLDER_PATTERN = /^MODEL_PLACEHOLDER_/
|
||||
|
||||
function dropPlaceholderModelId(model: string): string {
|
||||
return MODEL_PLACEHOLDER_PATTERN.test(model) ? 'unknown' : model
|
||||
}
|
||||
|
||||
function getCanonicalModelId(key: string, displayName?: string): string {
|
||||
if (displayName) {
|
||||
const lower = displayName.toLowerCase()
|
||||
if (lower.includes('3.5 flash')) {
|
||||
if (lower.includes('high')) return 'gemini-3.5-flash-high'
|
||||
if (lower.includes('medium')) return 'gemini-3.5-flash-medium'
|
||||
if (lower.includes('low')) return 'gemini-3.5-flash-low'
|
||||
return 'gemini-3.5-flash'
|
||||
}
|
||||
if (lower.includes('3.1 pro')) {
|
||||
if (lower.includes('high')) return 'gemini-3.1-pro-high'
|
||||
if (lower.includes('low')) return 'gemini-3.1-pro-low'
|
||||
return 'gemini-3.1-pro'
|
||||
}
|
||||
if (lower.includes('3.1 flash')) {
|
||||
if (lower.includes('image')) return 'gemini-3.1-flash-image'
|
||||
if (lower.includes('lite')) return 'gemini-3.1-flash-lite'
|
||||
return 'gemini-3.1-flash'
|
||||
}
|
||||
if (lower.includes('3 flash')) {
|
||||
return 'gemini-3-flash'
|
||||
}
|
||||
if (lower.includes('3 pro')) {
|
||||
return 'gemini-3-pro'
|
||||
}
|
||||
}
|
||||
return dropPlaceholderModelId(key)
|
||||
}
|
||||
|
||||
function isAntigravityResponseId(value: string): boolean {
|
||||
return /^[^\s]+$/.test(value)
|
||||
}
|
||||
|
||||
function antigravitySqliteResponseId(usageFields: readonly ProtoField[], fallback: string): string {
|
||||
const responseId = protoFieldText(firstProtoField(usageFields, 11))
|
||||
return responseId && isAntigravityResponseId(responseId) ? responseId : fallback
|
||||
}
|
||||
|
||||
function genMetadataDataBytes(value: Uint8Array | string): Uint8Array {
|
||||
return typeof value === 'string'
|
||||
? new TextEncoder().encode(value)
|
||||
: value
|
||||
}
|
||||
|
||||
function antigravitySqliteMetadataAttributes(chatFields: readonly ProtoField[]): Map<string, string> {
|
||||
const attributes = new Map<string, string>()
|
||||
for (const field of chatFields) {
|
||||
if (field.number !== 20) continue
|
||||
const pairFields = parseProtoFields(protoFieldBytes(field) ?? new Uint8Array())
|
||||
const key = protoFieldText(firstProtoField(pairFields, 1))
|
||||
const value = protoFieldText(firstProtoField(pairFields, 2))
|
||||
if (key && value) attributes.set(key, value)
|
||||
}
|
||||
return attributes
|
||||
}
|
||||
|
||||
function antigravitySqliteModel(chatFields: readonly ProtoField[]): string {
|
||||
const attributes = antigravitySqliteMetadataAttributes(chatFields)
|
||||
const displayName = protoFieldText(firstProtoField(chatFields, 21))
|
||||
const rawModel = protoFieldText(firstProtoField(chatFields, 19))
|
||||
?? attributes.get('model_enum')
|
||||
?? displayName
|
||||
?? 'unknown'
|
||||
|
||||
return getCanonicalModelId(rawModel, displayName)
|
||||
}
|
||||
|
||||
// Decode a proto field that carries a time into an ISO-8601 string. Antigravity
|
||||
// may encode ChatStartMetadata.created_at as an ISO string, a Timestamp
|
||||
// submessage (seconds in field 1), or a bare unix varint. Returns '' when the
|
||||
// field is absent or unparseable so the caller can fall back.
|
||||
function protoTimestampToIso(field: ProtoField | undefined): string {
|
||||
if (!field) return ''
|
||||
const text = protoFieldText(field)
|
||||
if (text && !Number.isNaN(Date.parse(text))) return new Date(text).toISOString()
|
||||
if (field.bytes) {
|
||||
// google.protobuf.Timestamp submessage: seconds (#1), nanos (#2).
|
||||
const tsFields = parseProtoFields(field.bytes)
|
||||
const seconds = firstProtoField(tsFields, 1)?.value
|
||||
if (seconds !== undefined) {
|
||||
const nanos = firstProtoField(tsFields, 2)?.value ?? 0n
|
||||
const ms = Number(seconds) * 1000 + Math.floor(Number(nanos) / 1e6)
|
||||
if (Number.isSafeInteger(ms) && ms > 0) return new Date(ms).toISOString()
|
||||
}
|
||||
}
|
||||
if (field.value !== undefined) {
|
||||
const raw = Number(field.value)
|
||||
const ms = raw < 1e12 ? raw * 1000 : raw
|
||||
if (Number.isSafeInteger(ms) && ms > 0) return new Date(ms).toISOString()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// ChatStartMetadata lives at chatModel(#1).#9; its created_at is #4. Not every
|
||||
// gen_metadata row carries it, so this returns '' when missing.
|
||||
function antigravitySqliteCreatedAt(chatFields: readonly ProtoField[]): string {
|
||||
const metadataBytes = protoFieldBytes(firstProtoField(chatFields, 9))
|
||||
if (!metadataBytes) return ''
|
||||
return protoTimestampToIso(firstProtoField(parseProtoFields(metadataBytes), 4))
|
||||
}
|
||||
|
||||
function decodeAntigravityGenMetadataRow(
|
||||
cascadeId: string,
|
||||
row: AntigravityGenMetadataRow,
|
||||
): AntigravityDecodedCall | null {
|
||||
const rootFields = parseProtoFields(genMetadataDataBytes(row.data))
|
||||
const chatFields = parseProtoFields(protoFieldBytes(firstProtoField(rootFields, 1)) ?? new Uint8Array())
|
||||
const usageFields = parseProtoFields(protoFieldBytes(firstProtoField(chatFields, 4)) ?? new Uint8Array())
|
||||
if (usageFields.length === 0) return null
|
||||
|
||||
const inputTokens = protoFieldPositiveInteger(firstProtoField(usageFields, 2))
|
||||
|| protoFieldPositiveInteger(firstProtoField(usageFields, 1))
|
||||
const totalOutputTokens = protoFieldPositiveInteger(firstProtoField(usageFields, 3))
|
||||
let responseTokens = protoFieldPositiveInteger(firstProtoField(usageFields, 9))
|
||||
let thinkingTokens = protoFieldPositiveInteger(firstProtoField(usageFields, 10))
|
||||
|
||||
if (responseTokens === 0 && thinkingTokens === 0) {
|
||||
responseTokens = totalOutputTokens
|
||||
} else if (totalOutputTokens > 0 && responseTokens + thinkingTokens !== totalOutputTokens) {
|
||||
const adjustedResponseTokens = totalOutputTokens - thinkingTokens
|
||||
if (adjustedResponseTokens >= 0) responseTokens = adjustedResponseTokens
|
||||
}
|
||||
|
||||
if (inputTokens === 0 && totalOutputTokens === 0) return null
|
||||
|
||||
const responseId = antigravitySqliteResponseId(usageFields, String(row.idx))
|
||||
const model = antigravitySqliteModel(chatFields)
|
||||
|
||||
return {
|
||||
provider: 'antigravity',
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens: responseTokens,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: thinkingTokens,
|
||||
webSearchRequests: 0,
|
||||
timestamp: antigravitySqliteCreatedAt(chatFields),
|
||||
speed: 'standard',
|
||||
deduplicationKey: `antigravity:${cascadeId}:${responseId}`,
|
||||
sessionId: cascadeId,
|
||||
}
|
||||
}
|
||||
|
||||
/** gen_metadata rows -> calls. Per-cascade dedup on deduplicationKey (host-side
|
||||
* cross-file dedup is applied later by the caller, exactly as before). */
|
||||
export function decodeAntigravityGenMetadata(
|
||||
input: AntigravityGenMetadataDecodeInput,
|
||||
): AntigravityDecodeResult {
|
||||
const { records, cascadeId } = input
|
||||
const calls: AntigravityDecodedCall[] = []
|
||||
const seenResponseIds = new Set<string>()
|
||||
|
||||
for (const row of records as AntigravityGenMetadataRow[]) {
|
||||
const call = decodeAntigravityGenMetadataRow(cascadeId, row)
|
||||
if (!call) continue
|
||||
if (seenResponseIds.has(call.deduplicationKey)) continue
|
||||
seenResponseIds.add(call.deduplicationKey)
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
return { calls, diagnostics: [] }
|
||||
}
|
||||
|
||||
export function extractAntigravityModelMap(resp: unknown): AntigravityModelMap {
|
||||
if (!resp || typeof resp !== 'object') return {}
|
||||
const data = resp as AntigravityModelMapResponse
|
||||
const models = data.response?.models ?? data.models
|
||||
const map = new Map<string, string>()
|
||||
if (!models) return {}
|
||||
for (const [key, info] of Object.entries(models)) {
|
||||
if (info && typeof info === 'object' && typeof info.model === 'string') {
|
||||
const canonicalKey = getCanonicalModelId(key, info.displayName)
|
||||
map.set(info.model, canonicalKey)
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(map)
|
||||
}
|
||||
|
||||
export function extractAntigravityGeneratorMetadata(resp: unknown): AntigravityGeneratorMetadata[] {
|
||||
if (!resp || typeof resp !== 'object') return []
|
||||
const data = resp as AntigravityGeneratorMetadataResponse
|
||||
const metadata = data.response?.generatorMetadata ?? data.generatorMetadata
|
||||
return Array.isArray(metadata) ? metadata : []
|
||||
}
|
||||
|
||||
function parseFiniteToken(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||
? Math.floor(value)
|
||||
: 0
|
||||
}
|
||||
|
||||
function usageSignature(event: AntigravityStatusLineEvent): string {
|
||||
const u = event.usage
|
||||
return [
|
||||
event.model,
|
||||
u.inputTokens,
|
||||
u.outputTokens,
|
||||
u.cacheCreationInputTokens,
|
||||
u.cacheReadInputTokens,
|
||||
].join(':')
|
||||
}
|
||||
|
||||
function usageHasTokens(usage: AntigravityStatusLineEvent['usage']): boolean {
|
||||
return (
|
||||
usage.inputTokens > 0 ||
|
||||
usage.outputTokens > 0 ||
|
||||
usage.cacheCreationInputTokens > 0 ||
|
||||
usage.cacheReadInputTokens > 0
|
||||
)
|
||||
}
|
||||
|
||||
function usageIsMonotonic(
|
||||
current: AntigravityStatusLineEvent['usage'],
|
||||
previous: AntigravityStatusLineEvent['usage'],
|
||||
): boolean {
|
||||
return (
|
||||
current.inputTokens >= previous.inputTokens &&
|
||||
current.outputTokens >= previous.outputTokens &&
|
||||
current.cacheCreationInputTokens >= previous.cacheCreationInputTokens &&
|
||||
current.cacheReadInputTokens >= previous.cacheReadInputTokens
|
||||
)
|
||||
}
|
||||
|
||||
function usageDelta(
|
||||
current: AntigravityStatusLineEvent['usage'],
|
||||
previous: AntigravityStatusLineEvent['usage'],
|
||||
): AntigravityStatusLineEvent['usage'] {
|
||||
return {
|
||||
inputTokens: current.inputTokens - previous.inputTokens,
|
||||
outputTokens: current.outputTokens - previous.outputTokens,
|
||||
cacheCreationInputTokens: current.cacheCreationInputTokens - previous.cacheCreationInputTokens,
|
||||
cacheReadInputTokens: current.cacheReadInputTokens - previous.cacheReadInputTokens,
|
||||
}
|
||||
}
|
||||
|
||||
/** Hook-capture record parse. The wall clock is INJECTED (`at`) so the decoder
|
||||
* stays pure; the host passes a freshly-generated ISO-8601 timestamp. */
|
||||
export function parseAntigravityStatusLinePayload(
|
||||
input: unknown,
|
||||
at: string,
|
||||
): AntigravityStatusLineEvent | null {
|
||||
if (!input || typeof input !== 'object') return null
|
||||
const payload = input as AntigravityStatusLinePayload
|
||||
if (typeof payload.conversation_id !== 'string' || payload.conversation_id.length === 0) return null
|
||||
const usage = payload.context_window?.current_usage
|
||||
if (!usage) return null
|
||||
|
||||
const event: AntigravityStatusLineEvent = {
|
||||
at,
|
||||
conversationId: payload.conversation_id,
|
||||
sessionId: typeof payload.session_id === 'string' ? payload.session_id : undefined,
|
||||
model: typeof payload.model === 'string'
|
||||
? payload.model
|
||||
: payload.model?.id ?? payload.model?.display_name ?? 'unknown',
|
||||
usage: {
|
||||
inputTokens: parseFiniteToken(usage.input_tokens),
|
||||
outputTokens: parseFiniteToken(usage.output_tokens),
|
||||
cacheCreationInputTokens: parseFiniteToken(usage.cache_creation_input_tokens),
|
||||
cacheReadInputTokens: parseFiniteToken(usage.cache_read_input_tokens),
|
||||
},
|
||||
}
|
||||
|
||||
const u = event.usage
|
||||
if (u.inputTokens === 0 && u.outputTokens === 0 && u.cacheCreationInputTokens === 0 && u.cacheReadInputTokens === 0) {
|
||||
return null
|
||||
}
|
||||
if (event.model === 'unknown') return null
|
||||
return event
|
||||
}
|
||||
|
||||
function parseStatusLineEvent(input: unknown): AntigravityStatusLineEvent | null {
|
||||
if (!input || typeof input !== 'object') return null
|
||||
const event = input as AntigravityStatusLineEvent
|
||||
if (typeof event.at !== 'string' || Number.isNaN(new Date(event.at).getTime())) return null
|
||||
if (typeof event.conversationId !== 'string' || event.conversationId.length === 0) return null
|
||||
if (typeof event.model !== 'string' || event.model.length === 0) return null
|
||||
if (!event.usage || typeof event.usage !== 'object') return null
|
||||
|
||||
const usage = {
|
||||
inputTokens: parseFiniteToken(event.usage.inputTokens),
|
||||
outputTokens: parseFiniteToken(event.usage.outputTokens),
|
||||
cacheCreationInputTokens: parseFiniteToken(event.usage.cacheCreationInputTokens),
|
||||
cacheReadInputTokens: parseFiniteToken(event.usage.cacheReadInputTokens),
|
||||
}
|
||||
|
||||
if (
|
||||
usage.inputTokens === 0 &&
|
||||
usage.outputTokens === 0 &&
|
||||
usage.cacheCreationInputTokens === 0 &&
|
||||
usage.cacheReadInputTokens === 0
|
||||
) return null
|
||||
|
||||
return {
|
||||
at: event.at,
|
||||
conversationId: event.conversationId,
|
||||
sessionId: typeof event.sessionId === 'string' ? event.sessionId : undefined,
|
||||
model: event.model,
|
||||
usage,
|
||||
}
|
||||
}
|
||||
|
||||
function hasRpcCacheForConversation(seenKeys: ReadonlySet<string>, conversationId: string): boolean {
|
||||
const prefix = `antigravity:${conversationId}:`
|
||||
for (const key of seenKeys) {
|
||||
if (key.startsWith(prefix)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** statusline jsonl -> calls (run collapse + monotonic deltas). */
|
||||
export function decodeAntigravityStatusLine(
|
||||
input: AntigravityStatusLineDecodeInput,
|
||||
): AntigravityDecodeResult {
|
||||
const { records, seenKeys } = input
|
||||
const runsByConversation = new Map<string, Array<{ event: AntigravityStatusLineEvent; signature: string; count: number }>>()
|
||||
|
||||
for (const line of records as string[]) {
|
||||
if (!line.trim()) continue
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(line)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const event = parseStatusLineEvent(parsed)
|
||||
if (!event) continue
|
||||
if (hasRpcCacheForConversation(seenKeys, event.conversationId)) continue
|
||||
|
||||
const signature = usageSignature(event)
|
||||
const runs = runsByConversation.get(event.conversationId) ?? []
|
||||
const lastRun = runs.at(-1)
|
||||
if (lastRun?.signature === signature) {
|
||||
lastRun.count += 1
|
||||
lastRun.event = event
|
||||
} else {
|
||||
runs.push({ event, signature, count: 1 })
|
||||
runsByConversation.set(event.conversationId, runs)
|
||||
}
|
||||
}
|
||||
|
||||
const calls: AntigravityDecodedCall[] = []
|
||||
|
||||
for (const runs of runsByConversation.values()) {
|
||||
let turnIndex = 0
|
||||
let previousSnapshotUsage: AntigravityStatusLineEvent['usage'] | null = null
|
||||
for (let i = 0; i < runs.length; i++) {
|
||||
const run = runs[i]!
|
||||
const isLastRun = i === runs.length - 1
|
||||
if (run.count === 1 && !isLastRun) continue
|
||||
|
||||
const event = run.event
|
||||
const signature = run.signature
|
||||
const billableUsage = previousSnapshotUsage && usageIsMonotonic(event.usage, previousSnapshotUsage)
|
||||
? usageDelta(event.usage, previousSnapshotUsage)
|
||||
: event.usage
|
||||
previousSnapshotUsage = event.usage
|
||||
if (!usageHasTokens(billableUsage)) continue
|
||||
|
||||
const dedupKey = `antigravity-statusline:${event.conversationId}:${turnIndex}:${signature}`
|
||||
turnIndex += 1
|
||||
if (seenKeys.has(dedupKey)) continue
|
||||
|
||||
const u = billableUsage
|
||||
|
||||
calls.push({
|
||||
provider: 'antigravity',
|
||||
model: event.model,
|
||||
inputTokens: u.inputTokens,
|
||||
outputTokens: u.outputTokens,
|
||||
cacheCreationInputTokens: u.cacheCreationInputTokens,
|
||||
cacheReadInputTokens: u.cacheReadInputTokens,
|
||||
cachedInputTokens: 0,
|
||||
// StatusLine current_usage exposes aggregate output tokens, not a
|
||||
// separate thinking/response split. Preserve the exact total instead
|
||||
// of inventing a breakdown.
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
timestamp: event.at,
|
||||
speed: 'standard',
|
||||
deduplicationKey: dedupKey,
|
||||
sessionId: event.conversationId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { calls, diagnostics: [] }
|
||||
}
|
||||
|
||||
/** RPC generatorMetadata entries -> calls. */
|
||||
export function decodeAntigravityGeneratorMetadata(
|
||||
input: AntigravityGeneratorMetadataDecodeInput,
|
||||
): AntigravityDecodeResult {
|
||||
const { records, cascadeId, modelMap } = input
|
||||
const calls: AntigravityDecodedCall[] = []
|
||||
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const entry = records[i] as AntigravityGeneratorMetadata
|
||||
const usage = entry.chatModel?.usage
|
||||
if (!usage) continue
|
||||
|
||||
const inputTokens = parseInt(usage.inputTokens ?? '0', 10)
|
||||
const outputTokens = parseInt(usage.outputTokens ?? '0', 10)
|
||||
const thinkingTokens = parseInt(usage.thinkingOutputTokens ?? '0', 10)
|
||||
const responseTokens = parseInt(usage.responseOutputTokens ?? '0', 10)
|
||||
|
||||
if (inputTokens === 0 && outputTokens === 0) continue
|
||||
|
||||
const responseId = usage.responseId || String(i)
|
||||
const dedupKey = `antigravity:${cascadeId}:${responseId}`
|
||||
|
||||
const model = dropPlaceholderModelId(modelMap[usage.model] ?? usage.model)
|
||||
const timestamp = entry.chatModel?.chatStartMetadata?.createdAt ?? ''
|
||||
|
||||
calls.push({
|
||||
provider: 'antigravity',
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens: responseTokens,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: thinkingTokens,
|
||||
webSearchRequests: 0,
|
||||
timestamp,
|
||||
speed: 'standard',
|
||||
deduplicationKey: dedupKey,
|
||||
sessionId: cascadeId,
|
||||
})
|
||||
}
|
||||
|
||||
return { calls, diagnostics: [] }
|
||||
}
|
||||
44
packages/core/src/providers/antigravity/index.ts
Normal file
44
packages/core/src/providers/antigravity/index.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// @codeburn/core Antigravity provider.
|
||||
//
|
||||
// Two layers:
|
||||
// - Rich pure decode (`decodeAntigravityGenMetadata`,
|
||||
// `decodeAntigravityGeneratorMetadata`, `decodeAntigravityStatusLine`,
|
||||
// `parseAntigravityStatusLinePayload`): host-facing, NOT part of the stable
|
||||
// minimized surface. Pure over supplied records; carries token buckets but no
|
||||
// pricing (cost leaves the decoder).
|
||||
// - Minimizing transform (`toObservations`): maps the rich decode into the
|
||||
// strict observation envelope; the content-smuggling guarantees bind here.
|
||||
|
||||
export {
|
||||
decodeAntigravityGenMetadata,
|
||||
decodeAntigravityGeneratorMetadata,
|
||||
decodeAntigravityStatusLine,
|
||||
parseAntigravityStatusLinePayload,
|
||||
extractAntigravityModelMap,
|
||||
extractAntigravityGeneratorMetadata,
|
||||
type AntigravityGenMetadataDecodeInput,
|
||||
type AntigravityGeneratorMetadataDecodeInput,
|
||||
type AntigravityStatusLineDecodeInput,
|
||||
type AntigravityDecodeResult,
|
||||
} from './decode.js'
|
||||
|
||||
export {
|
||||
toObservations,
|
||||
type RichAntigravitySessionDecode,
|
||||
type AntigravityToObservationsContext,
|
||||
} from './observations.js'
|
||||
|
||||
export type {
|
||||
AntigravityDecodedCall,
|
||||
AntigravityGeneratorMetadata,
|
||||
AntigravityGeneratorMetadataResponse,
|
||||
AntigravityGenMetadataRow,
|
||||
AntigravityModelMap,
|
||||
AntigravityModelMapResponse,
|
||||
AntigravityStatusLineCurrentUsage,
|
||||
AntigravityStatusLineEvent,
|
||||
AntigravityStatusLinePayload,
|
||||
AntigravityUsageEntry,
|
||||
ProtoField,
|
||||
ProtoVarint,
|
||||
} from './types.js'
|
||||
85
packages/core/src/providers/antigravity/observations.ts
Normal file
85
packages/core/src/providers/antigravity/observations.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
// Minimizing transform: rich Antigravity decode -> the strict observation
|
||||
// envelope. Only opaque ids, fingerprints, enums, numbers, timestamps, and
|
||||
// canonical tool names cross into the output. Antigravity never records file
|
||||
// paths and has no tool calls, so projectPath is fingerprinted and toolNames is
|
||||
// always empty.
|
||||
|
||||
import { projectRef, sessionRef } from '../../fingerprint.js'
|
||||
import type { RecordDiagnostic } from '../../diagnostics.js'
|
||||
import type { CallObservation, SessionObservation } from '../../observations.js'
|
||||
import type { AntigravityDecodedCall } from './types.js'
|
||||
|
||||
/** One Antigravity cascade's rich decode, as the host holds it before minimization. */
|
||||
export interface RichAntigravitySessionDecode {
|
||||
sessionId: string
|
||||
/** Absolute project path; fingerprinted, never emitted raw. */
|
||||
projectPath: string
|
||||
calls: AntigravityDecodedCall[]
|
||||
}
|
||||
|
||||
export interface AntigravityToObservationsContext {
|
||||
/** HMAC key that scopes every fingerprint. */
|
||||
privacyKey: string
|
||||
/** Provider id stamped onto sessions/calls and folded into sessionRef. */
|
||||
provider?: string
|
||||
}
|
||||
|
||||
function toCallObservation(call: AntigravityDecodedCall, 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,
|
||||
costBasis: 'estimated',
|
||||
timestamp: call.timestamp,
|
||||
dedupKey: call.deduplicationKey,
|
||||
// Antigravity records carry no tool calls at all, so there is nothing to
|
||||
// filter through a canonical-name gate here.
|
||||
toolNames: [],
|
||||
turnIndex,
|
||||
}
|
||||
}
|
||||
|
||||
function toSessionObservation(
|
||||
decode: RichAntigravitySessionDecode,
|
||||
ctx: AntigravityToObservationsContext,
|
||||
): SessionObservation {
|
||||
const provider = ctx.provider ?? 'antigravity'
|
||||
const calls: CallObservation[] = 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]! : ''
|
||||
|
||||
const session: SessionObservation = {
|
||||
sessionRef: sessionRef(ctx.privacyKey, provider, decode.sessionId),
|
||||
projectRef: projectRef(ctx.privacyKey, decode.projectPath),
|
||||
providerId: provider,
|
||||
startedAt,
|
||||
...(endedAt ? { endedAt } : {}),
|
||||
calls,
|
||||
turnCount: calls.length,
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a rich Antigravity decode (one or many cascades) into the minimized
|
||||
* observation layer. Returns the `sessions` array plus any per-record
|
||||
* `diagnostics`.
|
||||
*/
|
||||
export function toObservations(
|
||||
decode: RichAntigravitySessionDecode | RichAntigravitySessionDecode[],
|
||||
ctx: AntigravityToObservationsContext,
|
||||
): { sessions: SessionObservation[]; diagnostics: RecordDiagnostic[] } {
|
||||
const decodes = Array.isArray(decode) ? decode : [decode]
|
||||
const sessions = decodes.map(d => toSessionObservation(d, ctx))
|
||||
return { sessions, diagnostics: [] }
|
||||
}
|
||||
113
packages/core/src/providers/antigravity/types.ts
Normal file
113
packages/core/src/providers/antigravity/types.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// Raw record + rich-decode types for the Antigravity provider.
|
||||
//
|
||||
// Antigravity is a Category D stateful multi-store provider. The host keeps all
|
||||
// I/O, discovery, durable caching, and project attribution; core owns only the
|
||||
// pure decode of the three record shapes the host hands it: sqlite gen_metadata
|
||||
// rows, RPC generatorMetadata entries, and statusline JSONL events.
|
||||
|
||||
export type AntigravityUsageEntry = {
|
||||
model: string
|
||||
inputTokens: string
|
||||
outputTokens: string
|
||||
thinkingOutputTokens?: string
|
||||
responseOutputTokens?: string
|
||||
apiProvider: string
|
||||
responseId?: string
|
||||
}
|
||||
|
||||
export type AntigravityGeneratorMetadata = {
|
||||
stepIndices?: number[]
|
||||
chatModel?: {
|
||||
model: string
|
||||
usage: AntigravityUsageEntry
|
||||
chatStartMetadata?: {
|
||||
createdAt?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type AntigravityModelMapResponse = {
|
||||
models?: Record<string, { model?: string; displayName?: string }>
|
||||
response?: {
|
||||
models?: Record<string, { model?: string; displayName?: string }>
|
||||
}
|
||||
}
|
||||
|
||||
export type AntigravityGeneratorMetadataResponse = {
|
||||
generatorMetadata?: AntigravityGeneratorMetadata[]
|
||||
response?: {
|
||||
generatorMetadata?: AntigravityGeneratorMetadata[]
|
||||
}
|
||||
}
|
||||
|
||||
export type AntigravityModelMap = Record<string, string>
|
||||
|
||||
export type AntigravityStatusLineCurrentUsage = {
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
cache_creation_input_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
}
|
||||
|
||||
export type AntigravityStatusLinePayload = {
|
||||
conversation_id?: string
|
||||
session_id?: string
|
||||
model?: string | {
|
||||
id?: string
|
||||
display_name?: string
|
||||
}
|
||||
context_window?: {
|
||||
current_usage?: AntigravityStatusLineCurrentUsage | null
|
||||
}
|
||||
}
|
||||
|
||||
export type AntigravityStatusLineEvent = {
|
||||
at: string
|
||||
conversationId: string
|
||||
sessionId?: string
|
||||
model: string
|
||||
usage: {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheCreationInputTokens: number
|
||||
cacheReadInputTokens: number
|
||||
}
|
||||
}
|
||||
|
||||
export type AntigravityGenMetadataRow = {
|
||||
idx: number
|
||||
data: Uint8Array | string
|
||||
}
|
||||
|
||||
export type ProtoField = {
|
||||
number: number
|
||||
wireType: number
|
||||
value?: bigint
|
||||
bytes?: Uint8Array
|
||||
}
|
||||
|
||||
export type ProtoVarint = {
|
||||
value: bigint
|
||||
offset: number
|
||||
}
|
||||
|
||||
// The rich decode of one Antigravity call, pre-pricing. Mirrors the host's
|
||||
// ParsedProviderCall minus everything the host owns: cost (costUSD/costBasis/
|
||||
// pricingModel), project attribution, and the always-empty tools/bashCommands
|
||||
// plus the host-supplied empty text field. Antigravity records carry no tool
|
||||
// calls and no user text at all.
|
||||
export type AntigravityDecodedCall = {
|
||||
provider: 'antigravity'
|
||||
model: string
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheCreationInputTokens: number
|
||||
cacheReadInputTokens: number
|
||||
cachedInputTokens: number
|
||||
reasoningTokens: number
|
||||
webSearchRequests: number
|
||||
timestamp: string
|
||||
speed: 'standard'
|
||||
deduplicationKey: string
|
||||
sessionId: string
|
||||
}
|
||||
|
|
@ -38,6 +38,12 @@ import { decodeCopilot, toObservations as toCopilotObservations } from '../src/p
|
|||
import { decodeVscodeCline, toObservations as toVscodeClineObservations } from '../src/providers/vscode-cline/index.js'
|
||||
import { decodeOpenCodeSession, toObservations as toOpenCodeSessionObservations } from '../src/providers/opencode-session/index.js'
|
||||
import { decodeMistralVibe, toObservations as toMistralVibeObservations } from '../src/providers/mistral-vibe/index.js'
|
||||
import {
|
||||
decodeAntigravityGenMetadata,
|
||||
decodeAntigravityGeneratorMetadata,
|
||||
decodeAntigravityStatusLine,
|
||||
toObservations as toAntigravityObservations,
|
||||
} from '../src/providers/antigravity/index.js'
|
||||
import type { DecodeContext } from '../src/contracts.js'
|
||||
import type { ZedThreadRow } from '../src/providers/zed/index.js'
|
||||
|
||||
|
|
@ -1571,3 +1577,172 @@ describe('content-smuggling guardrail: real mistral-vibe decode -> toObservation
|
|||
expect(allToolNames).not.toContain(SECRETS.commandLine)
|
||||
})
|
||||
})
|
||||
|
||||
describe('content-smuggling guardrail: real antigravity decode -> toObservations is secret-free', () => {
|
||||
// A hostile Antigravity cascade planting secrets in every free-text field the
|
||||
// decode reads but never emits: proto attribute values other than model_enum,
|
||||
// ignored proto fields, the statusline envelope's cwd/session_id, and the RPC
|
||||
// usage.apiProvider. Only the canonicalized model and the responseId (inside
|
||||
// the dedupKey) are emitted by design — they are the provider's own machine
|
||||
// identifiers, exactly like every other provider's model and dedupKey.
|
||||
const antigravityContext: DecodeContext = {
|
||||
privacyKey: 'test-privacy-key',
|
||||
providerId: 'antigravity',
|
||||
sourceRef: 'ref',
|
||||
}
|
||||
|
||||
function varint(n: number): number[] {
|
||||
const out: number[] = []
|
||||
let v = n
|
||||
while (v > 0x7f) {
|
||||
out.push((v & 0x7f) | 0x80)
|
||||
v = Math.floor(v / 128)
|
||||
}
|
||||
out.push(v)
|
||||
return out
|
||||
}
|
||||
|
||||
function tag(field: number, wire: number): number[] {
|
||||
return varint(field * 8 + wire)
|
||||
}
|
||||
|
||||
function varintField(field: number, n: number): number[] {
|
||||
return [...tag(field, 0), ...varint(n)]
|
||||
}
|
||||
|
||||
function lenField(field: number, bytes: number[]): number[] {
|
||||
return [...tag(field, 2), ...varint(bytes.length), ...bytes]
|
||||
}
|
||||
|
||||
function textField(field: number, text: string): number[] {
|
||||
return lenField(field, [...new TextEncoder().encode(text)])
|
||||
}
|
||||
|
||||
function attrField(key: string, value: string): number[] {
|
||||
return lenField(20, [...textField(1, key), ...textField(2, value)])
|
||||
}
|
||||
|
||||
function buildHostileGenMetadataRow(): { idx: number; data: Uint8Array } {
|
||||
const chatStartMetadata = textField(4, '2026-07-17T10:00:00.000Z')
|
||||
const usage = lenField(4, [
|
||||
...varintField(2, 100),
|
||||
...varintField(3, 50),
|
||||
...varintField(6, 999),
|
||||
])
|
||||
const chatModel = [
|
||||
...usage,
|
||||
...lenField(9, chatStartMetadata),
|
||||
...textField(19, 'gemini-3-pro'),
|
||||
...attrField('trajectory_id', SECRETS.commandLine),
|
||||
...attrField('used_claude', SECRETS.apiKey),
|
||||
...attrField('last_step_index', SECRETS.prompt),
|
||||
]
|
||||
const root = [
|
||||
...lenField(2, [...new TextEncoder().encode(SECRETS.fileContent)]),
|
||||
...lenField(4, [...new TextEncoder().encode(SECRETS.absPath)]),
|
||||
...lenField(1, chatModel),
|
||||
]
|
||||
return { idx: 0, data: Buffer.from(root) }
|
||||
}
|
||||
|
||||
function decodeAndMinimize() {
|
||||
const genMetadataCalls = decodeAntigravityGenMetadata({
|
||||
records: [buildHostileGenMetadataRow()],
|
||||
context: antigravityContext,
|
||||
cascadeId: 'sess-hostile',
|
||||
}).calls
|
||||
|
||||
// The statusline decoder consumes RECORDED events (camelCase, already
|
||||
// normalized by parseAntigravityStatusLinePayload), not the raw hook
|
||||
// payload. `sessionId` and any stray envelope field such as `cwd` are read
|
||||
// past but never emitted — the emitted call's sessionId is conversationId.
|
||||
const statusLineCalls = decodeAntigravityStatusLine({
|
||||
records: [
|
||||
JSON.stringify({
|
||||
at: '2026-07-17T10:00:00.000Z',
|
||||
conversationId: 'sess-hostile-statusline',
|
||||
sessionId: SECRETS.apiKey,
|
||||
cwd: SECRETS.absPath,
|
||||
model: 'gemini-3-pro',
|
||||
usage: {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
},
|
||||
}),
|
||||
],
|
||||
context: antigravityContext,
|
||||
seenKeys: new Set(),
|
||||
}).calls
|
||||
|
||||
const rpcCalls = decodeAntigravityGeneratorMetadata({
|
||||
records: [
|
||||
{
|
||||
chatModel: {
|
||||
model: 'gemini-3-pro',
|
||||
usage: {
|
||||
model: 'gemini-3-pro',
|
||||
inputTokens: '100',
|
||||
outputTokens: '50',
|
||||
responseOutputTokens: '50',
|
||||
apiProvider: SECRETS.commandLine,
|
||||
responseId: 'rpc-secret',
|
||||
},
|
||||
chatStartMetadata: { createdAt: '2026-07-17T10:00:00.000Z' },
|
||||
},
|
||||
},
|
||||
],
|
||||
context: antigravityContext,
|
||||
cascadeId: 'sess-hostile',
|
||||
modelMap: {},
|
||||
}).calls
|
||||
|
||||
const calls = [...genMetadataCalls, ...statusLineCalls, ...rpcCalls]
|
||||
|
||||
const { sessions } = toAntigravityObservations(
|
||||
{ sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls },
|
||||
{ privacyKey: 'test-privacy-key', provider: 'antigravity' },
|
||||
)
|
||||
|
||||
return {
|
||||
schemaVersion: OBSERVATION_SCHEMA_VERSION,
|
||||
generator: { name: '@codeburn/core', version: '0.0.0-test' },
|
||||
sessions,
|
||||
}
|
||||
}
|
||||
|
||||
it('produces a schema-valid envelope from the hostile cascade', () => {
|
||||
const envelope = decodeAndMinimize()
|
||||
expect(ObservationEnvelope.safeParse(envelope).success).toBe(true)
|
||||
// Guards against a vacuous fixture: a malformed record would be dropped by
|
||||
// its decoder and the secret assertions below would then prove nothing.
|
||||
expect(envelope.sessions[0]?.calls).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('the serialized envelope contains none of the planted secrets', () => {
|
||||
const serialized = JSON.stringify(decodeAndMinimize())
|
||||
for (const secret of ALL_SECRETS) {
|
||||
expect(serialized).not.toContain(secret)
|
||||
}
|
||||
})
|
||||
|
||||
it('emits no tool names (antigravity has no tool map)', () => {
|
||||
const env = decodeAndMinimize()
|
||||
const allToolNames = env.sessions.flatMap(s => s.calls.flatMap(c => c.toolNames))
|
||||
expect(allToolNames).toEqual([])
|
||||
})
|
||||
|
||||
it('a MODEL_PLACEHOLDER id with no displayName surfaces as unknown, never the raw placeholder', () => {
|
||||
const usage = lenField(4, [...varintField(2, 10), ...varintField(3, 1)])
|
||||
const chatModel = [...usage, ...textField(19, 'MODEL_PLACEHOLDER_HOSTILE')]
|
||||
const row = { idx: 0, data: Buffer.from(lenField(1, chatModel)) }
|
||||
const { calls } = decodeAntigravityGenMetadata({
|
||||
records: [row],
|
||||
context: antigravityContext,
|
||||
cascadeId: 'placeholder-test',
|
||||
})
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.model).toBe('unknown')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
400
packages/core/tests/providers/antigravity-decode.test.ts
Normal file
400
packages/core/tests/providers/antigravity-decode.test.ts
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
decodeAntigravityGenMetadata,
|
||||
decodeAntigravityGeneratorMetadata,
|
||||
decodeAntigravityStatusLine,
|
||||
parseAntigravityStatusLinePayload,
|
||||
toObservations,
|
||||
} from '../../src/providers/antigravity/index.js'
|
||||
import { ObservationEnvelope } from '../../src/observations.js'
|
||||
import { OBSERVATION_SCHEMA_VERSION } from '../../src/schema.js'
|
||||
import type { DecodeContext } from '../../src/contracts.js'
|
||||
|
||||
const context: DecodeContext = { privacyKey: 'k', providerId: 'antigravity', sourceRef: 'ref' }
|
||||
|
||||
function varint(n: number): number[] {
|
||||
const out: number[] = []
|
||||
let v = n
|
||||
while (v > 0x7f) {
|
||||
out.push((v & 0x7f) | 0x80)
|
||||
v = Math.floor(v / 128)
|
||||
}
|
||||
out.push(v)
|
||||
return out
|
||||
}
|
||||
|
||||
function tag(field: number, wire: number): number[] {
|
||||
return varint(field * 8 + wire)
|
||||
}
|
||||
|
||||
function varintField(field: number, n: number): number[] {
|
||||
return [...tag(field, 0), ...varint(n)]
|
||||
}
|
||||
|
||||
function lenField(field: number, bytes: number[]): number[] {
|
||||
return [...tag(field, 2), ...varint(bytes.length), ...bytes]
|
||||
}
|
||||
|
||||
function textField(field: number, text: string): number[] {
|
||||
const bytes = [...new TextEncoder().encode(text)]
|
||||
return lenField(field, bytes)
|
||||
}
|
||||
|
||||
function attrField(key: string, value: string): number[] {
|
||||
return lenField(20, [...textField(1, key), ...textField(2, value)])
|
||||
}
|
||||
|
||||
function sqliteRow(idx: number, hex: string): { idx: number; data: Uint8Array } {
|
||||
return { idx, data: Buffer.from(hex, 'hex') }
|
||||
}
|
||||
|
||||
describe('antigravity rich decode (moved to @codeburn/core)', () => {
|
||||
it('G2: per-cascade seenResponseIds deduplicates identical response ids', () => {
|
||||
const usage = lenField(4, [...varintField(2, 100), ...varintField(3, 10), ...textField(11, 'dup-response')])
|
||||
const chatModel = [...usage, ...textField(19, 'gemini-pro-default'), ...textField(21, 'Gemini 3.1 Pro (High)')]
|
||||
const hex = Buffer.from(lenField(1, chatModel)).toString('hex')
|
||||
|
||||
const { calls } = decodeAntigravityGenMetadata({
|
||||
records: [sqliteRow(0, hex), sqliteRow(1, hex)],
|
||||
context,
|
||||
cascadeId: 'g2-dedup',
|
||||
})
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toEqual({
|
||||
provider: 'antigravity',
|
||||
model: 'gemini-3.1-pro-high',
|
||||
inputTokens: 100,
|
||||
outputTokens: 10,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
timestamp: '',
|
||||
speed: 'standard',
|
||||
deduplicationKey: 'antigravity:g2-dedup:dup-response',
|
||||
sessionId: 'g2-dedup',
|
||||
})
|
||||
})
|
||||
|
||||
it('G3: responseTokens falls back to totalOutputTokens when split fields are absent', () => {
|
||||
const usage = lenField(4, [...varintField(2, 50), ...varintField(3, 500)])
|
||||
const chatModel = [...usage, ...textField(19, 'gemini-pro-default'), ...textField(21, 'Gemini 3.1 Pro (High)')]
|
||||
const hex = Buffer.from(lenField(1, chatModel)).toString('hex')
|
||||
|
||||
const { calls } = decodeAntigravityGenMetadata({
|
||||
records: [sqliteRow(0, hex)],
|
||||
context,
|
||||
cascadeId: 'g3-total-output',
|
||||
})
|
||||
|
||||
expect(calls[0]).toMatchObject({ outputTokens: 500, reasoningTokens: 0 })
|
||||
})
|
||||
|
||||
it('G4: output split adjust arm reconciles response + thinking to total', () => {
|
||||
const usage = lenField(4, [
|
||||
...varintField(2, 80),
|
||||
...varintField(3, 100),
|
||||
...varintField(9, 10),
|
||||
...varintField(10, 30),
|
||||
])
|
||||
const chatModel = [...usage, ...textField(19, 'gemini-pro-default'), ...textField(21, 'Gemini 3.1 Pro (High)')]
|
||||
const hex = Buffer.from(lenField(1, chatModel)).toString('hex')
|
||||
|
||||
const { calls } = decodeAntigravityGenMetadata({
|
||||
records: [sqliteRow(0, hex)],
|
||||
context,
|
||||
cascadeId: 'g4-adjust',
|
||||
})
|
||||
|
||||
expect(calls[0]).toMatchObject({ outputTokens: 70, reasoningTokens: 30 })
|
||||
})
|
||||
|
||||
it('G5: inputTokens falls back to field #1 when field #2 is absent', () => {
|
||||
const usage = lenField(4, [...varintField(1, 777), ...varintField(3, 5)])
|
||||
const chatModel = [...usage, ...textField(19, 'gemini-pro-default'), ...textField(21, 'Gemini 3.1 Pro (High)')]
|
||||
const hex = Buffer.from(lenField(1, chatModel)).toString('hex')
|
||||
|
||||
const { calls } = decodeAntigravityGenMetadata({
|
||||
records: [sqliteRow(0, hex)],
|
||||
context,
|
||||
cascadeId: 'g5-input-fallback',
|
||||
})
|
||||
|
||||
expect(calls[0]).toMatchObject({ inputTokens: 777 })
|
||||
})
|
||||
|
||||
it('G6: responseId falls back to row idx when field #11 is absent', () => {
|
||||
const usage = lenField(4, [...varintField(2, 33), ...varintField(3, 3)])
|
||||
const chatModel = [...usage, ...textField(19, 'gemini-pro-default'), ...textField(21, 'Gemini 3.1 Pro (High)')]
|
||||
const hex = Buffer.from(lenField(1, chatModel)).toString('hex')
|
||||
|
||||
const { calls } = decodeAntigravityGenMetadata({
|
||||
records: [sqliteRow(3, hex)],
|
||||
context,
|
||||
cascadeId: 'g6-response-id-fallback',
|
||||
})
|
||||
|
||||
expect(calls[0]).toMatchObject({ deduplicationKey: 'antigravity:g6-response-id-fallback:3' })
|
||||
})
|
||||
|
||||
it('G7: model precedence and placeholder guard produce distinct canonical models', () => {
|
||||
const baseUsage = lenField(4, [...varintField(2, 10), ...varintField(3, 1)])
|
||||
|
||||
const aChat = [...baseUsage, ...textField(19, 'gemini-3.1-pro-high')]
|
||||
const bChat = [...baseUsage, ...attrField('model_enum', 'gemini-3.5-flash-agent')]
|
||||
const cChat = [...baseUsage, ...textField(21, 'Gemini 3 Flash')]
|
||||
const dChat = [...baseUsage]
|
||||
const eChat = [...baseUsage, ...textField(19, 'MODEL_PLACEHOLDER_M99')]
|
||||
|
||||
const { calls } = decodeAntigravityGenMetadata({
|
||||
records: [
|
||||
sqliteRow(0, Buffer.from(lenField(1, aChat)).toString('hex')),
|
||||
sqliteRow(1, Buffer.from(lenField(1, bChat)).toString('hex')),
|
||||
sqliteRow(2, Buffer.from(lenField(1, cChat)).toString('hex')),
|
||||
sqliteRow(3, Buffer.from(lenField(1, dChat)).toString('hex')),
|
||||
sqliteRow(4, Buffer.from(lenField(1, eChat)).toString('hex')),
|
||||
],
|
||||
context,
|
||||
cascadeId: 'g7-model-precedence',
|
||||
})
|
||||
|
||||
expect(calls.map(c => c.model)).toEqual([
|
||||
'gemini-3.1-pro-high',
|
||||
'gemini-3.5-flash-agent',
|
||||
'gemini-3-flash',
|
||||
'unknown',
|
||||
'unknown',
|
||||
])
|
||||
})
|
||||
|
||||
it('G8: chatStartMetadata.created_at beats any host mtime', () => {
|
||||
const seconds = 1783326234
|
||||
const nanos = 724675400
|
||||
const timestamp = [...varintField(1, seconds), ...varintField(2, nanos)]
|
||||
const chatStartMetadata = lenField(4, timestamp)
|
||||
const usage = lenField(4, [...varintField(2, 100), ...varintField(3, 50)])
|
||||
const chatModel = [...usage, ...lenField(9, chatStartMetadata)]
|
||||
const hex = Buffer.from(lenField(1, chatModel)).toString('hex')
|
||||
|
||||
const { calls } = decodeAntigravityGenMetadata({
|
||||
records: [sqliteRow(0, hex)],
|
||||
context,
|
||||
cascadeId: 'g8-created-at',
|
||||
})
|
||||
|
||||
expect(calls[0]).toMatchObject({ timestamp: '2026-07-06T08:23:54.724Z' })
|
||||
})
|
||||
|
||||
it('G9: zero input and output tokens skip the row', () => {
|
||||
const usage = lenField(4, [...varintField(2, 0), ...varintField(3, 0)])
|
||||
const chatModel = [...usage, ...textField(19, 'gemini-pro-default'), ...textField(21, 'Gemini 3.1 Pro (High)')]
|
||||
const hex = Buffer.from(lenField(1, chatModel)).toString('hex')
|
||||
|
||||
const { calls } = decodeAntigravityGenMetadata({
|
||||
records: [sqliteRow(0, hex)],
|
||||
context,
|
||||
cascadeId: 'g9-zero',
|
||||
})
|
||||
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
|
||||
it('G14: RPC generator metadata decode handles skips, fallbacks, model map, and timestamps', () => {
|
||||
const modelMap = {
|
||||
'raw-model-a': 'gemini-3-pro',
|
||||
'placeholder-key': 'MODEL_PLACEHOLDER_7',
|
||||
}
|
||||
|
||||
const metadata = [
|
||||
{ chatModel: { model: 'no-usage' } },
|
||||
{
|
||||
chatModel: {
|
||||
model: 'zero',
|
||||
usage: {
|
||||
model: 'zero',
|
||||
inputTokens: '0',
|
||||
outputTokens: '0',
|
||||
apiProvider: 'google',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
chatModel: {
|
||||
model: 'unmapped-model-a',
|
||||
usage: {
|
||||
model: 'unmapped-model-a',
|
||||
inputTokens: '100',
|
||||
outputTokens: '20',
|
||||
responseOutputTokens: '15',
|
||||
thinkingOutputTokens: '5',
|
||||
apiProvider: 'google',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
chatModel: {
|
||||
model: 'raw-model-a',
|
||||
usage: {
|
||||
model: 'raw-model-a',
|
||||
inputTokens: '50',
|
||||
outputTokens: '10',
|
||||
responseOutputTokens: '8',
|
||||
thinkingOutputTokens: '2',
|
||||
apiProvider: 'google',
|
||||
responseId: 'mapped-call',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
chatModel: {
|
||||
model: 'unmapped-model-b',
|
||||
usage: {
|
||||
model: 'unmapped-model-b',
|
||||
inputTokens: '77',
|
||||
outputTokens: '7',
|
||||
responseOutputTokens: '6',
|
||||
thinkingOutputTokens: '1',
|
||||
apiProvider: 'google',
|
||||
responseId: 'raw-passthrough-call',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
chatModel: {
|
||||
model: 'placeholder-key',
|
||||
usage: {
|
||||
model: 'placeholder-key',
|
||||
inputTokens: '25',
|
||||
outputTokens: '5',
|
||||
responseOutputTokens: '4',
|
||||
thinkingOutputTokens: '1',
|
||||
apiProvider: 'google',
|
||||
responseId: 'placeholder-call',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
chatModel: {
|
||||
model: 'with-timestamp',
|
||||
usage: {
|
||||
model: 'with-timestamp',
|
||||
inputTokens: '99',
|
||||
outputTokens: '9',
|
||||
responseOutputTokens: '9',
|
||||
apiProvider: 'google',
|
||||
responseId: 'timestamp-call',
|
||||
},
|
||||
chatStartMetadata: { createdAt: '2026-03-15T09:30:00.000Z' },
|
||||
},
|
||||
},
|
||||
{
|
||||
chatModel: {
|
||||
model: 'no-timestamp',
|
||||
usage: {
|
||||
model: 'no-timestamp',
|
||||
inputTokens: '11',
|
||||
outputTokens: '2',
|
||||
responseOutputTokens: '2',
|
||||
apiProvider: 'google',
|
||||
responseId: 'no-timestamp-call',
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const { calls } = decodeAntigravityGeneratorMetadata({
|
||||
records: metadata as unknown[],
|
||||
context,
|
||||
cascadeId: 'g14-rpc',
|
||||
modelMap,
|
||||
})
|
||||
|
||||
expect(calls).toHaveLength(6)
|
||||
expect(calls.map(c => c.deduplicationKey)).toEqual([
|
||||
'antigravity:g14-rpc:2',
|
||||
'antigravity:g14-rpc:mapped-call',
|
||||
'antigravity:g14-rpc:raw-passthrough-call',
|
||||
'antigravity:g14-rpc:placeholder-call',
|
||||
'antigravity:g14-rpc:timestamp-call',
|
||||
'antigravity:g14-rpc:no-timestamp-call',
|
||||
])
|
||||
expect(calls.map(c => ({ model: c.model, inputTokens: c.inputTokens, outputTokens: c.outputTokens, reasoningTokens: c.reasoningTokens, timestamp: c.timestamp }))).toEqual([
|
||||
{ model: 'unmapped-model-a', inputTokens: 100, outputTokens: 15, reasoningTokens: 5, timestamp: '' },
|
||||
{ model: 'gemini-3-pro', inputTokens: 50, outputTokens: 8, reasoningTokens: 2, timestamp: '' },
|
||||
{ model: 'unmapped-model-b', inputTokens: 77, outputTokens: 6, reasoningTokens: 1, timestamp: '' },
|
||||
{ model: 'unknown', inputTokens: 25, outputTokens: 4, reasoningTokens: 1, timestamp: '' },
|
||||
{ model: 'with-timestamp', inputTokens: 99, outputTokens: 9, reasoningTokens: 0, timestamp: '2026-03-15T09:30:00.000Z' },
|
||||
{ model: 'no-timestamp', inputTokens: 11, outputTokens: 2, reasoningTokens: 0, timestamp: '' },
|
||||
])
|
||||
})
|
||||
|
||||
it('statusline: run collapse, monotonic delta, and seenKeys respect', () => {
|
||||
const lines = [
|
||||
JSON.stringify({ at: '2026-01-01T00:00:01.000Z', conversationId: 's1', model: 'm', usage: { inputTokens: 100, outputTokens: 10, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 } }),
|
||||
JSON.stringify({ at: '2026-01-01T00:00:02.000Z', conversationId: 's1', model: 'm', usage: { inputTokens: 200, outputTokens: 20, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 } }),
|
||||
JSON.stringify({ at: '2026-01-01T00:00:03.000Z', conversationId: 's1', model: 'm', usage: { inputTokens: 200, outputTokens: 20, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 } }),
|
||||
JSON.stringify({ at: '2026-01-01T00:00:04.000Z', conversationId: 's1', model: 'm', usage: { inputTokens: 300, outputTokens: 30, cacheCreationInputTokens: 0, cacheReadInputTokens: 50 } }),
|
||||
]
|
||||
|
||||
const { calls } = decodeAntigravityStatusLine({ records: lines, context, seenKeys: new Set() })
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0]).toMatchObject({ inputTokens: 200, outputTokens: 20, cacheReadInputTokens: 0 })
|
||||
expect(calls[1]).toMatchObject({ inputTokens: 100, outputTokens: 10, cacheReadInputTokens: 50 })
|
||||
})
|
||||
|
||||
it('parseAntigravityStatusLinePayload uses the injected at value and never captures cwd', () => {
|
||||
const fixedAt = '2026-05-05T05:05:05.005Z'
|
||||
const payload = {
|
||||
conversation_id: 'parse-test',
|
||||
session_id: 'session-secret',
|
||||
cwd: '/Users/victim/secret-project',
|
||||
model: { id: 'gemini-3-pro', display_name: 'Gemini 3 Pro' },
|
||||
context_window: {
|
||||
current_usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const event = parseAntigravityStatusLinePayload(payload, fixedAt)
|
||||
expect(event).not.toBeNull()
|
||||
expect(event!.at).toBe(fixedAt)
|
||||
expect(event).not.toHaveProperty('cwd')
|
||||
expect(JSON.stringify(event)).not.toContain('/Users/victim/secret-project')
|
||||
})
|
||||
|
||||
it('toObservations produces a schema-valid, secret-free envelope', () => {
|
||||
const chatStartMetadata = textField(4, '2026-07-17T10:00:00.000Z')
|
||||
const usage = lenField(4, [...varintField(2, 100), ...varintField(3, 10)])
|
||||
const chatModel = [...usage, ...lenField(9, chatStartMetadata), ...textField(19, 'gemini-3.1-pro-high')]
|
||||
const hex = Buffer.from(lenField(1, chatModel)).toString('hex')
|
||||
|
||||
const { calls } = decodeAntigravityGenMetadata({
|
||||
records: [sqliteRow(0, hex)],
|
||||
context,
|
||||
cascadeId: 'obs-test',
|
||||
})
|
||||
|
||||
const { sessions } = toObservations(
|
||||
{ sessionId: 'obs-test', projectPath: '/Users/t/secret-project', calls },
|
||||
{ privacyKey: 'test-privacy-key', provider: 'antigravity' },
|
||||
)
|
||||
|
||||
const envelope = {
|
||||
schemaVersion: OBSERVATION_SCHEMA_VERSION,
|
||||
generator: { name: '@codeburn/core', version: '0.0.0-test' },
|
||||
sessions,
|
||||
}
|
||||
|
||||
expect(ObservationEnvelope.safeParse(envelope).success).toBe(true)
|
||||
expect(sessions[0]?.calls[0]?.toolNames).toEqual([])
|
||||
expect(JSON.stringify(envelope)).not.toContain('/Users/t/secret-project')
|
||||
})
|
||||
})
|
||||
|
|
@ -41,6 +41,7 @@ export default defineConfig({
|
|||
'src/providers/copilot/index.ts',
|
||||
'src/providers/vscode-cline/index.ts',
|
||||
'src/providers/opencode-session/index.ts',
|
||||
'src/providers/antigravity/index.ts',
|
||||
],
|
||||
format: ['esm'],
|
||||
target: 'node20',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue