llm-simple-router 0.5.5 → 0.5.6

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.
Files changed (74) hide show
  1. package/dist/admin/metrics.js +4 -4
  2. package/dist/admin/stats.js +5 -3
  3. package/dist/admin/usage.js +40 -19
  4. package/dist/db/metrics.d.ts +2 -0
  5. package/dist/db/metrics.js +4 -7
  6. package/dist/db/migrations/026_metrics_independent.sql +54 -0
  7. package/dist/db/stats.d.ts +3 -2
  8. package/dist/db/stats.js +15 -6
  9. package/dist/db/usage-windows.d.ts +5 -4
  10. package/dist/db/usage-windows.js +48 -20
  11. package/dist/proxy/proxy-handler.js +11 -2
  12. package/dist/proxy/proxy-logging.d.ts +1 -1
  13. package/dist/proxy/proxy-logging.js +5 -2
  14. package/dist/proxy/usage-window-tracker.d.ts +5 -3
  15. package/dist/proxy/usage-window-tracker.js +21 -22
  16. package/dist/utils/time-range.d.ts +1 -1
  17. package/dist/utils/time-range.js +13 -7
  18. package/frontend-dist/assets/{CardContent-CVofwD9T.js → CardContent-ByybpNZM.js} +1 -1
  19. package/frontend-dist/assets/{CardTitle-MLH-EpHz.js → CardTitle-Cv39_iQu.js} +1 -1
  20. package/frontend-dist/assets/{Checkbox-KiNnjeB_.js → Checkbox-F8_Gy_s5.js} +1 -1
  21. package/frontend-dist/assets/{CollapsibleTrigger-CbiBtEE8.js → CollapsibleTrigger-BPzLViBo.js} +1 -1
  22. package/frontend-dist/assets/{Collection-D9NeXDOI.js → Collection-Dafpcl-w.js} +1 -1
  23. package/frontend-dist/assets/Dashboard-BTwf4ZtI.js +3 -0
  24. package/frontend-dist/assets/{DialogTitle-CTcpSq_w.js → DialogTitle-BMNhmnin.js} +1 -1
  25. package/frontend-dist/assets/{Input-DDF6744B.js → Input-BkzqSK7i.js} +1 -1
  26. package/frontend-dist/assets/{Label-_cw0EkS1.js → Label-DwqBcp6d.js} +1 -1
  27. package/frontend-dist/assets/{Login-Cca1HpLA.js → Login-B7sSST00.js} +1 -1
  28. package/frontend-dist/assets/Logs-DJc0hZ8C.js +1 -0
  29. package/frontend-dist/assets/ModelMappings-BavaEbnL.js +1 -0
  30. package/frontend-dist/assets/{Monitor-BXpf9sjE.js → Monitor-B4hCGdS-.js} +1 -1
  31. package/frontend-dist/assets/{PopoverTrigger-DIOWDISK.js → PopoverTrigger-vgVugQwU.js} +1 -1
  32. package/frontend-dist/assets/{PopperContent-DIEU7Y6E.js → PopperContent-tf2A4fsa.js} +1 -1
  33. package/frontend-dist/assets/{Providers-CvYtuvfg.js → Providers-BmrsbthR.js} +1 -1
  34. package/frontend-dist/assets/{ProxyEnhancement-BxTbZGYB.js → ProxyEnhancement-BQ02PeEF.js} +1 -1
  35. package/frontend-dist/assets/{RetryRules-COK28WDb.js → RetryRules-H460Dyek.js} +1 -1
  36. package/frontend-dist/assets/{RouterKeys-DH7nUWDa.js → RouterKeys-D8rXsmpq.js} +1 -1
  37. package/frontend-dist/assets/{RovingFocusItem-DyNl4G42.js → RovingFocusItem-Bo0dNNmj.js} +1 -1
  38. package/frontend-dist/assets/{SelectValue-DyjOpyaF.js → SelectValue-BU16UnrX.js} +1 -1
  39. package/frontend-dist/assets/{Settings-BmG999ol.js → Settings-YiR7zqua.js} +1 -1
  40. package/frontend-dist/assets/{Setup-D1xUsqUD.js → Setup-D7EXZ1Nv.js} +1 -1
  41. package/frontend-dist/assets/{Switch-DrDIdQ_V.js → Switch-CA_wdlEs.js} +1 -1
  42. package/frontend-dist/assets/{TableHeader-BNPqkg9S.js → TableHeader-BuObvzlS.js} +1 -1
  43. package/frontend-dist/assets/{TabsTrigger-D-fGrdG3.js → TabsTrigger-DZIFRVA_.js} +1 -1
  44. package/frontend-dist/assets/{Teleport-BYzsRHC6.js → Teleport-FxAUQAZT.js} +1 -1
  45. package/frontend-dist/assets/{TooltipTrigger-C3v2O7fX.js → TooltipTrigger-D9nCGsBG.js} +1 -1
  46. package/frontend-dist/assets/{UnifiedRequestDialog-DJOIbAUb.js → UnifiedRequestDialog-BPv5B17F.js} +1 -1
  47. package/frontend-dist/assets/{VisuallyHidden-Bz0LPzC7.js → VisuallyHidden-clBSgYdG.js} +1 -1
  48. package/frontend-dist/assets/{VisuallyHiddenInput-Bf9hgsd7.js → VisuallyHiddenInput-CmDbYWUO.js} +1 -1
  49. package/frontend-dist/assets/{alert-dialog-Bn1IauFR.js → alert-dialog-BvbdNhnK.js} +1 -1
  50. package/frontend-dist/assets/{badge-dyL-tG0V.js → badge-CcCt1-ig.js} +1 -1
  51. package/frontend-dist/assets/{button-B6f3Nfab.js → button-DeOsxcjG.js} +2 -2
  52. package/frontend-dist/assets/chevron-down-D_DCDFPY.js +1 -0
  53. package/frontend-dist/assets/{dialog-1FvPG-71.js → dialog-CPO2KcC1.js} +1 -1
  54. package/frontend-dist/assets/{file-text-BFyk5DT1.js → file-text-C-6LFEhP.js} +1 -1
  55. package/frontend-dist/assets/index-DHONWydQ.css +1 -0
  56. package/frontend-dist/assets/{index-B2SjbR82.js → index-DW58MMV6.js} +1 -1
  57. package/frontend-dist/assets/{lib-C_27TgFv.js → lib-DkM_rWnj.js} +1 -1
  58. package/frontend-dist/assets/loader-circle-BS4uI1Z4.js +1 -0
  59. package/frontend-dist/assets/{ohash.D__AXeF1-BcwqVPVj.js → ohash.D__AXeF1-CBYQgVou.js} +1 -1
  60. package/frontend-dist/assets/{useClipboard-DCoGBvxy.js → useClipboard-NBCgpr6Z.js} +1 -1
  61. package/frontend-dist/assets/{useLogRetention-BGcJntJ-.js → useLogRetention-B_u8u74J.js} +1 -1
  62. package/frontend-dist/assets/useNonce-D1dqoOZO.js +1 -0
  63. package/frontend-dist/assets/x-DVLhwc3Q.js +1 -0
  64. package/frontend-dist/index.html +19 -19
  65. package/package.json +2 -2
  66. package/frontend-dist/assets/Dashboard-CuBkXd58.js +0 -3
  67. package/frontend-dist/assets/Logs-CUIPRZEO.js +0 -1
  68. package/frontend-dist/assets/ModelMappings-DEswiX1e.js +0 -1
  69. package/frontend-dist/assets/chevron-down-DKTxr1tk.js +0 -1
  70. package/frontend-dist/assets/circle-question-mark-WJzB-je7.js +0 -1
  71. package/frontend-dist/assets/index-uA-D1xVT.css +0 -1
  72. package/frontend-dist/assets/loader-circle-DEqD4BxX.js +0 -1
  73. package/frontend-dist/assets/useNonce-Ccld5giw.js +0 -1
  74. package/frontend-dist/assets/x-iwmjHBXS.js +0 -1
