llm-simple-router 0.4.0 → 0.4.2

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 (125) hide show
  1. package/README.md +128 -91
  2. package/dist/admin/logs.js +7 -1
  3. package/dist/admin/metrics.js +7 -3
  4. package/dist/admin/recommended.d.ts +7 -0
  5. package/dist/admin/recommended.js +25 -0
  6. package/dist/admin/routes.js +6 -0
  7. package/dist/admin/settings.d.ts +7 -0
  8. package/dist/admin/settings.js +16 -0
  9. package/dist/admin/usage.d.ts +7 -0
  10. package/dist/admin/usage.js +66 -0
  11. package/dist/config/recommended.d.ts +24 -0
  12. package/dist/config/recommended.js +30 -0
  13. package/dist/db/index.d.ts +4 -2
  14. package/dist/db/index.js +21 -4
  15. package/dist/db/log-cleaner.d.ts +10 -0
  16. package/dist/db/log-cleaner.js +42 -0
  17. package/dist/db/logs.d.ts +33 -8
  18. package/dist/db/logs.js +52 -6
  19. package/dist/db/metrics.d.ts +3 -3
  20. package/dist/db/metrics.js +50 -42
  21. package/dist/db/migrations/019_create_usage_windows.sql +11 -0
  22. package/dist/db/migrations/020_drop_log_redundancy.sql +8 -0
  23. package/dist/db/migrations/021_merge_metrics_columns.sql +28 -0
  24. package/dist/db/retry-rules.d.ts +0 -5
  25. package/dist/db/retry-rules.js +0 -36
  26. package/dist/db/settings.d.ts +2 -0
  27. package/dist/db/settings.js +7 -0
  28. package/dist/db/usage-windows.d.ts +19 -0
  29. package/dist/db/usage-windows.js +37 -0
  30. package/dist/index.d.ts +2 -0
  31. package/dist/index.js +20 -3
  32. package/dist/monitor/types.d.ts +3 -0
  33. package/dist/proxy/log-helpers.d.ts +0 -2
  34. package/dist/proxy/log-helpers.js +3 -5
  35. package/dist/proxy/orchestrator.d.ts +2 -0
  36. package/dist/proxy/orchestrator.js +1 -0
  37. package/dist/proxy/proxy-handler.js +29 -4
  38. package/dist/proxy/proxy-logging.d.ts +0 -1
  39. package/dist/proxy/proxy-logging.js +8 -10
  40. package/dist/proxy/usage-window-tracker.d.ts +11 -0
  41. package/dist/proxy/usage-window-tracker.js +75 -0
  42. package/dist/utils/datetime.d.ts +4 -0
  43. package/dist/utils/datetime.js +10 -0
  44. package/frontend-dist/assets/CardContent-3ytnac7B.js +1 -0
  45. package/frontend-dist/assets/CardTitle-BHZE8Rty.js +1 -0
  46. package/frontend-dist/assets/Checkbox-CMYgDuxw.js +1 -0
  47. package/frontend-dist/assets/CollapsibleTrigger-DooxvEnx.js +1 -0
  48. package/frontend-dist/assets/Collection-GDvpW_uY.js +3 -0
  49. package/frontend-dist/assets/Dashboard-BJslVTg8.js +3 -0
  50. package/frontend-dist/assets/DialogTitle-lj6NAA5R.js +1 -0
  51. package/frontend-dist/assets/Input-JApdUstN.js +1 -0
  52. package/frontend-dist/assets/Label-IbQFgxLe.js +1 -0
  53. package/frontend-dist/assets/Login-BjuVvrPV.js +1 -0
  54. package/frontend-dist/assets/Logs-J08HyZWA.js +1 -0
  55. package/frontend-dist/assets/ModelMappings-DWVmxMy6.js +1 -0
  56. package/frontend-dist/assets/Monitor-BTEW0evp.js +1 -0
  57. package/frontend-dist/assets/PopperContent-ZhhkKJo0.js +1 -0
  58. package/frontend-dist/assets/Providers-BqLSKXuv.js +1 -0
  59. package/frontend-dist/assets/ProxyEnhancement-TAHOKnxW.js +5 -0
  60. package/frontend-dist/assets/RetryRules-Cn6KHzgB.js +1 -0
  61. package/frontend-dist/assets/RouterKeys-CBgWAJ6-.js +1 -0
  62. package/frontend-dist/assets/SelectValue-DS4Z8y0u.js +1 -0
  63. package/frontend-dist/assets/Setup-QKmeMDtB.js +1 -0
  64. package/frontend-dist/assets/Switch-BYebebrY.js +1 -0
  65. package/frontend-dist/assets/TableHeader-B2A48qgy.js +1 -0
  66. package/frontend-dist/assets/TabsContent-BcNBY5CB.js +1 -0
  67. package/frontend-dist/assets/TabsTrigger-8W_mNsGI.js +1 -0
  68. package/frontend-dist/assets/UnifiedRequestDialog-BmEamR1L.js +3 -0
  69. package/frontend-dist/assets/UnifiedRequestDialog-Dk3IIDDx.css +1 -0
  70. package/frontend-dist/assets/VisuallyHidden-DPKPka_x.js +1 -0
  71. package/frontend-dist/assets/VisuallyHiddenInput-Bnglr6yR.js +1 -0
  72. package/frontend-dist/assets/alert-dialog-BzyDZnoE.js +1 -0
  73. package/frontend-dist/assets/badge-BTjuxlp4.js +1 -0
  74. package/frontend-dist/assets/button-BKJB3nEQ.js +12 -0
  75. package/frontend-dist/assets/{createLucideIcon-DGZkBjcJ.js → createLucideIcon-igIAnu_Y.js} +1 -1
  76. package/frontend-dist/assets/dialog-C0B-Xn-S.js +1 -0
  77. package/frontend-dist/assets/file-text-Ci7Mgh3F.js +1 -0
  78. package/frontend-dist/assets/index-BrDOp_gc.js +1 -0
  79. package/frontend-dist/assets/index-DMdVJThL.css +1 -0
  80. package/frontend-dist/assets/lib-BGW4QyKP.js +1 -0
  81. package/frontend-dist/assets/{ohash.D__AXeF1-B64hB831.js → ohash.D__AXeF1-CsY_LBk-.js} +1 -1
  82. package/frontend-dist/assets/{useClipboard-CWc1cTDo.js → useClipboard-wnGQAe3I.js} +1 -1
  83. package/frontend-dist/assets/useForwardExpose-bqtcPo63.js +1 -0
  84. package/frontend-dist/assets/useNonce-DN0Hrw3l.js +1 -0
  85. package/frontend-dist/assets/x-Cy_v5hrA.js +1 -0
  86. package/frontend-dist/index.html +8 -7
  87. package/package.json +1 -1
  88. package/frontend-dist/assets/CardContent-CTnwqTdL.js +0 -1
  89. package/frontend-dist/assets/CardHeader-CfUeY7tk.js +0 -1
  90. package/frontend-dist/assets/CardTitle-CWiDwWqd.js +0 -1
  91. package/frontend-dist/assets/Checkbox-BxNz70R_.js +0 -1
  92. package/frontend-dist/assets/CollapsibleTrigger-Uz1aGdtH.js +0 -1
  93. package/frontend-dist/assets/Collection-1EHC87X5.js +0 -3
  94. package/frontend-dist/assets/Dashboard-C3FL30UN.js +0 -3
  95. package/frontend-dist/assets/DialogTitle-CAOFxr83.js +0 -1
  96. package/frontend-dist/assets/Input-DRIid2C6.js +0 -1
  97. package/frontend-dist/assets/Label-UyNN2jyE.js +0 -1
  98. package/frontend-dist/assets/LogDetailDialog-8BT4vIlV.js +0 -3
  99. package/frontend-dist/assets/Login-CnzH6TdS.js +0 -1
  100. package/frontend-dist/assets/Logs-CbK8NB_X.js +0 -1
  101. package/frontend-dist/assets/ModelMappings-DeRFgsYG.js +0 -1
  102. package/frontend-dist/assets/Monitor-Dd80bdUn.js +0 -1
  103. package/frontend-dist/assets/PopperContent-B3fZao7v.js +0 -1
  104. package/frontend-dist/assets/Providers-B_DbV-_y.js +0 -1
  105. package/frontend-dist/assets/ProxyEnhancement-up1fnPzq.js +0 -5
  106. package/frontend-dist/assets/RetryRules-Dkuhjh0u.js +0 -1
  107. package/frontend-dist/assets/RouterKeys-CvMMAa4t.js +0 -1
  108. package/frontend-dist/assets/RovingFocusItem-X0bfqWWS.js +0 -1
  109. package/frontend-dist/assets/SelectValue-zO8t-tx1.js +0 -1
  110. package/frontend-dist/assets/Setup-ByT2ThOQ.js +0 -1
  111. package/frontend-dist/assets/Switch-BEMjVugO.js +0 -1
  112. package/frontend-dist/assets/TableHeader-DpHWSnxK.js +0 -1
  113. package/frontend-dist/assets/TabsTrigger-Db6RqsZc.js +0 -1
  114. package/frontend-dist/assets/VisuallyHidden-hs8pj8OP.js +0 -1
  115. package/frontend-dist/assets/VisuallyHiddenInput-1m0nNADN.js +0 -1
  116. package/frontend-dist/assets/alert-dialog-PP91kaO8.js +0 -1
  117. package/frontend-dist/assets/button-Dcc0gF5i.js +0 -1
  118. package/frontend-dist/assets/client-DIIo9zPK.js +0 -12
  119. package/frontend-dist/assets/dialog-CxSyR-fN.js +0 -1
  120. package/frontend-dist/assets/index-BL-LAtac.css +0 -1
  121. package/frontend-dist/assets/index-CvT41fGL.js +0 -1
  122. package/frontend-dist/assets/lib-Bl0OuBjh.js +0 -1
  123. package/frontend-dist/assets/useForwardExpose-AkE0lq8y.js +0 -1
  124. package/frontend-dist/assets/useNonce-DGyPxdjq.js +0 -1
  125. package/frontend-dist/assets/x-BuUpx9Fr.js +0 -1
