From 97ca91341c929201fe5881cd4b62514900058016 Mon Sep 17 00:00:00 2001 From: dtzp555-max Date: Thu, 16 Apr 2026 21:45:44 +1000 Subject: [PATCH] feat(server): per-key quota + response cache (v3.8.0) (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Claude Opus 4.6 (1M context) --- keys.mjs | 206 +++++++++++++++++++++++++++++++++++- package.json | 2 +- server.mjs | 131 ++++++++++++++++++++++- test-features.mjs | 260 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 592 insertions(+), 7 deletions(-) create mode 100644 test-features.mjs diff --git a/keys.mjs b/keys.mjs index c920b18..f8ada0d 100644 --- a/keys.mjs +++ b/keys.mjs @@ -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; } } diff --git a/package.json b/package.json index e324dbd..f3c0670 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/server.mjs b/server.mjs index 5d2f97b..d9df02b 100644 --- a/server.mjs +++ b/server.mjs @@ -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)`); diff --git a/test-features.mjs b/test-features.mjs new file mode 100644 index 0000000..b3ad97e --- /dev/null +++ b/test-features.mjs @@ -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);