@@ -33,13 +33,13 @@ const TimeseriesQuerySchema = Type.Object({
33
33
  end_time: Type.Optional(Type.String()),
34
34
  });
35
35
  const DASHBOARD_PERIODS = new Set(["window", "weekly", "monthly"]);
36
- function resolveMetricsTime(query, db, routerKeyId) {
36
+ function resolveMetricsTime(query, db, routerKeyId, providerId) {
37
37
  if (query.start_time && query.end_time) {
38
38
  return { startTime: query.start_time, endTime: query.end_time, legacyPeriod: "30d" };
39
39
  }
40
40
  const period = query.period ?? "weekly";
41
41
  if (DASHBOARD_PERIODS.has(period)) {
42
- const range = resolveTimeRange(period, db, routerKeyId);
42
+ const range = resolveTimeRange(period, db, routerKeyId, providerId);
43
43
  return { startTime: range.startTime, endTime: range.endTime, legacyPeriod: "5h" };
44
44
  }
45
45
  return { legacyPeriod: period };
@@ -48,14 +48,14 @@ export const adminMetricsRoutes = (app, options, done) => {
48
48
  const { db } = options;
49
49
  app.get("/admin/api/metrics/summary", { schema: { querystring: SummaryQuerySchema } }, async (request, reply) => {
50
50
  const query = request.query;
51
- const { startTime, endTime, legacyPeriod } = resolveMetricsTime(query, db, query.router_key_id);
51
+ const { startTime, endTime, legacyPeriod } = resolveMetricsTime(query, db, query.router_key_id, query.provider_id);
52
52
  const summary = getMetricsSummary(db, legacyPeriod, query.provider_id, query.backend_model, query.router_key_id, startTime, endTime);
53
53
  return reply.send(summary);
54
54
  });
55
55
  app.get("/admin/api/metrics/timeseries", { schema: { querystring: TimeseriesQuerySchema } }, async (request, reply) => {
56
56
  const query = request.query;
57
57
  const metric = query.metric;
58
- const { startTime, endTime, legacyPeriod } = resolveMetricsTime(query, db, query.router_key_id);
58
+ const { startTime, endTime, legacyPeriod } = resolveMetricsTime(query, db, query.router_key_id, query.provider_id);
59
59
  const timeseries = getMetricsTimeseries(db, legacyPeriod, metric, query.provider_id, query.backend_model, query.router_key_id, startTime, endTime);
60
60
  return reply.send(timeseries);
61
61
  });
@@ -10,6 +10,8 @@ const StatsQuerySchema = Type.Object({
10
10
  start_time: Type.Optional(Type.String()),
11
11
  end_time: Type.Optional(Type.String()),
12
12
  router_key_id: Type.Optional(Type.String()),
13
+ provider_id: Type.Optional(Type.String()),
14
+ backend_model: Type.Optional(Type.String()),
13
15
  });
14
16
  export const adminStatsRoutes = (app, options, done) => {
15
17
  app.get("/admin/api/stats", { schema: { querystring: StatsQuerySchema } }, async (request, reply) => {
@@ -21,12 +23,12 @@ export const adminStatsRoutes = (app, options, done) => {
21
23
  endTime = query.end_time;
22
24
  }
23
25
  else {
24
- const range = resolveTimeRange((query.period ?? "weekly"), options.db, query.router_key_id);
26
+ const range = resolveTimeRange((query.period ?? "weekly"), options.db, query.router_key_id, query.provider_id);
25
27
  startTime = range.startTime;
26
28
  endTime = range.endTime;
27
29
  }
28
- const stats = getStats(options.db, startTime, endTime, query.router_key_id);
29
- return reply.send(stats);
30
+ const stats = getStats(options.db, startTime, endTime, query.router_key_id, query.provider_id, query.backend_model);
31
+ return reply.send({ ...stats, startTime, endTime });
30
32
  });
31
33
  done();
32
34
  };
@@ -1,16 +1,26 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import { getWindowsInRange, getWindowUsage } from "../db/usage-windows.js";
3
+ import { getProviderById } from "../db/providers.js";
3
4
  import { resolveTimeRange } from "../utils/time-range.js";
4
5
  const UsageQuerySchema = Type.Object({
5
6
  router_key_id: Type.Optional(Type.String()),
7
+ provider_id: Type.Optional(Type.String()),
6
8
  });
7
- function getDailyUsage(db, startTime, endTime, routerKeyId) {
8
- const routerKeyFilter = routerKeyId
9
- ? " AND rl.router_key_id = ?"
10
- : "";
11
- const params = routerKeyId
12
- ? [startTime, endTime, routerKeyId]
13
- : [startTime, endTime];
9
+ function getDailyUsage(db, startTime, endTime, routerKeyId, providerId) {
10
+ const conditions = [
11
+ "rm.is_complete = 1",
12
+ "rm.created_at >= datetime(?)",
13
+ "rm.created_at < datetime(?)",
14
+ ];
15
+ const params = [startTime, endTime];
16
+ if (routerKeyId) {
17
+ conditions.push("rm.router_key_id = ?");
18
+ params.push(routerKeyId);
19
+ }
20
+ if (providerId) {
21
+ conditions.push("rm.provider_id = ?");
22
+ params.push(providerId);
23
+ }
14
24
  return db.prepare(`
15
25
  SELECT
16
26
  date(rm.created_at) AS date,
@@ -18,37 +28,48 @@ function getDailyUsage(db, startTime, endTime, routerKeyId) {
18
28
  COALESCE(SUM(rm.input_tokens), 0) AS total_input_tokens,
19
29
  COALESCE(SUM(rm.output_tokens), 0) AS total_output_tokens
20
30
  FROM request_metrics rm
21
- JOIN request_logs rl ON rl.id = rm.request_log_id
22
- WHERE rm.is_complete = 1
23
- AND rm.created_at >= datetime(?)
24
- AND rm.created_at < datetime(?)
25
- ${routerKeyFilter}
31
+ WHERE ${conditions.join(" AND ")}
26
32
  GROUP BY date(rm.created_at)
27
33
  ORDER BY date ASC
28
34
  `).all(...params);
29
35
  }
36
+ function resolveProviderName(db, providerId) {
37
+ if (!providerId)
38
+ return null;
39
+ return getProviderById(db, providerId)?.name ?? null;
40
+ }
30
41
  export const adminUsageRoutes = (app, options, done) => {
31
42
  const { db } = options;
32
43
  app.get("/admin/api/usage/windows", { schema: { querystring: UsageQuerySchema } }, async (request) => {
33
44
  const query = request.query;
34
- const range = resolveTimeRange("window", db, query.router_key_id);
35
- const windows = getWindowsInRange(db, range.startTime, range.endTime, query.router_key_id);
36
- if (windows.length === 0)
45
+ if (query.provider_id) {
46
+ const range = resolveTimeRange("window", db, query.router_key_id, query.provider_id);
47
+ const windows = getWindowsInRange(db, range.startTime, range.endTime, query.router_key_id, query.provider_id);
48
+ if (windows.length === 0)
49
+ return [];
50
+ return windows.map(w => ({
51
+ window: { ...w, provider_name: resolveProviderName(db, w.provider_id) },
52
+ usage: getWindowUsage(db, w.start_time, w.end_time, query.router_key_id, query.provider_id),
53
+ }));
54
+ }
55
+ const allWindows = getWindowsInRange(db, "1970-01-01", "2099-12-31", query.router_key_id)
56
+ .filter((w) => w.provider_id !== null);
57
+ if (allWindows.length === 0)
37
58
  return [];
38
- return windows.map(w => ({
39
- window: w,
59
+ return allWindows.map(w => ({
60
+ window: { ...w, provider_name: resolveProviderName(db, w.provider_id) },
40
61
  usage: getWindowUsage(db, w.start_time, w.end_time, query.router_key_id),
41
62
  }));
42
63
  });
43
64
  app.get("/admin/api/usage/weekly", { schema: { querystring: UsageQuerySchema } }, async (request) => {
44
65
  const query = request.query;
45
66
  const range = resolveTimeRange("weekly", db, query.router_key_id);
46
- return getDailyUsage(db, range.startTime, range.endTime, query.router_key_id);
67
+ return getDailyUsage(db, range.startTime, range.endTime, query.router_key_id, query.provider_id);
47
68
  });
48
69
  app.get("/admin/api/usage/monthly", { schema: { querystring: UsageQuerySchema } }, async (request) => {
49
70
  const query = request.query;
50
71
  const range = resolveTimeRange("monthly", db, query.router_key_id);
51
- return getDailyUsage(db, range.startTime, range.endTime, query.router_key_id);
72
+ return getDailyUsage(db, range.startTime, range.endTime, query.router_key_id, query.provider_id);
52
73
  });
53
74
  done();
54
75
  };
@@ -23,6 +23,8 @@ export type MetricsInsert = {
23
23
  provider_id: string;
24
24
  backend_model: string;
25
25
  api_type: string;
26
+ router_key_id?: string | null;
27
+ status_code?: number | null;
26
28
  input_tokens?: number | null;
27
29
  output_tokens?: number | null;
28
30
  cache_creation_tokens?: number | null;
@@ -2,8 +2,8 @@ import { randomUUID } from "crypto";
2
2
  import { MS_PER_SECOND } from "../constants.js";
3
3
  export function insertMetrics(db, m) {
4
4
  const id = randomUUID();
5
- db.prepare(`INSERT INTO request_metrics (id, request_log_id, provider_id, backend_model, api_type, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, ttft_ms, total_duration_ms, tokens_per_second, stop_reason, is_complete)
6
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, m.request_log_id, m.provider_id, m.backend_model, m.api_type, m.input_tokens ?? null, m.output_tokens ?? null, m.cache_creation_tokens ?? null, m.cache_read_tokens ?? null, m.ttft_ms ?? null, m.total_duration_ms ?? null, m.tokens_per_second ?? null, m.stop_reason ?? null, m.is_complete ?? 1);
5
+ db.prepare(`INSERT INTO request_metrics (id, request_log_id, provider_id, backend_model, api_type, router_key_id, status_code, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, ttft_ms, total_duration_ms, tokens_per_second, stop_reason, is_complete)
6
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, m.request_log_id, m.provider_id, m.backend_model, m.api_type, m.router_key_id ?? null, m.status_code ?? null, m.input_tokens ?? null, m.output_tokens ?? null, m.cache_creation_tokens ?? null, m.cache_read_tokens ?? null, m.ttft_ms ?? null, m.total_duration_ms ?? null, m.tokens_per_second ?? null, m.stop_reason ?? null, m.is_complete ?? 1);
7
7
  return id;
8
8
  }
9
9
  const PERIOD_OFFSET = {
@@ -64,8 +64,7 @@ export function getMetricsSummary(db, period, providerId, backendModel, routerKe
64
64
  params.push(backendModel);
65
65
  }
66
66
  if (routerKeyId) {
67
- joins.push("LEFT JOIN request_logs rl ON rl.id = rm.request_log_id");
68
- conditions.push("rl.router_key_id = ?");
67
+ conditions.push("rm.router_key_id = ?");
69
68
  params.push(routerKeyId);
70
69
  }
71
70
  return db.prepare(`
@@ -108,19 +107,17 @@ export function getMetricsTimeseries(db, period, metric, providerId, backendMode
108
107
  params.push(backendModel);
109
108
  }
110
109
  if (routerKeyId) {
111
- conditions.push("rl.router_key_id = ?");
110
+ conditions.push("rm.router_key_id = ?");
112
111
  params.push(routerKeyId);
113
112
  }
114
113
  const where = conditions.join(" AND ");
115
114
  const expr = METRIC_EXPR[metric];
116
- const joinClause = routerKeyId ? "LEFT JOIN request_logs rl ON rl.id = rm.request_log_id" : "";
117
115
  const rows = db.prepare(`
118
116
  SELECT
119
117
  (unixepoch(rm.created_at) / ?) * ? AS bucket_key,
120
118
  ${expr} AS avg_value,
121
119
  COUNT(*) AS count
122
120
  FROM request_metrics rm
123
- ${joinClause}
124
121
  WHERE ${where}
125
122
  GROUP BY bucket_key
126
123
  ORDER BY bucket_key ASC
@@ -0,0 +1,54 @@
1
+ -- Metrics 独立化:request_metrics 增加路由维度列,解除级联删除依赖
2
+ -- usage_windows 增加 provider_id 支持按 provider 维度追踪使用量
3
+
4
+ -- 1. 重建 request_metrics:CASCADE -> SET NULL,同时新增 router_key_id / status_code
5
+ CREATE TABLE request_metrics_new (
6
+ id TEXT PRIMARY KEY,
7
+ request_log_id TEXT UNIQUE REFERENCES request_logs(id) ON DELETE SET NULL,
8
+ provider_id TEXT NOT NULL,
9
+ backend_model TEXT NOT NULL,
10
+ api_type TEXT NOT NULL,
11
+ input_tokens INTEGER,
12
+ output_tokens INTEGER,
13
+ cache_creation_tokens INTEGER,
14
+ cache_read_tokens INTEGER,
15
+ ttft_ms INTEGER,
16
+ total_duration_ms INTEGER,
17
+ tokens_per_second REAL,
18
+ stop_reason TEXT,
19
+ is_complete INTEGER NOT NULL DEFAULT 1,
20
+ router_key_id TEXT,
21
+ status_code INTEGER,
22
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
23
+ );
24
+
25
+ INSERT INTO request_metrics_new
26
+ (id, request_log_id, provider_id, backend_model, api_type,
27
+ input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
28
+ ttft_ms, total_duration_ms, tokens_per_second, stop_reason,
29
+ is_complete, created_at)
30
+ SELECT
31
+ id, request_log_id, provider_id, backend_model, api_type,
32
+ input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
33
+ ttft_ms, total_duration_ms, tokens_per_second, stop_reason,
34
+ is_complete, created_at
35
+ FROM request_metrics;
36
+
37
+ -- 回填 router_key_id 和 status_code 从 request_logs
38
+ UPDATE request_metrics_new
39
+ SET
40
+ router_key_id = rl.router_key_id,
41
+ status_code = rl.status_code
42
+ FROM request_logs rl
43
+ WHERE rl.id = request_metrics_new.request_log_id;
44
+
45
+ DROP TABLE request_metrics;
46
+ ALTER TABLE request_metrics_new RENAME TO request_metrics;
47
+
48
+ -- 重建原有索引
49
+ CREATE INDEX idx_metrics_time_provider_model ON request_metrics(created_at, provider_id, backend_model);
50
+ CREATE INDEX idx_metrics_api_type_created_at ON request_metrics(api_type, created_at);
51
+
52
+ -- 2. usage_windows 增加 provider_id 列
53
+ ALTER TABLE usage_windows ADD COLUMN provider_id TEXT;
54
+ CREATE INDEX IF NOT EXISTS idx_usage_windows_provider_id ON usage_windows(provider_id);
@@ -3,6 +3,7 @@ export interface Stats {
3
3
  totalRequests: number;
4
4
  successRate: number;
5
5
  avgTps: number;
6
- totalTokens: number;
6
+ totalInputTokens: number;
7
+ totalOutputTokens: number;
7
8
  }
8
- export declare function getStats(db: Database.Database, startTime: string, endTime: string, routerKeyId?: string): Stats;
9
+ export declare function getStats(db: Database.Database, startTime: string, endTime: string, routerKeyId?: string, providerId?: string, backendModel?: string): Stats;
package/dist/db/stats.js CHANGED
@@ -1,4 +1,4 @@
1
- export function getStats(db, startTime, endTime, routerKeyId) {
1
+ export function getStats(db, startTime, endTime, routerKeyId, providerId, backendModel) {
2
2
  const conditions = [
3
3
  "rm.is_complete = 1",
4
4
  "rm.created_at >= datetime(?)",
@@ -6,18 +6,26 @@ export function getStats(db, startTime, endTime, routerKeyId) {
6
6
  ];
7
7
  const params = [startTime, endTime];
8
8
  if (routerKeyId) {
9
- conditions.push("rl.router_key_id = ?");
9
+ conditions.push("rm.router_key_id = ?");
10
10
  params.push(routerKeyId);
11
11
  }
12
+ if (providerId) {
13
+ conditions.push("rm.provider_id = ?");
14
+ params.push(providerId);
15
+ }
16
+ if (backendModel) {
17
+ conditions.push("rm.backend_model = ?");
18
+ params.push(backendModel);
19
+ }
12
20
  const where = conditions.join(" AND ");
13
21
  const row = db.prepare(`
14
22
  SELECT
15
23
  COUNT(*) AS total_requests,
16
- SUM(CASE WHEN rl.status_code >= 200 AND rl.status_code < 300 THEN 1 ELSE 0 END) AS success_count,
24
+ SUM(CASE WHEN rm.status_code >= 200 AND rm.status_code < 300 THEN 1 ELSE 0 END) AS success_count,
17
25
  AVG(rm.tokens_per_second) AS avg_tps,
18
- COALESCE(SUM(rm.input_tokens), 0) + COALESCE(SUM(rm.output_tokens), 0) AS total_tokens
26
+ COALESCE(SUM(rm.input_tokens), 0) AS total_input_tokens,
27
+ COALESCE(SUM(rm.output_tokens), 0) AS total_output_tokens
19
28
  FROM request_metrics rm
20
- JOIN request_logs rl ON rl.id = rm.request_log_id
21
29
  WHERE ${where}
22
30
  `).get(...params);
23
31
  const total = row?.total_requests ?? 0;
@@ -25,6 +33,7 @@ export function getStats(db, startTime, endTime, routerKeyId) {
25
33
  totalRequests: total,
26
34
  successRate: total > 0 ? (row?.success_count ?? 0) / total : 0,
27
35
  avgTps: row?.avg_tps ?? 0,
28
- totalTokens: row?.total_tokens ?? 0,
36
+ totalInputTokens: row?.total_input_tokens ?? 0,
37
+ totalOutputTokens: row?.total_output_tokens ?? 0,
29
38
  };
30
39
  }
@@ -2,6 +2,7 @@ import Database from "better-sqlite3";
2
2
  export interface UsageWindow {
3
3
  id: string;
4
4
  router_key_id: string | null;
5
+ provider_id: string | null;
5
6
  start_time: string;
6
7
  end_time: string;
7
8
  created_at: string;
@@ -12,8 +13,8 @@ export interface WindowUsage {
12
13
  total_output_tokens: number;
13
14
  }
14
15
  export declare function insertWindow(db: Database.Database, w: Omit<UsageWindow, "created_at">): string;
15
- export declare function getLatestWindow(db: Database.Database, routerKeyId?: string): UsageWindow | null;
16
- /** 返回与 [start, end) 区间有重叠的窗口 */
17
- export declare function getWindowsInRange(db: Database.Database, start: string, end: string, routerKeyId?: string): UsageWindow[];
16
+ export declare function getLatestWindow(db: Database.Database, routerKeyId?: string, providerId?: string): UsageWindow | null;
17
+ /** 返回与 [start, end) 区间有重叠的窗口。可选参数不传表示不过滤该维度(与 getLatestWindow 的 IS NULL 语义不同) */
18
+ export declare function getWindowsInRange(db: Database.Database, start: string, end: string, routerKeyId?: string, providerId?: string): UsageWindow[];
18
19
  /** 聚合指定时间窗口内的请求计数和 token 用量 */
19
- export declare function getWindowUsage(db: Database.Database, startTime: string, endTime: string, routerKeyId?: string): WindowUsage;
20
+ export declare function getWindowUsage(db: Database.Database, startTime: string, endTime: string, routerKeyId?: string, providerId?: string): WindowUsage;
@@ -1,37 +1,65 @@
1
1
  import { randomUUID } from "crypto";
2
2
  export function insertWindow(db, w) {
3
3
  const id = w.id || randomUUID();
4
- db.prepare("INSERT INTO usage_windows (id, router_key_id, start_time, end_time) VALUES (?, ?, ?, ?)").run(id, w.router_key_id ?? null, w.start_time, w.end_time);
4
+ db.prepare("INSERT INTO usage_windows (id, router_key_id, provider_id, start_time, end_time) VALUES (?, ?, ?, ?, ?)").run(id, w.router_key_id ?? null, w.provider_id ?? null, w.start_time, w.end_time);
5
5
  return id;
6
6
  }
7
- export function getLatestWindow(db, routerKeyId) {
8
- const sql = routerKeyId
9
- ? "SELECT * FROM usage_windows WHERE router_key_id = ? ORDER BY start_time DESC LIMIT 1"
10
- : "SELECT * FROM usage_windows ORDER BY start_time DESC LIMIT 1";
11
- const params = routerKeyId ? [routerKeyId] : [];
7
+ export function getLatestWindow(db, routerKeyId, providerId) {
8
+ const conditions = [];
9
+ const params = [];
10
+ if (routerKeyId) {
11
+ conditions.push("router_key_id = ?");
12
+ params.push(routerKeyId);
13
+ }
14
+ else {
15
+ conditions.push("router_key_id IS NULL");
16
+ }
17
+ if (providerId) {
18
+ conditions.push("provider_id = ?");
19
+ params.push(providerId);
20
+ }
21
+ else {
22
+ conditions.push("provider_id IS NULL");
23
+ }
24
+ const sql = `SELECT * FROM usage_windows WHERE ${conditions.join(" AND ")} ORDER BY start_time DESC LIMIT 1`;
12
25
  return db.prepare(sql).get(...params) ?? null;
13
26
  }
14
- /** 返回与 [start, end) 区间有重叠的窗口 */
15
- export function getWindowsInRange(db, start, end, routerKeyId) {
27
+ /** 返回与 [start, end) 区间有重叠的窗口。可选参数不传表示不过滤该维度(与 getLatestWindow 的 IS NULL 语义不同) */
28
+ export function getWindowsInRange(db, start, end, routerKeyId, providerId) {
29
+ const conditions = ["start_time < ?", "end_time > ?"];
30
+ const params = [end, start];
16
31
  if (routerKeyId) {
17
- return db.prepare("SELECT * FROM usage_windows WHERE start_time < ? AND end_time > ? AND router_key_id = ? ORDER BY start_time ASC").all(end, start, routerKeyId);
32
+ conditions.push("router_key_id = ?");
33
+ params.push(routerKeyId);
34
+ }
35
+ if (providerId) {
36
+ conditions.push("provider_id = ?");
37
+ params.push(providerId);
18
38
  }
19
- return db.prepare("SELECT * FROM usage_windows WHERE start_time < ? AND end_time > ? ORDER BY start_time ASC").all(end, start);
39
+ return db.prepare(`SELECT * FROM usage_windows WHERE ${conditions.join(" AND ")} ORDER BY start_time ASC`).all(...params);
20
40
  }
21
41
  /** 聚合指定时间窗口内的请求计数和 token 用量 */
22
- export function getWindowUsage(db, startTime, endTime, routerKeyId) {
23
- const baseSql = `
42
+ export function getWindowUsage(db, startTime, endTime, routerKeyId, providerId) {
43
+ const conditions = [
44
+ "rm.is_complete = 1",
45
+ "rm.created_at >= datetime(?)",
46
+ "rm.created_at < datetime(?)",
47
+ ];
48
+ const params = [startTime, endTime];
49
+ if (routerKeyId) {
50
+ conditions.push("rm.router_key_id = ?");
51
+ params.push(routerKeyId);
52
+ }
53
+ if (providerId) {
54
+ conditions.push("rm.provider_id = ?");
55
+ params.push(providerId);
56
+ }
57
+ return db.prepare(`
24
58
  SELECT
25
59
  COUNT(*) AS request_count,
26
60
  COALESCE(SUM(rm.input_tokens), 0) AS total_input_tokens,
27
61
  COALESCE(SUM(rm.output_tokens), 0) AS total_output_tokens
28
62
  FROM request_metrics rm
29
- JOIN request_logs rl ON rl.id = rm.request_log_id
30
- WHERE rm.is_complete = 1
31
- AND rm.created_at >= datetime(?)
32
- AND rm.created_at < datetime(?)`;
33
- if (routerKeyId) {
34
- return db.prepare(`${baseSql} AND rl.router_key_id = ?`).get(startTime, endTime, routerKeyId);
35
- }
36
- return db.prepare(baseSql).get(startTime, endTime);
63
+ WHERE ${conditions.join(" AND ")}
64
+ `).get(...params);
37
65
  }
@@ -16,6 +16,15 @@ import { applyProviderPatches } from "./patch/index.js";
16
16
  const HTTP_ERROR_THRESHOLD = 400;
17
17
  const MAX_LOG_FIELD_LENGTH = 80;
18
18
  const UPSTREAM_ERROR_STATUS = 502;
19
+ /** 从 TransportResult 中提取最终 HTTP status code */
20
+ function getTransportStatusCode(result) {
21
+ if (result.kind === "success" || result.kind === "error" || result.kind === "stream_error")
22
+ return result.statusCode;
23
+ if (result.kind === "stream_success" || result.kind === "stream_abort")
24
+ return result.statusCode;
25
+ // kind === "throw":无 HTTP 状态码
26
+ return null;
27
+ }
19
28
  function rejectAndReply(reply, params, error, errorMessage, providerId) {
20
29
  insertRejectedLog({
21
30
  db: params.db, logId: params.logId, apiType: params.apiType, model: params.model,
@@ -150,11 +159,11 @@ async function executeFailoverLoop(ctx) {
150
159
  clientReq, upstreamReqBase, logId, routerKeyId, originalModel, sessionId,
151
160
  failover: { isFailoverIteration, rootLogId: rootLogId },
152
161
  }, resilienceResult.attempts, resilienceResult.result, startTime);
153
- collectTransportMetrics(deps.db, apiType, resilienceResult.result, isStream, lastLogId, provider.id, resolved.backend_model, request);
162
+ collectTransportMetrics(deps.db, apiType, resilienceResult.result, isStream, lastLogId, provider.id, resolved.backend_model, request, routerKeyId, getTransportStatusCode(resilienceResult.result));
154
163
  const tr = resilienceResult.result;
155
164
  const succeeded = tr.kind === "success" || tr.kind === "stream_success" || tr.kind === "stream_abort";
156
165
  if (succeeded)
157
- deps.usageWindowTracker?.recordRequest(routerKeyId ?? undefined);
166
+ deps.usageWindowTracker?.recordRequest(provider.id, routerKeyId ?? undefined);
158
167
  if (isStream && deps.tracker) {
159
168
  const sc = deps.tracker.get(logId)?.streamContent;
160
169
  const blocks = sc?.blocks;
@@ -23,4 +23,4 @@ export declare function logResilienceResult(db: Database.Database, params: {
23
23
  sessionId?: string | null;
24
24
  failover?: FailoverContext;
25
25
  }, attempts: ResilienceAttempt[], result: TransportResult, startTime: number): string;
26
- export declare function collectTransportMetrics(db: Database.Database, apiType: "openai" | "anthropic", result: TransportResult, isStream: boolean, lastSuccessLogId: string, providerId: string, backendModel: string, request: FastifyRequest): void;
26
+ export declare function collectTransportMetrics(db: Database.Database, apiType: "openai" | "anthropic", result: TransportResult, isStream: boolean, lastSuccessLogId: string, providerId: string, backendModel: string, request: FastifyRequest, routerKeyId?: string | null, statusCode?: number | null): void;
@@ -116,8 +116,11 @@ export function logResilienceResult(db, params, attempts, result, startTime) {
116
116
  }
117
117
  return lastSuccessLogId;
118
118
  }
119
- export function collectTransportMetrics(db, apiType, result, isStream, lastSuccessLogId, providerId, backendModel, request) {
120
- const base = { request_log_id: lastSuccessLogId, provider_id: providerId, backend_model: backendModel, api_type: apiType };
119
+ export function collectTransportMetrics(db, apiType, result, isStream, lastSuccessLogId, providerId, backendModel, request, routerKeyId, statusCode) {
120
+ const base = {
121
+ request_log_id: lastSuccessLogId, provider_id: providerId, backend_model: backendModel, api_type: apiType,
122
+ router_key_id: routerKeyId ?? null, status_code: statusCode ?? null,
123
+ };
121
124
  try {
122
125
  if (isStream && (result.kind === "stream_success" || result.kind === "stream_abort")) {
123
126
  if (result.metrics) {
@@ -3,9 +3,11 @@ export declare class UsageWindowTracker {
3
3
  private db;
4
4
  constructor(db: Database.Database);
5
5
  /** 请求成功后调用,按需创建新窗口 */
6
- recordRequest(routerKeyId?: string): void;
7
- /** 启动时补齐因宕机/重启而缺失的窗口 */
6
+ recordRequest(providerId: string, routerKeyId?: string): void;
7
+ /** 启动时按活跃 provider 补齐缺失的窗口 */
8
8
  reconcileOnStartup(): void;
9
+ /** 为单个 provider 补齐窗口 */
10
+ private reconcileProvider;
9
11
  /** 从 baseTime 开始,每 5h 一个窗口,直到覆盖 lastLogTime */
10
- private backfillWindows;
12
+ private backfillProviderWindows;
11
13
  }
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "crypto";
2
2
  import { getLatestWindow, insertWindow } from "../db/usage-windows.js";
3
+ import { getAllProviders } from "../db/providers.js";
3
4
  import { toSqliteDatetime, parseSqliteDatetime as parseDate } from "../utils/datetime.js";
4
5
  // eslint-disable-next-line no-magic-numbers
5
6
  const WINDOW_DURATION_MS = 5 * 3600_000;
@@ -9,49 +10,46 @@ export class UsageWindowTracker {
9
10
  this.db = db;
10
11
  }
11
12
  /** 请求成功后调用,按需创建新窗口 */
12
- recordRequest(routerKeyId) {
13
+ recordRequest(providerId, routerKeyId) {
13
14
  const now = new Date();
14
- const latest = getLatestWindow(this.db, routerKeyId);
15
+ const latest = getLatestWindow(this.db, routerKeyId, providerId);
15
16
  if (!latest || now > parseDate(latest.end_time)) {
16
17
  const startTime = truncateToMinute(now);
17
18
  insertWindow(this.db, {
18
19
  id: randomUUID(),
19
20
  router_key_id: routerKeyId ?? null,
21
+ provider_id: providerId,
20
22
  start_time: toSqliteDatetime(startTime),
21
23
  end_time: toSqliteDatetime(new Date(startTime.getTime() + WINDOW_DURATION_MS)),
22
24
  });
23
25
  }
24
26
  }
25
- /** 启动时补齐因宕机/重启而缺失的窗口 */
27
+ /** 启动时按活跃 provider 补齐缺失的窗口 */
26
28
  reconcileOnStartup() {
27
- const latest = getLatestWindow(this.db);
28
- // 查找 request_logs 中最新一条请求的时间
29
- const lastLog = this.db.prepare("SELECT created_at FROM request_logs ORDER BY created_at DESC LIMIT 1").get();
29
+ const providers = getAllProviders(this.db).filter((p) => p.is_active);
30
+ for (const provider of providers) {
31
+ this.reconcileProvider(provider.id);
32
+ }
33
+ }
34
+ /** 为单个 provider 补齐窗口 */
35
+ reconcileProvider(providerId) {
36
+ const latest = getLatestWindow(this.db, undefined, providerId);
37
+ const lastLog = this.db.prepare("SELECT created_at FROM request_logs WHERE provider_id = ? ORDER BY created_at DESC LIMIT 1").get(providerId);
30
38
  if (!lastLog)
31
39
  return;
32
40
  if (!latest) {
33
- // 从未创建过窗口,但有请求记录,从最早请求创建初始窗口
34
- const firstLog = this.db.prepare("SELECT created_at FROM request_logs ORDER BY created_at ASC LIMIT 1").get();
41
+ const firstLog = this.db.prepare("SELECT created_at FROM request_logs WHERE provider_id = ? ORDER BY created_at ASC LIMIT 1").get(providerId);
35
42
  if (!firstLog)
36
43
  return;
37
- const start = parseDate(firstLog.created_at);
38
- const truncated = truncateToMinute(start);
39
- insertWindow(this.db, {
40
- id: randomUUID(),
41
- router_key_id: null,
42
- start_time: toSqliteDatetime(truncated),
43
- end_time: toSqliteDatetime(new Date(truncated.getTime() + WINDOW_DURATION_MS)),
44
- });
45
- // 继续补齐后续窗口
46
- this.backfillWindows(truncated);
44
+ const truncated = truncateToMinute(parseDate(firstLog.created_at));
45
+ this.backfillProviderWindows(providerId, truncated);
47
46
  return;
48
47
  }
49
- // 有窗口,检查 end_time 之后是否有请求
50
- this.backfillWindows(parseDate(latest.end_time));
48
+ this.backfillProviderWindows(providerId, parseDate(latest.end_time));
51
49
  }
52
50
  /** 从 baseTime 开始,每 5h 一个窗口,直到覆盖 lastLogTime */
53
- backfillWindows(baseTime) {
54
- const lastLog = this.db.prepare("SELECT created_at FROM request_logs ORDER BY created_at DESC LIMIT 1").get();
51
+ backfillProviderWindows(providerId, baseTime) {
52
+ const lastLog = this.db.prepare("SELECT created_at FROM request_logs WHERE provider_id = ? ORDER BY created_at DESC LIMIT 1").get(providerId);
55
53
  if (!lastLog)
56
54
  return;
57
55
  const lastLogTime = parseDate(lastLog.created_at);
@@ -61,6 +59,7 @@ export class UsageWindowTracker {
61
59
  insertWindow(this.db, {
62
60
  id: randomUUID(),
63
61
  router_key_id: null,
62
+ provider_id: providerId,
64
63
  start_time: toSqliteDatetime(windowStart),
65
64
  end_time: toSqliteDatetime(windowEnd),
66
65
  });
@@ -4,6 +4,6 @@ export interface TimeRange {
4
4
  startTime: string;
5
5
  endTime: string;
6
6
  }
7
- export declare function resolveTimeRange(period: DashboardPeriod, db: Database.Database, routerKeyId?: string): TimeRange;
7
+ export declare function resolveTimeRange(period: DashboardPeriod, db: Database.Database, routerKeyId?: string, providerId?: string): TimeRange;
8
8
  /** 从 date 对象中计算出当周的周一 */
9
9
  export declare function getMonday(date: Date): Date;
@@ -5,28 +5,33 @@ const WINDOW_HOURS = 5;
5
5
  const MS_PER_HOUR = 3600_000;
6
6
  // 与 usage-windows 的默认窗口时长对齐
7
7
  const WINDOW_DURATION_MS = WINDOW_HOURS * MS_PER_HOUR;
8
- export function resolveTimeRange(period, db, routerKeyId) {
8
+ export function resolveTimeRange(period, db, routerKeyId, providerId) {
9
9
  const now = new Date();
10
10
  switch (period) {
11
11
  case "window": {
12
- const latest = getLatestWindow(db, routerKeyId);
12
+ const latest = getLatestWindow(db, routerKeyId, providerId);
13
13
  if (!latest) {
14
- return createAndReturnWindow(db, now, routerKeyId);
14
+ return createAndReturnWindow(db, now, routerKeyId, providerId);
15
15
  }
16
16
  // 最新窗口已过期(无请求触发新窗口创建),主动补齐
17
17
  if (now > parseSqliteDatetime(latest.end_time)) {
18
- return createAndReturnWindow(db, now, routerKeyId);
18
+ return createAndReturnWindow(db, now, routerKeyId, providerId);
19
19
  }
20
20
  return { startTime: latest.start_time, endTime: latest.end_time };
21
21
  }
22
22
  case "weekly": {
23
23
  const monday = getMonday(now);
24
24
  monday.setHours(0, 0, 0, 0);
25
- return { startTime: toSqliteDatetime(monday), endTime: toSqliteDatetime(now) };
25
+ const sunday = new Date(monday);
26
+ sunday.setDate(sunday.getDate() + 6);
27
+ sunday.setHours(23, 59, 59, 999);
28
+ return { startTime: toSqliteDatetime(monday), endTime: toSqliteDatetime(sunday) };
26
29
  }
27
30
  case "monthly": {
28
31
  const first = new Date(now.getFullYear(), now.getMonth(), 1);
29
- return { startTime: toSqliteDatetime(first), endTime: toSqliteDatetime(now) };
32
+ const last = new Date(now.getFullYear(), now.getMonth() + 1, 0);
33
+ last.setHours(23, 59, 59, 999);
34
+ return { startTime: toSqliteDatetime(first), endTime: toSqliteDatetime(last) };
30
35
  }
31
36
  }
32
37
  }
@@ -41,13 +46,14 @@ export function getMonday(date) {
41
46
  d.setDate(diff);
42
47
  return d;
43
48
  }
44
- function createAndReturnWindow(db, now, routerKeyId) {
49
+ function createAndReturnWindow(db, now, routerKeyId, providerId) {
45
50
  const start = new Date(now);
46
51
  start.setMinutes(0, 0, 0);
47
52
  const end = new Date(start.getTime() + WINDOW_DURATION_MS);
48
53
  insertWindow(db, {
49
54
  id: randomUUID(),
50
55
  router_key_id: routerKeyId ?? null,
56
+ provider_id: providerId ?? null,
51
57
  start_time: toSqliteDatetime(start),
52
58
  end_time: toSqliteDatetime(end),
53
59
  });