mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-19 09:44:07 +00:00
feat(server): per-key quota + response cache (v3.8.0) (#18)
Add two new features for LAN sharing governance:
Quota (budget control):
- Per-key daily/weekly/monthly request limits (NULL = unlimited)
- Idempotent schema migration for quota columns
- Single-query check (SUM/CASE) for all 3 periods — no N+1
- PATCH /api/keys/:id/quota (partial update, input validation)
- GET /api/keys/:id/quota (current limits + usage)
- 429 with structured error when exceeded
- Only applies to identified per-key users, not admin/anonymous
Response cache:
- SHA-256 hash of model + messages + temperature/max_tokens/top_p
- Opt-in via CLAUDE_CACHE_TTL env var (0 = disabled, default)
- Cache hit serves both streaming (simulated SSE) and non-streaming
- Streaming responses accumulated and cached on success
- Skips multi-turn sessions (conversationId present)
- GET /cache/stats, DELETE /cache admin endpoints
- Runtime-tunable cacheTTL via PATCH /settings
- 10-minute periodic cleanup of expired entries
Bug fix discovered during testing:
- SQLite datetime('now') stores 'YYYY-MM-DD HH:MM:SS' but JS
.toISOString() produces 'YYYY-MM-DDTHH:MM:SS.sssZ'. String
comparison breaks for same-day ranges. Added sqliteDatetime()
helper for correct format matching.
Code review fixes:
- DELETE /api/keys/:id no longer shadows /quota sub-routes
- updateKeyQuota uses partial UPDATE (only SET provided fields)
- cacheHash includes temperature/max_tokens/top_p in hash
- Replaced raw getDb() in server.mjs with findKey() encapsulation
- Unified UTC midnight calculation across checkQuota/getKeyQuota
Includes 24-test integration suite (test-features.mjs).
Co-authored-by: Tao Deng <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
// keys.mjs — API key management and usage tracking for OCP LAN mode
|
||||
// Uses Node.js built-in SQLite (node:sqlite) — zero external dependencies.
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { randomBytes, createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
@@ -47,7 +47,31 @@ function initSchema() {
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_created ON usage_log(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_key ON usage_log(key_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS response_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hash TEXT UNIQUE NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
response TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_hit_at TEXT,
|
||||
hits INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cache_hash ON response_cache(hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_cache_created ON response_cache(created_at);
|
||||
`);
|
||||
|
||||
// Idempotent migrations: add quota columns if they don't exist yet.
|
||||
for (const col of [
|
||||
"ALTER TABLE api_keys ADD COLUMN quota_daily INTEGER DEFAULT NULL",
|
||||
"ALTER TABLE api_keys ADD COLUMN quota_weekly INTEGER DEFAULT NULL",
|
||||
"ALTER TABLE api_keys ADD COLUMN quota_monthly INTEGER DEFAULT NULL",
|
||||
]) {
|
||||
try { db.exec(col); } catch (e) {
|
||||
// SQLite throws "duplicate column name" if already present — safe to ignore.
|
||||
if (!e.message?.includes("duplicate column")) throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Key CRUD ──
|
||||
@@ -63,7 +87,7 @@ export function createKey(name) {
|
||||
export function listKeys() {
|
||||
const d = getDb();
|
||||
return d.prepare(
|
||||
"SELECT id, key, name, created_at, revoked FROM api_keys ORDER BY created_at DESC"
|
||||
"SELECT id, key, name, created_at, revoked, quota_daily, quota_weekly, quota_monthly FROM api_keys ORDER BY created_at DESC"
|
||||
).all().map(({ key, ...rest }) => ({
|
||||
...rest,
|
||||
keyPreview: key.slice(0, 8) + "..." + key.slice(-4),
|
||||
@@ -155,6 +179,184 @@ export function getRecentUsage(limit = 50) {
|
||||
`).all(limit);
|
||||
}
|
||||
|
||||
// ── SQLite datetime helper ──
|
||||
// SQLite datetime('now') stores as 'YYYY-MM-DD HH:MM:SS' (no T, no Z).
|
||||
// JavaScript .toISOString() produces 'YYYY-MM-DDTHH:MM:SS.sssZ'.
|
||||
// String comparison between the two breaks for same-day ranges (T > space).
|
||||
// This helper formats Date to match SQLite's format for correct comparisons.
|
||||
function sqliteDatetime(date) {
|
||||
return date.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, "");
|
||||
}
|
||||
|
||||
// ── Quota management ──
|
||||
|
||||
// Returns { period, limit, used, resetsIn } if a quota is exceeded, null otherwise.
|
||||
// Anonymous/admin callers (keyId === null) are never subject to quotas.
|
||||
export function checkQuota(keyId, _keyName) {
|
||||
if (keyId === null || keyId === undefined) return null;
|
||||
|
||||
const d = getDb();
|
||||
const keyRow = d.prepare(
|
||||
"SELECT quota_daily, quota_weekly, quota_monthly FROM api_keys WHERE id = ? AND revoked = 0"
|
||||
).get(keyId);
|
||||
if (!keyRow) return null;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// UTC period boundaries (SQLite-compatible format)
|
||||
const startOfToday = sqliteDatetime(new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())));
|
||||
const sevenDaysAgo = sqliteDatetime(new Date(Date.now() - 7 * 86400000));
|
||||
const thirtyDaysAgo = sqliteDatetime(new Date(Date.now() - 30 * 86400000));
|
||||
|
||||
// Next reset times for human display
|
||||
const tomorrowUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
|
||||
function msToHuman(ms) {
|
||||
if (ms <= 0) return "now";
|
||||
const h = Math.floor(ms / 3600000);
|
||||
const m = Math.floor((ms % 3600000) / 60000);
|
||||
if (h >= 24) { const d = Math.floor(h / 24); return `${d}d ${h % 24}h`; }
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
}
|
||||
|
||||
// Single query for all periods (widest window = monthly)
|
||||
const row = d.prepare(`
|
||||
SELECT
|
||||
SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END) as daily_cnt,
|
||||
SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END) as weekly_cnt,
|
||||
COUNT(*) as monthly_cnt
|
||||
FROM usage_log
|
||||
WHERE key_id = ? AND success = 1 AND created_at >= ?
|
||||
`).get(startOfToday, sevenDaysAgo, keyId, thirtyDaysAgo);
|
||||
|
||||
const checks = [
|
||||
{ period: "daily", limit: keyRow.quota_daily, used: row?.daily_cnt ?? 0, resetsIn: msToHuman(tomorrowUTC - now) },
|
||||
{ period: "weekly", limit: keyRow.quota_weekly, used: row?.weekly_cnt ?? 0, resetsIn: "rolling 7-day window" },
|
||||
{ period: "monthly", limit: keyRow.quota_monthly, used: row?.monthly_cnt ?? 0, resetsIn: "rolling 30-day window" },
|
||||
];
|
||||
|
||||
for (const { period, limit, used, resetsIn } of checks) {
|
||||
if (limit === null || limit === undefined) continue;
|
||||
if (used >= limit) {
|
||||
return { period, limit, used, resetsIn };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Set quota for a key. Only updates fields explicitly present in the input object.
|
||||
// Pass null to clear a specific limit. Omit a field to leave it unchanged.
|
||||
export function updateKeyQuota(idOrName, updates = {}) {
|
||||
const d = getDb();
|
||||
const setClauses = [];
|
||||
const params = [];
|
||||
if ("daily" in updates) { setClauses.push("quota_daily = ?"); params.push(updates.daily ?? null); }
|
||||
if ("weekly" in updates) { setClauses.push("quota_weekly = ?"); params.push(updates.weekly ?? null); }
|
||||
if ("monthly" in updates){ setClauses.push("quota_monthly = ?");params.push(updates.monthly ?? null); }
|
||||
if (setClauses.length === 0) return false;
|
||||
params.push(idOrName, idOrName);
|
||||
const result = d.prepare(
|
||||
`UPDATE api_keys SET ${setClauses.join(", ")} WHERE id = ? OR name = ?`
|
||||
).run(...params);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
// Returns { daily: { limit, used }, weekly: { limit, used }, monthly: { limit, used } }
|
||||
export function getKeyQuota(keyId) {
|
||||
const d = getDb();
|
||||
const keyRow = d.prepare(
|
||||
"SELECT quota_daily, quota_weekly, quota_monthly FROM api_keys WHERE id = ?"
|
||||
).get(keyId);
|
||||
if (!keyRow) return null;
|
||||
|
||||
const now = new Date();
|
||||
const startOfToday = sqliteDatetime(new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())));
|
||||
const sevenDaysAgo = sqliteDatetime(new Date(Date.now() - 7 * 86400000));
|
||||
const thirtyDaysAgo = sqliteDatetime(new Date(Date.now() - 30 * 86400000));
|
||||
|
||||
const row = d.prepare(`
|
||||
SELECT
|
||||
SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END) as daily_cnt,
|
||||
SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END) as weekly_cnt,
|
||||
COUNT(*) as monthly_cnt
|
||||
FROM usage_log
|
||||
WHERE key_id = ? AND success = 1 AND created_at >= ?
|
||||
`).get(startOfToday, sevenDaysAgo, keyId, thirtyDaysAgo);
|
||||
|
||||
return {
|
||||
daily: { limit: keyRow.quota_daily ?? null, used: row?.daily_cnt ?? 0 },
|
||||
weekly: { limit: keyRow.quota_weekly ?? null, used: row?.weekly_cnt ?? 0 },
|
||||
monthly: { limit: keyRow.quota_monthly ?? null, used: row?.monthly_cnt ?? 0 },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Response cache ──
|
||||
|
||||
// Generate a cache key from model + messages + request params that affect output
|
||||
export function cacheHash(model, messages, opts = {}) {
|
||||
const h = createHash("sha256");
|
||||
h.update(model);
|
||||
if (opts.temperature != null) h.update(`t:${opts.temperature}`);
|
||||
if (opts.max_tokens != null) h.update(`mt:${opts.max_tokens}`);
|
||||
if (opts.top_p != null) h.update(`tp:${opts.top_p}`);
|
||||
for (const m of messages) {
|
||||
h.update(m.role || "");
|
||||
h.update(typeof m.content === "string" ? m.content : JSON.stringify(m.content));
|
||||
}
|
||||
return h.digest("hex");
|
||||
}
|
||||
|
||||
// Look up a cached response. Returns { response, hits } or null.
|
||||
// Also updates last_hit_at and increments hits counter on hit.
|
||||
export function getCachedResponse(hash, ttlMs) {
|
||||
const d = getDb();
|
||||
const cutoff = sqliteDatetime(new Date(Date.now() - ttlMs));
|
||||
const row = d.prepare(
|
||||
"SELECT id, response, hits FROM response_cache WHERE hash = ? AND created_at >= ?"
|
||||
).get(hash, cutoff);
|
||||
if (!row) return null;
|
||||
// Update hit stats
|
||||
d.prepare("UPDATE response_cache SET hits = hits + 1, last_hit_at = datetime('now') WHERE id = ?").run(row.id);
|
||||
return { response: row.response, hits: row.hits + 1 };
|
||||
}
|
||||
|
||||
// Store a response in the cache
|
||||
export function setCachedResponse(hash, model, response) {
|
||||
const d = getDb();
|
||||
// Upsert: if hash already exists (race condition), just update
|
||||
d.prepare(`
|
||||
INSERT INTO response_cache (hash, model, response) VALUES (?, ?, ?)
|
||||
ON CONFLICT(hash) DO UPDATE SET response = excluded.response, created_at = datetime('now'), hits = 0
|
||||
`).run(hash, model, response);
|
||||
}
|
||||
|
||||
// Clear all cached responses, or expired ones only
|
||||
export function clearCache(ttlMs = null) {
|
||||
const d = getDb();
|
||||
if (ttlMs === null) {
|
||||
const result = d.prepare("DELETE FROM response_cache").run();
|
||||
return result.changes;
|
||||
}
|
||||
const cutoff = sqliteDatetime(new Date(Date.now() - ttlMs));
|
||||
const result = d.prepare("DELETE FROM response_cache WHERE created_at < ?").run(cutoff);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
// Get cache statistics
|
||||
export function getCacheStats() {
|
||||
const d = getDb();
|
||||
const total = d.prepare("SELECT COUNT(*) as cnt FROM response_cache").get()?.cnt ?? 0;
|
||||
const totalHits = d.prepare("SELECT SUM(hits) as total FROM response_cache").get()?.total ?? 0;
|
||||
const sizeBytes = d.prepare("SELECT SUM(LENGTH(response)) as size FROM response_cache").get()?.size ?? 0;
|
||||
return { entries: total, totalHits, sizeBytes };
|
||||
}
|
||||
|
||||
// Find a key by id or name (returns { id, name } or null)
|
||||
export function findKey(idOrName) {
|
||||
const d = getDb();
|
||||
return d.prepare("SELECT id, name FROM api_keys WHERE id = ? OR name = ?").get(idOrName, idOrName) || null;
|
||||
}
|
||||
|
||||
export function closeDb() {
|
||||
if (db) { db.close(); db = null; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user