373 lines
13 KiB
JavaScript
373 lines
13 KiB
JavaScript
// 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 }));
|
|
}
|