@@ -0,0 +1,42 @@
1
+ import { deleteLogsBefore } from "./logs.js";
2
+ import { getLogRetentionDays } from "./settings.js";
3
+ const MS_PER_DAY = 86_400_000;
4
+ const CLEANUP_INTERVAL_MS = 3_600_000; // 1 小时
5
+ /** 运行一次清理,返回删除条数 */
6
+ export function runLogCleanup(db) {
7
+ const days = getLogRetentionDays(db);
8
+ if (days <= 0)
9
+ return 0;
10
+ const cutoff = new Date(Date.now() - days * MS_PER_DAY).toISOString();
11
+ return deleteLogsBefore(db, cutoff);
12
+ }
13
+ /** 启动定时清理,返回 handle 用于停止 */
14
+ export function scheduleLogCleanup(db, log) {
15
+ let cleaning = false;
16
+ let timer = null;
17
+ const doCleanup = () => {
18
+ if (cleaning)
19
+ return;
20
+ cleaning = true;
21
+ try {
22
+ const deleted = runLogCleanup(db);
23
+ if (deleted > 0)
24
+ log.info(`Log cleanup: deleted ${deleted} records`);
25
+ }
26
+ finally {
27
+ cleaning = false;
28
+ }
29
+ };
30
+ // 启动时立即执行一次
31
+ doCleanup();
32
+ // 定时执行
33
+ timer = setInterval(doCleanup, CLEANUP_INTERVAL_MS);
34
+ return {
35
+ stop: () => {
36
+ if (timer) {
37
+ clearInterval(timer);
38
+ timer = null;
39
+ }
40
+ },
41
+ };
42
+ }
package/dist/db/logs.d.ts CHANGED
@@ -9,21 +9,27 @@ export interface RequestLog {
9
9
  is_stream: number;
10
10
  error_message: string | null;
11
11
  created_at: string;
12
- request_body: string | null;
13
- response_body: string | null;
14
12
  client_request: string | null;
15
13
  upstream_request: string | null;
16
14
  upstream_response: string | null;
17
- client_response: string | null;
18
15
  is_retry: number;
19
16
  is_failover: number;
20
17
  original_request_id: string | null;
21
18
  original_model: string | null;
19
+ input_tokens: number | null;
20
+ output_tokens: number | null;
21
+ cache_read_tokens: number | null;
22
+ ttft_ms: number | null;
23
+ tokens_per_second: number | null;
24
+ stop_reason: string | null;
25
+ backend_model: string | null;
26
+ metrics_complete: number;
27
+ stream_text_content: string | null;
22
28
  }
