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

371 lines (370 loc) • 15.2 kB
/** * Per-account token and cost totals, read incrementally from the proxy's own * request log. * * `~/.neurolink/logs` runs to hundreds of megabytes, so a polled endpoint * cannot re-read it per request. This keeps a per-file cursor and only ever * reads bytes appended since the last call. * * ## Why this is not built on proxyAnalysis.ts * * That module always re-reads whole files and sweeps the lifecycle, attempts * and debug streams too. Attempts carry one row per retry for the same * requestId, so folding them in would multiply a retried request's tokens by * its retry count. * * ## Correctness rules this file exists to enforce * * - **Only `proxy-<date>.jsonl`.** Never attempts/lifecycle/debug. * - **Dedupe by requestId.** A request can be logged twice: once when the * response headers are known and again when a streamed body finishes and its * token counts arrive (the Codex engine does exactly this). Totals are * derived from a requestId-keyed map, never by adding up raw lines. * - **Only advance the cursor to the last complete newline**, so a line still * being appended is re-read whole next time rather than parsed truncated. * - **A shrinking file means delete-and-recreate**, not truncation — the writer * only appends. Reset the cursor rather than reporting corruption. */ import { homedir } from "node:os"; import { join } from "node:path"; import { calculateCost, hasPricing } from "../utils/pricing.js"; /** Same pattern proxyAnalysis.ts uses, deliberately duplicated as a constant. */ const REQUEST_FILE_PATTERN = /^proxy-\d{4}-\d{2}-\d{2}\.jsonl$/; /** * Account types written by the Anthropic pool. The request log is shared with * the Codex engine, and an operator can use the same email for both, so rows * are attributed by provider-qualified key, never by bare label. */ const ANTHROPIC_ACCOUNT_TYPES = new Set(["oauth", "api_key"]); const CODEX_ACCOUNT_TYPE = "codex-oauth"; /** * The key a log row is attributed under. Current builds write `accountKey`; * rows from before that field existed carry only the label and the engine's * account type, which is enough to rebuild it — the two engines never share a * type string. */ function ledgerAccountKey(record, account) { if (typeof record.accountKey === "string" && record.accountKey.length > 0) { return record.accountKey; } const type = typeof record.accountType === "string" ? record.accountType : ""; if (ANTHROPIC_ACCOUNT_TYPES.has(type)) { return `anthropic:${account.trim().toLowerCase()}`; } if (type === CODEX_ACCOUNT_TYPE) { return `codex:${account}`; } return account; } const cursors = new Map(); function getLogsDir() { return join(homedir(), ".neurolink", "logs"); } function finiteNumber(value) { return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0; } /** * Provider to price against, given a log row. * * The Anthropic pool never sets `provider` on its records, so the account type * is the only signal for the overwhelming majority of rows. */ function resolveProvider(entry) { if (entry.provider) { return entry.provider; } if (ANTHROPIC_ACCOUNT_TYPES.has(entry.accountType)) { return "anthropic"; } if (entry.accountType === "codex-oauth") { return "openai"; } // Translation and unknown rows can have landed anywhere; the cross-provider // lookup finds the model wherever it lives rather than guessing a vendor. return "openai-compatible"; } /** * Read newly appended lines and fold them into the file's requestId map. * * `node:fs` is imported dynamically rather than statically: this module is * reachable from the bundled SDK, whose browser build stubs `node:fs` and has * no `readSync`, so a static import fails the bundle outright. */ async function advanceCursor(fileName, cursor) { const { closeSync, openSync, readSync, statSync } = await import("node:fs"); const path = join(getLogsDir(), fileName); let size; try { size = statSync(path).size; } catch { // Retention deleted the file. Its accumulated totals are the final answer // for that day and can never be recomputed, so freeze rather than drop. return; } if (size < cursor.size) { // Only reachable via delete-and-recreate; the writer appends in place. cursor.offset = 0; cursor.entries.clear(); } cursor.size = size; if (size <= cursor.offset) { return; } let chunk; const fd = openSync(path, "r"); try { const length = size - cursor.offset; chunk = Buffer.alloc(length); readSync(fd, chunk, 0, length, cursor.offset); } finally { closeSync(fd); } const text = chunk.toString("utf8"); const lastBreak = text.lastIndexOf("\n"); if (lastBreak === -1) { // A partial line with no terminator yet; leave the cursor where it is. return; } cursor.offset += Buffer.byteLength(text.slice(0, lastBreak + 1), "utf8"); for (const line of text.slice(0, lastBreak).split("\n")) { if (!line.trim()) { continue; } let record; try { record = JSON.parse(line); } catch { continue; } const requestId = record.requestId; const account = record.account; if (typeof requestId !== "string" || typeof account !== "string") { continue; } if (!account) { continue; } // requestId can come straight from a client-supplied X-Request-ID header, // so it is not guaranteed unique across genuinely separate requests. Keying // on the triple means two distinct calls that collide on id but differ in // account or model are still counted separately; a true re-log of the same // request keeps the same triple and merges. const accountKey = ledgerAccountKey(record, account); const entryKey = `${requestId}\u0000${accountKey}\u0000${typeof record.model === "string" ? record.model : ""}`; const next = { account, accountKey, accountType: typeof record.accountType === "string" ? record.accountType : "", model: typeof record.model === "string" ? record.model : "", provider: typeof record.provider === "string" ? record.provider : undefined, inputTokens: finiteNumber(record.inputTokens), outputTokens: finiteNumber(record.outputTokens), cacheReadTokens: finiteNumber(record.cacheReadTokens), cacheCreationTokens: finiteNumber(record.cacheCreationTokens), cacheCreation1hTokens: finiteNumber(record.cacheCreation1hTokens), clientApp: resolveClientApp(record), }; // A later record for the same request enriches the earlier one — it must // replace it, never add to it. But token fields take the MAX rather than // the newer value: a request can be re-logged with no token fields at all // (a terminal error recorded after a successful response), and letting // those zeros win would silently erase real usage. // // The catch is that the key is not guaranteed unique. requestId can come // straight from a client-supplied X-Request-ID, so a fixed correlation // header or an idempotency wrapper produces N genuinely distinct requests // that agree on id, account and model. Merging those undercounts by a // factor of N. // // What separates the two cases is usage. The re-log this dedup exists for // is an *enrichment*: the Codex engine writes once when the response // headers are known, carrying no tokens, and again when the stream ends, // carrying them all. No writer ever emits two token-bearing lines for one // request — the Anthropic engine's terminal log is guarded by a per-request // flag, and retries go to a separate attempts file this reader never opens. // So a second line that carries its own usage is a second request, and // takes its own slot. const slotKey = resolveSlotKey(cursor, entryKey, next); const previous = cursor.entries.get(slotKey); cursor.entries.set(slotKey, previous ? { ...previous, ...next, inputTokens: Math.max(previous.inputTokens, next.inputTokens), outputTokens: Math.max(previous.outputTokens, next.outputTokens), cacheReadTokens: Math.max(previous.cacheReadTokens, next.cacheReadTokens), cacheCreationTokens: Math.max(previous.cacheCreationTokens, next.cacheCreationTokens), cacheCreation1hTokens: Math.max(previous.cacheCreation1hTokens, next.cacheCreation1hTokens), // Likewise keep a real model name over a placeholder. model: next.model && next.model !== "-" ? next.model : previous.model, } : next); } } /** Whether a log record carries any usage at all. */ function hasUsage(entry) { return (entry.inputTokens > 0 || entry.outputTokens > 0 || entry.cacheReadTokens > 0 || entry.cacheCreationTokens > 0); } /** * Where this record belongs: the existing slot for its id, or a fresh one. * * Returns `entryKey` for the first record with that id, and for any later * record that looks like a re-log of it. A later record that reports its own, * different usage is a separate request that merely collided on a * client-supplied id, so it gets the next free numbered slot instead. */ function resolveSlotKey(cursor, entryKey, next) { const base = cursor.entries.get(entryKey); if (!base || !hasUsage(next)) { return entryKey; } let slot = entryKey; let occupant = base; let index = 1; while (occupant) { if (!hasUsage(occupant)) { return slot; } index += 1; slot = `${entryKey}\u0000#${index}`; occupant = cursor.entries.get(slot); } return slot; } /** * Which CLI a log row came from. * * Prefers the derived name the proxy recorded. Falls back to the raw * User-Agent's leading token so an unclassified client is still attributable * instead of collapsing into one bucket with everything else. Rows written * before attribution existed carry neither and are reported as "unattributed" * — distinct from "unknown", which means a client we saw but could not name. */ function resolveClientApp(record) { if (typeof record.clientApp === "string" && record.clientApp) { return record.clientApp; } if (typeof record.userAgent === "string" && record.userAgent) { const token = record.userAgent.trim().split(/[\s/]/)[0]; if (token) { return token; } } return "unattributed"; } /** UTC date stamp of the log file the totals cover. */ export function currentUsageDate(now = new Date()) { return now.toISOString().slice(0, 10); } function emptyTotals() { return { requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, costUsd: 0, unpricedRequests: 0, unpricedModels: [], byClient: {}, }; } /** * Token and cost totals for one UTC day, keyed by provider-qualified account * key ("anthropic:<label>" / "codex:<label>"). * * Both engines' rows are attributed, each under its own key, so a Codex login * sharing an email with an Anthropic one contributes to its own row and never * to the other's. Keying by bare label used to force a choice between merging * them and dropping Codex outright; it dropped Codex. */ export async function readAccountUsage(date = currentUsageDate()) { const fileName = `proxy-${date}.jsonl`; if (!REQUEST_FILE_PATTERN.test(fileName)) { return new Map(); } // Only the current day is ever read, so anything else is a stale cursor // holding one object per requestId seen that day. Without this the map grows // for the life of the process. for (const key of cursors.keys()) { if (key !== fileName) { cursors.delete(key); } } let cursor = cursors.get(fileName); if (!cursor) { cursor = { offset: 0, size: 0, entries: new Map() }; cursors.set(fileName, cursor); } await advanceCursor(fileName, cursor); const totals = new Map(); const unpriced = new Map(); for (const entry of cursor.entries.values()) { if (!ANTHROPIC_ACCOUNT_TYPES.has(entry.accountType) && entry.accountType !== CODEX_ACCOUNT_TYPE) { // Translation and internal rows are proxy plumbing, not a login's spend. continue; } const row = totals.get(entry.accountKey) ?? emptyTotals(); row.requests += 1; row.inputTokens += entry.inputTokens; row.outputTokens += entry.outputTokens; row.cacheReadTokens += entry.cacheReadTokens; row.cacheCreationTokens += entry.cacheCreationTokens; const client = (row.byClient[entry.clientApp] ??= { requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, costUsd: 0, }); client.requests += 1; client.inputTokens += entry.inputTokens; client.outputTokens += entry.outputTokens; client.cacheReadTokens += entry.cacheReadTokens; client.cacheCreationTokens += entry.cacheCreationTokens; const provider = resolveProvider(entry); if (entry.model && entry.model !== "-") { if (hasPricing(provider, entry.model)) { const cost = calculateCost(provider, entry.model, { input: entry.inputTokens, output: entry.outputTokens, total: entry.inputTokens + entry.outputTokens, cacheReadTokens: entry.cacheReadTokens, cacheCreationTokens: entry.cacheCreationTokens, cacheCreation1hTokens: entry.cacheCreation1hTokens, }); row.costUsd += cost; client.costUsd += cost; } else { row.unpricedRequests += 1; const seen = unpriced.get(entry.accountKey) ?? new Set(); seen.add(entry.model); unpriced.set(entry.accountKey, seen); } } totals.set(entry.accountKey, row); } for (const [account, row] of totals) { row.costUsd = Number(row.costUsd.toFixed(6)); for (const slice of Object.values(row.byClient)) { slice.costUsd = Number(slice.costUsd.toFixed(6)); } row.unpricedModels = [...(unpriced.get(account) ?? [])].sort(); } return totals; } /** Drop all cached cursors. Exported for tests, which vary HOME per case. */ export function resetAccountLedgerCache() { cursors.clear(); }