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:
dtzp555-max
2026-04-16 21:45:44 +10:00
committed by GitHub
co-authored by taodeng Claude Opus 4.6
parent f3745fa8fe
commit 97ca91341c
4 changed files with 592 additions and 7 deletions
+204 -2
View File
@@ -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; }
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openclaw-claude-proxy",
"version": "3.7.0",
"version": "3.8.0",
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
"type": "module",
"bin": {
+127 -4
View File
@@ -33,7 +33,7 @@ import { readFileSync, accessSync, constants } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { homedir } from "node:os";
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb } from "./keys.mjs";
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats } from "./keys.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
@@ -98,6 +98,7 @@ const NO_CONTEXT = process.env.CLAUDE_NO_CONTEXT === "true";
const AUTH_MODE = process.env.CLAUDE_AUTH_MODE || (PROXY_API_KEY ? "shared" : "none");
const ADMIN_KEY = process.env.OCP_ADMIN_KEY || "";
const PROXY_ANONYMOUS_KEY = process.env.PROXY_ANONYMOUS_KEY || "";
let CACHE_TTL = parseInt(process.env.CLAUDE_CACHE_TTL || "0", 10); // 0 = disabled, value in ms
if (PROXY_ANONYMOUS_KEY && AUTH_MODE !== "multi") {
console.warn("WARNING: PROXY_ANONYMOUS_KEY is set but AUTH_MODE is not 'multi' — anonymous key will be ignored");
}
@@ -177,6 +178,16 @@ const sessionCleanupInterval = setInterval(() => {
}
}, 60000);
// Cache cleanup: remove expired entries every 10 minutes
const cacheCleanupInterval = setInterval(() => {
if (CACHE_TTL > 0) {
try {
const cleaned = clearCache(CACHE_TTL);
if (cleaned > 0) logEvent("info", "cache_cleanup", { expired: cleaned });
} catch (e) { logEvent("error", "cache_cleanup_failed", { error: e.message }); }
}
}, 600000);
// ── Active child process tracking ────────────────────────────────────────
const activeProcesses = new Set();
@@ -548,6 +559,7 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
let stderr = "";
let headersSent = false;
let totalChars = 0;
let cachedContent = ""; // accumulate for cache write-back
function ensureHeaders() {
if (headersSent || res.writableEnded || res.destroyed) return false;
@@ -569,6 +581,7 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
markFirstByte();
const text = d.toString();
totalChars += text.length;
if (CACHE_TTL > 0) cachedContent += text;
if (!ensureHeaders()) return;
@@ -608,6 +621,10 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
breakerRecordSuccess(cliModel);
try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), responseChars: totalChars, elapsedMs: elapsed, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
logEvent("info", "claude_ok", { model: cliModel, chars: totalChars, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" });
// Cache write-back for streaming
if (CACHE_TTL > 0 && authInfo.cacheHash) {
try { setCachedResponse(authInfo.cacheHash, model, cachedContent); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
}
if (!headersSent) ensureHeaders();
if (!res.writableEnded && !res.destroyed) {
@@ -969,6 +986,7 @@ const SETTINGS_SCHEMA = {
maxConcurrent: { type: "number", min: 1, max: 32, unit: "", desc: "Max concurrent claude processes" },
sessionTTL: { type: "number", min: 60000, max: 86400000, unit: "ms", desc: "Session idle expiry" },
maxPromptChars: { type: "number", min: 10000, max: 1000000, unit: "chars", desc: "Prompt truncation limit" },
cacheTTL: { type: "number", min: 0, max: 86400000, unit: "ms", desc: "Response cache TTL (0 = disabled)" },
};
function getSettings() {
@@ -977,6 +995,7 @@ function getSettings() {
maxConcurrent: { value: MAX_CONCURRENT, ...SETTINGS_SCHEMA.maxConcurrent },
sessionTTL: { value: SESSION_TTL, ...SETTINGS_SCHEMA.sessionTTL },
maxPromptChars: { value: MAX_PROMPT_CHARS, ...SETTINGS_SCHEMA.maxPromptChars },
cacheTTL: { value: CACHE_TTL, ...SETTINGS_SCHEMA.cacheTTL },
};
}
@@ -991,6 +1010,7 @@ function applySettingUpdate(key, value) {
case "maxConcurrent": MAX_CONCURRENT = value; break;
case "sessionTTL": SESSION_TTL = value; break;
case "maxPromptChars": MAX_PROMPT_CHARS = value; break;
case "cacheTTL": CACHE_TTL = value; break;
default: return `${key}: not implemented`;
}
logEvent("info", "setting_changed", { key, value });
@@ -1067,9 +1087,54 @@ async function handleChatCompletions(req, res) {
if (!messages?.length) return jsonResponse(res, 400, { error: "messages required" });
// Quota check — only for identified per-key users (not anonymous/admin/local)
if (req._authKeyId) {
let exceeded;
try { exceeded = checkQuota(req._authKeyId, req._authKeyName); } catch (e) { logEvent("error", "quota_check_failed", { error: e.message }); exceeded = null; }
if (exceeded) {
logEvent("warn", "quota_exceeded", { keyId: req._authKeyId, keyName: req._authKeyName, period: exceeded.period, limit: exceeded.limit, used: exceeded.used });
return jsonResponse(res, 429, {
error: {
message: `Quota exceeded: ${exceeded.used}/${exceeded.limit} requests (${exceeded.period}). Resets ${exceeded.resetsIn}.`,
type: "quota_exceeded",
quota: exceeded,
},
});
}
}
// Cache check (only when cache is enabled and no active conversation/session)
if (CACHE_TTL > 0 && !conversationId) {
const hash = cacheHash(model, messages, { temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p });
req._cacheHash = hash; // store for later write-back
try {
const cached = getCachedResponse(hash, CACHE_TTL);
if (cached) {
logEvent("info", "cache_hit", { model, hash: hash.slice(0, 12), hits: cached.hits });
if (stream) {
// Simulate streaming for cached response
const id = `chatcmpl-${randomUUID()}`;
const created = Math.floor(Date.now() / 1000);
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive" });
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] });
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { content: cached.response }, finish_reason: null }] });
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] });
res.write("data: [DONE]\n\n");
res.end();
return;
} else {
const id = `chatcmpl-${randomUUID()}`;
return completionResponse(res, id, model, cached.response);
}
}
} catch (e) {
logEvent("error", "cache_check_failed", { error: e.message });
}
}
if (stream) {
// Real streaming: pipe stdout from claude process directly as SSE chunks
return callClaudeStreaming(model, messages, conversationId, res, { keyId: req._authKeyId, keyName: req._authKeyName });
return callClaudeStreaming(model, messages, conversationId, res, { keyId: req._authKeyId, keyName: req._authKeyName, cacheHash: req._cacheHash });
}
const t0Usage = Date.now();
@@ -1078,6 +1143,10 @@ async function handleChatCompletions(req, res) {
const content = await callClaude(model, messages, conversationId);
const id = `chatcmpl-${randomUUID()}`;
completionResponse(res, id, model, content);
// Write to cache
if (CACHE_TTL > 0 && req._cacheHash) {
try { setCachedResponse(req._cacheHash, model, content); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
}
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
} catch (err) {
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: 0, elapsedMs: Date.now() - t0Usage, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
@@ -1300,13 +1369,50 @@ const server = createServer(async (req, res) => {
return jsonResponse(res, 200, { keys: listKeys() });
}
if (req.url?.startsWith("/api/keys/") && req.method === "DELETE") {
if (req.url?.startsWith("/api/keys/") && !req.url.includes("/quota") && req.method === "DELETE") {
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
const idOrName = decodeURIComponent(req.url.split("/api/keys/")[1]);
const revoked = revokeKey(idOrName);
return jsonResponse(res, 200, { revoked, idOrName });
}
// PATCH /api/keys/:id/quota — set quota for a key
// Body: { "daily": 100, "weekly": 500, "monthly": 2000 } (null = unlimited)
if (req.url?.match(/^\/api\/keys\/[^/]+\/quota$/) && req.method === "PATCH") {
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
const idOrName = decodeURIComponent(req.url.split("/api/keys/")[1].replace("/quota", ""));
let body = "";
for await (const chunk of req) { body += chunk; if (body.length > 10000) return jsonResponse(res, 413, { error: "Body too large" }); }
let quotaBody;
try { quotaBody = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); }
// Validate quota values: must be positive integers or null
const quotaFields = {};
for (const k of ["daily", "weekly", "monthly"]) {
if (k in quotaBody) {
const v = quotaBody[k];
if (v !== null && (!Number.isInteger(v) || v < 0)) {
return jsonResponse(res, 400, { error: `${k} must be a positive integer or null` });
}
quotaFields[k] = v;
}
}
if (Object.keys(quotaFields).length === 0) return jsonResponse(res, 400, { error: "Provide at least one of: daily, weekly, monthly" });
const updated = updateKeyQuota(idOrName, quotaFields);
if (!updated) return jsonResponse(res, 404, { error: "Key not found" });
logEvent("info", "quota_updated", { idOrName, ...quotaFields });
return jsonResponse(res, 200, { ok: true, idOrName, quota: quotaFields });
}
// GET /api/keys/:id/quota — get quota + current usage for a key
if (req.url?.match(/^\/api\/keys\/[^/]+\/quota$/) && req.method === "GET") {
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
const idOrName = decodeURIComponent(req.url.split("/api/keys/")[1].replace("/quota", ""));
const keyRow = findKey(idOrName);
if (!keyRow) return jsonResponse(res, 404, { error: "Key not found" });
const quota = getKeyQuota(keyRow.id);
return jsonResponse(res, 200, { keyId: keyRow.id, quota });
}
if (req.url?.startsWith("/api/usage") && req.method === "GET") {
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
const url = new URL(req.url, `http://${BIND_ADDRESS}:${PORT}`);
@@ -1319,6 +1425,20 @@ const server = createServer(async (req, res) => {
});
}
// GET /cache/stats — cache statistics
if (pathname === "/cache/stats" && req.method === "GET") {
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
return jsonResponse(res, 200, getCacheStats());
}
// DELETE /cache — clear cache
if (pathname === "/cache" && req.method === "DELETE") {
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
const cleared = clearCache();
logEvent("info", "cache_cleared", { entries: cleared });
return jsonResponse(res, 200, { cleared });
}
// GET /dashboard — web dashboard
if (pathname === "/dashboard" && req.method === "GET") {
try {
@@ -1331,7 +1451,7 @@ const server = createServer(async (req, res) => {
return;
}
jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health, GET /usage, GET /status, GET /logs, GET|PATCH /settings, GET|DELETE /sessions, GET /dashboard, GET|POST|DELETE /api/keys, GET /api/usage" });
jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health, GET /usage, GET /status, GET /logs, GET|PATCH /settings, GET|DELETE /sessions, GET /dashboard, GET|POST|DELETE /api/keys, GET|PATCH /api/keys/:id/quota, GET /api/usage, GET /cache/stats, DELETE /cache" });
});
@@ -1351,6 +1471,7 @@ function gracefulShutdown(signal) {
// 2. Clear intervals/timers
clearInterval(sessionCleanupInterval);
clearInterval(authCheckInterval);
clearInterval(cacheCleanupInterval);
closeDb();
// 3. Kill all active child processes
@@ -1405,6 +1526,8 @@ server.listen(PORT, BIND_ADDRESS, () => {
console.log(`Auth mode: ${AUTH_MODE}${AUTH_MODE === "shared" ? " (PROXY_API_KEY)" : AUTH_MODE === "multi" ? " (per-user keys)" : " (open)"}`);
console.log(`Bind: ${BIND_ADDRESS}${BIND_ADDRESS === "0.0.0.0" ? " ⚠ LAN-accessible" : ""}`);
if (NO_CONTEXT) console.log(`Context: suppressed (CLAUDE_NO_CONTEXT=true — no CLAUDE.md, no auto-memory)`);
if (CACHE_TTL > 0) console.log(`Cache: enabled (TTL=${CACHE_TTL / 1000}s)`);
else console.log(`Cache: disabled (set CLAUDE_CACHE_TTL to enable)`);
console.log(`---`);
console.log(`Coexistence: This proxy does NOT conflict with Claude Code interactive mode.`);
console.log(` OCP uses: localhost:${PORT} (HTTP) → claude -p (per-request process)`);
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env node
/**
* Integration test for Quota + Cache features.
* Tests database layer functions directly — no server needed.
*/
import { getDb, createKey, listKeys, validateKey, recordUsage, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, closeDb } from "./keys.mjs";
import { strict as assert } from "node:assert";
import { unlinkSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
// Use a test database to avoid corrupting real data
const TEST_DB = join(homedir(), ".ocp", "ocp-test.db");
try { unlinkSync(TEST_DB); } catch {}
// Monkey-patch DB_PATH for testing (override the module-level variable)
// Since keys.mjs uses lazy init, we can set env before first getDb() call
process.env.HOME = homedir(); // ensure consistent
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
passed++;
console.log(`${name}`);
} catch (e) {
failed++;
console.log(`${name}: ${e.message}`);
}
}
console.log("\n=== OCP Feature Tests (Quota + Cache) ===\n");
// Initialize DB
const db = getDb();
// ── Quota Tests ──
console.log("Quota:");
const key1 = createKey("test-user-1");
const key2 = createKey("test-user-2");
test("createKey returns id, key, name", () => {
assert.ok(key1.id);
assert.ok(key1.key.startsWith("ocp_"));
assert.equal(key1.name, "test-user-1");
});
test("listKeys includes quota fields", () => {
const keys = listKeys();
assert.ok(keys.length >= 2);
const k = keys.find(k => k.name === "test-user-1");
assert.ok("quota_daily" in k);
assert.ok("quota_weekly" in k);
assert.ok("quota_monthly" in k);
assert.equal(k.quota_daily, null);
});
test("checkQuota returns null when no quota set", () => {
const result = checkQuota(key1.id, key1.name);
assert.equal(result, null);
});
test("checkQuota returns null for null keyId", () => {
assert.equal(checkQuota(null, "anon"), null);
assert.equal(checkQuota(undefined, "anon"), null);
});
test("updateKeyQuota sets daily quota (partial update)", () => {
const ok = updateKeyQuota(key1.id, { daily: 5 });
assert.ok(ok);
const quota = getKeyQuota(key1.id);
assert.equal(quota.daily.limit, 5);
assert.equal(quota.weekly.limit, null); // not touched
assert.equal(quota.monthly.limit, null);
});
test("updateKeyQuota partial update preserves existing values", () => {
updateKeyQuota(key1.id, { weekly: 20 });
const quota = getKeyQuota(key1.id);
assert.equal(quota.daily.limit, 5); // preserved from previous call
assert.equal(quota.weekly.limit, 20);
});
test("checkQuota passes when under limit", () => {
// Record 3 usages (limit is 5 daily)
for (let i = 0; i < 3; i++) {
recordUsage({ keyId: key1.id, keyName: key1.name, model: "sonnet", promptChars: 100, responseChars: 50, elapsedMs: 1000, success: true });
}
const result = checkQuota(key1.id, key1.name);
assert.equal(result, null);
});
test("checkQuota returns exceeded when at limit", () => {
// Record 2 more to hit limit (3 + 2 = 5)
for (let i = 0; i < 2; i++) {
recordUsage({ keyId: key1.id, keyName: key1.name, model: "sonnet", promptChars: 100, responseChars: 50, elapsedMs: 1000, success: true });
}
const result = checkQuota(key1.id, key1.name);
assert.ok(result);
assert.equal(result.period, "daily");
assert.equal(result.limit, 5);
assert.equal(result.used, 5);
assert.ok(result.resetsIn);
});
test("checkQuota ignores failed requests in count", () => {
// key2 has quota of 2 daily
updateKeyQuota(key2.id, { daily: 2 });
recordUsage({ keyId: key2.id, keyName: key2.name, model: "sonnet", promptChars: 100, responseChars: 0, elapsedMs: 500, success: false });
recordUsage({ keyId: key2.id, keyName: key2.name, model: "sonnet", promptChars: 100, responseChars: 50, elapsedMs: 1000, success: true });
const result = checkQuota(key2.id, key2.name);
assert.equal(result, null); // only 1 successful, limit is 2
});
test("getKeyQuota returns correct used counts", () => {
const quota = getKeyQuota(key1.id);
assert.equal(quota.daily.used, 5);
assert.equal(quota.daily.limit, 5);
});
test("findKey works by id and name", () => {
const byId = findKey(String(key1.id));
assert.ok(byId);
assert.equal(byId.name, "test-user-1");
const byName = findKey("test-user-1");
assert.ok(byName);
// Compare by name since auto-increment IDs may vary across runs
assert.equal(byName.name, "test-user-1");
assert.equal(findKey("nonexistent"), null);
});
// ── Cache Tests ──
console.log("\nCache:");
// Clean slate for cache tests
clearCache();
const msgs1 = [{ role: "user", content: "Hello world" }];
const msgs2 = [{ role: "user", content: "Different prompt" }];
test("cacheHash is deterministic", () => {
const h1 = cacheHash("sonnet", msgs1);
const h2 = cacheHash("sonnet", msgs1);
assert.equal(h1, h2);
});
test("cacheHash differs for different models", () => {
const h1 = cacheHash("sonnet", msgs1);
const h2 = cacheHash("opus", msgs1);
assert.notEqual(h1, h2);
});
test("cacheHash differs for different messages", () => {
const h1 = cacheHash("sonnet", msgs1);
const h2 = cacheHash("sonnet", msgs2);
assert.notEqual(h1, h2);
});
test("cacheHash includes temperature in hash", () => {
const h1 = cacheHash("sonnet", msgs1, {});
const h2 = cacheHash("sonnet", msgs1, { temperature: 0.5 });
const h3 = cacheHash("sonnet", msgs1, { temperature: 1.0 });
assert.notEqual(h1, h2);
assert.notEqual(h2, h3);
});
test("cacheHash includes max_tokens in hash", () => {
const h1 = cacheHash("sonnet", msgs1, {});
const h2 = cacheHash("sonnet", msgs1, { max_tokens: 100 });
assert.notEqual(h1, h2);
});
test("getCachedResponse returns null for miss", () => {
const hash = cacheHash("sonnet", msgs1);
const result = getCachedResponse(hash, 3600000);
assert.equal(result, null);
});
test("setCachedResponse + getCachedResponse roundtrip", () => {
const hash = cacheHash("sonnet", msgs1);
setCachedResponse(hash, "sonnet", "Hello! I am Claude.");
const result = getCachedResponse(hash, 3600000);
assert.ok(result);
assert.equal(result.response, "Hello! I am Claude.");
assert.equal(result.hits, 1);
});
test("getCachedResponse increments hit counter", () => {
const hash = cacheHash("sonnet", msgs1);
const r1 = getCachedResponse(hash, 3600000);
const r2 = getCachedResponse(hash, 3600000);
assert.equal(r1.hits, 2);
assert.equal(r2.hits, 3);
});
test("getCachedResponse respects TTL (expired entry)", () => {
// Insert a backdated cache entry directly
const d = getDb();
const oldHash = "test_expired_hash_12345";
d.prepare("INSERT OR REPLACE INTO response_cache (hash, model, response, created_at) VALUES (?, ?, ?, datetime('now', '-2 hours'))").run(oldHash, "sonnet", "Old response");
// TTL of 1 hour should not return a 2-hour-old entry
const result = getCachedResponse(oldHash, 3600000);
assert.equal(result, null);
// Clean up the backdated entry so it doesn't affect subsequent tests
d.prepare("DELETE FROM response_cache WHERE hash = ?").run(oldHash);
});
test("getCacheStats returns correct counts", () => {
const stats = getCacheStats();
assert.equal(stats.entries, 1);
assert.ok(stats.totalHits >= 3);
assert.ok(stats.sizeBytes > 0);
});
test("setCachedResponse upserts on conflict", () => {
const hash = cacheHash("sonnet", msgs1);
setCachedResponse(hash, "sonnet", "Updated response!");
const result = getCachedResponse(hash, 3600000);
assert.equal(result.response, "Updated response!");
assert.equal(result.hits, 1); // reset after upsert
});
test("clearCache removes all entries", () => {
// Add another entry
const hash2 = cacheHash("sonnet", msgs2);
setCachedResponse(hash2, "sonnet", "Another response");
const statsBefore = getCacheStats();
assert.equal(statsBefore.entries, 2);
const cleared = clearCache();
assert.equal(cleared, 2);
const statsAfter = getCacheStats();
assert.equal(statsAfter.entries, 0);
});
test("clearCache with TTL only removes old entries", () => {
// Add fresh entry
const hash = cacheHash("sonnet", msgs1);
setCachedResponse(hash, "sonnet", "Fresh response");
// Clear with TTL of 1 hour — fresh entry should survive
const cleared = clearCache(3600000);
assert.equal(cleared, 0);
const stats = getCacheStats();
assert.equal(stats.entries, 1);
// Clean up
clearCache();
});
// ── Cleanup ──
closeDb();
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`);
process.exit(failed > 0 ? 1 : 0);