initial relay scaffold
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
// 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, // for Core lifetime cap
|
||||
// month: "YYYY-MM", // calendar-month key
|
||||
// monthly_consumed: number, // total this month
|
||||
// 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,
|
||||
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).
|
||||
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));
|
||||
return {
|
||||
remaining,
|
||||
capped: "lifetime",
|
||||
gemini_remaining: null, // lifetime tier doesn't split Gemini/hardware
|
||||
};
|
||||
}
|
||||
|
||||
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.
|
||||
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;
|
||||
} 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 }));
|
||||
}
|
||||
Reference in New Issue
Block a user