Spaces:
Running
Running
| ; | |
| const Database = require("better-sqlite3"); | |
| const path = require("path"); | |
| const { logger } = require("./logger"); | |
| const DB_PATH = process.env.DB_PATH || path.join(__dirname, "../data/gateway.db"); | |
| let db; | |
| function getDb() { | |
| if (!db) { | |
| db = new Database(DB_PATH, { verbose: null }); | |
| db.pragma("journal_mode = WAL"); | |
| db.pragma("foreign_keys = ON"); | |
| migrate(db); | |
| logger.info(`SQLite database opened at ${DB_PATH}`); | |
| } | |
| return db; | |
| } | |
| function migrate(db) { | |
| db.exec(` | |
| CREATE TABLE IF NOT EXISTS models ( | |
| id TEXT PRIMARY KEY, | |
| name TEXT NOT NULL UNIQUE, | |
| display_name TEXT NOT NULL, | |
| provider TEXT NOT NULL, | |
| litellm_model TEXT NOT NULL, | |
| api_base TEXT, | |
| api_key TEXT, | |
| description TEXT, | |
| tags TEXT DEFAULT '[]', | |
| model_type TEXT DEFAULT 'chat', | |
| enabled INTEGER DEFAULT 1, | |
| litellm_id TEXT, | |
| created_at TEXT DEFAULT (datetime('now')), | |
| updated_at TEXT DEFAULT (datetime('now')) | |
| ); | |
| CREATE TABLE IF NOT EXISTS usage_logs ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| model_id TEXT REFERENCES models(id), | |
| model_name TEXT NOT NULL, | |
| request_type TEXT NOT NULL, | |
| status INTEGER NOT NULL, | |
| latency_ms INTEGER, | |
| tokens_in INTEGER, | |
| tokens_out INTEGER, | |
| created_at TEXT DEFAULT (datetime('now')) | |
| ); | |
| CREATE TABLE IF NOT EXISTS settings ( | |
| key TEXT PRIMARY KEY, | |
| value TEXT NOT NULL | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_models_enabled ON models(enabled); | |
| CREATE INDEX IF NOT EXISTS idx_usage_model ON usage_logs(model_id); | |
| CREATE INDEX IF NOT EXISTS idx_usage_created ON usage_logs(created_at); | |
| `); | |
| // Seed default settings | |
| const upsertSetting = db.prepare( | |
| `INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)` | |
| ); | |
| upsertSetting.run("gateway_version", "1.0.0"); | |
| upsertSetting.run("require_auth", "false"); | |
| } | |
| // βββ Field mapping βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // | |
| // BUG FIX #5: Replace the fragile inline ternary chain with a single | |
| // declarative map. Every camelCase JS key maps to its snake_case SQL column. | |
| // Adding a new field only requires one entry here β not edits spread across | |
| // multiple places. | |
| // | |
| // Convention: if a JS key IS the same as its SQL column, it can be omitted | |
| // (the fallback at the end of jsToCol() handles it), but explicit entries are | |
| // preferred for clarity. | |
| // | |
| const JS_TO_COL = { | |
| displayName: "display_name", | |
| litellmModel: "litellm_model", | |
| apiBase: "api_base", | |
| apiKey: "api_key", | |
| modelType: "model_type", | |
| litellmId: "litellm_id", | |
| // Fields whose JS name == SQL column (listed for documentation purposes) | |
| id: "id", | |
| name: "name", | |
| provider: "provider", | |
| description: "description", | |
| tags: "tags", | |
| enabled: "enabled", | |
| }; | |
| function jsToCol(key) { | |
| return JS_TO_COL[key] || key; | |
| } | |
| // βββ Model CRUD ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function listModels({ enabledOnly = false } = {}) { | |
| const db = getDb(); | |
| const where = enabledOnly ? "WHERE enabled = 1" : ""; | |
| const rows = db.prepare(`SELECT * FROM models ${where} ORDER BY created_at DESC`).all(); | |
| return rows.map(deserializeModel); | |
| } | |
| function getModel(id) { | |
| const db = getDb(); | |
| const row = db.prepare("SELECT * FROM models WHERE id = ?").get(id); | |
| return row ? deserializeModel(row) : null; | |
| } | |
| function getModelByName(name) { | |
| const db = getDb(); | |
| const row = db.prepare("SELECT * FROM models WHERE name = ?").get(name); | |
| return row ? deserializeModel(row) : null; | |
| } | |
| function createModel(model) { | |
| const db = getDb(); | |
| const stmt = db.prepare(` | |
| INSERT INTO models | |
| (id, name, display_name, provider, litellm_model, api_base, api_key, | |
| description, tags, model_type, enabled, litellm_id) | |
| VALUES | |
| (@id, @name, @display_name, @provider, @litellm_model, @api_base, @api_key, | |
| @description, @tags, @model_type, @enabled, @litellm_id) | |
| `); | |
| stmt.run({ ...serializeModel(model) }); | |
| return getModel(model.id); | |
| } | |
| function updateModel(id, updates) { | |
| const db = getDb(); | |
| const keys = Object.keys(updates).filter((k) => k !== "id"); | |
| if (keys.length === 0) return getModel(id); | |
| // Normalize types for SQLite | |
| const normalized = { ...updates }; | |
| if (typeof normalized.enabled === "boolean") { | |
| normalized.enabled = normalized.enabled ? 1 : 0; | |
| } | |
| if (normalized.tags && Array.isArray(normalized.tags)) { | |
| normalized.tags = JSON.stringify(normalized.tags); | |
| } | |
| // BUG FIX #5: Use the declarative JS_TO_COL map instead of an inline | |
| // ternary chain. Each SET clause: `sql_col = @jsKey` so that | |
| // better-sqlite3's named binding (@jsKey) finds the value in `normalized`. | |
| const fields = keys | |
| .map((k) => `${jsToCol(k)} = @${k}`) | |
| .join(", "); | |
| db.prepare( | |
| `UPDATE models SET ${fields}, updated_at = datetime('now') WHERE id = @id` | |
| ).run({ id, ...normalized }); | |
| return getModel(id); | |
| } | |
| function deleteModel(id) { | |
| const db = getDb(); | |
| db.prepare("DELETE FROM models WHERE id = ?").run(id); | |
| } | |
| function logUsage(entry) { | |
| const db = getDb(); | |
| db.prepare(` | |
| INSERT INTO usage_logs (model_id, model_name, request_type, status, latency_ms, tokens_in, tokens_out) | |
| VALUES (@modelId, @modelName, @requestType, @status, @latencyMs, @tokensIn, @tokensOut) | |
| `).run(entry); | |
| } | |
| function getStats() { | |
| const db = getDb(); | |
| return { | |
| totalModels: db.prepare("SELECT COUNT(*) as n FROM models").get().n, | |
| enabledModels: db.prepare("SELECT COUNT(*) as n FROM models WHERE enabled = 1").get().n, | |
| totalRequests: db.prepare("SELECT COUNT(*) as n FROM usage_logs").get().n, | |
| successRequests: db.prepare("SELECT COUNT(*) as n FROM usage_logs WHERE status = 200").get().n, | |
| avgLatency: db.prepare("SELECT AVG(latency_ms) as n FROM usage_logs WHERE status = 200").get().n || 0, | |
| }; | |
| } | |
| // βββ Serialization helpers βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function serializeModel(m) { | |
| // IMPORTANT: raw key is stored as _apiKey in model records coming from routes, | |
| // m.apiKey may be the masked "β’β’β’β’β’β’β’β’" string β never write that to the DB. | |
| const rawKey = m._apiKey || (m.apiKey && m.apiKey !== "β’β’β’β’β’β’β’β’" ? m.apiKey : null) || m.api_key || null; | |
| return { | |
| id: m.id, | |
| name: m.name, | |
| display_name: m.displayName || m.display_name, | |
| provider: m.provider, | |
| litellm_model: m.litellmModel || m.litellm_model, | |
| api_base: m.apiBase || m.api_base || null, | |
| api_key: rawKey, | |
| description: m.description || null, | |
| tags: JSON.stringify(m.tags || []), | |
| model_type: m.modelType || m.model_type || "chat", | |
| enabled: m.enabled !== false ? 1 : 0, | |
| litellm_id: m.litellmId || m.litellm_id || null, | |
| }; | |
| } | |
| function deserializeModel(row) { | |
| return { | |
| id: row.id, | |
| name: row.name, | |
| displayName: row.display_name, | |
| provider: row.provider, | |
| litellmModel: row.litellm_model, | |
| apiBase: row.api_base, | |
| apiKey: row.api_key ? "β’β’β’β’β’β’β’β’" : null, // mask key | |
| _apiKey: row.api_key, // raw key for internal use | |
| description: row.description, | |
| tags: JSON.parse(row.tags || "[]"), | |
| modelType: row.model_type, | |
| enabled: row.enabled === 1, | |
| litellmId: row.litellm_id, | |
| createdAt: row.created_at, | |
| updatedAt: row.updated_at, | |
| }; | |
| } | |
| module.exports = { | |
| getDb, | |
| listModels, | |
| getModel, | |
| getModelByName, | |
| createModel, | |
| updateModel, | |
| deleteModel, | |
| logUsage, | |
| getStats, | |
| }; | |