UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

320 lines (319 loc) • 13.2 kB
/** * Reads token usage out of Gemini CLI's own session transcripts. * * Layout, confirmed on a real machine (18 files spanning June–August 2026): * `~/.gemini/tmp/<projectSlug>/chats/session-<timestamp>-<id>.jsonl`. The * sibling `~/.gemini/history/<projectSlug>/` directories are a git-based * shadow-history of edited files, not chat logs — no JSONL lives there. * * Each file is a small append-only patch log rather than one JSON object per * line of "the same shape": line 0 is a header (`sessionId`, `projectHash`, * `startTime`, `lastUpdated`, `kind`); every line after that is either * `{"$set": {...}}` (a partial merge — `messages` on session bootstrap, * scalar fields like `lastUpdated`/`summary` afterwards) or a bare message * object appended directly. Confirmed on the installed CLI's own bundled * source (`@google/gemini-cli` `ChatRecordingService.pushMessage`): a bare * append is `appendRecord(msg)` with no wrapper, which is why real transcripts * mix both shapes. This reader unwraps `$set.messages[]` (only ever seen once * per file, holding the single injected `<session_context>` bootstrap * message) and reads bare objects that carry `id`/`type` directly. * * The installed source also revealed a real double-write race, invisible in * this machine's own samples (0 duplicate ids across all 18 files) but * reachable on any machine: `recordMessage()` first pushes a `type: "gemini"` * message with whatever `tokens` value is already queued (often `null`, * before the response's usage metadata has arrived), and * `recordMessageTokens()` — called separately once usage arrives — re-pushes * the SAME message id with `tokens` now filled in if it finds the last * message still token-less. `pushMessage()` unconditionally appends a new * line every time it is called, even for an id it has already written. So the * same `id` can legitimately appear twice: once without tokens, once with. * Dedup here keeps, per id, whichever record has the larger `tokens.total` * (a record with no tokens contributes 0), which is correct for both that * race and an ordinary resumed-session replay. The map is per-file, mirroring * `claudeCodeReader.ts`. * * `tokens` is `{input, output, cached, thoughts, tool, total}`, and the source * (`recordMessageTokens`) maps it straight from the GenAI response's own * `usageMetadata` — `input = promptTokenCount`, `output = candidatesTokenCount`, * `cached = cachedContentTokenCount`, `thoughts = thoughtsTokenCount`, * `tool = toolUsePromptTokenCount`, `total = totalTokenCount`. Real data * confirms `total = input + output + thoughts + tool` and that `cached` is a * SUBSET of `input`: the one real record with nonzero cache had * `input: 12121, cached: 4073`, and `total (12344) = input (12121) + output * (1) + thoughts (222)` — cached tokens are not added on top of input, they * are already inside it. So `inputTokens` here is `input - cached`, the same * subtraction `codexReader.ts` and `qwenCodeReader.ts` make for the same * reason: this subsystem's `LocalUsageTotals.inputTokens` + * `.cacheReadTokens` must sum to the true prompt size without double-counting. * No cache-CREATION concept exists in this API family's usage metadata, so * `cacheCreationTokens` stays 0. * * The write path also nests subagent transcripts one level deeper — * `chats/<parentSessionId>/<subagentSessionId>.jsonl`, per the same source — * so this reader recurses under `chats/` rather than globbing one level, even * though no nested subagent file exists on the machine this was verified * against. * * Cost is deliberately `unavailable`. Gemini CLI supports three genuinely * different auth modes with unrelated billing (`gemini-api-key`: metered, and * the Flash models specifically have a real free tier; `oauth-personal`: free, * rate-limited; `vertex-ai`: billed to a GCP project at negotiated rates), and * no session record — not the header, not a message — carries which mode was * active when it was written. `settings.json` records only the CURRENT mode, * which cannot be projected onto historical sessions. Modeling a per-token * dollar figure from the public API price list would misrepresent every * free-tier or Vertex-billed session as if it were pay-as-you-go. Concretely, * on the reference machine even the majority model logged (`gemini-3.5-flash`, * 12 of 14 sampled turns) has no entry in `pricing.ts` at all — only the * minority model (`gemini-3-flash-preview`) does — so most real traffic would * be unpriced regardless. */ import { createReadStream } from "fs"; import { createInterface } from "readline"; import { readdir, stat } from "fs/promises"; import { homedir } from "os"; import { join } from "path"; import { resolveScanCutoffMs } from "./scanWindow.js"; const CLI_ID = "gemini-cli"; function tmpRoot() { return join(homedir(), ".gemini", "tmp"); } function emptyTotals() { return { requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, costUsd: 0, costConfidence: "unavailable", unpricedRequests: 0, unpricedModels: [], }; } function num(value) { return typeof value === "number" && Number.isFinite(value) ? value : 0; } /** * A scan that cannot read a directory must say so. * * Swallowing every readdir failure made an unreadable tree indistinguishable * from an empty one: the report showed zero usage, no failure entry, and an * operator with a permissions problem had nothing to look at. ENOENT stays * silent because a missing root legitimately means "nothing recorded yet"; * anything else is a real failure and is surfaced. */ function isMissing(error) { return (typeof error === "object" && error !== null && error.code === "ENOENT"); } /** Every `chats/` directory, one per project, under `~/.gemini/tmp`. */ async function collectChatsDirs(root, errors) { let entries; try { entries = await readdir(root, { withFileTypes: true }); } catch (error) { if (!isMissing(error)) { errors.push({ cliId: CLI_ID, filePath: root, message: error instanceof Error ? error.message : String(error), }); } return []; } const dirs = []; for (const entry of entries) { if (!entry.isDirectory()) { continue; } const chatsDir = join(root, entry.name, "chats"); try { const info = await stat(chatsDir); if (info.isDirectory()) { dirs.push(chatsDir); } } catch { // No `chats/` for this project temp dir — nothing recorded yet. } } return dirs; } /** Every `.jsonl` transcript at any depth under a `chats/` directory. */ async function collectTranscripts(dir, out, errors) { let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch (error) { if (!isMissing(error)) { errors.push({ cliId: CLI_ID, filePath: dir, message: error instanceof Error ? error.message : String(error), }); } return; } for (const entry of entries) { const full = join(dir, entry.name); if (entry.isDirectory()) { await collectTranscripts(full, out, errors); } else if (entry.isFile() && entry.name.endsWith(".jsonl")) { out.push(full); } } } /** Pull the message-like objects out of one parsed JSONL line, if any. */ function messagesFromLine(parsed) { if (typeof parsed !== "object" || parsed === null) { return []; } const record = parsed; // `!== undefined` also admits `$set: null`, and reading `.messages` off it // throws. So does a null element surviving into the loop, where `msg.type` // is read. Either exception escapes to the per-file catch and discards that // whole transcript's totals — one malformed line silently costing a file. if (record.$set !== undefined) { if (typeof record.$set !== "object" || record.$set === null) { return []; } const messages = record.$set.messages; return Array.isArray(messages) ? messages.filter((m) => typeof m === "object" && m !== null) : []; } if (typeof record.id === "string" && typeof record.type === "string") { return [parsed]; } return []; } /** * Dedup is by message `id`, keeping the larger `tokens.total` seen for that * id — see the module header for why the same id can legitimately appear * twice. The map is per-file and discarded after, bounding memory regardless * of how many files a scan covers. */ async function foldTranscript(filePath, totals, unpriced) { const seen = new Map(); const rl = createInterface({ input: createReadStream(filePath, { encoding: "utf8" }), crlfDelay: Infinity, }); try { let lineNo = 0; for await (const line of rl) { lineNo += 1; if (lineNo === 1 || !line || line.charCodeAt(0) !== 123 /* '{' */) { // Line 0 is the session header, never a message. continue; } let parsed; try { parsed = JSON.parse(line); } catch { continue; } for (const msg of messagesFromLine(parsed)) { if (msg.type !== "gemini" || !msg.tokens) { continue; } const id = msg.id; if (typeof id !== "string" || id.length === 0) { continue; } const tokens = msg.tokens; const input = num(tokens.input); const cached = num(tokens.cached); const candidate = { model: msg.model ?? "unknown", input: Math.max(0, input - cached), output: num(tokens.output) + num(tokens.thoughts) + num(tokens.tool), cached, }; const existing = seen.get(id); // Cached tokens are part of the raw prompt, so a record can carry more // total usage and still lose on input+output alone — keep-max would then // keep the smaller of two duplicates. const candidateTotal = candidate.input + candidate.output + candidate.cached; const existingTotal = existing ? existing.input + existing.output + existing.cached : -1; if (!existing || candidateTotal > existingTotal) { seen.set(id, candidate); } } } } finally { rl.close(); } for (const turn of seen.values()) { totals.requests += 1; totals.inputTokens += turn.input; totals.outputTokens += turn.output; totals.cacheReadTokens += turn.cached; totals.unpricedRequests += 1; unpriced.add(turn.model); } } export async function createGeminiCliReader() { return { descriptor: { id: CLI_ID, displayName: "Gemini CLI", verified: true, dedupStrategy: "message-id-keep-max", costConfidence: "unavailable", requiresSqlite: false, }, detect: async () => { try { const info = await stat(tmpRoot()); return info.isDirectory(); } catch { return false; } }, scan: async (options) => { const totals = emptyTotals(); const errors = []; const unpriced = new Set(); const chatsDirs = await collectChatsDirs(tmpRoot(), errors); const files = []; for (const dir of chatsDirs) { await collectTranscripts(dir, files, errors); } const cutoff = resolveScanCutoffMs(options?.sinceDays); let filesScanned = 0; for (const file of files) { try { if (cutoff !== undefined) { const info = await stat(file); if (info.mtimeMs < cutoff) { continue; } } await foldTranscript(file, totals, unpriced); filesScanned += 1; } catch (error) { errors.push({ cliId: CLI_ID, filePath: file, message: error instanceof Error ? error.message : String(error), }); } } totals.unpricedModels = [...unpriced].sort(); return { cliId: CLI_ID, totals, filesScanned, errors }; }, }; }