lynkr
Version:
Self-hosted LLM gateway and tier-routing proxy for Claude Code, Cursor, and Codex. Routes across Ollama, AWS Bedrock, OpenRouter, Databricks, Azure OpenAI, llama.cpp, and LM Studio with prompt caching, MCP tools, and 60-80% cost savings.
160 lines (142 loc) • 4.54 kB
JavaScript
/**
* Hierarchical budget controls (Phase 6.2).
*
* Tracks spend at four levels: virtual_key → team → customer → org.
* Each level has a ceiling; a request must pass *every* level it belongs
* to.
*
* Storage: in-process Map by default. Operations are atomic-by-design (single
* Node event loop), so no locking needed. For multi-process deployments,
* swap the storage implementation for Redis (the interface is stable; see
* RedisBudgetStore stub at the bottom of the file).
*/
const fs = require('fs');
const path = require('path');
const logger = require('../logger');
const CONFIG_PATH = path.join(__dirname, '../../data/budgets.json');
const RELOAD_INTERVAL_MS = 60_000;
const LEVELS = ['virtual_key', 'team', 'customer', 'org'];
class MapBudgetStore {
constructor() {
this._spend = new Map(); // `${level}:${id}` → { spent, periodStart }
}
_key(level, id) {
return `${level}:${id}`;
}
get(level, id) {
return this._spend.get(this._key(level, id)) || { spent: 0, periodStart: Date.now() };
}
set(level, id, value) {
this._spend.set(this._key(level, id), value);
}
incr(level, id, amount) {
const current = this.get(level, id);
current.spent += amount;
this.set(level, id, current);
return current;
}
resetIfStale(level, id, periodMs) {
const current = this.get(level, id);
if (Date.now() - current.periodStart > periodMs) {
current.spent = 0;
current.periodStart = Date.now();
this.set(level, id, current);
}
return current;
}
}
let _config = null;
let _configLoadedAt = 0;
function _loadConfig() {
if (_config && Date.now() - _configLoadedAt < RELOAD_INTERVAL_MS) return _config;
try {
if (fs.existsSync(CONFIG_PATH)) {
_config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
_configLoadedAt = Date.now();
return _config;
}
} catch (err) {
logger.debug({ err: err.message }, '[HierarchicalBudget] Config load failed');
}
_config = { defaults: { periodMs: 86400000 }, limits: {} };
_configLoadedAt = Date.now();
return _config;
}
class HierarchicalBudget {
constructor(store = new MapBudgetStore()) {
this.store = store;
}
/**
* Check whether all relevant ceilings still allow `amount` of spend.
* @param {object} context — { virtual_key, team, customer, org }
* @param {number} amount — dollars
* @returns {{ ok: boolean, exceeded?: { level, id, limit, spent } }}
*/
check(context, amount) {
const config = _loadConfig();
const periodMs = config.defaults?.periodMs || 86400000;
for (const level of LEVELS) {
const id = context[level];
if (!id) continue;
const limit = config.limits?.[level]?.[id] ?? config.defaults?.[level];
if (typeof limit !== 'number') continue;
const current = this.store.resetIfStale(level, id, periodMs);
if (current.spent + amount > limit) {
return {
ok: false,
exceeded: { level, id, limit, spent: current.spent },
};
}
}
return { ok: true };
}
/**
* Record spend after a request completes. Increments all relevant levels.
*/
record(context, amount) {
if (typeof amount !== 'number' || amount <= 0) return;
for (const level of LEVELS) {
const id = context[level];
if (!id) continue;
this.store.incr(level, id, amount);
}
}
/**
* Summary for the dashboard.
*/
status(context) {
const config = _loadConfig();
const periodMs = config.defaults?.periodMs || 86400000;
const out = {};
for (const level of LEVELS) {
const id = context[level];
if (!id) continue;
const limit = config.limits?.[level]?.[id] ?? config.defaults?.[level];
const current = this.store.resetIfStale(level, id, periodMs);
out[level] = { id, spent: current.spent, limit, periodStart: current.periodStart };
}
return out;
}
}
let _instance = null;
function getHierarchicalBudget() {
if (!_instance) _instance = new HierarchicalBudget();
return _instance;
}
/**
* Redis backend stub. Implement this when scaling beyond a single Node
* process. The interface mirrors MapBudgetStore so HierarchicalBudget can
* use either.
*/
class RedisBudgetStore {
constructor(_redisClient) {
throw new Error('RedisBudgetStore not implemented. Stub — wire your Redis client and use INCRBY with periodic TTL.');
}
}
module.exports = {
HierarchicalBudget,
MapBudgetStore,
RedisBudgetStore,
getHierarchicalBudget,
LEVELS,
};