// Credit ledger keyed by install-id. JSON-file backed (single file at // /data/credits.json). Write throughput is low — at most one mutation // per relay request — so a plain JSON file with mutex-style serial // writes is plenty. Swap to SQLite if a single relay starts seeing // dozens of req/sec sustained. // // Per-install row shape: // { // install_id: "uuid", // tier_snapshot: "core" | "pro" | "max", // last-seen tier // lifetime_consumed: number, // total Core credits ever used // lifetime_gemini_consumed: number, // Core credits served by Gemini // month: "YYYY-MM", // calendar-month key // monthly_consumed: number, // total this month (paid tiers) // monthly_gemini_consumed: number, // Gemini-only this month // last_active_at: ISO-8601 string, // } import fs from "fs/promises"; import path from "path"; let dataDir = "/data"; let ledgerPath = "/data/credits.json"; let ledger = { rows: {} }; let writing = null; // serializes concurrent writes export async function initCredits({ dataDir: dd }) { if (dd) dataDir = dd; ledgerPath = path.join(dataDir, "credits.json"); await fs.mkdir(dataDir, { recursive: true }).catch(() => {}); try { const raw = await fs.readFile(ledgerPath, "utf8"); ledger = JSON.parse(raw) || { rows: {} }; if (!ledger.rows) ledger.rows = {}; } catch (err) { if (err.code !== "ENOENT") { console.warn(`[credits] failed to read ledger: ${err.message} — starting empty`); } ledger = { rows: {} }; } console.log(`[credits] loaded ${Object.keys(ledger.rows).length} install rows from ${ledgerPath}`); } function currentMonthKey() { const d = new Date(); return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`; } // Lazily rolls over the per-install monthly counters when the calendar // month changes. Lifetime counter is left untouched (Core lifetime // credits never reset). function ensureCurrentMonth(row) { const m = currentMonthKey(); if (row.month !== m) { row.month = m; row.monthly_consumed = 0; row.monthly_gemini_consumed = 0; } return row; } function blankRow(installId) { return { install_id: installId, tier_snapshot: "core", lifetime_consumed: 0, lifetime_gemini_consumed: 0, month: currentMonthKey(), monthly_consumed: 0, monthly_gemini_consumed: 0, last_active_at: new Date().toISOString(), }; } async function persist() { // Coalesce concurrent writes — multiple in-flight mutations resolve // against the same persisted snapshot in fifo order. if (writing) await writing; writing = (async () => { const tmp = ledgerPath + ".tmp"; await fs.writeFile(tmp, JSON.stringify(ledger), { mode: 0o600 }); await fs.rename(tmp, ledgerPath); })(); try { await writing; } finally { writing = null; } } // Returns the row for an install, creating + persisting a blank one // if this is the first time we've seen it. export async function getOrCreateRow(installId) { if (!installId) throw new Error("getOrCreateRow: installId required"); let row = ledger.rows[installId]; if (!row) { row = blankRow(installId); ledger.rows[installId] = row; await persist(); } return ensureCurrentMonth(row); } // Compute the remaining balance for a row against its tier's quota. // Returns: // { remaining: number | null, capped: "lifetime" | "monthly" | "none", gemini_remaining: number | null } // `null` for remaining means "unlimited" (Max tier total). // `null` for gemini_remaining means "no Gemini cap on this tier" — the // router can always pick Gemini. export function computeRemaining(row, quota) { const tier = row.tier_snapshot; const tierQuota = quota[tier] || quota.core; if (tierQuota.lifetime != null) { const remaining = Math.max(0, tierQuota.lifetime - (row.lifetime_consumed || 0)); // Core tier may carve out a portion of the lifetime budget for // Gemini specifically (geminiCapLifetime). When set, remaining // Gemini credits = cap - lifetime_gemini_consumed; the rest of // the lifetime budget falls through to operator hardware. When // null, lifetime tier ignores the Gemini/hardware split and uses // whichever backend is available. const geminiRemaining = tierQuota.geminiCapLifetime == null ? null : Math.max( 0, tierQuota.geminiCapLifetime - (row.lifetime_gemini_consumed || 0) ); return { remaining, capped: "lifetime", gemini_remaining: geminiRemaining, }; } let remaining; if (tierQuota.monthly == null) { remaining = null; // unlimited } else { remaining = Math.max(0, tierQuota.monthly - (row.monthly_consumed || 0)); } const geminiRemaining = tierQuota.geminiCapMonthly == null ? null : Math.max(0, tierQuota.geminiCapMonthly - (row.monthly_gemini_consumed || 0)); return { remaining, capped: "monthly", gemini_remaining: geminiRemaining, }; } // Decide what backend a request should go to and whether it can be // served at all. Returns { allowed, backend: "gemini"|"hardware", // reason }. Does NOT debit — that's a separate commit step after the // backend call succeeds. export function planBackend(row, quota, { hasHardware }) { const balance = computeRemaining(row, quota); // Out of credits entirely? if (balance.remaining === 0) { return { allowed: false, backend: null, reason: "out_of_credits" }; } // Pick backend: Gemini if there's room under the Gemini cap; else // fall back to hardware if configured; else 402. if (balance.gemini_remaining === null || balance.gemini_remaining > 0) { return { allowed: true, backend: "gemini", reason: null }; } if (hasHardware) { return { allowed: true, backend: "hardware", reason: null }; } return { allowed: false, backend: null, reason: "gemini_cap_exceeded_no_hardware" }; } // Debit one credit on a successful call. Persists immediately. // Tracks Gemini-vs-hardware separately for Core (lifetime_gemini_consumed) // and paid tiers (monthly_gemini_consumed) so the planner can enforce // the per-tier Gemini cap. export async function commitCredit(installId, { backend, tier }) { const row = await getOrCreateRow(installId); row.tier_snapshot = tier; if (tier === "core") { row.lifetime_consumed = (row.lifetime_consumed || 0) + 1; if (backend === "gemini") { row.lifetime_gemini_consumed = (row.lifetime_gemini_consumed || 0) + 1; } } else { row.monthly_consumed = (row.monthly_consumed || 0) + 1; if (backend === "gemini") { row.monthly_gemini_consumed = (row.monthly_gemini_consumed || 0) + 1; } } row.last_active_at = new Date().toISOString(); await persist(); } // For the admin dashboard. export function snapshotAll() { return Object.values(ledger.rows).map((r) => ({ ...r })); }