23
- /** 列表查询扩展字段:JOIN request_metrics + providers 获得 */
29
+ /** 列表查询扩展字段:JOIN providers 获得 provider_name */
24
30
  export interface RequestLogListRow extends RequestLog {
25
- backend_model: string | null;
26
31
  provider_name: string | null;
32
+ child_count?: number;
27
33
  }
28
34
  export interface RequestLogInsert {
29
35
  id: string;
@@ -35,12 +41,9 @@ export interface RequestLogInsert {
35
41
  is_stream: number;
36
42
  error_message: string | null;
37
43
  created_at: string;
38
- request_body?: string | null;
39
- response_body?: string | null;
40
44
  client_request?: string | null;
41
45
  upstream_request?: string | null;
42
46
  upstream_response?: string | null;
43
- client_response?: string | null;
44
47
  is_retry?: number;
45
48
  is_failover?: number;
46
49
  original_request_id?: string | null;
@@ -54,11 +57,29 @@ export declare function getRequestLogs(db: Database.Database, options: {
54
57
  api_type?: string;
55
58
  model?: string;
56
59
  router_key_id?: string;
60
+ provider_id?: string;
61
+ start_time?: string;
62
+ end_time?: string;
57
63
  }): {
58
64
  data: RequestLogListRow[];
59
65
  total: number;
60
66
  };
61
67
  export declare function getRequestLogById(db: Database.Database, id: string): RequestLog | undefined;
68
+ type MetricsUpdate = {
69
+ input_tokens?: number | null;
70
+ output_tokens?: number | null;
71
+ cache_read_tokens?: number | null;
72
+ ttft_ms?: number | null;
73
+ tokens_per_second?: number | null;
74
+ stop_reason?: string | null;
75
+ is_complete?: number;
76
+ };
77
+ /** 双写:collectTransportMetrics 写 request_metrics 的同时,更新 request_logs 的冗余列 */
78
+ export declare function updateLogMetrics(db: Database.Database, logId: string, m: MetricsUpdate): void;
79
+ /** 流式请求完成后,将 tracker 中累积的文本内容写入 request_logs */
80
+ export declare function updateLogStreamContent(db: Database.Database, logId: string, textContent: string): void;
81
+ /** 启动时回填:从 request_metrics 补齐 metrics_complete = 0 但实际有指标的行 */
82
+ export declare function backfillMetricsFromRequestMetrics(db: Database.Database): number;
62
83
  export declare function deleteLogsBefore(db: Database.Database, beforeDate: string): number;
63
84
  /** 查询某条日志的子请求(retry/failover 关联),上限 100 条 */
64
85
  export declare function getRequestLogChildren(db: Database.Database, parentId: string): RequestLogListRow[];
@@ -72,7 +93,11 @@ export declare function getRequestLogsGrouped(db: Database.Database, options: {
72
93
  api_type?: string;
73
94
  model?: string;
74
95
  router_key_id?: string;
96
+ provider_id?: string;
97
+ start_time?: string;
98
+ end_time?: string;
75
99
  }): {
76
100
  data: RequestLogGroupedRow[];
77
101
  total: number;
78
102
  };
103
+ export {};
package/dist/db/logs.js CHANGED
@@ -1,14 +1,17 @@
1
1
  // --- request_logs ---
2
- /** 三处日志列表查询共享的 SELECT 列 + JOIN 子句 */
2
+ /** 日志列表查询共享的 SELECT 列 + JOIN 子句(metrics 已冗余到 request_logs,无需 JOIN request_metrics) */
3
3
  const LOG_LIST_SELECT = `rl.id, rl.api_type, rl.model, rl.provider_id, rl.status_code, rl.latency_ms,
4
4
  rl.is_stream, rl.error_message, rl.created_at, rl.is_retry, rl.is_failover, rl.original_request_id, rl.original_model,
5
5
  CASE WHEN rl.provider_id = 'router' THEN rl.upstream_request ELSE NULL END AS upstream_request,
6
- rm.backend_model, COALESCE(p.name, rl.provider_id) AS provider_name`;
7
- const LOG_LIST_JOIN = `LEFT JOIN request_metrics rm ON rm.request_log_id = rl.id
8
- LEFT JOIN providers p ON p.id = rl.provider_id`;
6
+ rl.input_tokens, rl.output_tokens, rl.cache_read_tokens, rl.ttft_ms, rl.tokens_per_second, rl.stop_reason,
7
+ rl.backend_model, rl.metrics_complete,
8
+ COALESCE(p.name, rl.provider_id) AS provider_name`;
9
+ const LOG_LIST_JOIN = `LEFT JOIN providers p ON p.id = rl.provider_id`;
9
10
  export function insertRequestLog(db, log) {
10
- db.prepare(`INSERT INTO request_logs (id, api_type, model, provider_id, status_code, latency_ms, is_stream, error_message, created_at, request_body, response_body, client_request, upstream_request, upstream_response, client_response, is_retry, is_failover, original_request_id, router_key_id, original_model)
11
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(log.id, log.api_type, log.model, log.provider_id, log.status_code, log.latency_ms, log.is_stream, log.error_message, log.created_at, log.request_body ?? null, log.response_body ?? null, log.client_request ?? null, log.upstream_request ?? null, log.upstream_response ?? null, log.client_response ?? null, log.is_retry ?? 0, log.is_failover ?? 0, log.original_request_id ?? null, log.router_key_id ?? null, log.original_model ?? null);
11
+ db.prepare(`INSERT INTO request_logs (id, api_type, model, provider_id, status_code, latency_ms,
12
+ is_stream, error_message, created_at, client_request, upstream_request, upstream_response,
13
+ is_retry, is_failover, original_request_id, router_key_id, original_model)
14
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(log.id, log.api_type, log.model, log.provider_id, log.status_code, log.latency_ms, log.is_stream, log.error_message, log.created_at, log.client_request ?? null, log.upstream_request ?? null, log.upstream_response ?? null, log.is_retry ?? 0, log.is_failover ?? 0, log.original_request_id ?? null, log.router_key_id ?? null, log.original_model ?? null);
12
15
  }
13
16
  function buildLogWhereClause(options, baseCondition) {
14
17
  let where = baseCondition;
@@ -25,6 +28,18 @@ function buildLogWhereClause(options, baseCondition) {
25
28
  where += " AND rl.router_key_id = ?";
26
29
  params.push(options.router_key_id);
27
30
  }
31
+ if (options.provider_id) {
32
+ where += " AND rl.provider_id = ?";
33
+ params.push(options.provider_id);
34
+ }
35
+ if (options.start_time) {
36
+ where += " AND rl.created_at >= ?";
37
+ params.push(options.start_time);
38
+ }
39
+ if (options.end_time) {
40
+ where += " AND rl.created_at <= ?";
41
+ params.push(options.end_time);
42
+ }
28
43
  return { where, params };
29
44
  }
30
45
  export function getRequestLogs(db, options) {
@@ -42,6 +57,37 @@ export function getRequestLogs(db, options) {
42
57
  export function getRequestLogById(db, id) {
43
58
  return db.prepare("SELECT * FROM request_logs WHERE id = ?").get(id);
44
59
  }
60
+ /** 双写:collectTransportMetrics 写 request_metrics 的同时,更新 request_logs 的冗余列 */
61
+ export function updateLogMetrics(db, logId, m) {
62
+ db.prepare(`UPDATE request_logs SET
63
+ input_tokens = ?, output_tokens = ?, cache_read_tokens = ?,
64
+ ttft_ms = ?, tokens_per_second = ?, stop_reason = ?,
65
+ backend_model = (SELECT backend_model FROM request_metrics WHERE request_log_id = ?),
66
+ metrics_complete = ?
67
+ WHERE id = ?`).run(m.input_tokens ?? null, m.output_tokens ?? null, m.cache_read_tokens ?? null, m.ttft_ms ?? null, m.tokens_per_second ?? null, m.stop_reason ?? null, logId, m.is_complete ?? 1, logId);
68
+ }
69
+ /** 流式请求完成后,将 tracker 中累积的文本内容写入 request_logs */
70
+ export function updateLogStreamContent(db, logId, textContent) {
71
+ db.prepare("UPDATE request_logs SET stream_text_content = ? WHERE id = ?").run(textContent, logId);
72
+ }
73
+ /** 启动时回填:从 request_metrics 补齐 metrics_complete = 0 但实际有指标的行 */
74
+ export function backfillMetricsFromRequestMetrics(db) {
75
+ return db.prepare(`
76
+ UPDATE request_logs
77
+ SET
78
+ input_tokens = rm.input_tokens,
79
+ output_tokens = rm.output_tokens,
80
+ cache_read_tokens = rm.cache_read_tokens,
81
+ ttft_ms = rm.ttft_ms,
82
+ tokens_per_second = rm.tokens_per_second,
83
+ stop_reason = rm.stop_reason,
84
+ backend_model = rm.backend_model,
85
+ metrics_complete = rm.is_complete
86
+ FROM request_metrics rm
87
+ WHERE rm.request_log_id = request_logs.id
88
+ AND request_logs.metrics_complete = 0
89
+ `).run().changes;
90
+ }
45
91
  export function deleteLogsBefore(db, beforeDate) {
46
92
  return db.prepare("DELETE FROM request_logs WHERE created_at < ?").run(beforeDate).changes;
47
93
  }
@@ -1,5 +1,5 @@
1
1
  import Database from "better-sqlite3";
2
- export type MetricsPeriod = "1h" | "6h" | "24h" | "7d" | "30d";
2
+ export type MetricsPeriod = "1h" | "5h" | "6h" | "24h" | "7d" | "30d";
3
3
  export type MetricsMetric = "ttft" | "tps" | "tokens" | "cache_rate" | "request_count" | "input_tokens" | "output_tokens" | "cache_hit_tokens";
4
4
  export interface MetricsRow {
5
5
  id: string;
@@ -48,10 +48,10 @@ export interface MetricsSummaryRow {
48
48
  total_cache_hit_tokens: number;
49
49
  cache_hit_rate: number | null;
50
50
  }
51
- export declare function getMetricsSummary(db: Database.Database, period: MetricsPeriod, providerId?: string, backendModel?: string, routerKeyId?: string): MetricsSummaryRow[];
51
+ export declare function getMetricsSummary(db: Database.Database, period: MetricsPeriod, providerId?: string, backendModel?: string, routerKeyId?: string, startTime?: string, endTime?: string): MetricsSummaryRow[];
52
52
  export interface MetricsTimeseriesRow {
53
53
  time_bucket: string;
54
54
  avg_value: number | null;
55
55
  count: number;
56
56
  }
57
- export declare function getMetricsTimeseries(db: Database.Database, period: MetricsPeriod, metric: MetricsMetric, providerId?: string, backendModel?: string, routerKeyId?: string): MetricsTimeseriesRow[];
57
+ export declare function getMetricsTimeseries(db: Database.Database, period: MetricsPeriod, metric: MetricsMetric, providerId?: string, backendModel?: string, routerKeyId?: string, startTime?: string, endTime?: string): MetricsTimeseriesRow[];
@@ -7,6 +7,7 @@ export function insertMetrics(db, m) {
7
7
  }
8
8
  const PERIOD_OFFSET = {
9
9
  "1h": "-1 hours",
10
+ "5h": "-5 hours",
10
11
  "6h": "-6 hours",
11
12
  "24h": "-1 day",
12
13
  "7d": "-7 days",
@@ -14,6 +15,7 @@ const PERIOD_OFFSET = {
14
15
  };
15
16
  const BUCKET_SECONDS = {
16
17
  "1h": 60,
18
+ "5h": 300,
17
19
  "6h": 300,
18
20
  "24h": 900,
19
21
  "7d": 3600,
@@ -21,10 +23,39 @@ const BUCKET_SECONDS = {
21
23
  };
22
24
  // unix epoch 秒转毫秒的乘数
23
25
  const MS_PER_SEC = 1000;
24
- export function getMetricsSummary(db, period, providerId, backendModel, routerKeyId) {
25
- const offset = PERIOD_OFFSET[period];
26
- const conditions = ["rm.is_complete = 1", "rm.created_at >= datetime('now', ?)"];
27
- const params = [offset];
26
+ // 时间跨度(秒)→ 桶大小(秒)的阶梯映射,与 BUCKET_SECONDS 保持对齐
27
+ const BUCKET_THRESHOLDS = [
28
+ { maxSec: 3600, bucketSec: 60 }, // ≤1h: 1min
29
+ { maxSec: 21600, bucketSec: 300 }, // ≤6h: 5min
30
+ { maxSec: 86400, bucketSec: 900 }, // ≤1d: 15min
31
+ { maxSec: 604800, bucketSec: 3600 }, // ≤7d: 1h
32
+ ];
33
+ const FALLBACK_BUCKET_SEC = 14400; // >7d: 4h
34
+ function calculateBucketSeconds(startTime, endTime) {
35
+ const ms = new Date(endTime).getTime() - new Date(startTime).getTime();
36
+ const sec = ms / MS_PER_SEC;
37
+ const match = BUCKET_THRESHOLDS.find((t) => sec <= t.maxSec);
38
+ return match ? match.bucketSec : FALLBACK_BUCKET_SEC;
39
+ }
40
+ function buildTimeCondition(period, startTime, endTime) {
41
+ if (startTime && endTime) {
42
+ // request_metrics.created_at 用 datetime('now') 格式 (YYYY-MM-DD HH:MM:SS),
43
+ // 前端传入 ISO 8601,需要转换格式以匹配字符串比较
44
+ return {
45
+ timeWhere: "rm.created_at >= datetime(?) AND rm.created_at < datetime(?)",
46
+ timeParams: [startTime, endTime],
47
+ };
48
+ }
49
+ return {
50
+ timeWhere: "rm.created_at >= datetime('now', ?)",
51
+ timeParams: [PERIOD_OFFSET[period]],
52
+ };
53
+ }
54
+ export function getMetricsSummary(db, period, providerId, backendModel, routerKeyId, startTime, endTime) {
55
+ const { timeWhere, timeParams } = buildTimeCondition(period, startTime, endTime);
56
+ const conditions = ["rm.is_complete = 1", timeWhere];
57
+ const params = [...timeParams];
58
+ const joins = ["LEFT JOIN providers p ON p.id = rm.provider_id"];
28
59
  if (providerId) {
29
60
  conditions.push("rm.provider_id = ?");
30
61
  params.push(providerId);
@@ -33,48 +64,23 @@ export function getMetricsSummary(db, period, providerId, backendModel, routerKe
33
64
  conditions.push("rm.backend_model = ?");
34
65
  params.push(backendModel);
35
66
  }
36
- const joins = "LEFT JOIN providers p ON p.id = rm.provider_id";
37
67
  if (routerKeyId) {
68
+ joins.push("LEFT JOIN request_logs rl ON rl.id = rm.request_log_id");
38
69
  conditions.push("rl.router_key_id = ?");
39
70
  params.push(routerKeyId);
40
- return db.prepare(`
41
- SELECT
42
- rm.provider_id, COALESCE(p.name, rm.provider_id) AS provider_name, rm.backend_model,
43
- COUNT(*) AS request_count, AVG(rm.ttft_ms) AS avg_ttft_ms, NULL AS p50_ttft_ms, NULL AS p95_ttft_ms,
44
- AVG(rm.tokens_per_second) AS avg_tps,
45
- COALESCE(SUM(rm.input_tokens), 0) AS total_input_tokens, COALESCE(SUM(rm.output_tokens), 0) AS total_output_tokens,
46
- COALESCE(SUM(rm.cache_read_tokens), 0) AS total_cache_hit_tokens,
47
- CASE WHEN SUM(rm.input_tokens) > 0 THEN SUM(rm.cache_read_tokens) * 1.0 / SUM(rm.input_tokens) ELSE NULL END AS cache_hit_rate
48
- FROM request_metrics rm
49
- LEFT JOIN providers p ON p.id = rm.provider_id
50
- LEFT JOIN request_logs rl ON rl.id = rm.request_log_id
51
- WHERE ${conditions.join(" AND ")}
52
- GROUP BY rm.provider_id, rm.backend_model ORDER BY request_count DESC
53
- `).all(...params);
54
71
  }
55
- const where = conditions.join(" AND ");
56
72
  return db.prepare(`
57
73
  SELECT
58
- rm.provider_id,
59
- COALESCE(p.name, rm.provider_id) AS provider_name,
60
- rm.backend_model,
61
- COUNT(*) AS request_count,
62
- AVG(rm.ttft_ms) AS avg_ttft_ms,
63
- NULL AS p50_ttft_ms,
64
- NULL AS p95_ttft_ms,
74
+ rm.provider_id, COALESCE(p.name, rm.provider_id) AS provider_name, rm.backend_model,
75
+ COUNT(*) AS request_count, AVG(rm.ttft_ms) AS avg_ttft_ms, NULL AS p50_ttft_ms, NULL AS p95_ttft_ms,
65
76
  AVG(rm.tokens_per_second) AS avg_tps,
66
- COALESCE(SUM(rm.input_tokens), 0) AS total_input_tokens,
67
- COALESCE(SUM(rm.output_tokens), 0) AS total_output_tokens,
77
+ COALESCE(SUM(rm.input_tokens), 0) AS total_input_tokens, COALESCE(SUM(rm.output_tokens), 0) AS total_output_tokens,
68
78
  COALESCE(SUM(rm.cache_read_tokens), 0) AS total_cache_hit_tokens,
69
- CASE WHEN SUM(rm.input_tokens) > 0
70
- THEN SUM(rm.cache_read_tokens) * 1.0 / SUM(rm.input_tokens)
71
- ELSE NULL
72
- END AS cache_hit_rate
79
+ CASE WHEN SUM(rm.input_tokens) > 0 THEN SUM(rm.cache_read_tokens) * 1.0 / SUM(rm.input_tokens) ELSE NULL END AS cache_hit_rate
73
80
  FROM request_metrics rm
74
- ${joins}
75
- WHERE ${where}
76
- GROUP BY rm.provider_id, rm.backend_model
77
- ORDER BY request_count DESC
81
+ ${joins.join(" ")}
82
+ WHERE ${conditions.join(" AND ")}
83
+ GROUP BY rm.provider_id, rm.backend_model ORDER BY request_count DESC
78
84
  `).all(...params);
79
85
  }
80
86
  const METRIC_EXPR = {
@@ -87,11 +93,13 @@ const METRIC_EXPR = {
87
93
  output_tokens: "SUM(rm.output_tokens)",
88
94
  cache_hit_tokens: "SUM(rm.cache_read_tokens)",
89
95
  };
90
- export function getMetricsTimeseries(db, period, metric, providerId, backendModel, routerKeyId) {
91
- const offset = PERIOD_OFFSET[period];
92
- const bucketSec = BUCKET_SECONDS[period];
93
- const conditions = ["rm.is_complete = 1", "rm.created_at >= datetime('now', ?)"];
94
- const params = [offset];
96
+ export function getMetricsTimeseries(db, period, metric, providerId, backendModel, routerKeyId, startTime, endTime) {
97
+ const bucketSec = (startTime && endTime)
98
+ ? calculateBucketSeconds(startTime, endTime)
99
+ : BUCKET_SECONDS[period];
100
+ const { timeWhere, timeParams } = buildTimeCondition(period, startTime, endTime);
101
+ const conditions = ["rm.is_complete = 1", timeWhere];
102
+ const params = [...timeParams];
95
103
  if (providerId) {
96
104
  conditions.push("rm.provider_id = ?");
97
105
  params.push(providerId);
@@ -0,0 +1,11 @@
1
+ CREATE TABLE IF NOT EXISTS usage_windows (
2
+ id TEXT PRIMARY KEY,
3
+ router_key_id TEXT,
4
+ start_time TEXT NOT NULL,
5
+ end_time TEXT NOT NULL,
6
+ created_at TEXT DEFAULT (datetime('now'))
7
+ );
8
+
9
+ CREATE INDEX IF NOT EXISTS idx_usage_windows_start ON usage_windows(start_time);
10
+ CREATE INDEX IF NOT EXISTS idx_usage_windows_router_key ON usage_windows(router_key_id);
11
+ CREATE INDEX IF NOT EXISTS idx_usage_windows_end_time ON usage_windows(end_time);
@@ -0,0 +1,8 @@
1
+ -- 019_drop_log_redundancy.sql
2
+ -- 删除冗余大文本字段:request_body = client_request.body, response_body = upstream_response.body, client_response ≈ upstream_response
3
+ ALTER TABLE request_logs DROP COLUMN request_body;
4
+ ALTER TABLE request_logs DROP COLUMN response_body;
5
+ ALTER TABLE request_logs DROP COLUMN client_response;
6
+
7
+ -- 日志自动清理保留天数(默认 3 天,0 = 不自动清理)
8
+ INSERT OR IGNORE INTO settings (key, value) VALUES ('log_retention_days', '3');
@@ -0,0 +1,28 @@
1
+ -- V1 双写:将 request_metrics 列冗余到 request_logs,消除日志查询的 JOIN
2
+ -- request_metrics 表保留不动,聚合查询仍查它
3
+
4
+ ALTER TABLE request_logs ADD COLUMN input_tokens INTEGER;
5
+ ALTER TABLE request_logs ADD COLUMN output_tokens INTEGER;
6
+ ALTER TABLE request_logs ADD COLUMN cache_read_tokens INTEGER;
7
+ ALTER TABLE request_logs ADD COLUMN ttft_ms INTEGER;
8
+ ALTER TABLE request_logs ADD COLUMN tokens_per_second REAL;
9
+ ALTER TABLE request_logs ADD COLUMN stop_reason TEXT;
10
+ ALTER TABLE request_logs ADD COLUMN backend_model TEXT;
11
+ ALTER TABLE request_logs ADD COLUMN metrics_complete INTEGER NOT NULL DEFAULT 0;
12
+
13
+ -- 流式请求的累积文本内容(从 tracker.appendStreamChunk 中提取的纯文本)
14
+ ALTER TABLE request_logs ADD COLUMN stream_text_content TEXT;
15
+
16
+ -- 回填历史数据:把已有的 request_metrics 写入 request_logs 新列
17
+ UPDATE request_logs
18
+ SET
19
+ input_tokens = rm.input_tokens,
20
+ output_tokens = rm.output_tokens,
21
+ cache_read_tokens = rm.cache_read_tokens,
22
+ ttft_ms = rm.ttft_ms,
23
+ tokens_per_second = rm.tokens_per_second,
24
+ stop_reason = rm.stop_reason,
25
+ backend_model = rm.backend_model,
26
+ metrics_complete = rm.is_complete
27
+ FROM request_metrics rm
28
+ WHERE rm.request_log_id = request_logs.id;
@@ -25,8 +25,3 @@ export declare function createRetryRule(db: Database.Database, rule: {
25
25
  }): string;
26
26
  export declare function updateRetryRule(db: Database.Database, id: string, fields: Partial<Pick<RetryRule, "name" | "status_code" | "body_pattern" | "is_active" | "retry_strategy" | "retry_delay_ms" | "max_retries" | "max_delay_ms">>): void;
27
27
  export declare function deleteRetryRule(db: Database.Database, id: string): void;
28
- /**
29
- * 启动时按名称查重插入默认重试规则。
30
- * 已存在的规则不会被重复插入或覆盖。
31
- */
32
- export declare function seedDefaultRules(db: Database.Database): void;
@@ -27,39 +27,3 @@ export function updateRetryRule(db, id, fields) {
27
27
  export function deleteRetryRule(db, id) {
28
28
  deleteById(db, "retry_rules", id);
29
29
  }
30
- // ---------- Default seed rules ----------
31
- const DEFAULT_RETRY_FIELDS = {
32
- is_active: 1,
33
- retry_strategy: "exponential",
34
- retry_delay_ms: DEFAULT_RETRY_DELAY_MS,
35
- max_retries: DEFAULT_MAX_RETRIES,
36
- max_delay_ms: DEFAULT_MAX_DELAY_MS,
37
- };
38
- const DEFAULT_RULES = [
39
- { name: "429 Too Many Requests", status_code: 429, body_pattern: ".*", ...DEFAULT_RETRY_FIELDS },
40
- { name: "503 Service Unavailable", status_code: 503, body_pattern: ".*", ...DEFAULT_RETRY_FIELDS },
41
- { name: 'ZAI 网络错误 (code 1234)', status_code: 400, body_pattern: '"type"\\s*:\\s*"error".*"code"\\s*:\\s*"1234"', ...DEFAULT_RETRY_FIELDS },
42
- { name: 'ZAI 临时不可用', status_code: 400, body_pattern: '"type"\\s*:\\s*"error".*请稍后重试', ...DEFAULT_RETRY_FIELDS },
43
- { name: 'ZAI 操作失败 (code 500)', status_code: 400, body_pattern: '"type"\\s*:\\s*"error".*"code"\\s*:\\s*"500"', ...DEFAULT_RETRY_FIELDS },
44
- { name: 'ZAI 速率限制 (HTTP 200, code 1302)', status_code: 200, body_pattern: '"error".*"code"\\s*:\\s*"1302"', ...DEFAULT_RETRY_FIELDS },
45
- { name: 'ZAI SSE 错误 (HTTP 200, code 500)', status_code: 200, body_pattern: '"error".*"code"\\s*:\\s*"500"', ...DEFAULT_RETRY_FIELDS },
46
- { name: 'ZAI SSE 错误 (HTTP 200, code 1234)', status_code: 200, body_pattern: '"error".*"code"\\s*:\\s*"1234"', ...DEFAULT_RETRY_FIELDS },
47
- ];
48
- /**
49
- * 启动时按名称查重插入默认重试规则。
50
- * 已存在的规则不会被重复插入或覆盖。
51
- */
52
- export function seedDefaultRules(db) {
53
- const names = DEFAULT_RULES.map(r => r.name);
54
- const placeholders = names.map(() => '?').join(',');
55
- const existing = db.prepare(`SELECT name FROM retry_rules WHERE name IN (${placeholders})`).all(...names);
56
- const existingSet = new Set(existing.map(r => r.name));
57
- const stmt = db.prepare(`INSERT INTO retry_rules (id, name, status_code, body_pattern, is_active, created_at, retry_strategy, retry_delay_ms, max_retries, max_delay_ms)
58
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
59
- const now = new Date().toISOString();
60
- for (const rule of DEFAULT_RULES) {
61
- if (!existingSet.has(rule.name)) {
62
- stmt.run(randomUUID(), rule.name, rule.status_code, rule.body_pattern, rule.is_active, now, rule.retry_strategy, rule.retry_delay_ms, rule.max_retries, rule.max_delay_ms);
63
- }
64
- }
65
- }
@@ -2,3 +2,5 @@ import Database from "better-sqlite3";
2
2
  export declare function getSetting(db: Database.Database, key: string): string | null;
3
3
  export declare function setSetting(db: Database.Database, key: string, value: string): void;
4
4
  export declare function isInitialized(db: Database.Database): boolean;
5
+ export declare function getLogRetentionDays(db: Database.Database): number;
6
+ export declare function setLogRetentionDays(db: Database.Database, days: number): void;
@@ -8,3 +8,10 @@ export function setSetting(db, key, value) {
8
8
  export function isInitialized(db) {
9
9
  return getSetting(db, "initialized") === "true";
10
10
  }
11
+ export function getLogRetentionDays(db) {
12
+ const val = getSetting(db, "log_retention_days");
13
+ return val ? parseInt(val, 10) : 3;
14
+ }
15
+ export function setLogRetentionDays(db, days) {
16
+ setSetting(db, "log_retention_days", String(days));
17
+ }
@@ -0,0 +1,19 @@
1
+ import Database from "better-sqlite3";
2
+ export interface UsageWindow {
3
+ id: string;
4
+ router_key_id: string | null;
5
+ start_time: string;
6
+ end_time: string;
7
+ created_at: string;
8
+ }
9
+ export interface WindowUsage {
10
+ request_count: number;
11
+ total_input_tokens: number;
12
+ total_output_tokens: number;
13
+ }
14
+ 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[];
18
+ /** 聚合指定时间窗口内的请求计数和 token 用量 */
19
+ export declare function getWindowUsage(db: Database.Database, startTime: string, endTime: string, routerKeyId?: string): WindowUsage;
@@ -0,0 +1,37 @@
1
+ import { randomUUID } from "crypto";
2
+ export function insertWindow(db, w) {
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);
5
+ return id;
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] : [];
12
+ return db.prepare(sql).get(...params) ?? null;
13
+ }
14
+ /** 返回与 [start, end) 区间有重叠的窗口 */
15
+ export function getWindowsInRange(db, start, end, routerKeyId) {
16
+ 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);
18
+ }
19
+ return db.prepare("SELECT * FROM usage_windows WHERE start_time < ? AND end_time > ? ORDER BY start_time ASC").all(end, start);
20
+ }
21
+ /** 聚合指定时间窗口内的请求计数和 token 用量 */
22
+ export function getWindowUsage(db, startTime, endTime, routerKeyId) {
23
+ const baseSql = `
24
+ SELECT
25
+ COUNT(*) AS request_count,
26
+ COALESCE(SUM(rm.input_tokens), 0) AS total_input_tokens,
27
+ COALESCE(SUM(rm.output_tokens), 0) AS total_output_tokens
28
+ 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);
37
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { FastifyInstance } from "fastify";
3
3
  import { Config } from "./config.js";
4
+ import { UsageWindowTracker } from "./proxy/usage-window-tracker.js";
4
5
  import Database from "better-sqlite3";
5
6
  export interface AppOptions {
6
7
  config?: Config;
@@ -9,6 +10,7 @@ export interface AppOptions {
9
10
  export declare function buildApp(options?: AppOptions): Promise<{
10
11
  app: FastifyInstance;
11
12
  db: Database.Database;
13
+ usageWindowTracker: UsageWindowTracker;
12
14
  close: () => Promise<void>;
13
15
  }>;
14
16
  export declare function main(): Promise<void>;
package/dist/index.js CHANGED
@@ -21,7 +21,8 @@ function getProxyApiType(url) {
21
21
  const __filename = fileURLToPath(import.meta.url);
22
22
  const __dirname = path.dirname(__filename);
23
23
  import { getConfig } from "./config.js";
24
- import { initDatabase, seedDefaultRules, getAllProviders } from "./db/index.js";
24
+ import { initDatabase, getAllProviders, backfillMetricsFromRequestMetrics } from "./db/index.js";
25
+ import { loadRecommendedConfig } from "./config/recommended.js";
25
26
  import { authMiddleware } from "./middleware/auth.js";
26
27
  import { openaiProxy } from "./proxy/openai.js";
27
28
  import { anthropicProxy } from "./proxy/anthropic.js";
@@ -30,16 +31,20 @@ import { RetryRuleMatcher } from "./proxy/retry-rules.js";
30
31
  import { ProviderSemaphoreManager } from "./proxy/semaphore.js";
31
32
  import { RequestTracker } from "./monitor/request-tracker.js";
32
33
  import { modelState } from "./proxy/model-state.js";
34
+ import { UsageWindowTracker } from "./proxy/usage-window-tracker.js";
35
+ import { scheduleLogCleanup } from "./db/log-cleaner.js";
33
36
  import fastifyStatic from "@fastify/static";
34
37
  export async function buildApp(options) {
35
38
  const config = options?.config ?? getBaseConfig();
36
39
  // 允许外部传入已初始化的 DB(测试用),否则自行创建
37
40
  let db;
41
+ let shouldBackfill = false;
38
42
  if (options?.db) {
39
43
  db = options.db;
40
44
  }
41
45
  else {
42
46
  db = initDatabase(config.DB_PATH);
47
+ shouldBackfill = true;
43
48
  }
44
49
  const isDev = process.env.NODE_ENV !== "production";
45
50
  const app = Fastify({
@@ -100,8 +105,14 @@ export async function buildApp(options) {
100
105
  }
101
106
  return reply.code(status).send({ error: { message: fastifyError.message } });
102
107
  });
103
- // 首次启动时插入默认重试规则(表为空时)
104
- seedDefaultRules(db);
108
+ loadRecommendedConfig();
109
+ // 启动时回填:补齐回退老版本期间缺失的 metrics 冗余列
110
+ if (shouldBackfill) {
111
+ const backfilled = backfillMetricsFromRequestMetrics(db);
112
+ if (backfilled > 0) {
113
+ app.log.info({ backfilled }, "Backfilled metrics from request_metrics");
114
+ }
115
+ }
105
116
  // 注入 DB 到 modelState 单例,启用会话级持久化
106
117
  modelState.init(db);
107
118
  const matcher = new RetryRuleMatcher();
@@ -109,6 +120,9 @@ export async function buildApp(options) {
109
120
  const semaphoreManager = new ProviderSemaphoreManager();
110
121
  const tracker = new RequestTracker({ semaphoreManager, logger: app.log });
111
122
  tracker.startPushInterval();
123
+ // 5h 用量窗口追踪器,启动时自动补齐缺失窗口
124
+ const usageWindowTracker = new UsageWindowTracker(db);
125
+ usageWindowTracker.reconcileOnStartup();
112
126
  // 从 DB 读取已有 provider 的并发配置,初始化信号量管理器和 tracker
113
127
  const allProviders = getAllProviders(db);
114
128
  for (const p of allProviders) {
@@ -169,10 +183,13 @@ export async function buildApp(options) {
169
183
  app.get("/health", async () => {
170
184
  return { status: "ok" };
171
185
  });
186
+ const logCleanup = scheduleLogCleanup(db, app.log);
172
187
  return {
173
188
  app,
174
189
  db,
190
+ usageWindowTracker,
175
191
  close: async () => {
192
+ logCleanup.stop();
176
193
  tracker.stopPushInterval();
177
194
  await app.close();
178
195
  db.close();
@@ -27,6 +27,7 @@ export interface ActiveRequest {
27
27
  streamMetrics?: StreamMetricsSnapshot;
28
28
  streamContent?: StreamContentSnapshot;
29
29
  clientIp?: string;
30
+ sessionId?: string;
30
31
  completedAt?: number;
31
32
  }
32
33
  export interface AttemptSnapshot {
@@ -38,7 +39,9 @@ export interface AttemptSnapshot {
38
39
  export interface StreamMetricsSnapshot {
39
40
  inputTokens: number | null;
40
41
  outputTokens: number | null;
42
+ cacheReadTokens: number | null;
41
43
  ttftMs: number | null;
44
+ tokensPerSecond: number | null;
42
45
  stopReason: string | null;
43
46
  isComplete: boolean;
44
47
  }