@chenpu17/cc-gw 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,8 +2,8 @@
2
2
  import Fastify from "fastify";
3
3
  import fastifyCors from "@fastify/cors";
4
4
  import fastifyStatic from "@fastify/static";
5
- import fs3 from "fs";
6
- import path3 from "path";
5
+ import fs4 from "fs";
6
+ import path4 from "path";
7
7
  import process2 from "process";
8
8
  import { fileURLToPath } from "url";
9
9
 
@@ -20,7 +20,8 @@ var LOG_LEVELS = /* @__PURE__ */ new Set([
20
20
  "debug",
21
21
  "trace"
22
22
  ]);
23
- var HOME_DIR = path.join(os.homedir(), ".cc-gw");
23
+ var HOME_OVERRIDE = process.env.CC_GW_HOME;
24
+ var HOME_DIR = path.resolve(HOME_OVERRIDE ?? path.join(os.homedir(), ".cc-gw"));
24
25
  var CONFIG_PATH = path.join(HOME_DIR, "config.json");
25
26
  var TypedEmitter = class extends EventEmitter {
26
27
  on(event, listener) {
@@ -34,7 +35,54 @@ var TypedEmitter = class extends EventEmitter {
34
35
  }
35
36
  };
36
37
  var emitter = new TypedEmitter();
38
+ var KNOWN_ENDPOINTS = ["anthropic", "openai"];
37
39
  var cachedConfig = null;
40
+ function sanitizeDefaults(input) {
41
+ const defaults = {
42
+ completion: null,
43
+ reasoning: null,
44
+ background: null,
45
+ longContextThreshold: 6e4
46
+ };
47
+ if (input) {
48
+ if (typeof input.completion === "string" || input.completion === null) {
49
+ defaults.completion = input.completion ?? null;
50
+ }
51
+ if (typeof input.reasoning === "string" || input.reasoning === null) {
52
+ defaults.reasoning = input.reasoning ?? null;
53
+ }
54
+ if (typeof input.background === "string" || input.background === null) {
55
+ defaults.background = input.background ?? null;
56
+ }
57
+ if (typeof input.longContextThreshold === "number" && Number.isFinite(input.longContextThreshold)) {
58
+ defaults.longContextThreshold = input.longContextThreshold;
59
+ }
60
+ }
61
+ return defaults;
62
+ }
63
+ function sanitizeModelRoutes(input) {
64
+ if (!input)
65
+ return {};
66
+ const sanitized = {};
67
+ for (const [key, value] of Object.entries(input)) {
68
+ if (typeof value !== "string")
69
+ continue;
70
+ const trimmedKey = key.trim();
71
+ const trimmedValue = value.trim();
72
+ if (!trimmedKey || !trimmedValue)
73
+ continue;
74
+ sanitized[trimmedKey] = trimmedValue;
75
+ }
76
+ return sanitized;
77
+ }
78
+ function resolveEndpointRouting(source, fallback) {
79
+ const defaultsRaw = typeof source === "object" && source !== null ? source.defaults : void 0;
80
+ const routesRaw = typeof source === "object" && source !== null ? source.modelRoutes : void 0;
81
+ return {
82
+ defaults: sanitizeDefaults(defaultsRaw ?? fallback.defaults),
83
+ modelRoutes: sanitizeModelRoutes(routesRaw ?? fallback.modelRoutes)
84
+ };
85
+ }
38
86
  function parseConfig(raw) {
39
87
  const data = JSON.parse(raw);
40
88
  if (typeof data.port !== "number") {
@@ -43,43 +91,43 @@ function parseConfig(raw) {
43
91
  if (!Array.isArray(data.providers)) {
44
92
  data.providers = [];
45
93
  }
46
- if (!data.defaults) {
47
- data.defaults = {
48
- completion: null,
49
- reasoning: null,
50
- background: null,
51
- longContextThreshold: 6e4
52
- };
53
- } else {
54
- data.defaults.longContextThreshold ??= 6e4;
55
- }
94
+ const legacyDefaults = sanitizeDefaults(data.defaults);
56
95
  if (typeof data.logRetentionDays !== "number") {
57
96
  data.logRetentionDays = 30;
58
97
  }
59
98
  if (typeof data.storePayloads !== "boolean") {
60
99
  data.storePayloads = true;
61
100
  }
62
- if (!data.modelRoutes || typeof data.modelRoutes !== "object") {
63
- data.modelRoutes = {};
64
- } else {
65
- const sanitized = {};
66
- for (const [key, value] of Object.entries(data.modelRoutes)) {
67
- if (typeof value !== "string")
68
- continue;
69
- const trimmedKey = key.trim();
70
- const trimmedValue = value.trim();
71
- if (!trimmedKey || !trimmedValue)
72
- continue;
73
- sanitized[trimmedKey] = trimmedValue;
74
- }
75
- data.modelRoutes = sanitized;
76
- }
101
+ const legacyRoutes = sanitizeModelRoutes(data.modelRoutes);
77
102
  if (typeof data.logLevel !== "string" || !LOG_LEVELS.has(data.logLevel)) {
78
103
  data.logLevel = "info";
79
104
  }
80
105
  if (typeof data.requestLogging !== "boolean") {
81
106
  data.requestLogging = true;
82
107
  }
108
+ if (typeof data.responseLogging !== "boolean") {
109
+ data.responseLogging = data.requestLogging !== false;
110
+ }
111
+ const endpointRouting = {};
112
+ const sourceRouting = data.endpointRouting && typeof data.endpointRouting === "object" ? data.endpointRouting : {};
113
+ const fallbackAnthropic = {
114
+ defaults: legacyDefaults,
115
+ modelRoutes: legacyRoutes
116
+ };
117
+ const fallbackOpenAI = {
118
+ defaults: sanitizeDefaults(void 0),
119
+ modelRoutes: {}
120
+ };
121
+ for (const endpoint of KNOWN_ENDPOINTS) {
122
+ const fallback = endpoint === "anthropic" ? fallbackAnthropic : fallbackOpenAI;
123
+ endpointRouting[endpoint] = resolveEndpointRouting(
124
+ sourceRouting[endpoint],
125
+ fallback
126
+ );
127
+ }
128
+ data.endpointRouting = endpointRouting;
129
+ data.defaults = { ...endpointRouting.anthropic.defaults };
130
+ data.modelRoutes = { ...endpointRouting.anthropic.modelRoutes };
83
131
  return data;
84
132
  }
85
133
  function loadConfig() {
@@ -97,8 +145,9 @@ function getConfig() {
97
145
  }
98
146
  function updateConfig(next) {
99
147
  fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
100
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(next, null, 2), "utf-8");
101
- cachedConfig = next;
148
+ const normalized = parseConfig(JSON.stringify(next));
149
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(normalized, null, 2), "utf-8");
150
+ cachedConfig = normalized;
102
151
  emitter.emitTyped("change", cachedConfig);
103
152
  }
104
153
  function onConfigChange(listener) {
@@ -301,19 +350,23 @@ function resolveByIdentifier(identifier, providers) {
301
350
  }
302
351
  function resolveRoute(ctx) {
303
352
  const config = getConfig();
353
+ const endpointConfig = config.endpointRouting?.[ctx.endpoint] ?? config.endpointRouting?.anthropic;
354
+ if (!endpointConfig) {
355
+ throw new Error(`\u672A\u627E\u5230\u7AEF\u70B9 ${ctx.endpoint} \u7684\u8DEF\u7531\u914D\u7F6E`);
356
+ }
304
357
  const providers = config.providers;
305
358
  if (!providers.length) {
306
359
  throw new Error("\u672A\u914D\u7F6E\u4EFB\u4F55\u6A21\u578B\u63D0\u4F9B\u5546\uFF0C\u8BF7\u5148\u5728 Web UI \u4E2D\u6DFB\u52A0 Provider\u3002");
307
360
  }
308
361
  const requestedModel = ctx.requestedModel?.trim();
309
- const mappedIdentifier = requestedModel ? config.modelRoutes?.[requestedModel] ?? null : null;
362
+ const mappedIdentifier = requestedModel ? endpointConfig.modelRoutes?.[requestedModel] ?? null : null;
310
363
  const fallbackModelId = providers[0].defaultModel ?? providers[0].models?.[0]?.id ?? "gpt-4o";
311
364
  const tokenEstimate = estimateTokens(
312
365
  ctx.payload,
313
366
  mappedIdentifier ?? requestedModel ?? fallbackModelId
314
367
  );
315
368
  const strategy = ctx.payload;
316
- const defaults = config.defaults;
369
+ const defaults = endpointConfig.defaults;
317
370
  if (mappedIdentifier) {
318
371
  const mapped = resolveByIdentifier(mappedIdentifier, providers);
319
372
  if (mapped) {
@@ -771,7 +824,8 @@ import fs2 from "fs";
771
824
  import os2 from "os";
772
825
  import path2 from "path";
773
826
  import sqlite3 from "sqlite3";
774
- var HOME_DIR2 = path2.join(os2.homedir(), ".cc-gw");
827
+ var HOME_OVERRIDE2 = process.env.CC_GW_HOME;
828
+ var HOME_DIR2 = path2.resolve(HOME_OVERRIDE2 ?? path2.join(os2.homedir(), ".cc-gw"));
775
829
  var DATA_DIR = path2.join(HOME_DIR2, "data");
776
830
  var DB_PATH = path2.join(DATA_DIR, "gateway.db");
777
831
  sqlite3.verbose();
@@ -840,6 +894,45 @@ async function columnExists(db, table, column) {
840
894
  const rows = await all(db, `PRAGMA table_info(${table})`);
841
895
  return rows.some((row) => row.name === column);
842
896
  }
897
+ async function migrateDailyMetricsTable(db) {
898
+ const columns = await all(db, "PRAGMA table_info(daily_metrics)");
899
+ if (columns.length === 0)
900
+ return;
901
+ const hasEndpointColumn = columns.some((column) => column.name === "endpoint");
902
+ const primaryKeyColumns = columns.filter((column) => column.pk > 0);
903
+ const hasCompositePrimaryKey = primaryKeyColumns.length > 1;
904
+ if (!hasEndpointColumn || !hasCompositePrimaryKey) {
905
+ const endpointSelector = hasEndpointColumn ? "COALESCE(endpoint, 'anthropic')" : "'anthropic'";
906
+ await exec(
907
+ db,
908
+ `ALTER TABLE daily_metrics RENAME TO daily_metrics_old;
909
+ CREATE TABLE daily_metrics (
910
+ date TEXT NOT NULL,
911
+ endpoint TEXT NOT NULL DEFAULT 'anthropic',
912
+ request_count INTEGER DEFAULT 0,
913
+ total_input_tokens INTEGER DEFAULT 0,
914
+ total_output_tokens INTEGER DEFAULT 0,
915
+ total_latency_ms INTEGER DEFAULT 0,
916
+ PRIMARY KEY (date, endpoint)
917
+ );
918
+ INSERT INTO daily_metrics (date, endpoint, request_count, total_input_tokens, total_output_tokens, total_latency_ms)
919
+ SELECT date,
920
+ ${endpointSelector},
921
+ request_count,
922
+ total_input_tokens,
923
+ total_output_tokens,
924
+ total_latency_ms
925
+ FROM daily_metrics_old;
926
+ DROP TABLE daily_metrics_old;`
927
+ );
928
+ } else {
929
+ await run(db, "UPDATE daily_metrics SET endpoint = 'anthropic' WHERE endpoint IS NULL OR endpoint = ''");
930
+ }
931
+ await run(
932
+ db,
933
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_metrics_date_endpoint ON daily_metrics(date, endpoint)"
934
+ );
935
+ }
843
936
  async function maybeAddColumn(db, table, column, definition) {
844
937
  const exists = await columnExists(db, table, column);
845
938
  if (!exists) {
@@ -853,6 +946,7 @@ async function ensureSchema(db) {
853
946
  id INTEGER PRIMARY KEY AUTOINCREMENT,
854
947
  timestamp INTEGER NOT NULL,
855
948
  session_id TEXT,
949
+ endpoint TEXT NOT NULL DEFAULT 'anthropic',
856
950
  provider TEXT NOT NULL,
857
951
  model TEXT NOT NULL,
858
952
  client_model TEXT,
@@ -864,7 +958,10 @@ async function ensureSchema(db) {
864
958
  cached_tokens INTEGER,
865
959
  ttft_ms INTEGER,
866
960
  tpot_ms REAL,
867
- error TEXT
961
+ error TEXT,
962
+ api_key_id INTEGER,
963
+ api_key_name TEXT,
964
+ api_key_value TEXT
868
965
  );
869
966
 
870
967
  CREATE TABLE IF NOT EXISTS request_payloads (
@@ -875,11 +972,43 @@ async function ensureSchema(db) {
875
972
  );
876
973
 
877
974
  CREATE TABLE IF NOT EXISTS daily_metrics (
878
- date TEXT PRIMARY KEY,
975
+ date TEXT NOT NULL,
976
+ endpoint TEXT NOT NULL DEFAULT 'anthropic',
879
977
  request_count INTEGER DEFAULT 0,
880
978
  total_input_tokens INTEGER DEFAULT 0,
881
979
  total_output_tokens INTEGER DEFAULT 0,
882
- total_latency_ms INTEGER DEFAULT 0
980
+ total_latency_ms INTEGER DEFAULT 0,
981
+ PRIMARY KEY (date, endpoint)
982
+ );
983
+
984
+ CREATE TABLE IF NOT EXISTS api_keys (
985
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
986
+ name TEXT NOT NULL,
987
+ description TEXT,
988
+ key_hash TEXT NOT NULL UNIQUE,
989
+ key_ciphertext TEXT,
990
+ key_prefix TEXT,
991
+ key_suffix TEXT,
992
+ is_wildcard INTEGER DEFAULT 0,
993
+ enabled INTEGER DEFAULT 1,
994
+ created_at INTEGER NOT NULL,
995
+ updated_at INTEGER,
996
+ last_used_at INTEGER,
997
+ request_count INTEGER DEFAULT 0,
998
+ total_input_tokens INTEGER DEFAULT 0,
999
+ total_output_tokens INTEGER DEFAULT 0
1000
+ );
1001
+
1002
+ CREATE TABLE IF NOT EXISTS api_key_audit_logs (
1003
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1004
+ api_key_id INTEGER,
1005
+ api_key_name TEXT,
1006
+ operation TEXT NOT NULL,
1007
+ operator TEXT,
1008
+ details TEXT,
1009
+ ip_address TEXT,
1010
+ created_at TEXT NOT NULL,
1011
+ FOREIGN KEY(api_key_id) REFERENCES api_keys(id) ON DELETE SET NULL
883
1012
  );`
884
1013
  );
885
1014
  await maybeAddColumn(db, "request_logs", "client_model", "TEXT");
@@ -887,6 +1016,36 @@ async function ensureSchema(db) {
887
1016
  await maybeAddColumn(db, "request_logs", "ttft_ms", "INTEGER");
888
1017
  await maybeAddColumn(db, "request_logs", "tpot_ms", "REAL");
889
1018
  await maybeAddColumn(db, "request_logs", "stream", "INTEGER");
1019
+ await maybeAddColumn(db, "request_logs", "endpoint", "TEXT DEFAULT 'anthropic'");
1020
+ await maybeAddColumn(db, "request_logs", "api_key_id", "INTEGER");
1021
+ await maybeAddColumn(db, "request_logs", "api_key_name", "TEXT");
1022
+ await maybeAddColumn(db, "request_logs", "api_key_value", "TEXT");
1023
+ const hasKeyHash = await columnExists(db, "api_keys", "key_hash");
1024
+ if (!hasKeyHash) {
1025
+ await run(db, "ALTER TABLE api_keys ADD COLUMN key_hash TEXT");
1026
+ }
1027
+ await maybeAddColumn(db, "api_keys", "key_ciphertext", "TEXT");
1028
+ await maybeAddColumn(db, "api_keys", "key_prefix", "TEXT");
1029
+ await maybeAddColumn(db, "api_keys", "key_suffix", "TEXT");
1030
+ await maybeAddColumn(db, "api_keys", "updated_at", "INTEGER");
1031
+ await maybeAddColumn(db, "api_keys", "last_used_at", "INTEGER");
1032
+ await maybeAddColumn(db, "api_keys", "description", "TEXT");
1033
+ await maybeAddColumn(db, "api_keys", "request_count", "INTEGER DEFAULT 0");
1034
+ await maybeAddColumn(db, "api_keys", "total_input_tokens", "INTEGER DEFAULT 0");
1035
+ await maybeAddColumn(db, "api_keys", "total_output_tokens", "INTEGER DEFAULT 0");
1036
+ await migrateDailyMetricsTable(db);
1037
+ await run(db, "CREATE UNIQUE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash) WHERE key_hash IS NOT NULL");
1038
+ await run(db, "UPDATE api_keys SET key_hash = '*' WHERE is_wildcard = 1 AND (key_hash IS NULL OR key_hash = '')");
1039
+ await run(db, "UPDATE api_keys SET updated_at = created_at WHERE updated_at IS NULL");
1040
+ const wildcardRow = await get(db, "SELECT COUNT(*) as count FROM api_keys WHERE is_wildcard = 1");
1041
+ if (!wildcardRow || wildcardRow.count === 0) {
1042
+ const now = Date.now();
1043
+ await run(
1044
+ db,
1045
+ "INSERT INTO api_keys (name, description, key_hash, is_wildcard, enabled, created_at, updated_at) VALUES (?, ?, ?, 1, 1, ?, ?)",
1046
+ ["Any Key", null, "*", now, now]
1047
+ );
1048
+ }
890
1049
  }
891
1050
  async function getDb() {
892
1051
  if (dbInstance) {
@@ -961,12 +1120,14 @@ function decompressPayload(value) {
961
1120
  async function recordLog(entry) {
962
1121
  const result = await runQuery(
963
1122
  `INSERT INTO request_logs (
964
- timestamp, session_id, provider, model, client_model, stream,
965
- latency_ms, status_code, input_tokens, output_tokens, cached_tokens, error
966
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1123
+ timestamp, session_id, endpoint, provider, model, client_model, stream,
1124
+ latency_ms, status_code, input_tokens, output_tokens, cached_tokens, error,
1125
+ api_key_id, api_key_name, api_key_value
1126
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
967
1127
  [
968
1128
  entry.timestamp,
969
1129
  entry.sessionId ?? null,
1130
+ entry.endpoint,
970
1131
  entry.provider,
971
1132
  entry.model,
972
1133
  entry.clientModel ?? null,
@@ -976,7 +1137,10 @@ async function recordLog(entry) {
976
1137
  entry.inputTokens ?? null,
977
1138
  entry.outputTokens ?? null,
978
1139
  entry.cachedTokens ?? null,
979
- entry.error ?? null
1140
+ entry.error ?? null,
1141
+ entry.apiKeyId ?? null,
1142
+ entry.apiKeyName ?? null,
1143
+ entry.apiKeyValue ?? null
980
1144
  ]
981
1145
  );
982
1146
  return Number(result.lastID);
@@ -1038,17 +1202,18 @@ async function upsertLogPayload(requestId, payload) {
1038
1202
  [requestId, promptData, responseData]
1039
1203
  );
1040
1204
  }
1041
- async function updateMetrics(date, delta) {
1205
+ async function updateMetrics(date, endpoint, delta) {
1042
1206
  await runQuery(
1043
- `INSERT INTO daily_metrics (date, request_count, total_input_tokens, total_output_tokens, total_latency_ms)
1044
- VALUES (?, ?, ?, ?, ?)
1045
- ON CONFLICT(date) DO UPDATE SET
1207
+ `INSERT INTO daily_metrics (date, endpoint, request_count, total_input_tokens, total_output_tokens, total_latency_ms)
1208
+ VALUES (?, ?, ?, ?, ?, ?)
1209
+ ON CONFLICT(date, endpoint) DO UPDATE SET
1046
1210
  request_count = daily_metrics.request_count + excluded.request_count,
1047
1211
  total_input_tokens = daily_metrics.total_input_tokens + excluded.total_input_tokens,
1048
1212
  total_output_tokens = daily_metrics.total_output_tokens + excluded.total_output_tokens,
1049
1213
  total_latency_ms = daily_metrics.total_latency_ms + excluded.total_latency_ms`,
1050
1214
  [
1051
1215
  date,
1216
+ endpoint,
1052
1217
  delta.requests,
1053
1218
  delta.inputTokens,
1054
1219
  delta.outputTokens,
@@ -1071,6 +1236,358 @@ function getActiveRequestCount() {
1071
1236
  return activeRequests;
1072
1237
  }
1073
1238
 
1239
+ // api-keys/service.ts
1240
+ import { randomBytes as randomBytes2, createHash } from "crypto";
1241
+
1242
+ // security/encryption.ts
1243
+ import fs3 from "fs";
1244
+ import os3 from "os";
1245
+ import path3 from "path";
1246
+ import { randomBytes, createCipheriv, createDecipheriv } from "crypto";
1247
+ var HOME_OVERRIDE3 = process.env.CC_GW_HOME;
1248
+ var HOME_DIR3 = path3.resolve(HOME_OVERRIDE3 ?? path3.join(os3.homedir(), ".cc-gw"));
1249
+ var KEY_PATH = path3.join(HOME_DIR3, "encryption.key");
1250
+ var KEY_LENGTH = 32;
1251
+ var KEY_FILE_MODE = 384;
1252
+ var cachedKey = null;
1253
+ function writeKeyFile(buffer) {
1254
+ fs3.mkdirSync(HOME_DIR3, { recursive: true });
1255
+ fs3.writeFileSync(KEY_PATH, buffer.toString("base64"), { encoding: "utf8", mode: KEY_FILE_MODE });
1256
+ }
1257
+ function decodeKeyContent(content) {
1258
+ const trimmed = content.trim();
1259
+ if (!trimmed) {
1260
+ return null;
1261
+ }
1262
+ const tryBase64 = (() => {
1263
+ try {
1264
+ const decoded = Buffer.from(trimmed, "base64");
1265
+ return decoded.length === KEY_LENGTH ? decoded : null;
1266
+ } catch {
1267
+ return null;
1268
+ }
1269
+ })();
1270
+ if (tryBase64)
1271
+ return tryBase64;
1272
+ if (/^[0-9a-fA-F]+$/.test(trimmed) && trimmed.length === KEY_LENGTH * 2) {
1273
+ const decoded = Buffer.from(trimmed, "hex");
1274
+ if (decoded.length === KEY_LENGTH) {
1275
+ return decoded;
1276
+ }
1277
+ }
1278
+ if (trimmed.length === KEY_LENGTH) {
1279
+ const ascii = Buffer.from(trimmed, "utf8");
1280
+ if (ascii.length === KEY_LENGTH) {
1281
+ return ascii;
1282
+ }
1283
+ }
1284
+ return null;
1285
+ }
1286
+ function ensureKeyMaterial() {
1287
+ if (cachedKey) {
1288
+ return cachedKey;
1289
+ }
1290
+ fs3.mkdirSync(HOME_DIR3, { recursive: true });
1291
+ if (!fs3.existsSync(KEY_PATH)) {
1292
+ const generated = randomBytes(KEY_LENGTH);
1293
+ writeKeyFile(generated);
1294
+ cachedKey = generated;
1295
+ return generated;
1296
+ }
1297
+ const content = fs3.readFileSync(KEY_PATH, "utf8");
1298
+ const decoded = decodeKeyContent(content);
1299
+ if (decoded) {
1300
+ cachedKey = decoded;
1301
+ return decoded;
1302
+ }
1303
+ const regenerated = randomBytes(KEY_LENGTH);
1304
+ writeKeyFile(regenerated);
1305
+ cachedKey = regenerated;
1306
+ console.info("[cc-gw][encryption] regenerated encryption key due to invalid file format");
1307
+ return regenerated;
1308
+ }
1309
+ function encryptSecret(value) {
1310
+ if (value === null || value === void 0) {
1311
+ return null;
1312
+ }
1313
+ const key = ensureKeyMaterial();
1314
+ const iv = randomBytes(12);
1315
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
1316
+ const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
1317
+ const tag = cipher.getAuthTag();
1318
+ return Buffer.concat([iv, tag, encrypted]).toString("base64");
1319
+ }
1320
+ function decryptSecret(payload) {
1321
+ if (!payload) {
1322
+ return null;
1323
+ }
1324
+ try {
1325
+ const buffer = Buffer.from(payload, "base64");
1326
+ if (buffer.length <= 28) {
1327
+ return null;
1328
+ }
1329
+ const iv = buffer.subarray(0, 12);
1330
+ const tag = buffer.subarray(12, 28);
1331
+ const ciphertext = buffer.subarray(28);
1332
+ const decipher = createDecipheriv("aes-256-gcm", ensureKeyMaterial(), iv);
1333
+ decipher.setAuthTag(tag);
1334
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
1335
+ return decrypted.toString("utf8");
1336
+ } catch (error) {
1337
+ console.warn("[cc-gw][encryption] failed to decrypt payload", error);
1338
+ return null;
1339
+ }
1340
+ }
1341
+
1342
+ // api-keys/service.ts
1343
+ var apiKeysHasUpdatedAt = null;
1344
+ async function ensureApiKeysMetadataLoaded() {
1345
+ if (apiKeysHasUpdatedAt !== null) {
1346
+ return;
1347
+ }
1348
+ const info = await getAll("PRAGMA table_info(api_keys)");
1349
+ apiKeysHasUpdatedAt = info.some((row) => row.name === "updated_at");
1350
+ }
1351
+ function toIsoOrNull(value) {
1352
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
1353
+ try {
1354
+ return new Date(value).toISOString();
1355
+ } catch {
1356
+ }
1357
+ }
1358
+ return null;
1359
+ }
1360
+ var ApiKeyError = class extends Error {
1361
+ constructor(message, code) {
1362
+ super(message);
1363
+ this.code = code;
1364
+ this.name = "ApiKeyError";
1365
+ }
1366
+ };
1367
+ var KEY_PREFIX = "sk-ccgw-";
1368
+ function hashKey(value) {
1369
+ return createHash("sha256").update(value).digest("hex");
1370
+ }
1371
+ function maskKey(prefix, suffix) {
1372
+ if (!prefix && !suffix) {
1373
+ return "********";
1374
+ }
1375
+ const safePrefix = prefix ?? "";
1376
+ const safeSuffix = suffix ?? "";
1377
+ return `${safePrefix}****${safeSuffix}`;
1378
+ }
1379
+ async function recordAuditLog(payload) {
1380
+ const { apiKeyId = null, apiKeyName = null, operation, operator = null, details = null, ipAddress = null } = payload;
1381
+ const serializedDetails = details ? JSON.stringify(details) : null;
1382
+ await runQuery(
1383
+ `INSERT INTO api_key_audit_logs (api_key_id, api_key_name, operation, operator, details, ip_address, created_at)
1384
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
1385
+ [apiKeyId, apiKeyName, operation, operator, serializedDetails, ipAddress, (/* @__PURE__ */ new Date()).toISOString()]
1386
+ );
1387
+ }
1388
+ function generateKey() {
1389
+ const randomPart = randomBytes2(24).toString("base64url");
1390
+ const key = `${KEY_PREFIX}${randomPart}`;
1391
+ return {
1392
+ key,
1393
+ prefix: key.slice(0, 6),
1394
+ suffix: key.slice(-4)
1395
+ };
1396
+ }
1397
+ async function listApiKeys() {
1398
+ const rows = await getAll("SELECT id, name, description, key_prefix, key_suffix, is_wildcard, enabled, created_at, last_used_at, request_count, total_input_tokens, total_output_tokens FROM api_keys ORDER BY is_wildcard DESC, created_at DESC");
1399
+ return rows.map((row) => ({
1400
+ id: row.id,
1401
+ name: row.name,
1402
+ description: row.description ?? null,
1403
+ maskedKey: row.is_wildcard ? null : maskKey(row.key_prefix, row.key_suffix),
1404
+ isWildcard: Boolean(row.is_wildcard),
1405
+ enabled: Boolean(row.enabled),
1406
+ createdAt: toIsoOrNull(row.created_at),
1407
+ lastUsedAt: toIsoOrNull(row.last_used_at),
1408
+ requestCount: row.request_count ?? 0,
1409
+ totalInputTokens: row.total_input_tokens ?? 0,
1410
+ totalOutputTokens: row.total_output_tokens ?? 0
1411
+ }));
1412
+ }
1413
+ async function createApiKey(name, description, context) {
1414
+ const trimmed = name.trim();
1415
+ if (!trimmed) {
1416
+ throw new Error("Name is required");
1417
+ }
1418
+ if (trimmed.length > 100) {
1419
+ throw new Error("Name too long (max 100 characters)");
1420
+ }
1421
+ const trimmedDescription = typeof description === "string" ? description.trim() : "";
1422
+ if (trimmedDescription.length > 500) {
1423
+ throw new Error("Description too long (max 500 characters)");
1424
+ }
1425
+ await ensureApiKeysMetadataLoaded();
1426
+ const { key, prefix, suffix } = generateKey();
1427
+ const hashed = hashKey(key);
1428
+ const encrypted = encryptSecret(key);
1429
+ const now = Date.now();
1430
+ const columns = ["name", "description", "key_hash", "key_ciphertext", "key_prefix", "key_suffix", "is_wildcard", "enabled", "created_at"];
1431
+ const placeholders = ["?", "?", "?", "?", "?", "?", "?", "?", "?"];
1432
+ const values = [trimmed, trimmedDescription || null, hashed, encrypted, prefix, suffix, 0, 1, now];
1433
+ if (apiKeysHasUpdatedAt) {
1434
+ columns.push("updated_at");
1435
+ placeholders.push("?");
1436
+ values.push(now);
1437
+ }
1438
+ const result = await runQuery(
1439
+ `INSERT INTO api_keys (${columns.join(", ")}) VALUES (${placeholders.join(", ")})`,
1440
+ values
1441
+ );
1442
+ await recordAuditLog({
1443
+ apiKeyId: result.lastID,
1444
+ apiKeyName: trimmed,
1445
+ operation: "create",
1446
+ operator: context?.operator ?? null,
1447
+ ipAddress: context?.ipAddress ?? null
1448
+ });
1449
+ return {
1450
+ id: result.lastID,
1451
+ key,
1452
+ name: trimmed,
1453
+ description: trimmedDescription || null,
1454
+ createdAt: new Date(now).toISOString()
1455
+ };
1456
+ }
1457
+ async function setApiKeyEnabled(id, enabled, context) {
1458
+ await ensureApiKeysMetadataLoaded();
1459
+ const existing = await getOne("SELECT id, name, is_wildcard, enabled FROM api_keys WHERE id = ?", [id]);
1460
+ if (!existing) {
1461
+ throw new Error("API key not found");
1462
+ }
1463
+ if (apiKeysHasUpdatedAt) {
1464
+ await runQuery("UPDATE api_keys SET enabled = ?, updated_at = ? WHERE id = ?", [enabled ? 1 : 0, Date.now(), id]);
1465
+ } else {
1466
+ await runQuery("UPDATE api_keys SET enabled = ? WHERE id = ?", [enabled ? 1 : 0, id]);
1467
+ }
1468
+ await recordAuditLog({
1469
+ apiKeyId: existing.id,
1470
+ apiKeyName: existing.name,
1471
+ operation: enabled ? "enable" : "disable",
1472
+ operator: context?.operator ?? null,
1473
+ ipAddress: context?.ipAddress ?? null
1474
+ });
1475
+ }
1476
+ async function deleteApiKey(id, context) {
1477
+ const existing = await getOne("SELECT id, name, is_wildcard FROM api_keys WHERE id = ?", [id]);
1478
+ if (!existing) {
1479
+ throw new Error("API key not found");
1480
+ }
1481
+ if (existing.is_wildcard) {
1482
+ throw new Error("Cannot delete wildcard key");
1483
+ }
1484
+ await runQuery("DELETE FROM api_keys WHERE id = ?", [id]);
1485
+ await recordAuditLog({
1486
+ apiKeyId: existing.id,
1487
+ apiKeyName: existing.name,
1488
+ operation: "delete",
1489
+ operator: context?.operator ?? null,
1490
+ ipAddress: context?.ipAddress ?? null
1491
+ });
1492
+ }
1493
+ async function fetchWildcard() {
1494
+ const wildcard = await getOne("SELECT id, name, enabled FROM api_keys WHERE is_wildcard = 1 LIMIT 1");
1495
+ return wildcard ?? null;
1496
+ }
1497
+ async function resolveApiKey(providedRaw, context) {
1498
+ const provided = providedRaw?.trim() ?? "";
1499
+ const wildcard = await fetchWildcard();
1500
+ if (!provided) {
1501
+ if (wildcard && wildcard.enabled) {
1502
+ return {
1503
+ id: wildcard.id,
1504
+ name: wildcard.name,
1505
+ isWildcard: true,
1506
+ providedKey: ""
1507
+ };
1508
+ }
1509
+ await recordAuditLog({
1510
+ operation: "auth_failure",
1511
+ details: { reason: "missing" },
1512
+ ipAddress: context?.ipAddress ?? null
1513
+ });
1514
+ throw new ApiKeyError("API key is required", "missing");
1515
+ }
1516
+ const hashed = hashKey(provided);
1517
+ const existing = await getOne("SELECT id, name, enabled, is_wildcard FROM api_keys WHERE key_hash = ?", [hashed]);
1518
+ if (existing) {
1519
+ if (!existing.enabled) {
1520
+ await recordAuditLog({
1521
+ apiKeyId: existing.id,
1522
+ apiKeyName: existing.name,
1523
+ operation: "auth_failure",
1524
+ details: { reason: "disabled" },
1525
+ ipAddress: context?.ipAddress ?? null
1526
+ });
1527
+ throw new ApiKeyError("API key is disabled", "disabled");
1528
+ }
1529
+ return {
1530
+ id: existing.id,
1531
+ name: existing.name,
1532
+ isWildcard: Boolean(existing.is_wildcard),
1533
+ providedKey: provided
1534
+ };
1535
+ }
1536
+ if (wildcard && wildcard.enabled) {
1537
+ return {
1538
+ id: wildcard.id,
1539
+ name: wildcard.name,
1540
+ isWildcard: true,
1541
+ providedKey: provided
1542
+ };
1543
+ }
1544
+ await recordAuditLog({
1545
+ operation: "auth_failure",
1546
+ details: { reason: "invalid", hash: hashed.slice(0, 16) },
1547
+ ipAddress: context?.ipAddress ?? null
1548
+ });
1549
+ throw new ApiKeyError("Invalid API key provided", "invalid");
1550
+ }
1551
+ async function recordApiKeyUsage(id, delta) {
1552
+ const now = Date.now();
1553
+ await ensureApiKeysMetadataLoaded();
1554
+ if (apiKeysHasUpdatedAt) {
1555
+ await runQuery(
1556
+ `UPDATE api_keys
1557
+ SET last_used_at = ?,
1558
+ request_count = COALESCE(request_count, 0) + 1,
1559
+ total_input_tokens = COALESCE(total_input_tokens, 0) + ?,
1560
+ total_output_tokens = COALESCE(total_output_tokens, 0) + ?,
1561
+ updated_at = ?
1562
+ WHERE id = ?`,
1563
+ [now, delta.inputTokens, delta.outputTokens, now, id]
1564
+ );
1565
+ } else {
1566
+ await runQuery(
1567
+ `UPDATE api_keys
1568
+ SET last_used_at = ?,
1569
+ request_count = COALESCE(request_count, 0) + 1,
1570
+ total_input_tokens = COALESCE(total_input_tokens, 0) + ?,
1571
+ total_output_tokens = COALESCE(total_output_tokens, 0) + ?
1572
+ WHERE id = ?`,
1573
+ [now, delta.inputTokens, delta.outputTokens, id]
1574
+ );
1575
+ }
1576
+ }
1577
+ async function decryptApiKeyValue(value) {
1578
+ return decryptSecret(value);
1579
+ }
1580
+ async function ensureWildcardMetadata() {
1581
+ const wildcard = await fetchWildcard();
1582
+ if (!wildcard) {
1583
+ return;
1584
+ }
1585
+ await runQuery(
1586
+ "UPDATE api_keys SET key_prefix = COALESCE(key_prefix, ?), key_suffix = COALESCE(key_suffix, ?) WHERE id = ?",
1587
+ ["WILD", "KEY", wildcard.id]
1588
+ );
1589
+ }
1590
+
1074
1591
  // routes/messages.ts
1075
1592
  function mapStopReason(reason) {
1076
1593
  switch (reason) {
@@ -1235,12 +1752,60 @@ function buildClaudeResponse(openAI, model) {
1235
1752
  };
1236
1753
  }
1237
1754
  async function registerMessagesRoute(app) {
1238
- app.post("/v1/messages", async (request, reply) => {
1755
+ const handler = async (request, reply) => {
1239
1756
  const payload = request.body;
1240
1757
  if (!payload || typeof payload !== "object") {
1241
1758
  reply.code(400);
1242
1759
  return { error: "Invalid request body" };
1243
1760
  }
1761
+ const resolveHeaderValue = (value) => {
1762
+ if (!value)
1763
+ return void 0;
1764
+ if (typeof value === "string")
1765
+ return value;
1766
+ if (Array.isArray(value)) {
1767
+ const found = value.find((item) => typeof item === "string" && item.trim().length > 0);
1768
+ return found;
1769
+ }
1770
+ return void 0;
1771
+ };
1772
+ let providedApiKey = resolveHeaderValue(request.headers["x-api-key"]);
1773
+ if (!providedApiKey) {
1774
+ const authHeader = resolveHeaderValue(request.headers["authorization"]);
1775
+ if (authHeader && authHeader.toLowerCase().startsWith("bearer ")) {
1776
+ providedApiKey = authHeader.slice(7);
1777
+ }
1778
+ }
1779
+ let apiKeyContext;
1780
+ try {
1781
+ apiKeyContext = await resolveApiKey(providedApiKey, { ipAddress: request.ip });
1782
+ } catch (error) {
1783
+ if (error instanceof ApiKeyError) {
1784
+ reply.code(401);
1785
+ return {
1786
+ error: {
1787
+ code: "invalid_api_key",
1788
+ message: error.message
1789
+ }
1790
+ };
1791
+ }
1792
+ throw error;
1793
+ }
1794
+ const encryptedApiKeyValue = apiKeyContext.providedKey ? encryptSecret(apiKeyContext.providedKey) : null;
1795
+ let usageRecorded = false;
1796
+ const commitUsage = async (inputTokens, outputTokens) => {
1797
+ if (usageRecorded)
1798
+ return;
1799
+ usageRecorded = true;
1800
+ if (apiKeyContext.id) {
1801
+ const safeInput = Number.isFinite(inputTokens) ? inputTokens : 0;
1802
+ const safeOutput = Number.isFinite(outputTokens) ? outputTokens : 0;
1803
+ await recordApiKeyUsage(apiKeyContext.id, {
1804
+ inputTokens: safeInput,
1805
+ outputTokens: safeOutput
1806
+ });
1807
+ }
1808
+ };
1244
1809
  const rawUrl = typeof request.raw?.url === "string" ? request.raw.url : request.url ?? "";
1245
1810
  let querySuffix = null;
1246
1811
  if (typeof rawUrl === "string" && rawUrl.includes("?")) {
@@ -1258,14 +1823,13 @@ async function registerMessagesRoute(app) {
1258
1823
  requestedModel
1259
1824
  });
1260
1825
  const providerType = target.provider.type ?? "custom";
1261
- const modelDefinition = target.provider.models?.find((m) => m.id === target.modelId);
1262
- const supportsTools = modelDefinition?.capabilities?.tools === true;
1826
+ const supportsTools = (target.provider.type ?? "custom") !== "custom";
1263
1827
  const supportsMetadata = providerType !== "custom";
1264
1828
  let normalizedForProvider = supportsTools ? normalized : stripTooling(normalized);
1265
1829
  if (!supportsMetadata) {
1266
1830
  normalizedForProvider = stripMetadata(normalizedForProvider);
1267
1831
  }
1268
- const maxTokensOverride = payload.max_tokens ?? modelDefinition?.maxTokens;
1832
+ const maxTokensOverride = payload.max_tokens ?? void 0;
1269
1833
  const toolChoice = supportsTools ? payload.tool_choice : void 0;
1270
1834
  const overrideTools = supportsTools ? payload.tools : void 0;
1271
1835
  let providerBody;
@@ -1312,11 +1876,15 @@ async function registerMessagesRoute(app) {
1312
1876
  const storePayloads = getConfig().storePayloads !== false;
1313
1877
  const logId = await recordLog({
1314
1878
  timestamp: requestStart,
1879
+ endpoint: "anthropic",
1315
1880
  provider: target.providerId,
1316
1881
  model: target.modelId,
1317
1882
  clientModel: requestedModel,
1318
1883
  sessionId: payload.metadata?.user_id,
1319
- stream: normalized.stream
1884
+ stream: normalized.stream,
1885
+ apiKeyId: apiKeyContext.id,
1886
+ apiKeyName: apiKeyContext.name,
1887
+ apiKeyValue: encryptedApiKeyValue
1320
1888
  });
1321
1889
  incrementActiveRequests();
1322
1890
  if (storePayloads) {
@@ -1379,6 +1947,7 @@ async function registerMessagesRoute(app) {
1379
1947
  if (storePayloads) {
1380
1948
  await upsertLogPayload(logId, { response: bodyText || null });
1381
1949
  }
1950
+ await commitUsage(0, 0);
1382
1951
  await finalize(upstream.status, errorText);
1383
1952
  return { error: errorText };
1384
1953
  }
@@ -1408,7 +1977,8 @@ async function registerMessagesRoute(app) {
1408
1977
  ttftMs: latencyMs2,
1409
1978
  tpotMs: computeTpot(latencyMs2, outputTokens2, { streaming: false })
1410
1979
  });
1411
- await updateMetrics((/* @__PURE__ */ new Date()).toISOString().slice(0, 10), {
1980
+ await commitUsage(inputTokens2, outputTokens2);
1981
+ await updateMetrics((/* @__PURE__ */ new Date()).toISOString().slice(0, 10), "anthropic", {
1412
1982
  requests: 1,
1413
1983
  inputTokens: inputTokens2,
1414
1984
  outputTokens: outputTokens2,
@@ -1453,7 +2023,8 @@ async function registerMessagesRoute(app) {
1453
2023
  ttftMs: latencyMs,
1454
2024
  tpotMs: computeTpot(latencyMs, outputTokens, { streaming: false })
1455
2025
  });
1456
- await updateMetrics((/* @__PURE__ */ new Date()).toISOString().slice(0, 10), {
2026
+ await commitUsage(inputTokens, outputTokens);
2027
+ await updateMetrics((/* @__PURE__ */ new Date()).toISOString().slice(0, 10), "anthropic", {
1457
2028
  requests: 1,
1458
2029
  inputTokens,
1459
2030
  outputTokens,
@@ -1476,6 +2047,7 @@ async function registerMessagesRoute(app) {
1476
2047
  }
1477
2048
  if (!upstream.body) {
1478
2049
  reply.code(500);
2050
+ await commitUsage(0, 0);
1479
2051
  await finalize(500, "Upstream returned empty body");
1480
2052
  return { error: "Upstream returned empty body" };
1481
2053
  }
@@ -1572,7 +2144,8 @@ async function registerMessagesRoute(app) {
1572
2144
  ttftMs
1573
2145
  })
1574
2146
  });
1575
- await updateMetrics((/* @__PURE__ */ new Date()).toISOString().slice(0, 10), {
2147
+ await commitUsage(usagePrompt2, usageCompletion2);
2148
+ await updateMetrics((/* @__PURE__ */ new Date()).toISOString().slice(0, 10), "anthropic", {
1576
2149
  requests: 1,
1577
2150
  inputTokens: usagePrompt2,
1578
2151
  outputTokens: usageCompletion2,
@@ -1696,7 +2269,8 @@ data: ${JSON.stringify(data)}
1696
2269
  ttftMs
1697
2270
  })
1698
2271
  });
1699
- await updateMetrics((/* @__PURE__ */ new Date()).toISOString().slice(0, 10), {
2272
+ await commitUsage(finalPromptTokens, finalCompletionTokens);
2273
+ await updateMetrics((/* @__PURE__ */ new Date()).toISOString().slice(0, 10), "anthropic", {
1700
2274
  requests: 1,
1701
2275
  inputTokens: finalPromptTokens,
1702
2276
  outputTokens: finalCompletionTokens,
@@ -1853,7 +2427,8 @@ data: ${JSON.stringify(data)}
1853
2427
  ttftMs
1854
2428
  })
1855
2429
  });
1856
- await updateMetrics((/* @__PURE__ */ new Date()).toISOString().slice(0, 10), {
2430
+ await commitUsage(fallbackPrompt, fallbackCompletion);
2431
+ await updateMetrics((/* @__PURE__ */ new Date()).toISOString().slice(0, 10), "anthropic", {
1857
2432
  requests: 1,
1858
2433
  inputTokens: fallbackPrompt,
1859
2434
  outputTokens: fallbackCompletion,
@@ -1885,6 +2460,7 @@ data: ${JSON.stringify(data)}
1885
2460
  if (!reply.sent) {
1886
2461
  reply.code(500);
1887
2462
  }
2463
+ await commitUsage(0, 0);
1888
2464
  await finalize(reply.statusCode >= 400 ? reply.statusCode : 500, message);
1889
2465
  return { error: message };
1890
2466
  } finally {
@@ -1893,7 +2469,593 @@ data: ${JSON.stringify(data)}
1893
2469
  await finalize(reply.statusCode ?? 200, null);
1894
2470
  }
1895
2471
  }
1896
- });
2472
+ };
2473
+ app.post("/v1/messages", handler);
2474
+ app.post("/anthropic/v1/messages", handler);
2475
+ }
2476
+
2477
+ // protocol/normalize-openai.ts
2478
+ function coerceArray(value) {
2479
+ if (value == null)
2480
+ return [];
2481
+ return Array.isArray(value) ? value : [value];
2482
+ }
2483
+ function extractTextFromContent(content) {
2484
+ const textParts = [];
2485
+ const toolResults = [];
2486
+ const toolCalls = [];
2487
+ const blocks = coerceArray(content);
2488
+ for (const block of blocks) {
2489
+ if (!block || typeof block !== "object") {
2490
+ if (typeof block === "string") {
2491
+ textParts.push(block);
2492
+ }
2493
+ continue;
2494
+ }
2495
+ const type = block.type ?? block.kind ?? block.role;
2496
+ const textValue = typeof block.text === "string" ? block.text : typeof block.value === "string" ? block.value : typeof block.content === "string" ? block.content : "";
2497
+ if (type === "text" || type === "input_text" || type === "output_text") {
2498
+ if (textValue) {
2499
+ textParts.push(textValue);
2500
+ }
2501
+ continue;
2502
+ }
2503
+ if (type === "tool_result" || type === "function_result") {
2504
+ toolResults.push({
2505
+ id: typeof block.tool_call_id === "string" ? block.tool_call_id : typeof block.id === "string" ? block.id : `tool_result_${Math.random().toString(36).slice(2)}`,
2506
+ name: typeof block.name === "string" ? block.name : void 0,
2507
+ content: block.result ?? block.output ?? block.content ?? textValue ?? null,
2508
+ cacheControl: block.cache_control
2509
+ });
2510
+ continue;
2511
+ }
2512
+ if (type === "tool_use" || type === "function_call") {
2513
+ toolCalls.push({
2514
+ id: typeof block.id === "string" ? block.id : `tool_call_${Math.random().toString(36).slice(2)}`,
2515
+ name: typeof block.name === "string" ? block.name : block.function?.name ? block.function.name : "tool",
2516
+ arguments: block.arguments ?? block.input ?? block.function?.arguments ?? {},
2517
+ cacheControl: block.cache_control
2518
+ });
2519
+ continue;
2520
+ }
2521
+ }
2522
+ return {
2523
+ text: textParts.join("\n"),
2524
+ toolResults: toolResults.length > 0 ? toolResults : void 0,
2525
+ toolCalls: toolCalls.length > 0 ? toolCalls : void 0
2526
+ };
2527
+ }
2528
+ function mapInputToMessages(payload) {
2529
+ const messages = [];
2530
+ const systemParts = [];
2531
+ const inputItems = coerceArray(payload?.input ?? payload?.messages);
2532
+ for (const item of inputItems) {
2533
+ if (item == null)
2534
+ continue;
2535
+ if (typeof item === "string") {
2536
+ messages.push({
2537
+ role: "user",
2538
+ text: item
2539
+ });
2540
+ continue;
2541
+ }
2542
+ if (typeof item !== "object")
2543
+ continue;
2544
+ const role = item.role === "assistant" || item.role === "system" ? item.role : item.role === "developer" ? "system" : "user";
2545
+ if (role === "system") {
2546
+ const parts = extractTextFromContent(item.content);
2547
+ if (parts.text) {
2548
+ systemParts.push(parts.text);
2549
+ }
2550
+ continue;
2551
+ }
2552
+ const { text, toolResults, toolCalls } = extractTextFromContent(item.content);
2553
+ const normalized = {
2554
+ role: role === "assistant" ? "assistant" : "user",
2555
+ text
2556
+ };
2557
+ if (role === "user" && toolResults) {
2558
+ normalized.toolResults = toolResults;
2559
+ }
2560
+ if (role === "assistant") {
2561
+ const inlineToolCalls = coerceArray(item.tool_calls);
2562
+ if (inlineToolCalls.length > 0) {
2563
+ normalized.toolCalls = inlineToolCalls.map((call) => ({
2564
+ id: typeof call.id === "string" ? call.id : `tool_call_${Math.random().toString(36).slice(2)}`,
2565
+ name: call.function?.name ?? call.name ?? "tool",
2566
+ arguments: (() => {
2567
+ if (typeof call.function?.arguments === "string") {
2568
+ try {
2569
+ return JSON.parse(call.function.arguments);
2570
+ } catch {
2571
+ return call.function.arguments;
2572
+ }
2573
+ }
2574
+ return call.arguments ?? call.input ?? {};
2575
+ })(),
2576
+ cacheControl: call.cache_control
2577
+ }));
2578
+ } else if (toolCalls) {
2579
+ normalized.toolCalls = toolCalls;
2580
+ }
2581
+ }
2582
+ messages.push(normalized);
2583
+ }
2584
+ const extraInstructions = coerceArray(payload?.instructions);
2585
+ for (const instruction of extraInstructions) {
2586
+ if (typeof instruction === "string") {
2587
+ if (instruction.trim().length > 0) {
2588
+ systemParts.push(instruction.trim());
2589
+ }
2590
+ continue;
2591
+ }
2592
+ if (instruction && typeof instruction === "object") {
2593
+ const parts = extractTextFromContent(instruction);
2594
+ if (parts.text) {
2595
+ systemParts.push(parts.text);
2596
+ }
2597
+ }
2598
+ }
2599
+ const system = systemParts.length > 0 ? systemParts.join("\n\n") : null;
2600
+ return { messages, system };
2601
+ }
2602
+ function normalizeOpenAIResponsesPayload(payload) {
2603
+ const stream = Boolean(payload?.stream);
2604
+ const thinking = Boolean(payload?.reasoning ?? payload?.thinking);
2605
+ const { messages, system } = mapInputToMessages(payload);
2606
+ const toolsArray = coerceArray(payload?.tools);
2607
+ return {
2608
+ original: payload,
2609
+ system,
2610
+ messages,
2611
+ tools: toolsArray,
2612
+ stream,
2613
+ thinking
2614
+ };
2615
+ }
2616
+
2617
+ // routes/openai.ts
2618
+ var OPENAI_DEBUG = process.env.CC_GW_DEBUG_OPENAI === "1";
2619
+ var debugLog = (...args) => {
2620
+ if (OPENAI_DEBUG) {
2621
+ console.info("[cc-gw][openai]", ...args);
2622
+ }
2623
+ };
2624
+ var roundTwoDecimals2 = (value) => Math.round(value * 100) / 100;
2625
+ function computeTpot2(totalLatencyMs, outputTokens, options) {
2626
+ if (!Number.isFinite(outputTokens) || outputTokens <= 0) {
2627
+ return null;
2628
+ }
2629
+ const streaming = options?.streaming ?? false;
2630
+ const ttftMs = options?.ttftMs ?? null;
2631
+ const reasoningTokens = options?.reasoningTokens ?? 0;
2632
+ const totalTokensHint = options?.totalTokens ?? null;
2633
+ let effectiveLatency = totalLatencyMs;
2634
+ if (streaming && ttftMs != null && totalLatencyMs > 0) {
2635
+ const ttftRatio = ttftMs / totalLatencyMs;
2636
+ if (reasoningTokens > 0) {
2637
+ effectiveLatency = totalLatencyMs;
2638
+ } else if (ttftRatio <= 0.2) {
2639
+ effectiveLatency = Math.max(totalLatencyMs - ttftMs, totalLatencyMs * 0.2);
2640
+ } else {
2641
+ effectiveLatency = totalLatencyMs;
2642
+ }
2643
+ }
2644
+ const raw = effectiveLatency / outputTokens;
2645
+ return Number.isFinite(raw) ? roundTwoDecimals2(raw) : null;
2646
+ }
2647
+ function resolveCachedTokens2(usage) {
2648
+ if (!usage || typeof usage !== "object") {
2649
+ return null;
2650
+ }
2651
+ if (typeof usage.cached_tokens === "number") {
2652
+ return usage.cached_tokens;
2653
+ }
2654
+ const promptDetails = usage.prompt_tokens_details;
2655
+ if (promptDetails && typeof promptDetails.cached_tokens === "number") {
2656
+ return promptDetails.cached_tokens;
2657
+ }
2658
+ const inputDetails = usage.input_tokens_details;
2659
+ if (inputDetails && typeof inputDetails.cached_tokens === "number") {
2660
+ return inputDetails.cached_tokens;
2661
+ }
2662
+ if (typeof usage.cache_read_input_tokens === "number") {
2663
+ return usage.cache_read_input_tokens;
2664
+ }
2665
+ if (typeof usage.cache_creation_input_tokens === "number") {
2666
+ return usage.cache_creation_input_tokens;
2667
+ }
2668
+ return null;
2669
+ }
2670
+ async function registerOpenAiRoutes(app) {
2671
+ const handleResponses = async (request, reply) => {
2672
+ const payload = request.body;
2673
+ if (!payload || typeof payload !== "object") {
2674
+ reply.code(400);
2675
+ return { error: "Invalid request body" };
2676
+ }
2677
+ debugLog("incoming request", {
2678
+ stream: Boolean(payload.stream),
2679
+ model: payload.model,
2680
+ hasToolChoice: Boolean(payload.tool_choice),
2681
+ toolsCount: Array.isArray(payload.tools) ? payload.tools.length : 0
2682
+ });
2683
+ const resolveHeaderValue = (value) => {
2684
+ if (!value)
2685
+ return void 0;
2686
+ if (typeof value === "string")
2687
+ return value;
2688
+ if (Array.isArray(value)) {
2689
+ const found = value.find((item) => typeof item === "string" && item.trim().length > 0);
2690
+ return found;
2691
+ }
2692
+ return void 0;
2693
+ };
2694
+ let providedApiKey = resolveHeaderValue(request.headers["authorization"]);
2695
+ if (providedApiKey && providedApiKey.toLowerCase().startsWith("bearer ")) {
2696
+ providedApiKey = providedApiKey.slice(7);
2697
+ }
2698
+ if (!providedApiKey) {
2699
+ providedApiKey = resolveHeaderValue(request.headers["x-api-key"]);
2700
+ }
2701
+ let apiKeyContext;
2702
+ try {
2703
+ apiKeyContext = await resolveApiKey(providedApiKey, { ipAddress: request.ip });
2704
+ } catch (error) {
2705
+ if (error instanceof ApiKeyError) {
2706
+ reply.code(401);
2707
+ return {
2708
+ error: {
2709
+ code: "invalid_api_key",
2710
+ message: error.message
2711
+ }
2712
+ };
2713
+ }
2714
+ throw error;
2715
+ }
2716
+ const encryptedApiKeyValue = apiKeyContext.providedKey ? encryptSecret(apiKeyContext.providedKey) : null;
2717
+ let usageRecorded = false;
2718
+ const commitUsage = async (inputTokens, outputTokens) => {
2719
+ if (usageRecorded)
2720
+ return;
2721
+ usageRecorded = true;
2722
+ if (apiKeyContext.id) {
2723
+ const safeInput = Number.isFinite(inputTokens) ? inputTokens : 0;
2724
+ const safeOutput = Number.isFinite(outputTokens) ? outputTokens : 0;
2725
+ await recordApiKeyUsage(apiKeyContext.id, {
2726
+ inputTokens: safeInput,
2727
+ outputTokens: safeOutput
2728
+ });
2729
+ }
2730
+ };
2731
+ const normalized = normalizeOpenAIResponsesPayload(payload);
2732
+ const requestedModel = typeof payload.model === "string" ? payload.model : void 0;
2733
+ const target = resolveRoute({
2734
+ payload: normalized,
2735
+ requestedModel,
2736
+ endpoint: "openai"
2737
+ });
2738
+ let connector;
2739
+ const providerType = target.provider.type ?? "openai";
2740
+ if (providerType === "openai") {
2741
+ connector = createOpenAIConnector(target.provider, { defaultPath: "v1/responses" });
2742
+ } else {
2743
+ connector = getConnector(target.providerId);
2744
+ }
2745
+ const requestStart = Date.now();
2746
+ const storePayloads = getConfig().storePayloads !== false;
2747
+ const logId = await recordLog({
2748
+ timestamp: requestStart,
2749
+ endpoint: "openai",
2750
+ provider: target.providerId,
2751
+ model: target.modelId,
2752
+ clientModel: requestedModel,
2753
+ sessionId: payload.metadata?.user_id ?? payload.user,
2754
+ stream: normalized.stream,
2755
+ apiKeyId: apiKeyContext.id,
2756
+ apiKeyName: apiKeyContext.name,
2757
+ apiKeyValue: encryptedApiKeyValue
2758
+ });
2759
+ if (storePayloads) {
2760
+ await upsertLogPayload(logId, {
2761
+ prompt: (() => {
2762
+ try {
2763
+ return JSON.stringify(payload);
2764
+ } catch {
2765
+ return null;
2766
+ }
2767
+ })()
2768
+ });
2769
+ }
2770
+ incrementActiveRequests();
2771
+ let finalized = false;
2772
+ const finalize = async (statusCode, error) => {
2773
+ if (finalized)
2774
+ return;
2775
+ await finalizeLog(logId, {
2776
+ latencyMs: Date.now() - requestStart,
2777
+ statusCode,
2778
+ error,
2779
+ clientModel: requestedModel ?? null
2780
+ });
2781
+ finalized = true;
2782
+ };
2783
+ try {
2784
+ const providerBody = { ...payload };
2785
+ providerBody.model = target.modelId;
2786
+ providerBody.stream = normalized.stream;
2787
+ if (providerBody.max_output_tokens == null && typeof providerBody.max_tokens === "number") {
2788
+ providerBody.max_output_tokens = providerBody.max_tokens;
2789
+ }
2790
+ delete providerBody.max_tokens;
2791
+ if (typeof providerBody.thinking === "boolean") {
2792
+ delete providerBody.thinking;
2793
+ }
2794
+ if (typeof providerBody.reasoning === "boolean") {
2795
+ delete providerBody.reasoning;
2796
+ }
2797
+ if (providerBody.tool_choice === void 0) {
2798
+ delete providerBody.tool_choice;
2799
+ }
2800
+ if (providerBody.tools === void 0) {
2801
+ delete providerBody.tools;
2802
+ }
2803
+ if (providerBody.response_format === void 0) {
2804
+ delete providerBody.response_format;
2805
+ }
2806
+ const upstream = await connector.send({
2807
+ model: target.modelId,
2808
+ body: providerBody,
2809
+ stream: normalized.stream
2810
+ });
2811
+ if (upstream.status >= 400) {
2812
+ reply.code(upstream.status);
2813
+ const bodyText = upstream.body ? await new Response(upstream.body).text() : "";
2814
+ const errorText = bodyText || "Upstream provider error";
2815
+ debugLog("upstream error", upstream.status, errorText.slice(0, 200));
2816
+ if (storePayloads) {
2817
+ await upsertLogPayload(logId, { response: bodyText || null });
2818
+ }
2819
+ await commitUsage(0, 0);
2820
+ await finalize(upstream.status, errorText);
2821
+ return { error: errorText };
2822
+ }
2823
+ if (!normalized.stream) {
2824
+ const rawBody = upstream.body ? await new Response(upstream.body).text() : "";
2825
+ if (storePayloads) {
2826
+ await upsertLogPayload(logId, { response: rawBody });
2827
+ }
2828
+ let parsed = null;
2829
+ try {
2830
+ parsed = rawBody ? JSON.parse(rawBody) : {};
2831
+ } catch (error) {
2832
+ await commitUsage(0, 0);
2833
+ await finalize(200, null);
2834
+ reply.header("content-type", "application/json");
2835
+ return rawBody;
2836
+ }
2837
+ const usagePayload = parsed?.usage ?? null;
2838
+ const inputTokens2 = usagePayload?.input_tokens ?? usagePayload?.prompt_tokens ?? target.tokenEstimate ?? estimateTokens(normalized, target.modelId);
2839
+ const baseOutputTokens = usagePayload?.output_tokens ?? usagePayload?.completion_tokens ?? (typeof parsed?.content === "string" ? estimateTokens(normalized, target.modelId) : 0);
2840
+ const reasoningTokens2 = (() => {
2841
+ const details = usagePayload?.completion_tokens_details;
2842
+ if (details && typeof details.reasoning_tokens === "number") {
2843
+ return details.reasoning_tokens;
2844
+ }
2845
+ if (typeof usagePayload?.reasoning_tokens === "number") {
2846
+ return usagePayload.reasoning_tokens;
2847
+ }
2848
+ return 0;
2849
+ })();
2850
+ const outputTokens2 = baseOutputTokens + reasoningTokens2;
2851
+ const cachedTokens = resolveCachedTokens2(usagePayload);
2852
+ const latencyMs2 = Date.now() - requestStart;
2853
+ await updateLogTokens(logId, {
2854
+ inputTokens: inputTokens2,
2855
+ outputTokens: outputTokens2,
2856
+ cachedTokens,
2857
+ ttftMs: usagePayload?.first_token_latency_ms ?? latencyMs2,
2858
+ tpotMs: usagePayload?.tokens_per_second ? computeTpot2(latencyMs2, outputTokens2, { streaming: false, reasoningTokens: reasoningTokens2 }) : null
2859
+ });
2860
+ await commitUsage(inputTokens2, outputTokens2);
2861
+ await updateMetrics((/* @__PURE__ */ new Date()).toISOString().slice(0, 10), "openai", {
2862
+ requests: 1,
2863
+ inputTokens: inputTokens2,
2864
+ outputTokens: outputTokens2,
2865
+ latencyMs: latencyMs2
2866
+ });
2867
+ await finalize(200, null);
2868
+ reply.header("content-type", "application/json");
2869
+ return parsed ?? {};
2870
+ }
2871
+ if (!upstream.body) {
2872
+ reply.code(500);
2873
+ await commitUsage(0, 0);
2874
+ await finalize(500, "Upstream returned empty body");
2875
+ return { error: "Upstream returned empty body" };
2876
+ }
2877
+ reply.raw.setHeader("content-type", "text/event-stream; charset=utf-8");
2878
+ reply.raw.setHeader("cache-control", "no-cache, no-transform");
2879
+ reply.raw.setHeader("connection", "keep-alive");
2880
+ reply.raw.setHeader("x-accel-buffering", "no");
2881
+ if (typeof reply.raw.writeHead === "function") {
2882
+ reply.raw.writeHead(200);
2883
+ }
2884
+ if (typeof reply.raw.flushHeaders === "function") {
2885
+ ;
2886
+ reply.raw.flushHeaders();
2887
+ }
2888
+ const reader = upstream.body.getReader();
2889
+ const decoder = new TextDecoder();
2890
+ let buffer = "";
2891
+ let usagePrompt = null;
2892
+ let usageCompletion = null;
2893
+ let usageReasoning = null;
2894
+ let usageCached = null;
2895
+ let firstTokenAt = null;
2896
+ let chunkCount = 0;
2897
+ const capturedResponseChunks = storePayloads ? [] : null;
2898
+ const replyClosed = () => {
2899
+ debugLog("client connection closed before completion");
2900
+ };
2901
+ reply.raw.once("close", replyClosed);
2902
+ try {
2903
+ const selectMax = (candidates, current) => candidates.reduce((max, value) => {
2904
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
2905
+ return max == null || value > max ? value : max;
2906
+ }
2907
+ return max;
2908
+ }, current);
2909
+ const applyUsagePayload = (usagePayload) => {
2910
+ usagePrompt = selectMax(
2911
+ [
2912
+ typeof usagePayload.prompt_tokens === "number" ? usagePayload.prompt_tokens : null,
2913
+ typeof usagePayload.input_tokens === "number" ? usagePayload.input_tokens : null,
2914
+ typeof usagePayload.total_tokens === "number" && typeof usagePayload.completion_tokens === "number" ? usagePayload.total_tokens - usagePayload.completion_tokens : null
2915
+ ],
2916
+ usagePrompt
2917
+ );
2918
+ const reasoningTokens2 = typeof usagePayload?.completion_tokens_details?.reasoning_tokens === "number" ? usagePayload.completion_tokens_details.reasoning_tokens : typeof usagePayload.reasoning_tokens === "number" ? usagePayload.reasoning_tokens : null;
2919
+ usageCompletion = selectMax(
2920
+ [
2921
+ typeof usagePayload.output_tokens === "number" ? usagePayload.output_tokens : null,
2922
+ typeof usagePayload.completion_tokens === "number" ? usagePayload.completion_tokens : null,
2923
+ typeof usagePayload.response_tokens === "number" ? usagePayload.response_tokens : null,
2924
+ typeof usagePayload.total_tokens === "number" && typeof usagePrompt === "number" ? usagePayload.total_tokens - usagePrompt : null,
2925
+ reasoningTokens2
2926
+ ],
2927
+ usageCompletion
2928
+ );
2929
+ usageReasoning = selectMax(
2930
+ [reasoningTokens2],
2931
+ usageReasoning
2932
+ );
2933
+ if (usageCached == null) {
2934
+ usageCached = resolveCachedTokens2(usagePayload);
2935
+ }
2936
+ if (OPENAI_DEBUG) {
2937
+ debugLog("usage payload received", usagePayload);
2938
+ }
2939
+ };
2940
+ while (true) {
2941
+ const { value, done } = await reader.read();
2942
+ if (value && !firstTokenAt) {
2943
+ firstTokenAt = Date.now();
2944
+ }
2945
+ if (value) {
2946
+ const chunk = decoder.decode(value, { stream: !done });
2947
+ if (OPENAI_DEBUG) {
2948
+ debugLog("sse chunk", chunk.length > 200 ? `${chunk.slice(0, 200)}\u2026` : chunk);
2949
+ }
2950
+ buffer += chunk;
2951
+ chunkCount += 1;
2952
+ reply.raw.write(chunk);
2953
+ if (capturedResponseChunks) {
2954
+ capturedResponseChunks.push(chunk);
2955
+ }
2956
+ while (true) {
2957
+ const newlineIndex = buffer.indexOf("\n");
2958
+ if (newlineIndex === -1)
2959
+ break;
2960
+ const line = buffer.slice(0, newlineIndex);
2961
+ buffer = buffer.slice(newlineIndex + 1);
2962
+ const trimmed = line.trim();
2963
+ if (!trimmed.startsWith("data:"))
2964
+ continue;
2965
+ const dataStr = trimmed.slice(5).trim();
2966
+ if (dataStr === "[DONE]") {
2967
+ if (OPENAI_DEBUG) {
2968
+ debugLog("done marker received");
2969
+ }
2970
+ continue;
2971
+ }
2972
+ try {
2973
+ const parsed = JSON.parse(dataStr);
2974
+ const usagePayload = parsed?.usage || parsed?.response?.usage || null;
2975
+ if (usagePayload) {
2976
+ applyUsagePayload(usagePayload);
2977
+ }
2978
+ } catch (parseError) {
2979
+ if (OPENAI_DEBUG) {
2980
+ debugLog("failed to parse SSE data line (possibly incomplete):", dataStr.slice(0, 100));
2981
+ }
2982
+ }
2983
+ }
2984
+ }
2985
+ if (done) {
2986
+ if (buffer.length > 0) {
2987
+ const trimmed = buffer.trim();
2988
+ if (trimmed.startsWith("data:")) {
2989
+ const dataStr = trimmed.slice(5).trim();
2990
+ if (dataStr !== "[DONE]") {
2991
+ try {
2992
+ const parsed = JSON.parse(dataStr);
2993
+ const usagePayload = parsed?.usage || parsed?.response?.usage || null;
2994
+ if (usagePayload) {
2995
+ applyUsagePayload(usagePayload);
2996
+ }
2997
+ } catch {
2998
+ }
2999
+ }
3000
+ }
3001
+ }
3002
+ break;
3003
+ }
3004
+ }
3005
+ } finally {
3006
+ reply.raw.end();
3007
+ reply.raw.removeListener("close", replyClosed);
3008
+ debugLog("stream finished", { chunkCount, usagePrompt, usageCompletion, usageReasoning, usageCached });
3009
+ if (capturedResponseChunks && capturedResponseChunks.length > 0) {
3010
+ try {
3011
+ await upsertLogPayload(logId, { response: capturedResponseChunks.join("") });
3012
+ } catch (error) {
3013
+ debugLog("failed to persist streamed payload", error);
3014
+ }
3015
+ }
3016
+ }
3017
+ const latencyMs = Date.now() - requestStart;
3018
+ const inputTokens = usagePrompt ?? usageCompletion ?? target.tokenEstimate ?? estimateTokens(normalized, target.modelId);
3019
+ const textOutputTokens = usageCompletion ?? 0;
3020
+ const reasoningTokens = usageReasoning ?? 0;
3021
+ const outputTokens = textOutputTokens + reasoningTokens;
3022
+ await updateLogTokens(logId, {
3023
+ inputTokens,
3024
+ outputTokens,
3025
+ cachedTokens: usageCached,
3026
+ ttftMs: firstTokenAt ? firstTokenAt - requestStart : null,
3027
+ tpotMs: computeTpot2(latencyMs, outputTokens, {
3028
+ streaming: true,
3029
+ ttftMs: firstTokenAt ? firstTokenAt - requestStart : null,
3030
+ reasoningTokens
3031
+ })
3032
+ });
3033
+ await commitUsage(inputTokens, outputTokens);
3034
+ await updateMetrics((/* @__PURE__ */ new Date()).toISOString().slice(0, 10), "openai", {
3035
+ requests: 1,
3036
+ inputTokens,
3037
+ outputTokens,
3038
+ latencyMs
3039
+ });
3040
+ await finalize(200, null);
3041
+ return reply;
3042
+ } catch (error) {
3043
+ const message = error instanceof Error ? error.message : "Unexpected error";
3044
+ if (!reply.sent) {
3045
+ reply.code(500);
3046
+ }
3047
+ await commitUsage(0, 0);
3048
+ await finalize(reply.statusCode >= 400 ? reply.statusCode : 500, message);
3049
+ return { error: message };
3050
+ } finally {
3051
+ decrementActiveRequests();
3052
+ if (!finalized && reply.sent) {
3053
+ await finalize(reply.statusCode ?? 200, null);
3054
+ }
3055
+ }
3056
+ };
3057
+ app.post("/openai/v1/responses", handleResponses);
3058
+ app.post("/openai/responses", handleResponses);
1897
3059
  }
1898
3060
 
1899
3061
  // logging/queries.ts
@@ -1906,6 +3068,10 @@ async function queryLogs(options = {}) {
1906
3068
  conditions.push("provider = $provider");
1907
3069
  params.$provider = options.provider;
1908
3070
  }
3071
+ if (options.endpoint) {
3072
+ conditions.push("endpoint = $endpoint");
3073
+ params.$endpoint = options.endpoint;
3074
+ }
1909
3075
  if (options.model) {
1910
3076
  conditions.push("model = $model");
1911
3077
  params.$model = options.model;
@@ -1923,15 +3089,24 @@ async function queryLogs(options = {}) {
1923
3089
  conditions.push("timestamp <= $to");
1924
3090
  params.$to = options.to;
1925
3091
  }
3092
+ if (options.apiKeyIds && options.apiKeyIds.length > 0) {
3093
+ const placeholders = [];
3094
+ options.apiKeyIds.forEach((id, index) => {
3095
+ const key = `$apiKey${index}`;
3096
+ placeholders.push(key);
3097
+ params[key] = id;
3098
+ });
3099
+ conditions.push(`(api_key_id IN (${placeholders.join(", ")}))`);
3100
+ }
1926
3101
  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
1927
3102
  const totalRow = await getOne(
1928
3103
  `SELECT COUNT(*) AS count FROM request_logs ${whereClause}`,
1929
3104
  params
1930
3105
  );
1931
3106
  const items = await getAll(
1932
- `SELECT id, timestamp, session_id, provider, model, client_model,
3107
+ `SELECT id, timestamp, session_id, endpoint, provider, model, client_model,
1933
3108
  stream, latency_ms, status_code, input_tokens, output_tokens,
1934
- cached_tokens, ttft_ms, tpot_ms, error
3109
+ cached_tokens, ttft_ms, tpot_ms, error, api_key_id, api_key_name, api_key_value
1935
3110
  FROM request_logs
1936
3111
  ${whereClause}
1937
3112
  ORDER BY timestamp DESC
@@ -1945,9 +3120,9 @@ async function queryLogs(options = {}) {
1945
3120
  }
1946
3121
  async function getLogDetail(id) {
1947
3122
  const record = await getOne(
1948
- `SELECT id, timestamp, session_id, provider, model, client_model,
3123
+ `SELECT id, timestamp, session_id, endpoint, provider, model, client_model,
1949
3124
  stream, latency_ms, status_code, input_tokens, output_tokens,
1950
- cached_tokens, ttft_ms, tpot_ms, error
3125
+ cached_tokens, ttft_ms, tpot_ms, error, api_key_id, api_key_name, api_key_value
1951
3126
  FROM request_logs
1952
3127
  WHERE id = ?`,
1953
3128
  [id]
@@ -1979,7 +3154,12 @@ async function clearAllLogs() {
1979
3154
  metrics: Number(metricsResult.changes ?? 0)
1980
3155
  };
1981
3156
  }
1982
- async function getDailyMetrics(days = 7) {
3157
+ async function getDailyMetrics(days = 7, endpoint) {
3158
+ const params = [days];
3159
+ const whereClause = endpoint ? "WHERE endpoint = ?" : "";
3160
+ if (endpoint) {
3161
+ params.unshift(endpoint);
3162
+ }
1983
3163
  const rows = await getAll(
1984
3164
  `SELECT date,
1985
3165
  request_count AS requestCount,
@@ -1987,9 +3167,10 @@ async function getDailyMetrics(days = 7) {
1987
3167
  total_output_tokens AS outputTokens,
1988
3168
  total_latency_ms AS totalLatency
1989
3169
  FROM daily_metrics
3170
+ ${whereClause}
1990
3171
  ORDER BY date DESC
1991
3172
  LIMIT ?`,
1992
- [days]
3173
+ params
1993
3174
  );
1994
3175
  return rows.map((row) => ({
1995
3176
  date: row.date,
@@ -1999,14 +3180,17 @@ async function getDailyMetrics(days = 7) {
1999
3180
  avgLatencyMs: row.requestCount ? Math.round((row.totalLatency ?? 0) / row.requestCount) : 0
2000
3181
  })).reverse();
2001
3182
  }
2002
- async function getMetricsOverview() {
3183
+ async function getMetricsOverview(endpoint) {
3184
+ const totalsWhere = endpoint ? "WHERE endpoint = ?" : "";
2003
3185
  const totalsRow = await getOne(
2004
3186
  `SELECT
2005
3187
  COALESCE(SUM(request_count), 0) AS requests,
2006
3188
  COALESCE(SUM(total_input_tokens), 0) AS inputTokens,
2007
3189
  COALESCE(SUM(total_output_tokens), 0) AS outputTokens,
2008
3190
  COALESCE(SUM(total_latency_ms), 0) AS totalLatency
2009
- FROM daily_metrics`
3191
+ FROM daily_metrics
3192
+ ${totalsWhere}`,
3193
+ endpoint ? [endpoint] : []
2010
3194
  );
2011
3195
  const todayKey = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2012
3196
  const todayRow = await getOne(
@@ -2015,8 +3199,9 @@ async function getMetricsOverview() {
2015
3199
  total_output_tokens AS outputTokens,
2016
3200
  total_latency_ms AS totalLatency
2017
3201
  FROM daily_metrics
2018
- WHERE date = ?`,
2019
- [todayKey]
3202
+ WHERE date = ?
3203
+ ${endpoint ? "AND endpoint = ?" : ""}`,
3204
+ endpoint ? [todayKey, endpoint] : [todayKey]
2020
3205
  );
2021
3206
  const resolveAvg = (totalLatency, requests) => requests > 0 ? Math.round(totalLatency / requests) : 0;
2022
3207
  const totalsRequests = totalsRow?.requests ?? 0;
@@ -2038,8 +3223,13 @@ async function getMetricsOverview() {
2038
3223
  }
2039
3224
  };
2040
3225
  }
2041
- async function getModelUsageMetrics(days = 7, limit = 10) {
3226
+ async function getModelUsageMetrics(days = 7, limit = 10, endpoint) {
2042
3227
  const since = Date.now() - days * 24 * 60 * 60 * 1e3;
3228
+ const params = [since];
3229
+ const endpointClause = endpoint ? "AND endpoint = ?" : "";
3230
+ if (endpoint) {
3231
+ params.push(endpoint);
3232
+ }
2043
3233
  const rows = await getAll(
2044
3234
  `SELECT
2045
3235
  model,
@@ -2052,10 +3242,11 @@ async function getModelUsageMetrics(days = 7, limit = 10) {
2052
3242
  AVG(CASE WHEN tpot_ms IS NULL THEN NULL ELSE tpot_ms END) AS avgTpotMs
2053
3243
  FROM request_logs
2054
3244
  WHERE timestamp >= ?
3245
+ ${endpointClause}
2055
3246
  GROUP BY provider, model
2056
3247
  ORDER BY requests DESC
2057
3248
  LIMIT ?`,
2058
- [since, limit]
3249
+ [...params, limit]
2059
3250
  );
2060
3251
  const roundValue = (value, fractionDigits = 0) => value == null ? null : Number(value.toFixed(fractionDigits));
2061
3252
  return rows.map((row) => ({
@@ -2069,13 +3260,81 @@ async function getModelUsageMetrics(days = 7, limit = 10) {
2069
3260
  avgTpotMs: roundValue(row.avgTpotMs, 2)
2070
3261
  }));
2071
3262
  }
3263
+ async function getApiKeyOverviewMetrics(rangeDays = 7, endpoint) {
3264
+ const totals = await getOne("SELECT COUNT(*) AS total, SUM(CASE WHEN enabled = 1 THEN 1 ELSE 0 END) AS enabled FROM api_keys");
3265
+ const since = Date.now() - rangeDays * 24 * 60 * 60 * 1e3;
3266
+ const params = [since];
3267
+ const endpointClause = endpoint ? "AND endpoint = ?" : "";
3268
+ if (endpoint) {
3269
+ params.push(endpoint);
3270
+ }
3271
+ const active = await getOne(
3272
+ `SELECT COUNT(DISTINCT api_key_id) AS count
3273
+ FROM request_logs
3274
+ WHERE api_key_id IS NOT NULL
3275
+ AND timestamp >= ?
3276
+ ${endpointClause}`,
3277
+ params
3278
+ );
3279
+ return {
3280
+ totalKeys: totals?.total ?? 0,
3281
+ enabledKeys: totals?.enabled ?? 0,
3282
+ activeKeys: active?.count ?? 0,
3283
+ rangeDays
3284
+ };
3285
+ }
3286
+ async function getApiKeyUsageMetrics(days = 7, limit = 10, endpoint) {
3287
+ const since = Date.now() - days * 24 * 60 * 60 * 1e3;
3288
+ const params = [since];
3289
+ const endpointClause = endpoint ? "AND endpoint = ?" : "";
3290
+ if (endpoint) {
3291
+ params.push(endpoint);
3292
+ }
3293
+ const rows = await getAll(
3294
+ `SELECT
3295
+ api_key_id AS apiKeyId,
3296
+ api_key_name AS apiKeyName,
3297
+ COUNT(*) AS requests,
3298
+ COALESCE(SUM(input_tokens), 0) AS inputTokens,
3299
+ COALESCE(SUM(output_tokens), 0) AS outputTokens,
3300
+ MAX(timestamp) AS lastUsedAt
3301
+ FROM request_logs
3302
+ WHERE timestamp >= ?
3303
+ ${endpointClause}
3304
+ GROUP BY api_key_id, api_key_name
3305
+ ORDER BY requests DESC
3306
+ LIMIT ?`,
3307
+ [...params, limit]
3308
+ );
3309
+ return rows.map((row) => ({
3310
+ apiKeyId: row.apiKeyId ?? null,
3311
+ apiKeyName: row.apiKeyName ?? null,
3312
+ requests: row.requests ?? 0,
3313
+ inputTokens: row.inputTokens ?? 0,
3314
+ outputTokens: row.outputTokens ?? 0,
3315
+ lastUsedAt: row.lastUsedAt ? new Date(row.lastUsedAt).toISOString() : null
3316
+ }));
3317
+ }
2072
3318
 
2073
3319
  // routes/admin.ts
2074
3320
  async function registerAdminRoutes(app) {
2075
- const mapLogRecord = (record) => ({
2076
- ...record,
2077
- stream: Boolean(record?.stream)
2078
- });
3321
+ try {
3322
+ await ensureWildcardMetadata();
3323
+ } catch (error) {
3324
+ app.log.warn({ error }, "[api-keys] failed to ensure wildcard metadata");
3325
+ }
3326
+ const mapLogRecord = (record, options) => {
3327
+ const base = {
3328
+ ...record,
3329
+ stream: Boolean(record?.stream)
3330
+ };
3331
+ if (options?.includeKeyValue) {
3332
+ base.api_key_value = options.decryptedKey ?? record?.api_key_value ?? null;
3333
+ } else {
3334
+ delete base.api_key_value;
3335
+ }
3336
+ return base;
3337
+ };
2079
3338
  app.get("/api/status", async () => {
2080
3339
  const config = getConfig();
2081
3340
  return {
@@ -2144,12 +3403,12 @@ async function registerAdminRoutes(app) {
2144
3403
  system: "You are a connection diagnostic assistant."
2145
3404
  });
2146
3405
  const providerBody = provider.type === "anthropic" ? buildAnthropicBody(testPayload, {
2147
- maxTokens: provider.models?.find((m) => m.id === targetModel)?.maxTokens ?? 256,
3406
+ maxTokens: 256,
2148
3407
  temperature: 0,
2149
3408
  toolChoice: void 0,
2150
3409
  overrideTools: void 0
2151
3410
  }) : buildProviderBody(testPayload, {
2152
- maxTokens: provider.models?.find((m) => m.id === targetModel)?.maxTokens ?? 256,
3411
+ maxTokens: 256,
2153
3412
  temperature: 0,
2154
3413
  toolChoice: void 0,
2155
3414
  overrideTools: void 0
@@ -2176,6 +3435,16 @@ async function registerAdminRoutes(app) {
2176
3435
  try {
2177
3436
  parsed = raw ? JSON.parse(raw) : null;
2178
3437
  } catch {
3438
+ const fallbackSample = raw?.trim() ?? "";
3439
+ if (provider.type && provider.type !== "anthropic") {
3440
+ return {
3441
+ ok: fallbackSample.length > 0,
3442
+ status: upstream.status,
3443
+ statusText: fallbackSample ? "OK (text response)" : "Empty response",
3444
+ durationMs: duration,
3445
+ sample: fallbackSample ? fallbackSample.slice(0, 200) : null
3446
+ };
3447
+ }
2179
3448
  return {
2180
3449
  ok: false,
2181
3450
  status: upstream.status,
@@ -2226,6 +3495,7 @@ async function registerAdminRoutes(app) {
2226
3495
  const model = typeof query.model === "string" && query.model.length > 0 ? query.model : void 0;
2227
3496
  const statusParam = typeof query.status === "string" ? query.status : void 0;
2228
3497
  const status = statusParam === "success" || statusParam === "error" ? statusParam : void 0;
3498
+ const endpoint = isEndpoint(query.endpoint) ? query.endpoint : void 0;
2229
3499
  const parseTime = (value) => {
2230
3500
  if (!value)
2231
3501
  return void 0;
@@ -2237,9 +3507,22 @@ async function registerAdminRoutes(app) {
2237
3507
  };
2238
3508
  const from = parseTime(query.from);
2239
3509
  const to = parseTime(query.to);
2240
- const { items, total } = await queryLogs({ limit, offset, provider, model, status, from, to });
3510
+ const collectApiKeyIds = (value) => {
3511
+ if (!value)
3512
+ return [];
3513
+ if (Array.isArray(value)) {
3514
+ return value.flatMap((item) => collectApiKeyIds(item));
3515
+ }
3516
+ if (typeof value === "string") {
3517
+ return value.split(",").map((part) => Number(part.trim())).filter((num) => Number.isFinite(num));
3518
+ }
3519
+ return [];
3520
+ };
3521
+ const apiKeyIdsRaw = collectApiKeyIds(query.apiKeys ?? query.apiKeyIds ?? query.apiKey);
3522
+ const apiKeyIds = apiKeyIdsRaw.length > 0 ? Array.from(new Set(apiKeyIdsRaw)) : void 0;
3523
+ const { items, total } = await queryLogs({ limit, offset, provider, model, status, from, to, apiKeyIds, endpoint });
2241
3524
  reply.header("x-total-count", String(total));
2242
- return { total, items: items.map(mapLogRecord) };
3525
+ return { total, items: items.map((item) => mapLogRecord(item)) };
2243
3526
  });
2244
3527
  app.get("/api/logs/:id", async (request, reply) => {
2245
3528
  const id = Number(request.params.id);
@@ -2253,7 +3536,8 @@ async function registerAdminRoutes(app) {
2253
3536
  return { error: "Not found" };
2254
3537
  }
2255
3538
  const payload = await getLogPayload(id);
2256
- return { ...mapLogRecord(record), payload };
3539
+ const decryptedKey = await decryptApiKeyValue(record.api_key_value);
3540
+ return { ...mapLogRecord(record, { includeKeyValue: true, decryptedKey }), payload };
2257
3541
  });
2258
3542
  app.post("/api/logs/cleanup", async () => {
2259
3543
  const config = getConfig();
@@ -2277,14 +3561,17 @@ async function registerAdminRoutes(app) {
2277
3561
  sizeBytes: pageCount * pageSize
2278
3562
  };
2279
3563
  });
2280
- app.get("/api/stats/overview", async () => {
2281
- return getMetricsOverview();
3564
+ app.get("/api/stats/overview", async (request) => {
3565
+ const query = request.query ?? {};
3566
+ const endpoint = isEndpoint(query.endpoint) ? query.endpoint : void 0;
3567
+ return getMetricsOverview(endpoint);
2282
3568
  });
2283
3569
  app.get("/api/stats/daily", async (request) => {
2284
3570
  const query = request.query ?? {};
2285
3571
  const daysRaw = Number(query.days ?? 7);
2286
3572
  const days = Number.isFinite(daysRaw) ? Math.max(1, Math.min(daysRaw, 30)) : 7;
2287
- return getDailyMetrics(days);
3573
+ const endpoint = isEndpoint(query.endpoint) ? query.endpoint : void 0;
3574
+ return getDailyMetrics(days, endpoint);
2288
3575
  });
2289
3576
  app.get("/api/stats/model", async (request) => {
2290
3577
  const query = request.query ?? {};
@@ -2292,9 +3579,86 @@ async function registerAdminRoutes(app) {
2292
3579
  const limitRaw = Number(query.limit ?? 10);
2293
3580
  const days = Number.isFinite(daysRaw) ? Math.max(1, Math.min(daysRaw, 90)) : 7;
2294
3581
  const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(limitRaw, 50)) : 10;
2295
- return getModelUsageMetrics(days, limit);
3582
+ const endpoint = isEndpoint(query.endpoint) ? query.endpoint : void 0;
3583
+ return getModelUsageMetrics(days, limit, endpoint);
3584
+ });
3585
+ app.get("/api/stats/api-keys/overview", async (request) => {
3586
+ const query = request.query ?? {};
3587
+ const daysRaw = Number(query.days ?? 7);
3588
+ const rangeDays = Number.isFinite(daysRaw) ? Math.max(1, Math.min(daysRaw, 90)) : 7;
3589
+ const endpoint = isEndpoint(query.endpoint) ? query.endpoint : void 0;
3590
+ return getApiKeyOverviewMetrics(rangeDays, endpoint);
3591
+ });
3592
+ app.get("/api/stats/api-keys/usage", async (request) => {
3593
+ const query = request.query ?? {};
3594
+ const daysRaw = Number(query.days ?? 7);
3595
+ const limitRaw = Number(query.limit ?? 10);
3596
+ const days = Number.isFinite(daysRaw) ? Math.max(1, Math.min(daysRaw, 90)) : 7;
3597
+ const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(limitRaw, 50)) : 10;
3598
+ const endpoint = isEndpoint(query.endpoint) ? query.endpoint : void 0;
3599
+ return getApiKeyUsageMetrics(days, limit, endpoint);
3600
+ });
3601
+ app.get("/api/keys", async () => {
3602
+ return listApiKeys();
3603
+ });
3604
+ app.post("/api/keys", async (request, reply) => {
3605
+ const body = request.body;
3606
+ if (!body?.name || typeof body.name !== "string") {
3607
+ reply.code(400);
3608
+ return { error: "Name is required" };
3609
+ }
3610
+ try {
3611
+ return await createApiKey(body.name, body.description, { ipAddress: request.ip });
3612
+ } catch (error) {
3613
+ reply.code(400);
3614
+ return { error: error instanceof Error ? error.message : "Failed to create API key" };
3615
+ }
3616
+ });
3617
+ app.patch("/api/keys/:id", async (request, reply) => {
3618
+ const id = Number(request.params.id);
3619
+ if (!Number.isFinite(id)) {
3620
+ reply.code(400);
3621
+ return { error: "Invalid id" };
3622
+ }
3623
+ const body = request.body;
3624
+ if (typeof body?.enabled !== "boolean") {
3625
+ reply.code(400);
3626
+ return { error: "enabled field is required" };
3627
+ }
3628
+ try {
3629
+ await setApiKeyEnabled(id, body.enabled, { ipAddress: request.ip });
3630
+ return { success: true };
3631
+ } catch (error) {
3632
+ if (error instanceof Error && error.message === "API key not found") {
3633
+ reply.code(404);
3634
+ } else {
3635
+ reply.code(400);
3636
+ }
3637
+ return { error: error instanceof Error ? error.message : "Failed to update API key" };
3638
+ }
3639
+ });
3640
+ app.delete("/api/keys/:id", async (request, reply) => {
3641
+ const id = Number(request.params.id);
3642
+ if (!Number.isFinite(id)) {
3643
+ reply.code(400);
3644
+ return { error: "Invalid id" };
3645
+ }
3646
+ try {
3647
+ await deleteApiKey(id, { ipAddress: request.ip });
3648
+ return { success: true };
3649
+ } catch (error) {
3650
+ if (error instanceof Error && error.message === "API key not found") {
3651
+ reply.code(404);
3652
+ } else if (error instanceof Error && error.message === "Cannot delete wildcard key") {
3653
+ reply.code(403);
3654
+ } else {
3655
+ reply.code(400);
3656
+ }
3657
+ return { error: error instanceof Error ? error.message : "Failed to delete API key" };
3658
+ }
2296
3659
  });
2297
3660
  }
3661
+ var isEndpoint = (value) => value === "anthropic" || value === "openai";
2298
3662
 
2299
3663
  // tasks/maintenance.ts
2300
3664
  var DAY_MS = 24 * 60 * 60 * 1e3;
@@ -2330,17 +3694,17 @@ onConfigChange((config) => {
2330
3694
  });
2331
3695
  function resolveWebDist() {
2332
3696
  const __filename2 = fileURLToPath(import.meta.url);
2333
- const __dirname = path3.dirname(__filename2);
3697
+ const __dirname = path4.dirname(__filename2);
2334
3698
  const candidates = [
2335
3699
  process2.env.CC_GW_UI_ROOT,
2336
- path3.resolve(__dirname, "../web/public"),
2337
- path3.resolve(__dirname, "../web/dist"),
2338
- path3.resolve(__dirname, "../../web/dist"),
2339
- path3.resolve(__dirname, "../../../src/web/dist"),
2340
- path3.resolve(process2.cwd(), "src/web/dist")
3700
+ path4.resolve(__dirname, "../web/public"),
3701
+ path4.resolve(__dirname, "../web/dist"),
3702
+ path4.resolve(__dirname, "../../web/dist"),
3703
+ path4.resolve(__dirname, "../../../src/web/dist"),
3704
+ path4.resolve(process2.cwd(), "src/web/dist")
2341
3705
  ].filter((item) => Boolean(item));
2342
3706
  for (const candidate of candidates) {
2343
- if (fs3.existsSync(candidate)) {
3707
+ if (fs4.existsSync(candidate)) {
2344
3708
  return candidate;
2345
3709
  }
2346
3710
  }
@@ -2348,12 +3712,49 @@ function resolveWebDist() {
2348
3712
  }
2349
3713
  async function createServer() {
2350
3714
  const config = cachedConfig2 ?? loadConfig();
3715
+ const requestLogEnabled = config.requestLogging !== false;
3716
+ const responseLogEnabled = config.responseLogging !== false;
2351
3717
  const app = Fastify({
2352
3718
  logger: {
2353
3719
  level: config.logLevel ?? "info"
2354
3720
  },
2355
- disableRequestLogging: config.requestLogging === false
3721
+ disableRequestLogging: true
2356
3722
  });
3723
+ if (requestLogEnabled) {
3724
+ app.addHook("onRequest", (request, _reply, done) => {
3725
+ const socket = request.socket;
3726
+ const hostname = typeof request.hostname === "string" && request.hostname.length > 0 ? request.hostname : typeof request.headers.host === "string" ? request.headers.host : void 0;
3727
+ app.log.info(
3728
+ {
3729
+ reqId: request.id,
3730
+ req: {
3731
+ method: request.method,
3732
+ url: request.url,
3733
+ hostname,
3734
+ remoteAddress: request.ip,
3735
+ remotePort: socket && typeof socket.remotePort === "number" ? socket.remotePort : void 0
3736
+ }
3737
+ },
3738
+ "incoming request"
3739
+ );
3740
+ done();
3741
+ });
3742
+ }
3743
+ if (responseLogEnabled) {
3744
+ app.addHook("onResponse", (request, reply, done) => {
3745
+ app.log.info(
3746
+ {
3747
+ reqId: request.id,
3748
+ res: {
3749
+ statusCode: reply.statusCode
3750
+ },
3751
+ responseTime: typeof reply.getResponseTime === "function" ? reply.getResponseTime() : void 0
3752
+ },
3753
+ "request completed"
3754
+ );
3755
+ done();
3756
+ });
3757
+ }
2357
3758
  await app.register(fastifyCors, {
2358
3759
  origin: true,
2359
3760
  credentials: true
@@ -2373,7 +3774,7 @@ async function createServer() {
2373
3774
  reply.code(400);
2374
3775
  return { error: "Invalid asset path" };
2375
3776
  }
2376
- return reply.sendFile(path3.join("assets", target));
3777
+ return reply.sendFile(path4.join("assets", target));
2377
3778
  };
2378
3779
  app.get("/assets/*", assetHandler);
2379
3780
  app.head("/assets/*", assetHandler);
@@ -2392,6 +3793,7 @@ async function createServer() {
2392
3793
  app.log.warn("\u672A\u627E\u5230 Web UI \u6784\u5EFA\u4EA7\u7269\uFF0C/ui \u76EE\u5F55\u5C06\u4E0D\u53EF\u7528\u3002");
2393
3794
  }
2394
3795
  await registerMessagesRoute(app);
3796
+ await registerOpenAiRoutes(app);
2395
3797
  await registerAdminRoutes(app);
2396
3798
  startMaintenanceTimers();
2397
3799
  app.get("/health", async () => {
@@ -2434,7 +3836,7 @@ async function main() {
2434
3836
  }
2435
3837
  }
2436
3838
  var __filename = fileURLToPath(import.meta.url);
2437
- if (process2.argv[1] && path3.resolve(process2.argv[1]) === __filename) {
3839
+ if (process2.argv[1] && path4.resolve(process2.argv[1]) === __filename) {
2438
3840
  main();
2439
3841
  }
2440
3842
  export {