fix(grok): read per-request usage from logs/unified.jsonl (#1677)

* fix(grok): read per-inference usage from logs/unified.jsonl

Grok Build keeps logging every inference to ~/.grok/logs/unified.jsonl after
its session dirs are gone, so CodeBurn showed $0 for that usage. Read
shell.turn.inference_done records (prompt includes cached, completion includes
reasoning), with the model from the session's model changed event and the
project from session created. Sessions that still have a session dir are
skipped so they are not counted twice. DAILY_CACHE_VERSION 65.

* fix(grok): prefer the unified log over session-dir rollups

A session with inference records in logs/unified.jsonl is now read from the
log, one call per request, and its session dir is skipped. Session dirs only
count for sessions the log no longer holds. This keeps history when session
dirs are deleted after the log was cached, and prices grok-4.6's long-context
tier per request instead of on a session total. Logged calls can move to an
earlier day than the rollup, so grok joins PENDING_REDERIVE at 65.

* fix(grok): re-read skipped session dirs after log rotation, keep their tools

A session dir the unified log holds parses to nothing, and its fingerprint
does not track the log, so a rotated or truncated log left that empty
result cached. Empty grok session-dir entries now re-parse.

The skipped dir's summary, tools, bash commands and subagents now ride on
that session's first logged call, and its model and cwd fill in when the
log names neither. A call whose model nothing names is priced as
grok-build and marked estimated.
This commit is contained in:
Resham Joshi 2026-10-08 00:14:53 +02:00 • committed by GitHub
parent c826f1a479
commit 3c186491c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 341 additions and 12 deletions

View file

@ -314,7 +314,13 @@ import type { DateRange, ProjectSummary } from './types.js'
// From 11 Sep 2026 the alias served K2.8 Preview, which has no published rate;
// those calls price as K2.7 Code and are marked estimated.
// Calls are unchanged, so no PENDING_REDERIVE_PROVIDER_VERSIONS entry is needed.
export const DAILY_CACHE_VERSION = 64
// v65: Grok Build reads per-request usage from logs/unified.jsonl and uses it
// in place of a session dir's one-call rollup for every session the log holds;
// days finalized without it re-derive. A logged session's single rollup call,
// dated at its last activity, becomes one call per request dated at that
// request, so a session that crossed midnight moves calls to an earlier day and
// grok joins PENDING_REDERIVE_PROVIDER_VERSIONS at 65.
export const DAILY_CACHE_VERSION = 65
const MIN_SUPPORTED_VERSION = 28
/// Providers whose per-day CALL COUNT means something different at
@ -360,6 +366,9 @@ const PENDING_REDERIVE_PROVIDER_VERSIONS: Readonly<Record<string, number>> = {
// 59: standalone rows without created_at moved from the file mtime to the
// first step's time.
antigravity: 59,
// 65: logged sessions moved from one rollup at last activity to per-request
// calls at request time.
grok: 65,
}
function providersPendingRederiveFrom(fromVersion: number): string[] {

View file

@ -3221,6 +3221,11 @@ function cachedFileNeedsProviderReparse(providerName: string, sourcePath: string
// reparses. A sessions.db source's fingerprint folds in the WAL already.
if (providerName === 'devin') return sourcePath.endsWith('.json')
// A Grok session dir parses to nothing while logs/unified.jsonl holds its
// session, which its fingerprint does not track. Once the log rotates or is
// truncated past it, the dir has to count again.
if (providerName === 'grok') return cached.turns.length === 0
if (providerName !== 'gemini') return false
return cached.turns.some(turn =>

View file

@ -2,7 +2,7 @@ import { readdir, stat } from 'fs/promises'
import { basename, dirname, join } from 'path'
import { homedir } from 'os'
import { FS_SCAN_CONCURRENCY, mapWithConcurrency, readSessionFile } from '../fs-utils.js'
import { FS_SCAN_CONCURRENCY, mapWithConcurrency, readSessionFile, readSessionLines } from '../fs-utils.js'
import { calculateCost, getModelCosts, getShortModelName } from '../models.js'
import { extractBashCommands } from '../bash-utils.js'
import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
@ -21,6 +21,13 @@ import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderC
// `signals.json.contextTokensUsed` and the running `_meta.totalTokens` curve; for
// those we retain the old compaction-aware estimate and mark its cost estimated.
// `costUsdTicks` is deliberately ignored because its scale is not documented.
//
// <grok-home>/logs/unified.jsonl outlives the session dirs and logs every
// inference as `shell.turn.inference_done` (prompt_tokens includes
// cached_prompt_tokens, completion_tokens includes reasoning_tokens). A session
// with records there is read from the log, one call per request so the
// long-context tier applies per request; its session dir is then skipped. Session
// dirs only count for sessions the log no longer holds (rotated or truncated).
const toolNameMap: Record<string, string> = {
bash: 'Bash',
@ -370,13 +377,16 @@ function hasPositiveTotals(totals: GrokTokenTotals): boolean {
|| totals.reasoning > 0
}
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
function createParser(source: SessionSource, seenKeys: Set<string>, unifiedLogPath: string): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
const dir = dirname(source.path)
const summary = await readJson<GrokSummary>(join(dir, 'summary.json'))
if (!summary) return
const logged = await loggedSessionIds(unifiedLogPath)
if (logged.has(summary.info?.id ?? basename(dir)) || logged.has(basename(dir))) return
const updates = await readSessionFile(source.path)
if (!summary || updates === null) return
if (updates === null) return
const signals = await readJson<GrokSignals>(join(dir, 'signals.json'))
const existingModel =
@ -448,6 +458,151 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
}
}
type UnifiedLogLine = {
msg?: unknown
sid?: unknown
pid?: unknown
ts?: unknown
ctx?: Record<string, unknown>
}
function inferenceUsage(record: UnifiedLogLine): { sid: string; ts: string; prompt: number; cached: number; completion: number; reasoning: number } | null {
const { sid, ts, ctx } = record
if (record.msg !== 'shell.turn.inference_done' || typeof sid !== 'string' || !sid || typeof ts !== 'string' || !isRecord(ctx)) return null
const prompt = finiteNonNegative(ctx['prompt_tokens'])
const completion = finiteNonNegative(ctx['completion_tokens'])
if (prompt === undefined || completion === undefined || (prompt === 0 && completion === 0)) return null
return {
sid,
ts,
prompt,
cached: Math.min(finiteNonNegative(ctx['cached_prompt_tokens']) ?? 0, prompt),
completion,
reasoning: Math.min(finiteNonNegative(ctx['reasoning_tokens']) ?? 0, completion),
}
}
let loggedSessions: { key: string; sids: Promise<Set<string>> } | undefined
async function readLoggedSessionIds(path: string): Promise<Set<string>> {
const sids = new Set<string>()
for await (const line of readSessionLines(path)) {
if (!line.includes('shell.turn.inference_done')) continue
try {
const usage = inferenceUsage(JSON.parse(line) as UnifiedLogLine)
if (usage) sids.add(usage.sid)
} catch {
continue
}
}
return sids
}
// Every session dir asks; read the log once per version of it.
async function loggedSessionIds(path: string): Promise<Set<string>> {
const st = await stat(path).catch(() => null)
if (!st) return new Set()
const key = `${path}:${st.size}:${st.mtimeMs}`
if (loggedSessions?.key !== key) loggedSessions = { key, sids: readLoggedSessionIds(path) }
return loggedSessions.sids
}
// createParser skips a session dir the log holds; its summary, tools and
// commands ride on that session's first logged call instead.
async function readSessionDirInfo(dir: string | undefined) {
const summary = dir ? await readJson<GrokSummary>(join(dir, 'summary.json')) : null
const updates = dir ? await readSessionFile(join(dir, 'updates.jsonl')) : null
const parsed = updates === null ? null : parseUpdates(updates)
return {
model: summary?.current_model_id,
cwd: summary?.info?.cwd,
userMessage: summary?.session_summary ?? summary?.generated_title ?? '',
tools: parsed?.tools ?? [],
bashCommands: parsed?.bashCommands ?? [],
subagentTypes: parsed?.subagentTypes ?? [],
}
}
function createUnifiedLogParser(source: SessionSource, seenKeys: Set<string>, sessionsDir: string): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
const models = new Map<string, string>()
const pidModels = new Map<unknown, string>()
const cwds = new Map<string, string>()
const sessionDirs = new Map((await discoverSessions(sessionsDir)).map(s => [basename(dirname(s.path)), dirname(s.path)]))
const dirInfo = new Map<string, Awaited<ReturnType<typeof readSessionDirInfo>>>()
for await (const line of readSessionLines(source.path)) {
let record: UnifiedLogLine
try {
record = JSON.parse(line) as UnifiedLogLine
} catch {
continue
}
const sid = record.sid
const ctx = record.ctx
if (!isRecord(ctx)) continue
// A process announces its model before a session that predates the log
// file's first line can name one.
if (record.msg === 'model catalog: notifying clients' && typeof ctx['current_model_id'] === 'string') {
pidModels.set(record.pid, ctx['current_model_id'])
continue
}
if (typeof sid !== 'string' || !sid) continue
if (record.msg === 'model changed' && typeof ctx['model'] === 'string') {
models.set(sid, ctx['model'])
continue
}
if (record.msg === 'session created' && typeof ctx['cwd'] === 'string') {
cwds.set(sid, ctx['cwd'])
continue
}
const usage = inferenceUsage(record)
if (!usage) continue
const { prompt, cached, completion, reasoning } = usage
const deduplicationKey = `grok:unified:${sid}:${usage.ts}:${String(ctx['loop_index'] ?? 1)}`
if (seenKeys.has(deduplicationKey)) continue
seenKeys.add(deduplicationKey)
let info = dirInfo.get(sid)
const first = !info
if (!info) {
info = await readSessionDirInfo(sessionDirs.get(sid))
dirInfo.set(sid, info)
}
const namedModel = models.get(sid) ?? pidModels.get(record.pid) ?? info.model
const model = namedModel ?? 'grok-build'
const cwd = cwds.get(sid) ?? info.cwd
yield {
provider: source.provider,
model,
inputTokens: prompt - cached,
outputTokens: completion - reasoning,
cacheCreationInputTokens: 0,
cacheReadInputTokens: cached,
cachedInputTokens: cached,
reasoningTokens: reasoning,
webSearchRequests: 0,
costUSD: calculateCost(model, prompt - cached, completion, 0, cached, 0),
costIsEstimated: namedModel === undefined,
tools: first ? info.tools : [],
bashCommands: first ? info.bashCommands : [],
subagentTypes: first ? info.subagentTypes : [],
timestamp: usage.ts,
speed: 'standard',
deduplicationKey,
userMessage: first ? info.userMessage : '',
sessionId: sid,
project: cwd ? basename(cwd) : source.project,
projectPath: cwd,
}
}
},
}
}
async function discoverSessions(sessionsDir: string): Promise<SessionSource[]> {
const sources: SessionSource[] = []
@ -486,13 +641,14 @@ async function discoverSessions(sessionsDir: string): Promise<SessionSource[]> {
export function createGrokProvider(sessionsDir?: string): Provider {
const dir = sessionsDir ?? defaultSessionsDir()
const unifiedLogPath = join(dirname(dir), 'logs', 'unified.jsonl')
return {
name: 'grok',
displayName: 'Grok Build',
async probeRoots(): Promise<ProbeRoot[]> {
return [{ path: dir, label: 'sessions' }]
return [{ path: dir, label: 'sessions' }, { path: unifiedLogPath, label: 'unified log' }]
},
modelDisplayName(model: string): string {
@ -505,11 +661,16 @@ export function createGrokProvider(sessionsDir?: string): Provider {
},
async discoverSessions(): Promise<SessionSource[]> {
return discoverSessions(dir)
const sources = await discoverSessions(dir)
if (await stat(unifiedLogPath).catch(() => null)) {
sources.push({ path: unifiedLogPath, project: 'grok', provider: 'grok' })
}
return sources
},
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return createParser(source, seenKeys)
if (source.path === unifiedLogPath) return createUnifiedLogParser(source, seenKeys, dir)
return createParser(source, seenKeys, unifiedLogPath)
},
}
}

View file

@ -484,7 +484,9 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
// authoritative-usage-v4: persist one Grok session call from top-level
// authoritative totals, use modelUsage only for priced attribution, clamp
// reasoning per record, and label mixed sessions estimated.
grok: 'authoritative-usage-v4',
// unified-log-v1: per-request usage from logs/unified.jsonl replaces the
// session-dir rollup for every session the log holds.
grok: 'authoritative-usage-v4-unified-log-v1',
// Estimated from message text: Grok Bot's local mirror records no tokens.
// import-guess-est-v1: synced Grok Bot rows with no dollar amount are estimated.
grokbot: 'estimated-usage-v1-import-guess-est-v1',

View file

@ -814,7 +814,7 @@ describe('#946: a migration re-derives copilot instead of carrying it', () => {
const loaded = await loadDailyCache()
// From v32, Copilot (v56), Codex, Hermes, Devin, Cursor Agent (v58) and Antigravity (v59) are owed
// re-derivation; dsh's v32 contract is already satisfied.
expect(loaded.pendingRederive).toEqual(['copilot', 'codex', 'hermes', 'devin', 'cursor-agent', 'antigravity'])
expect(loaded.pendingRederive).toEqual(['copilot', 'codex', 'hermes', 'devin', 'cursor-agent', 'antigravity', 'grok'])
})
it('preserves an older cache pending repair while adding a newer provider repair', async () => {
@ -834,7 +834,7 @@ describe('#946: a migration re-derives copilot instead of carrying it', () => {
)
const loaded = await loadDailyCache()
expect(loaded.pendingRederive).toEqual(['copilot', 'codex', 'hermes', 'devin', 'cursor-agent', 'antigravity'])
expect(loaded.pendingRederive).toEqual(['copilot', 'codex', 'hermes', 'devin', 'cursor-agent', 'antigravity', 'grok'])
})
it('still carries the slice whole when the sources are gone (never-lose, #1033)', async () => {
@ -868,7 +868,7 @@ describe('#946: a migration re-derives copilot instead of carrying it', () => {
it('a PARTIAL parse does not spend the entitlement', async () => {
await seedOlderCache([day(settled, { copilot: PRE_STORE })])
const partial = await ensureCacheHydrated(noSessions, () => [], 'cfg-A', () => false)
expect(partial.pendingRederive).toEqual(['copilot', 'codex', 'hermes', 'dsh', 'devin', 'cursor-agent', 'antigravity'])
expect(partial.pendingRederive).toEqual(['copilot', 'codex', 'hermes', 'dsh', 'devin', 'cursor-agent', 'antigravity', 'grok'])
// The next COMPLETE run still gets to re-derive.
const out = await ensureCacheHydrated(noSessions, () => [day(settled, { copilot: STORE_BACKED })], 'cfg-A')
expect(out.days.find(d => d.date === settled)!.providers['copilot'])

View file

@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mkdir, rm, writeFile } from 'fs/promises'
import { appendFile, mkdir, rm, writeFile } from 'fs/promises'
import { join } from 'path'
import { calculateCost } from '../src/models.js'
@ -276,4 +276,35 @@ describe('Grok parser through the session-cache pipeline', () => {
const warmCall = (await parseGrokSessions())[0]!.turns[0]!.assistantCalls[0]!
expect(warmCall.costUSD).toBeCloseTo(expected, 12)
})
it('reads a logged session from the log, then from its dir once the log no longer holds it', async () => {
const uuid = '019edf9c-0000-7000-8000-000000000107'
await writeSession(usage({ input: 1000, output: 100 }), uuid)
const dir = join(GROK_HOME, 'sessions', '%2FUsers%2Ftest%2Fgrok-pipeline', uuid)
await appendFile(join(dir, 'updates.jsonl'), JSON.stringify({
method: 'session/update',
params: { sessionId: uuid, update: { sessionUpdate: 'tool_call', title: 'run_terminal_command', rawInput: { command: 'git status' } } },
}) + '\n')
const logPath = join(GROK_HOME, 'logs', 'unified.jsonl')
await mkdir(join(GROK_HOME, 'logs'), { recursive: true })
const inference = (ts: string) => JSON.stringify({
ts, pid: 1, msg: 'shell.turn.inference_done', sid: uuid,
ctx: { loop_index: 1, prompt_tokens: 500, cached_prompt_tokens: 0, completion_tokens: 50, reasoning_tokens: 0 },
})
await writeFile(logPath, [inference('2026-08-17T09:01:00.000Z'), inference('2026-08-17T09:02:00.000Z')].join('\n') + '\n')
const logged = (await parseGrokSessions())[0]!
const calls = logged.turns.flatMap(turn => turn.assistantCalls)
expect(calls.map(call => call.deduplicationKey.startsWith('grok:unified:'))).toEqual([true, true])
expect(logged.turns[0]!.userMessage).toBe('pipeline regression')
expect(calls[0]!.bashCommands).toEqual(['git'])
expect(calls[1]!.tools).toEqual([])
expect(calls[0]!.isEstimated).toBeFalsy()
await writeFile(logPath, JSON.stringify({ ts: '2026-08-18T00:00:00.000Z', pid: 2, msg: 'AuthManager::new' }) + '\n')
clearSessionCache()
const rotated = (await parseGrokSessions())[0]!
expect(rotated.apiCalls).toBe(1)
expect(rotated.turns[0]!.assistantCalls[0]!.deduplicationKey).not.toContain('unified')
})
})

View file

@ -79,9 +79,11 @@ describe('probeRoots mirrors discovery resolution (Tier 2, batch 1)', () => {
it('grok reports exactly its resolved sessions dir', async () => {
expect(await createGrokProvider('/tmp/grok-a').probeRoots!()).toEqual([
{ path: '/tmp/grok-a', label: 'sessions' },
{ path: join('/tmp', 'logs', 'unified.jsonl'), label: 'unified log' },
])
expect(await createGrokProvider().probeRoots!()).toEqual([
{ path: join(homedir(), '.grok', 'sessions'), label: 'sessions' },
{ path: join(homedir(), '.grok', 'logs', 'unified.jsonl'), label: 'unified log' },
])
})

View file

@ -478,6 +478,125 @@ describe('grok provider - parsing', () => {
})
})
describe('grok provider - unified log', () => {
const SID_A = '019f0000-0000-7000-8000-00000000000a'
const SID_B = '019f0000-0000-7000-8000-00000000000b'
function logLine(msg: string, sid: string | undefined, ts: string, ctx?: Record<string, unknown>) {
return JSON.stringify({ ts, src: 'shell', pid: 4242, lvl: 'info', msg, ...(sid ? { sid } : {}), ...(ctx ? { ctx } : {}) })
}
function inferenceDone(sid: string, ts: string, loopIndex: number, prompt: number, cached: number, completion: number, reasoning: number) {
return logLine('shell.turn.inference_done', sid, ts, {
loop_index: loopIndex, model_elapsed_ms: 1000, attempts: 1,
prompt_tokens: prompt, cached_prompt_tokens: cached, completion_tokens: completion, reasoning_tokens: reasoning,
})
}
async function writeLog(lines: string[]) {
await mkdir(join(tmpDir, 'logs'), { recursive: true })
await writeFile(join(tmpDir, 'logs', 'unified.jsonl'), [
logLine('AuthManager::new', undefined, '2026-07-28T09:59:59.000Z', { result: 'ok' }),
...lines,
].join('\n') + '\n')
}
async function parseAll() {
const provider = createGrokProvider(join(tmpDir, 'sessions'))
const calls: ParsedProviderCall[] = []
const seen = new Set<string>()
for (const source of await provider.discoverSessions()) {
for await (const call of provider.createSessionParser(source, seen).parse()) calls.push(call)
}
return calls
}
it('reads each inference as a call with cached and reasoning split out', async () => {
await writeLog([
logLine('session created', SID_A, '2026-07-28T10:00:00.000Z', { cwd: '/Users/test/grok-cwd' }),
logLine('model changed', SID_A, '2026-07-28T10:00:00.100Z', { model: 'grok-4.5' }),
logLine('shell.handle_prompt.start', SID_A, '2026-07-28T10:00:01.000Z', { prompt_id: 'p1', block_count: 1 }),
inferenceDone(SID_A, '2026-07-28T10:00:18.000Z', 1, 36828, 21888, 1522, 903),
inferenceDone(SID_A, '2026-07-28T10:00:30.000Z', 2, 40000, 36000, 500, 100),
])
const calls = await parseAll()
expect(calls).toHaveLength(2)
const [first] = calls
expect(first).toMatchObject({
model: 'grok-4.5',
inputTokens: 36828 - 21888,
cacheReadInputTokens: 21888,
cachedInputTokens: 21888,
outputTokens: 1522 - 903,
reasoningTokens: 903,
cacheCreationInputTokens: 0,
costIsEstimated: false,
timestamp: '2026-07-28T10:00:18.000Z',
sessionId: SID_A,
project: 'grok-cwd',
projectPath: '/Users/test/grok-cwd',
})
expect(first!.costUSD).toBeCloseTo((14940 * 2 + 21888 * 0.3 + 1522 * 6) / 1e6, 10)
expect(first!.costUSD).toBeCloseTo(calculateCost('grok-4.5', 14940, 1522, 0, 21888, 0), 10)
expect(new Set(calls.map(c => c.deduplicationKey)).size).toBe(2)
})
it('reads a logged session from the log instead of its session dir, and keeps session-only sessions', async () => {
const SID_C = '019f0000-0000-7000-8000-00000000000c'
const sessionsCwd = join('sessions', '%2FUsers%2Ftest')
await writeSession({ cwdEncoded: sessionsCwd, uuid: SID_A, completedTurns: [{ promptId: 'p1', usage: authoritativeUsage() }] })
await writeSession({ cwdEncoded: sessionsCwd, uuid: SID_C, completedTurns: [{ promptId: 'p1', usage: authoritativeUsage() }] })
await writeLog([
logLine('model changed', SID_A, '2026-07-28T10:00:00.000Z', { model: 'grok-4.5' }),
inferenceDone(SID_A, '2026-07-28T10:00:18.000Z', 1, 1000, 0, 100, 0),
inferenceDone(SID_A, '2026-07-28T10:00:30.000Z', 2, 1200, 1000, 50, 0),
logLine('model changed', SID_B, '2026-07-28T11:00:00.000Z', { model: 'grok-4.5' }),
inferenceDone(SID_B, '2026-07-28T11:00:18.000Z', 1, 2000, 500, 200, 50),
])
const calls = await parseAll()
const a = calls.filter(c => c.sessionId === SID_A)
expect(a).toHaveLength(2)
expect(a.every(c => c.deduplicationKey.startsWith('grok:unified:'))).toBe(true)
const c = calls.filter(c => c.sessionId === SID_C)
expect(c).toHaveLength(1)
expect(c[0]!.deduplicationKey).not.toContain('unified')
expect(calls.filter(c => c.sessionId === SID_B)).toHaveLength(1)
expect(calls.find(c => c.sessionId === SID_B)!.project).toBe('grok')
})
it('prices the long-context tier per request, not on the session total', async () => {
await writeLog([
logLine('model changed', SID_B, '2026-07-28T11:00:00.000Z', { model: 'grok-4.6' }),
inferenceDone(SID_B, '2026-07-28T11:00:18.000Z', 1, 150000, 0, 1000, 0),
inferenceDone(SID_B, '2026-07-28T11:01:18.000Z', 1, 150000, 0, 1000, 0),
])
const calls = await parseAll()
const total = calls.reduce((sum, c) => sum + c.costUSD, 0)
expect(total).toBeCloseTo(2 * calculateCost('grok-4.6', 150000, 1000, 0, 0, 0), 10)
expect(total).toBeLessThan(calculateCost('grok-4.6', 300000, 2000, 0, 0, 0))
})
it('takes the model from the process when the session never names one', async () => {
await writeLog([
logLine('model catalog: notifying clients', undefined, '2026-07-28T11:00:00.000Z', { current_model_id: 'grok-4.5', model_count: 3 }),
inferenceDone(SID_B, '2026-07-28T11:00:18.000Z', 1, 2000, 0, 200, 0),
])
const [call] = await parseAll()
expect(call!.model).toBe('grok-4.5')
})
it('falls back to grok-build, marked estimated, when nothing names the model', async () => {
await writeLog([inferenceDone(SID_B, '2026-07-28T11:00:18.000Z', 1, 2000, 0, 200, 0)])
const [call] = await parseAll()
expect(call!.model).toBe('grok-build')
expect(call!.costIsEstimated).toBe(true)
})
it('discovers nothing extra when there is no unified log', async () => {
expect(await createGrokProvider(join(tmpDir, 'sessions')).discoverSessions()).toEqual([])
})
})
describe('grok provider - display names', () => {
const provider = createGrokProvider('/tmp')