File size: 7,887 Bytes
e1d8498
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
"use strict";

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,
};