// 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 // last_renewal_at: ISO-8601 string, // start of current billing period // monthly_consumed: number, // total this period (paid tiers) // monthly_gemini_consumed: number, // Gemini-only this period // last_active_at: ISO-8601 string, // } // // Billing periods are CALENDAR-ANNIVERSARY, not calendar-month. A user // whose first paid request lands on the 17th of October renews on the // 17th of every subsequent month — not the 1st. This matches how typical // subscription billing works (Stripe et al.) so the relay's monthly-cap // resets line up with the actual renewal date the user is being charged // on. Edge cases (Jan 31 → Feb 28/29) clamp to the last day of the // target month, then resume the original day-of-month the next time // it's available, same as standard subscription convention. 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: {} }; } // Migrate any rows that still carry the old { month: "YYYY-MM" } // shape from pre-anniversary ledger versions. Conservative: anchor // their last_renewal_at at the first of that month so they don't // get a surprise re-issue. let migrated = 0; for (const row of Object.values(ledger.rows)) { if (row.last_renewal_at) continue; if (typeof row.month === "string" && /^\d{4}-\d{2}$/.test(row.month)) { const [y, m] = row.month.split("-").map(Number); row.last_renewal_at = new Date(Date.UTC(y, m - 1, 1, 0, 0, 0)).toISOString(); delete row.month; migrated += 1; } else { // No prior period info — anchor to now so the next renewal // happens one calendar month from this point. row.last_renewal_at = new Date().toISOString(); if ("month" in row) delete row.month; migrated += 1; } } if (migrated > 0) { console.log(`[credits] migrated ${migrated} row(s) from calendar-month to anniversary-renewal shape`); await persist(); } console.log(`[credits] loaded ${Object.keys(ledger.rows).length} install rows from ${ledgerPath}`); } // Add one calendar month to the given Date, clamping the day to the // last valid day of the target month. Examples (all UTC): // Jan 31 → Feb 28 (or Feb 29 in leap years) // Feb 28 → Mar 28 (note: NOT Mar 31 — we use the original day) // Mar 31 → Apr 30 // May 31 → Jun 30 → Jul 31 (clamping is per-step, not absorbed) // // Note the third example. We anchor to the ORIGINAL anniversary day // each step, so a user who started on the 31st keeps getting renewals // on the 31st in months that have one, and on the last day for months // that don't. This is the standard subscription-billing rule. export function nextRenewalAfter(d, originalDay) { // originalDay is the anniversary day-of-month. If not supplied, use // d's own date — useful for the first hop from a freshly-set // last_renewal_at. const anchor = originalDay ?? d.getUTCDate(); const targetMonth = d.getUTCMonth() + 1; const overflow = targetMonth > 11 ? 1 : 0; const targetYear = d.getUTCFullYear() + overflow; const actualMonth = targetMonth % 12; // Last day of the target month (e.g. for Feb non-leap, this is 28). const lastDayOfTarget = new Date(Date.UTC(targetYear, actualMonth + 1, 0)).getUTCDate(); const day = Math.min(anchor, lastDayOfTarget); return new Date( Date.UTC( targetYear, actualMonth, day, d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds() ) ); } // Determine the install's anniversary day-of-month, which stays stable // even if a clamp pushed last_renewal_at to a shorter month. Stored // explicitly as `anniversary_day` once the row is created at a non- // month-end date; for legacy rows without it we infer from // last_renewal_at as a one-time best-effort. function anniversaryDay(row) { if (typeof row.anniversary_day === "number" && row.anniversary_day >= 1 && row.anniversary_day <= 31) { return row.anniversary_day; } const d = new Date(row.last_renewal_at); return Number.isFinite(d.getTime()) ? d.getUTCDate() : 1; } // Step the renewal forward until next_renewal > now. Handles rows // that have been dormant for several months and need multiple rollovers // in one shot. Mutates `row` in place; caller is responsible for // persisting when something changed. function ensureRenewalRollover(row) { if (!row.last_renewal_at) { row.last_renewal_at = new Date().toISOString(); return false; } const now = Date.now(); const anchorDay = anniversaryDay(row); let last = new Date(row.last_renewal_at); let next = nextRenewalAfter(last, anchorDay); let rolled = false; while (next.getTime() <= now) { last = next; next = nextRenewalAfter(last, anchorDay); rolled = true; } if (rolled) { row.last_renewal_at = last.toISOString(); row.monthly_consumed = 0; row.monthly_gemini_consumed = 0; } return rolled; } function blankRow(installId) { const now = new Date(); return { install_id: installId, tier_snapshot: "core", lifetime_consumed: 0, lifetime_gemini_consumed: 0, last_renewal_at: now.toISOString(), anniversary_day: now.getUTCDate(), monthly_consumed: 0, monthly_gemini_consumed: 0, last_active_at: now.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]; let dirty = false; if (!row) { row = blankRow(installId); ledger.rows[installId] = row; dirty = true; } if (ensureRenewalRollover(row)) dirty = true; if (dirty) await persist(); return 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. // // `preference` is the operator-configured routing strategy for the // current pipeline step (transcribe or analyze), one of: // - "gemini_first" try Gemini until cap is exceeded, then hardware // (default — best quality routing on operator's // Gemini budget, hardware as overflow) // - "hardware_first" try hardware first, fall back to Gemini when // hardware isn't configured (lets the operator // conserve Gemini budget for premium use cases) // - "gemini_only" Gemini only, fail when cap exceeded (caps the // operator's spend at the per-tier limit) // - "hardware_only" Hardware only, fail when not configured (good // for fully local / offline deployments) // // The Gemini cap (geminiCapMonthly / geminiCapLifetime on the tier // quota) still applies regardless of preference — preference just // controls the order in which backends are tried. export function planBackend(row, quota, { hasHardware, preference = "gemini_first" }) { const balance = computeRemaining(row, quota); // Out of credits entirely? if (balance.remaining === 0) { return { allowed: false, backend: null, reason: "out_of_credits" }; } const geminiAvailable = balance.gemini_remaining === null || balance.gemini_remaining > 0; switch (preference) { case "hardware_only": if (hasHardware) { return { allowed: true, backend: "hardware", reason: null }; } return { allowed: false, backend: null, reason: "hardware_only_not_configured", }; case "gemini_only": if (geminiAvailable) { return { allowed: true, backend: "gemini", reason: null }; } return { allowed: false, backend: null, reason: "gemini_cap_exceeded_no_fallback", }; case "hardware_first": if (hasHardware) { return { allowed: true, backend: "hardware", reason: null }; } if (geminiAvailable) { return { allowed: true, backend: "gemini", reason: null }; } return { allowed: false, backend: null, reason: "no_backend_available", }; case "gemini_first": default: if (geminiAvailable) { 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. // // When a previously-Core install presents a paid license for the first // time, we treat THIS moment as the start of their billing period and // anchor last_renewal_at + anniversary_day to now. That way a user who // upgrades on the 17th gets renewals on the 17th going forward, not // at some earlier date that happens to be when their install_id was // first seen. export async function commitCredit(installId, { backend, tier }) { const row = await getOrCreateRow(installId); const wasCorePromotion = tier !== "core" && row.tier_snapshot === "core"; row.tier_snapshot = tier; if (wasCorePromotion) { const now = new Date(); row.last_renewal_at = now.toISOString(); row.anniversary_day = now.getUTCDate(); row.monthly_consumed = 0; row.monthly_gemini_consumed = 0; } 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 })); }