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

217 lines (216 loc) • 9.95 kB
/** * Reads token usage out of OpenCode's local SQLite store. * * Store: `~/.local/share/opencode/opencode.db` (NOT `~/.config/opencode`, * which holds only configuration). Usage lives on the `message` table, whose * `data` column is a JSON blob carrying `role`, `modelID`, `providerID`, * `cost` and a `tokens` object. * * Two things the real data settled, both of which would corrupt totals * silently if assumed instead of checked. * * **Cache is DISJOINT from input here.** Measured across all 4,674 * usage-bearing messages on a reference machine, `total` equals * `input + output + cache.read + cache.write` — never `input + output` alone * unless cache happened to be zero. This is the opposite of Codex, where * `cached_input_tokens` is a SUBSET of `input_tokens` and has to be subtracted * back out. Three readers now, three conventions; none of them is safe to * infer from the others. * * **OpenCode's own `cost` field is not usable.** It was 0 in all 4,674 * messages, while the provider mix spanned `github-copilot` (a subscription), * `openai` (metered) and `neurolink` (this proxy). A single cost figure for * that mixture would be wrong whichever way it was computed, so tokens are * reported and cost is declared `unavailable` rather than invented or * quietly zeroed. * * Worth knowing for anyone summing sources: traffic OpenCode sent through the * NeuroLink proxy appears under `providerID: "neurolink"` here AND in the * proxy's own ledger. The two are independent measurements of the same * requests, not additive. On the reference machine that was 41 messages and * ~772k tokens against 458M total, so it is small — but it is not zero, and a * dashboard adding local usage to proxy usage double-counts exactly that * slice. */ import { stat } from "fs/promises"; import { homedir } from "os"; import { join } from "path"; import { resolveScanCutoffMs } from "./scanWindow.js"; const CLI_ID = "opencode"; function databasePath() { return join(homedir(), ".local", "share", "opencode", "opencode.db"); } 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; } export async function createOpenCodeReader() { return { descriptor: { id: CLI_ID, displayName: "OpenCode", verified: true, dedupStrategy: "rowid-high-water-mark", costConfidence: "unavailable", requiresSqlite: true, }, detect: async () => { try { const info = await stat(databasePath()); return info.isFile(); } catch { return false; } }, scan: async (options) => { const totals = emptyTotals(); const errors = []; const models = new Set(); const dbPath = databasePath(); // `node:sqlite` is built in from Node 22 but still flagged experimental, // so it can be absent or change shape. Imported lazily and behind a // try/catch: a runtime without it must degrade to a reported failure for // this one reader, not take down a scan of all the others. let DatabaseSync; try { const sqlite = await import("node:sqlite"); // Validated, not asserted. The module is experimental and may change // shape between Node releases; a cast would let a changed export sail // through and fail later as an unrelated TypeError deep in the scan. if (typeof sqlite === "object" && sqlite !== null && "DatabaseSync" in sqlite && typeof sqlite.DatabaseSync === "function") { DatabaseSync = sqlite.DatabaseSync; } } catch (error) { errors.push({ cliId: CLI_ID, filePath: dbPath, message: `node:sqlite unavailable on this runtime: ${error instanceof Error ? error.message : String(error)}`, }); return { cliId: CLI_ID, totals, filesScanned: 0, errors }; } if (!DatabaseSync) { errors.push({ cliId: CLI_ID, filePath: dbPath, message: "node:sqlite did not expose a callable DatabaseSync — the experimental API has likely changed shape", }); return { cliId: CLI_ID, totals, filesScanned: 0, errors }; } // Only Infinity means "no time filter". A non-positive sinceDays used to // leave the cutoff undefined and read EVERYTHING — measured at 17,534 // files and 35.9s for `sinceDays: 0`, which is the widest possible scan // in answer to the narrowest possible request. Zero now means a // zero-length window, which is what it reads as. // NaN is not a window, and it is the case the previous fix missed: // Math.max(0, NaN) is NaN, every comparison against NaN is false, so the // filter passes EVERY file. Measured: sinceDays NaN read 17,537 files in // 32.8s — the same unbounded sweep this guard exists to prevent, reached // by a different door. The old guard caught it with Number.isFinite and // the replacement dropped that check. // // On this reader NaN was worse than an unbounded scan: the value went // into the SQL text, and SQLite parses a bare NaN as an identifier — // "no such column: NaN" — so the whole scan failed rather than // over-reading. The cutoff is a bound parameter now, so a value can // never be SQL syntax whatever it is. // `?? 0` because this value becomes a SQL parameter: an unbounded scan // is expressed as "since the epoch", never as a non-finite number. const cutoffMs = resolveScanCutoffMs(options?.sinceDays) ?? 0; let db; try { // Read-only: this is the user's live store and OpenCode may be running. db = new DatabaseSync(dbPath, { readOnly: true }); // Filtered in SQL rather than in JS. `time_created` is epoch ms, and // the table holds thousands of rows whose `data` blobs are large — the // point of the time window is not reading them at all. // Bound parameter, not interpolation. A number spliced into SQL text // is still SQL text: NaN parsed as an identifier and failed the whole // scan with "no such column: NaN". A bound value cannot become syntax // whatever it holds. const rows = db .prepare("SELECT data FROM message WHERE time_created >= ?") .all(cutoffMs); for (const row of rows) { if (typeof row.data !== "string") { continue; } let parsed; try { parsed = JSON.parse(row.data); } catch { continue; } const message = parsed; const tokens = message.tokens; if (!tokens || message.role !== "assistant") { continue; } const input = num(tokens.input); const output = num(tokens.output); const cacheRead = num(tokens.cache?.read); const cacheWrite = num(tokens.cache?.write); if (input === 0 && output === 0 && cacheRead === 0 && cacheWrite === 0) { continue; } totals.requests += 1; // Added as-is: cache is disjoint from input in this store. See the // note on this module — Codex is the other way round. totals.inputTokens += input; totals.outputTokens += output; totals.cacheReadTokens += cacheRead; totals.cacheCreationTokens += cacheWrite; if (message.modelID) { models.add(message.modelID); } } } catch (error) { errors.push({ cliId: CLI_ID, filePath: dbPath, message: error instanceof Error ? error.message : String(error), }); } finally { try { db?.close(); } catch { // Closing a database that failed to open is not a second failure. } } // Unpriced by construction rather than by a failed lookup — see the note // on this module about the mixed subscription/metered provider set. totals.unpricedRequests = totals.requests; totals.unpricedModels = [...models].sort(); return { cliId: CLI_ID, totals, filesScanned: totals.requests > 0 || errors.length === 0 ? 1 : 0, errors, }; }, }; }