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

407 lines (406 loc) • 15.7 kB
/** * NeuroCoin ledger and per-window consumption tracking for borrowed traffic. * * Two jobs, deliberately in one module because they settle from the same event: * * 1. **Coins.** A grant with a coin entitlement spends against a balance. The * balance itself lives on the grant (one source of truth); this module holds * the entries and drives the deduction. * 2. **Window buckets.** How much of an account's 5h/7d window a grant has taken, * keyed by that window's reset timestamp so the counter starts fresh when the * window does. This is what makes a slice ceiling mean "a fifth of *this* * window" rather than "a fifth, once, forever". * * **Why hold-then-settle.** Real usage is only known when the response finishes * — for a stream, at `message_delta`. A balance checked at admission and * deducted at completion lets N concurrent streams each pass the same check and * overspend by N-1 requests. So admission opens a *hold* for an estimate, the * available balance is `balance - Σ open holds`, and settlement replaces the * hold with the real figure. A crash loses in-flight holds, which is the safe * direction: the balance is only ever reduced by traffic that actually happened. * * @module proxy/shareLedger */ import { randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { AsyncMutex } from "../utils/asyncMutex.js"; import { logger } from "../utils/logger.js"; import { debitShareGrantCoins, updateShareGrant } from "./shareGrants.js"; import { writeJsonSnapshotAtomically } from "./snapshotPersistence.js"; const LEDGER_FILE = "proxy-share-ledger.json"; /** Normalized tokens per coin. */ export const TOKENS_PER_COIN = 1000; /** * Output tokens cost materially more than input everywhere, and cache reads * cost almost nothing. Weighting by that keeps a coin comparable across very * different request shapes rather than rewarding one long prompt over many * short ones. */ const INPUT_WEIGHT = 1; const OUTPUT_WEIGHT = 4; const CACHE_CREATE_WEIGHT = 1.25; const CACHE_READ_WEIGHT = 0.1; /** Tier multipliers, mirroring the published price ratios. */ const MODEL_WEIGHTS = [ ["haiku", 0.25], ["sonnet", 1], ["opus", 5], ["fable", 5], ]; let customLedgerFilePath = null; let buckets = {}; let loaded = false; const mutationMutex = new AsyncMutex(); /** Open holds, in memory only — they are request-scoped by definition. */ const holds = new Map(); export function initShareLedger(ledgerFilePath) { customLedgerFilePath = ledgerFilePath; buckets = {}; loaded = false; holds.clear(); } function getLedgerFilePath() { return customLedgerFilePath ?? join(homedir(), ".neurolink", LEDGER_FILE); } function bucketKey(grantId, accountKey) { return `${grantId}|${accountKey}`; } export function modelCoinWeight(model) { if (!model) { return 1; } const normalized = model.toLowerCase(); for (const [token, weight] of MODEL_WEIGHTS) { if (normalized.includes(token)) { return weight; } } return 1; } /** Convert real usage into coins. Pure — the pricing table is the whole story. */ export function usageToCoins(usage, model) { const normalized = usage.inputTokens * INPUT_WEIGHT + usage.outputTokens * OUTPUT_WEIGHT + (usage.cacheCreationTokens ?? 0) * CACHE_CREATE_WEIGHT + (usage.cacheReadTokens ?? 0) * CACHE_READ_WEIGHT; return (normalized * modelCoinWeight(model)) / TOKENS_PER_COIN; } /** * What to hold at admission, before anything is known about the response. * * Intentionally rough: the hold exists to stop concurrent streams from each * spending the last coin, not to predict the bill. Settlement replaces it with * the real figure moments later. */ export function estimateHoldCoins(model, maxTokens) { const assumedOutput = Math.min(Math.max(maxTokens ?? 4096, 256), 64_000); return usageToCoins({ inputTokens: 2000, outputTokens: assumedOutput }, model); } function isBucket(value) { if (!value || typeof value !== "object") { return false; } const candidate = value; return (typeof candidate.grantId === "string" && typeof candidate.accountKey === "string" && typeof candidate.sessionFraction === "number" && typeof candidate.weeklyFraction === "number"); } async function ensureLoaded() { if (loaded) { return; } try { const parsed = JSON.parse(await readFile(getLedgerFilePath(), "utf8")); buckets = Object.fromEntries(Object.entries(parsed?.buckets ?? {}).filter((entry) => isBucket(entry[1]))); } catch { buckets = {}; } loaded = true; } async function persist() { const file = { schemaVersion: 1, buckets }; await writeJsonSnapshotAtomically(getLedgerFilePath(), file); } /** Coins currently held against a grant by in-flight requests. */ export function heldCoins(grantId) { let total = 0; for (const hold of holds.values()) { if (hold.grantId === grantId) { total += hold.coins; } } return total; } /** * Balance a new request may draw on: the stored balance minus what in-flight * requests have already claimed. */ export function availableCoins(grant) { if (grant.entitlement.ledger !== "coins") { return Number.POSITIVE_INFINITY; } return (grant.entitlement.coins ?? 0) - heldCoins(grant.id); } export function openShareHold(grantId, coins, now = Date.now()) { const hold = { id: randomUUID(), grantId, coins: Math.max(0, coins), openedAt: now, }; holds.set(hold.id, hold); return hold; } /** Drop a hold without spending it — the request never reached the upstream. */ export function releaseShareHold(holdId) { if (holdId) { holds.delete(holdId); } } /** * Fold a utilization observation into a bucket. * * A reset timestamp that moved means the window rolled over, so the accumulated * fraction is discarded rather than carried into a window it does not describe. * A negative delta (the reset raced the response) is clamped to zero. */ function applyWindowDelta(bucket, settlement) { const sessionReset = settlement.sessionResetAt ?? null; if (sessionReset !== bucket.sessionResetAt) { bucket.sessionResetAt = sessionReset; bucket.sessionFraction = 0; } const weeklyReset = settlement.weeklyResetAt ?? null; if (weeklyReset !== bucket.weeklyResetAt) { bucket.weeklyResetAt = weeklyReset; bucket.weeklyFraction = 0; } if (settlement.sessionBefore !== null && settlement.sessionBefore !== undefined && settlement.sessionAfter !== null && settlement.sessionAfter !== undefined) { bucket.sessionFraction += Math.max(0, settlement.sessionAfter - settlement.sessionBefore); } if (settlement.weeklyBefore !== null && settlement.weeklyBefore !== undefined && settlement.weeklyAfter !== null && settlement.weeklyAfter !== undefined) { bucket.weeklyFraction += Math.max(0, settlement.weeklyAfter - settlement.weeklyBefore); } } function emptyBucket(grantId, accountKey, sessionResetAt, weeklyResetAt) { return { grantId, accountKey, sessionResetAt, weeklyResetAt, sessionFraction: 0, weeklyFraction: 0, coinsSpent: 0, requests: 0, updatedAt: Date.now(), }; } /** * Record how much of an account's windows a borrowed request consumed. * * Never throws: a bookkeeping failure must not turn a response the borrower has * already received into an error. */ export async function recordShareWindowDelta(observation) { try { await mutationMutex.runExclusive(async () => { await ensureLoaded(); const key = bucketKey(observation.grantId, observation.accountKey); const bucket = buckets[key] ?? emptyBucket(observation.grantId, observation.accountKey, observation.sessionResetAt ?? null, observation.weeklyResetAt ?? null); applyWindowDelta(bucket, observation); bucket.updatedAt = Date.now(); buckets[key] = bucket; await persist(); }); } catch (error) { logger.always(`[proxy] share window bookkeeping failed for grant=${observation.grantId}: ${error instanceof Error ? error.message : String(error)}`); } } /** * Charge a finished borrowed request against its grant and close the hold. * * Never throws, for the same reason as above. */ export async function settleShareUsage(settlement) { const coins = usageToCoins(settlement.usage, settlement.model); try { await mutationMutex.runExclusive(async () => { await ensureLoaded(); const key = bucketKey(settlement.grantId, settlement.accountKey); const bucket = buckets[key] ?? emptyBucket(settlement.grantId, settlement.accountKey, null, null); bucket.coinsSpent += coins; bucket.requests += 1; bucket.updatedAt = Date.now(); buckets[key] = bucket; await persist(); }); // Deduction is read-modify-write under the grant store's own lock; doing it // here would race a concurrently settling stream and lose one of the two. const balanceAfter = await debitShareGrantCoins(settlement.grantId, coins); // The borrower's copy of this charge. Imported here rather than at the top // because receipts read the price table from this module, and a static // import both ways is a cycle. const { issueShareReceipt } = await import("./shareReceipts.js"); await issueShareReceipt({ grantId: settlement.grantId, coins, usage: settlement.usage, ...(settlement.model ? { model: settlement.model } : {}), balanceAfter: balanceAfter ?? null, }); } catch (error) { logger.always(`[proxy] share settlement failed for grant=${settlement.grantId}: ${error instanceof Error ? error.message : String(error)}`); } finally { releaseShareHold(settlement.holdId); } return coins; } /** * What a grant has taken from one account's current windows. * * Buckets whose reset timestamp no longer matches the account's are reported as * zero: they describe a window that has since rolled over. */ export async function readShareWindowUsage(grantId, accountKey, currentSessionResetAt, currentWeeklyResetAt) { await ensureLoaded(); const bucket = buckets[bucketKey(grantId, accountKey)]; if (!bucket) { return { sessionFraction: 0, weeklyFraction: 0 }; } return { sessionFraction: bucket.sessionResetAt === currentSessionResetAt ? bucket.sessionFraction : 0, weeklyFraction: bucket.weeklyResetAt === currentWeeklyResetAt ? bucket.weeklyFraction : 0, }; } /** * What a grant has taken from the pool as a whole, normalised to one window. * * Summing the per-account fractions and dividing by the account count is what * makes a ceiling mean the same thing on a one-account pool and a ten-account * one. Without the division, `--max-slice 20` would silently grant 20% of every * credential — 200% of a single window's worth across ten accounts. * * Buckets whose reset timestamp no longer matches contribute zero: they * describe a window that has since rolled over. */ export async function readSharePoolWindowUsage(grantId, accounts) { await ensureLoaded(); if (accounts.length === 0) { return { sessionFraction: 0, weeklyFraction: 0 }; } let session = 0; let weekly = 0; for (const account of accounts) { const bucket = buckets[bucketKey(grantId, account.accountKey)]; if (!bucket) { continue; } if (bucket.sessionResetAt === account.sessionResetAt) { session += bucket.sessionFraction; } if (bucket.weeklyResetAt === account.weeklyResetAt) { weekly += bucket.weeklyFraction; } } return { sessionFraction: session / accounts.length, weeklyFraction: weekly / accounts.length, }; } /** Per-grant rollup across accounts, for `share status`. */ export async function summarizeGrantUsage(grantId) { await ensureLoaded(); let coinsSpent = 0; let requests = 0; let accounts = 0; let lastUsedAt = null; for (const bucket of Object.values(buckets)) { if (bucket.grantId !== grantId) { continue; } coinsSpent += bucket.coinsSpent; requests += bucket.requests; // A bucket exists as soon as a window movement is attributed, which happens // before settlement. Counting those would report accounts the grant merely // touched as accounts it drew on. if (bucket.requests > 0) { accounts += 1; } lastUsedAt = Math.max(lastUsedAt ?? 0, bucket.updatedAt) || null; } return { grantId, coinsSpent, requests, accounts, lastUsedAt }; } /** * Most periods a single catch-up may pay out. * * A node that was off for a month owes the borrower the periods it missed, but a * clock that jumps — a VM restored from a snapshot, a corrected system time — * would otherwise mint an unbounded balance in one call. */ const MAX_REFILL_CATCHUP_PERIODS = 8; /** * Apply a standing allowance for every period that has elapsed. * * Called opportunistically at admission — there is no timer, so a node that is * off across one or more refill boundaries pays them on its next borrowed * request. Paying only the newest period would quietly turn "100 a week" into * "100 whenever you next happen to ask", which is not what the operator wrote. * * `lastAt` advances by whole periods rather than to `now`, so the schedule stays * anchored to when the grant was issued instead of drifting later with every * catch-up. It advances past the periods the cap refused to pay as well — see * {@link MAX_REFILL_CATCHUP_PERIODS}. */ export async function applyRefillIfDue(grant, now = Date.now()) { const refill = grant.entitlement.refill; if (!refill || grant.entitlement.ledger !== "coins") { return grant; } const periodMs = refill.per === "week" ? 604_800_000 : 18_000_000; const lastAt = refill.lastAt ?? grant.createdAt; const elapsed = now - lastAt; if (elapsed < periodMs) { return grant; } const owed = Math.floor(elapsed / periodMs); const periods = Math.min(owed, MAX_REFILL_CATCHUP_PERIODS); // The cap forfeits the excess rather than carrying it. Advancing `lastAt` by // only what was paid would leave the same backlog waiting, and the next few // admissions would drain it a cap at a time — which is exactly the unbounded // mint the cap exists to prevent, just spread over more calls. if (owed > periods) { logger.always(`[proxy] grant ${grant.id} skipped ${owed - periods} refill periods — capped at ${MAX_REFILL_CATCHUP_PERIODS}`); } const topped = (grant.entitlement.coins ?? 0) + refill.amount * periods; const updated = await updateShareGrant(grant.id, { entitlement: { ledger: "coins", coins: topped, refill: { ...refill, lastAt: lastAt + owed * periodMs }, }, }); return updated ?? grant; } /** Drop all ledger state. Test isolation only. */ export function resetShareLedgerForTests() { buckets = {}; loaded = false; holds.clear(); }