From 4f72f4844e33896934c48a232c3ed95f8d1c7b2a Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Fri, 10 Apr 2026 21:04:00 +1000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20keys.mjs=20=E2=80=94=20API=20key?= =?UTF-8?q?=20management=20and=20SQLite=20usage=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- keys.mjs | 159 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 keys.mjs diff --git a/keys.mjs b/keys.mjs new file mode 100644 index 0000000..240e831 --- /dev/null +++ b/keys.mjs @@ -0,0 +1,159 @@ +// keys.mjs — API key management and usage tracking for OCP LAN mode +import Database from "better-sqlite3"; +import { randomBytes } from "node:crypto"; +import { join } from "node:path"; +import { mkdirSync } from "node:fs"; +import { homedir } from "node:os"; + +const OCP_DIR = join(homedir(), ".ocp"); +mkdirSync(OCP_DIR, { recursive: true }); +const DB_PATH = join(OCP_DIR, "ocp.db"); + +let db; + +export function getDb() { + if (!db) { + db = new Database(DB_PATH); + db.pragma("journal_mode = WAL"); + db.pragma("foreign_keys = ON"); + initSchema(); + } + return db; +} + +function initSchema() { + db.exec(` + CREATE TABLE IF NOT EXISTS api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + revoked INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS usage_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key_id INTEGER, + key_name TEXT NOT NULL DEFAULT 'anonymous', + model TEXT NOT NULL, + prompt_chars INTEGER NOT NULL DEFAULT 0, + response_chars INTEGER NOT NULL DEFAULT 0, + elapsed_ms INTEGER NOT NULL DEFAULT 0, + success INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (key_id) REFERENCES api_keys(id) + ); + + 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); + `); +} + +// ── Key CRUD ── + +export function createKey(name) { + const key = "ocp_" + randomBytes(24).toString("base64url"); + const d = getDb(); + const stmt = d.prepare("INSERT INTO api_keys (key, name) VALUES (?, ?)"); + const result = stmt.run(key, name); + return { id: result.lastInsertRowid, key, 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" + ).all().map(k => ({ + ...k, + keyPreview: k.key.slice(0, 8) + "..." + k.key.slice(-4), + })); +} + +export function revokeKey(idOrName) { + const d = getDb(); + const stmt = d.prepare( + "UPDATE api_keys SET revoked = 1 WHERE (id = ? OR name = ?) AND revoked = 0" + ); + return stmt.run(idOrName, idOrName).changes > 0; +} + +export function validateKey(key) { + const d = getDb(); + const row = d.prepare( + "SELECT id, name FROM api_keys WHERE key = ? AND revoked = 0" + ).get(key); + return row || null; +} + +// ── Usage recording ── + +export function recordUsage({ keyId, keyName, model, promptChars, responseChars, elapsedMs, success }) { + const d = getDb(); + d.prepare(` + INSERT INTO usage_log (key_id, key_name, model, prompt_chars, response_chars, elapsed_ms, success) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(keyId ?? null, keyName || "anonymous", model, promptChars, responseChars, elapsedMs, success ? 1 : 0); +} + +// ── Usage queries ── + +export function getUsageByKey({ since, until } = {}) { + const d = getDb(); + let where = "WHERE 1=1"; + const params = []; + if (since) { where += " AND created_at >= ?"; params.push(since); } + if (until) { where += " AND created_at <= ?"; params.push(until); } + + return d.prepare(` + SELECT + key_name, + COUNT(*) as requests, + SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successes, + SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as errors, + SUM(prompt_chars) as total_prompt_chars, + SUM(response_chars) as total_response_chars, + SUM(elapsed_ms) as total_elapsed_ms, + AVG(elapsed_ms) as avg_elapsed_ms, + MIN(created_at) as first_request, + MAX(created_at) as last_request + FROM usage_log + ${where} + GROUP BY key_name + ORDER BY requests DESC + `).all(...params); +} + +export function getUsageTimeline({ keyName, hours = 24 } = {}) { + const d = getDb(); + const since = new Date(Date.now() - hours * 3600000).toISOString(); + let where = "WHERE created_at >= ?"; + const params = [since]; + if (keyName) { where += " AND key_name = ?"; params.push(keyName); } + + return d.prepare(` + SELECT + strftime('%Y-%m-%dT%H:00:00', created_at) as hour, + COUNT(*) as requests, + SUM(prompt_chars) as prompt_chars, + SUM(response_chars) as response_chars, + AVG(elapsed_ms) as avg_elapsed_ms + FROM usage_log + ${where} + GROUP BY hour + ORDER BY hour + `).all(...params); +} + +export function getRecentUsage(limit = 50) { + const d = getDb(); + return d.prepare(` + SELECT key_name, model, prompt_chars, response_chars, elapsed_ms, success, created_at + FROM usage_log + ORDER BY created_at DESC + LIMIT ? + `).all(limit); +} + +export function closeDb() { + if (db) { db.close(); db = null; } +}