llm-simple-router 0.4.3 → 0.5.1

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 (122) hide show
  1. package/README.md +67 -18
  2. package/dist/admin/monitor.js +8 -0
  3. package/dist/admin/routes.js +4 -0
  4. package/dist/admin/settings-import-export.d.ts +11 -0
  5. package/dist/admin/settings-import-export.js +144 -0
  6. package/dist/admin/settings.js +40 -2
  7. package/dist/admin/upgrade.d.ts +13 -0
  8. package/dist/admin/upgrade.js +113 -0
  9. package/dist/db/db-size-monitor.d.ts +22 -0
  10. package/dist/db/db-size-monitor.js +81 -0
  11. package/dist/db/index.d.ts +4 -1
  12. package/dist/db/index.js +4 -1
  13. package/dist/db/log-cleaner.js +16 -7
  14. package/dist/db/logs.d.ts +9 -1
  15. package/dist/db/logs.js +55 -5
  16. package/dist/db/migrations/022_add_session_id_and_incremental_vacuum.sql +5 -0
  17. package/dist/db/settings.d.ts +6 -0
  18. package/dist/db/settings.js +25 -1
  19. package/dist/index.d.ts +2 -0
  20. package/dist/index.js +8 -0
  21. package/dist/metrics/metrics-extractor.js +4 -0
  22. package/dist/monitor/request-tracker.d.ts +2 -0
  23. package/dist/monitor/request-tracker.js +20 -1
  24. package/dist/monitor/types.d.ts +1 -0
  25. package/dist/proxy/log-helpers.d.ts +2 -0
  26. package/dist/proxy/log-helpers.js +4 -2
  27. package/dist/proxy/model-state.d.ts +2 -0
  28. package/dist/proxy/model-state.js +4 -0
  29. package/dist/proxy/orchestrator.d.ts +2 -0
  30. package/dist/proxy/orchestrator.js +1 -0
  31. package/dist/proxy/proxy-handler.js +66 -34
  32. package/dist/proxy/proxy-logging.d.ts +2 -1
  33. package/dist/proxy/proxy-logging.js +5 -1
  34. package/dist/proxy/semaphore.d.ts +2 -0
  35. package/dist/proxy/semaphore.js +11 -0
  36. package/dist/upgrade/checker.d.ts +25 -0
  37. package/dist/upgrade/checker.js +105 -0
  38. package/dist/upgrade/deployment.d.ts +2 -0
  39. package/dist/upgrade/deployment.js +20 -0
  40. package/dist/upgrade/version.d.ts +1 -0
  41. package/dist/upgrade/version.js +13 -0
  42. package/frontend-dist/assets/CardContent-Deyvo1TQ.js +1 -0
  43. package/frontend-dist/assets/CardTitle-DujSYXja.js +1 -0
  44. package/frontend-dist/assets/Checkbox-BJxf-QuV.js +1 -0
  45. package/frontend-dist/assets/CollapsibleTrigger-ByCvAsW0.js +1 -0
  46. package/frontend-dist/assets/Collection-V6gcBlwC.js +1 -0
  47. package/frontend-dist/assets/{Dashboard-D9ZMKiZw.js → Dashboard-xqf6PcmE.js} +2 -2
  48. package/frontend-dist/assets/DialogTitle-D0nwX87v.js +1 -0
  49. package/frontend-dist/assets/{Input-BA5UO-Ab.js → Input-D0kpZB31.js} +1 -1
  50. package/frontend-dist/assets/Label-BvYK0rd6.js +1 -0
  51. package/frontend-dist/assets/Login-C9oPKRcu.js +1 -0
  52. package/frontend-dist/assets/Logs-DVgenFav.js +1 -0
  53. package/frontend-dist/assets/ModelMappings-BoG2P9Rh.js +1 -0
  54. package/frontend-dist/assets/Monitor-W441wik3.js +1 -0
  55. package/frontend-dist/assets/PopperContent-DVJ4IxLF.js +1 -0
  56. package/frontend-dist/assets/Providers-D2rzb_Qk.js +1 -0
  57. package/frontend-dist/assets/ProxyEnhancement-DahQkV1g.js +5 -0
  58. package/frontend-dist/assets/RetryRules-Bg9p50oc.js +1 -0
  59. package/frontend-dist/assets/RouterKeys-C1LhXbqf.js +1 -0
  60. package/frontend-dist/assets/SelectValue-CAEBdE04.js +1 -0
  61. package/frontend-dist/assets/Settings-3lR8QVQt.js +6 -0
  62. package/frontend-dist/assets/Setup-Dzj1XvgF.js +1 -0
  63. package/frontend-dist/assets/Switch-CST3045A.js +1 -0
  64. package/frontend-dist/assets/TableHeader-CIrxcNRh.js +1 -0
  65. package/frontend-dist/assets/TabsContent-B4nroq3-.js +1 -0
  66. package/frontend-dist/assets/TabsTrigger-FsELRpyc.js +1 -0
  67. package/frontend-dist/assets/Teleport-DVgMe9KS.js +3 -0
  68. package/frontend-dist/assets/UnifiedRequestDialog-B2nt8nLl.css +1 -0
  69. package/frontend-dist/assets/UnifiedRequestDialog-Fe2TfhTD.js +3 -0
  70. package/frontend-dist/assets/{VisuallyHidden-ppNQPfSR.js → VisuallyHidden-CjuTDGlC.js} +1 -1
  71. package/frontend-dist/assets/VisuallyHiddenInput-BaW-2aEF.js +1 -0
  72. package/frontend-dist/assets/alert-dialog-Bv6dVarS.js +1 -0
  73. package/frontend-dist/assets/badge-CEfcely6.js +1 -0
  74. package/frontend-dist/assets/button-BmxhlpN-.js +12 -0
  75. package/frontend-dist/assets/{createLucideIcon-D2A5NyBH.js → createLucideIcon-UWoYUKtZ.js} +1 -1
  76. package/frontend-dist/assets/dialog-QaGxKbze.js +1 -0
  77. package/frontend-dist/assets/{file-text-D-Vekfc6.js → file-text-D38GtYz2.js} +1 -1
  78. package/frontend-dist/assets/index-CMBzqUyT.css +1 -0
  79. package/frontend-dist/assets/index-D484ZFa9.js +1 -0
  80. package/frontend-dist/assets/lib-CSYRBKqn.js +1 -0
  81. package/frontend-dist/assets/{ohash.D__AXeF1-B5oxGXRD.js → ohash.D__AXeF1-BUMsW586.js} +1 -1
  82. package/frontend-dist/assets/{useClipboard-D3mHD2V6.js → useClipboard-CuE5xXIg.js} +1 -1
  83. package/frontend-dist/assets/useLogRetention-DesMKwIU.js +1 -0
  84. package/frontend-dist/assets/useNonce-FLqOooWA.js +1 -0
  85. package/frontend-dist/assets/x-BEUXSxcj.js +1 -0
  86. package/frontend-dist/index.html +17 -8
  87. package/package.json +1 -1
  88. package/frontend-dist/assets/CardContent-BIgWZo6N.js +0 -1
  89. package/frontend-dist/assets/CardTitle-BVVp36Pq.js +0 -1
  90. package/frontend-dist/assets/Checkbox-CnvHc21k.js +0 -1
  91. package/frontend-dist/assets/CollapsibleTrigger-DG8JZQfm.js +0 -1
  92. package/frontend-dist/assets/Collection-B4yfvPwd.js +0 -3
  93. package/frontend-dist/assets/DialogTitle-C7Wl7fWN.js +0 -1
  94. package/frontend-dist/assets/Label-CmG21jDR.js +0 -1
  95. package/frontend-dist/assets/Login-Dqwj0s5l.js +0 -1
  96. package/frontend-dist/assets/Logs-CGzGzcDa.js +0 -1
  97. package/frontend-dist/assets/ModelMappings-qxaFWRsq.js +0 -1
  98. package/frontend-dist/assets/Monitor-Zy3O9UQA.js +0 -1
  99. package/frontend-dist/assets/PopperContent-B48BBgdW.js +0 -1
  100. package/frontend-dist/assets/Providers-DRMSjTlb.js +0 -1
  101. package/frontend-dist/assets/ProxyEnhancement-CtlDQ_oK.js +0 -5
  102. package/frontend-dist/assets/RetryRules-CxbZt6dv.js +0 -1
  103. package/frontend-dist/assets/RouterKeys-drAASJqH.js +0 -1
  104. package/frontend-dist/assets/SelectValue-Ct22fmR7.js +0 -1
  105. package/frontend-dist/assets/Setup-CfscyFFo.js +0 -1
  106. package/frontend-dist/assets/Switch-ROnHu48E.js +0 -1
  107. package/frontend-dist/assets/TableHeader-R3y9M8qo.js +0 -1
  108. package/frontend-dist/assets/TabsContent-CtT2wglf.js +0 -1
  109. package/frontend-dist/assets/TabsTrigger-D2S77VwS.js +0 -1
  110. package/frontend-dist/assets/UnifiedRequestDialog-BhGWzOYY.js +0 -3
  111. package/frontend-dist/assets/UnifiedRequestDialog-CotaDyW7.css +0 -1
  112. package/frontend-dist/assets/VisuallyHiddenInput-DOdhvOHf.js +0 -1
  113. package/frontend-dist/assets/alert-dialog-BbI_a1Xz.js +0 -1
  114. package/frontend-dist/assets/badge-CcgNasyU.js +0 -1
  115. package/frontend-dist/assets/button-BkhL20Qz.js +0 -12
  116. package/frontend-dist/assets/dialog-BeetdYQi.js +0 -1
  117. package/frontend-dist/assets/index-Bgqc9Lca.js +0 -1
  118. package/frontend-dist/assets/index-hzTjKE_2.css +0 -1
  119. package/frontend-dist/assets/lib-BAWhmkfz.js +0 -1
  120. package/frontend-dist/assets/useForwardExpose-Pf45y-q1.js +0 -1
  121. package/frontend-dist/assets/useNonce-Dfbnp6Co.js +0 -1
  122. package/frontend-dist/assets/x-CrCHzImd.js +0 -1
@@ -13,7 +13,8 @@ export function runLogCleanup(db) {
13
13
  /** 启动定时清理,返回 handle 用于停止 */
14
14
  export function scheduleLogCleanup(db, log) {
15
15
  let cleaning = false;
16
- let timer = null;
16
+ let initialTimer = null;
17
+ let intervalTimer = null;
17
18
  const doCleanup = () => {
18
19
  if (cleaning)
19
20
  return;
@@ -23,19 +24,27 @@ export function scheduleLogCleanup(db, log) {
23
24
  if (deleted > 0)
24
25
  log.info(`Log cleanup: deleted ${deleted} records`);
25
26
  }
27
+ catch (e) {
28
+ // DB 可能已关闭(测试清理、进程关闭等)
29
+ log.info(`Log cleanup skipped: ${e instanceof Error ? e.message : String(e)}`);
30
+ }
26
31
  finally {
27
32
  cleaning = false;
28
33
  }
29
34
  };
30
- // 启动时立即执行一次
31
- doCleanup();
35
+ // 推迟到下一个事件循环 tick,避免阻塞服务器启动
36
+ initialTimer = setTimeout(doCleanup, 0);
32
37
  // 定时执行
33
- timer = setInterval(doCleanup, CLEANUP_INTERVAL_MS);
38
+ intervalTimer = setInterval(doCleanup, CLEANUP_INTERVAL_MS);
34
39
  return {
35
40
  stop: () => {
36
- if (timer) {
37
- clearInterval(timer);
38
- timer = null;
41
+ if (initialTimer) {
42
+ clearTimeout(initialTimer);
43
+ initialTimer = null;
44
+ }
45
+ if (intervalTimer) {
46
+ clearInterval(intervalTimer);
47
+ intervalTimer = null;
39
48
  }
40
49
  },
41
50
  };
package/dist/db/logs.d.ts CHANGED
@@ -25,6 +25,7 @@ export interface RequestLog {
25
25
  backend_model: string | null;
26
26
  metrics_complete: number;
27
27
  stream_text_content: string | null;
28
+ session_id: string | null;
28
29
  }
29
30
  /** 列表查询扩展字段:JOIN providers 获得 provider_name */
30
31
  export interface RequestLogListRow extends RequestLog {
@@ -49,6 +50,7 @@ export interface RequestLogInsert {
49
50
  original_request_id?: string | null;
50
51
  router_key_id?: string | null;
51
52
  original_model?: string | null;
53
+ session_id?: string | null;
52
54
  }
53
55
  export declare function insertRequestLog(db: Database.Database, log: RequestLogInsert): void;
54
56
  export declare function getRequestLogs(db: Database.Database, options: {
@@ -64,7 +66,7 @@ export declare function getRequestLogs(db: Database.Database, options: {
64
66
  data: RequestLogListRow[];
65
67
  total: number;
66
68
  };
67
- export declare function getRequestLogById(db: Database.Database, id: string): RequestLog | undefined;
69
+ export declare function getRequestLogById(db: Database.Database, id: string): RequestLogListRow | undefined;
68
70
  type MetricsUpdate = {
69
71
  input_tokens?: number | null;
70
72
  output_tokens?: number | null;
@@ -81,6 +83,12 @@ export declare function updateLogStreamContent(db: Database.Database, logId: str
81
83
  /** 启动时回填:从 request_metrics 补齐 metrics_complete = 0 但实际有指标的行 */
82
84
  export declare function backfillMetricsFromRequestMetrics(db: Database.Database): number;
83
85
  export declare function deleteLogsBefore(db: Database.Database, beforeDate: string): number;
86
+ /** 估算 request_logs 表占用字节数 */
87
+ export declare function estimateLogTableSize(db: Database.Database): number;
88
+ /** 删除最旧的日志,保留 keepCount 条,返回实际删除条数。分批删除避免长时间锁表 */
89
+ export declare function deleteOldestLogs(db: Database.Database, keepCount: number): number;
90
+ /** 获取 request_logs 总行数 */
91
+ export declare function getLogCount(db: Database.Database): number;
84
92
  /** 查询某条日志的子请求(retry/failover 关联),上限 100 条 */
85
93
  export declare function getRequestLogChildren(db: Database.Database, parentId: string): RequestLogListRow[];
86
94
  export interface RequestLogGroupedRow extends RequestLogListRow {
package/dist/db/logs.js CHANGED
@@ -4,14 +4,14 @@ const LOG_LIST_SELECT = `rl.id, rl.api_type, rl.model, rl.provider_id, rl.status
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
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,
7
+ rl.backend_model, rl.metrics_complete, rl.session_id,
8
8
  COALESCE(p.name, rl.provider_id) AS provider_name`;
9
9
  const LOG_LIST_JOIN = `LEFT JOIN providers p ON p.id = rl.provider_id`;
10
10
  export function insertRequestLog(db, log) {
11
11
  db.prepare(`INSERT INTO request_logs (id, api_type, model, provider_id, status_code, latency_ms,
12
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);
13
+ is_retry, is_failover, original_request_id, router_key_id, original_model, session_id)
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, log.session_id ?? null);
15
15
  }
16
16
  function buildLogWhereClause(options, baseCondition) {
17
17
  let where = baseCondition;
@@ -55,7 +55,9 @@ export function getRequestLogs(db, options) {
55
55
  return { data, total };
56
56
  }
57
57
  export function getRequestLogById(db, id) {
58
- return db.prepare("SELECT * FROM request_logs WHERE id = ?").get(id);
58
+ return db.prepare(`SELECT rl.*, COALESCE(p.name, rl.provider_id) AS provider_name
59
+ FROM request_logs rl LEFT JOIN providers p ON p.id = rl.provider_id
60
+ WHERE rl.id = ?`).get(id);
59
61
  }
60
62
  /** 双写:collectTransportMetrics 写 request_metrics 的同时,更新 request_logs 的冗余列 */
61
63
  export function updateLogMetrics(db, logId, m) {
@@ -89,7 +91,55 @@ export function backfillMetricsFromRequestMetrics(db) {
89
91
  `).run().changes;
90
92
  }
91
93
  export function deleteLogsBefore(db, beforeDate) {
92
- return db.prepare("DELETE FROM request_logs WHERE created_at < ?").run(beforeDate).changes;
94
+ const changes = db.prepare("DELETE FROM request_logs WHERE created_at < ?").run(beforeDate).changes;
95
+ if (changes > 0) {
96
+ db.pragma("incremental_vacuum");
97
+ }
98
+ return changes;
99
+ }
100
+ /** 每行元数据(数字列+索引)的估算字节数 */
101
+ const ROW_METADATA_BYTES = 500;
102
+ /** 估算 request_logs 表占用字节数 */
103
+ export function estimateLogTableSize(db) {
104
+ const row = db.prepare(`
105
+ SELECT COALESCE(SUM(
106
+ COALESCE(length(client_request), 0) + COALESCE(length(upstream_request), 0) +
107
+ COALESCE(length(upstream_response), 0) + COALESCE(length(stream_text_content), 0) +
108
+ COALESCE(length(error_message), 0) + ?
109
+ ), 0) as size
110
+ FROM request_logs
111
+ `).get(ROW_METADATA_BYTES);
112
+ return row.size;
113
+ }
114
+ const DELETE_BATCH_SIZE = 1000;
115
+ /** 删除最旧的日志,保留 keepCount 条,返回实际删除条数。分批删除避免长时间锁表 */
116
+ export function deleteOldestLogs(db, keepCount) {
117
+ const total = db.prepare("SELECT count(*) as c FROM request_logs").get().c;
118
+ const toDelete = Math.max(0, total - keepCount);
119
+ if (toDelete === 0)
120
+ return 0;
121
+ let totalDeleted = 0;
122
+ const stmt = db.prepare(`
123
+ DELETE FROM request_logs
124
+ WHERE rowid IN (
125
+ SELECT rowid FROM request_logs ORDER BY created_at ASC LIMIT ?
126
+ )
127
+ `);
128
+ while (totalDeleted < toDelete) {
129
+ const batchSize = Math.min(DELETE_BATCH_SIZE, toDelete - totalDeleted);
130
+ const result = stmt.run(batchSize);
131
+ totalDeleted += result.changes;
132
+ if (result.changes < batchSize)
133
+ break;
134
+ }
135
+ if (totalDeleted > 0) {
136
+ db.pragma("incremental_vacuum");
137
+ }
138
+ return totalDeleted;
139
+ }
140
+ /** 获取 request_logs 总行数 */
141
+ export function getLogCount(db) {
142
+ return db.prepare("SELECT count(*) as c FROM request_logs").get().c;
93
143
  }
94
144
  /** 查询某条日志的子请求(retry/failover 关联),上限 100 条 */
95
145
  export function getRequestLogChildren(db, parentId) {
@@ -0,0 +1,5 @@
1
+ ALTER TABLE request_logs ADD COLUMN session_id TEXT;
2
+
3
+ -- NOTE: PRAGMA auto_vacuum only takes effect after VACUUM for existing databases.
4
+ -- Run scripts/vacuum-migrate.js for existing deployments.
5
+ PRAGMA auto_vacuum = 2;
@@ -4,3 +4,9 @@ export declare function setSetting(db: Database.Database, key: string, value: st
4
4
  export declare function isInitialized(db: Database.Database): boolean;
5
5
  export declare function getLogRetentionDays(db: Database.Database): number;
6
6
  export declare function setLogRetentionDays(db: Database.Database, days: number): void;
7
+ export declare function getDbMaxSizeMb(db: Database.Database): number;
8
+ export declare function setDbMaxSizeMb(db: Database.Database, mb: number): void;
9
+ export declare function getLogTableMaxSizeMb(db: Database.Database): number;
10
+ export declare function setLogTableMaxSizeMb(db: Database.Database, mb: number): void;
11
+ export declare function getConfigSyncSource(db: Database.Database): "github" | "gitee";
12
+ export declare function setConfigSyncSource(db: Database.Database, source: "github" | "gitee"): void;
@@ -10,8 +10,32 @@ export function isInitialized(db) {
10
10
  }
11
11
  export function getLogRetentionDays(db) {
12
12
  const val = getSetting(db, "log_retention_days");
13
- return val ? parseInt(val, 10) : 3;
13
+ const DEFAULT_LOG_RETENTION_DAYS = 3;
14
+ return val ? parseInt(val, 10) : DEFAULT_LOG_RETENTION_DAYS;
14
15
  }
15
16
  export function setLogRetentionDays(db, days) {
16
17
  setSetting(db, "log_retention_days", String(days));
17
18
  }
19
+ const DEFAULT_DB_MAX_SIZE_MB = 1024;
20
+ const DEFAULT_LOG_TABLE_MAX_SIZE_MB = 800;
21
+ export function getDbMaxSizeMb(db) {
22
+ const val = getSetting(db, "db_max_size_mb");
23
+ return val ? parseInt(val, 10) : DEFAULT_DB_MAX_SIZE_MB;
24
+ }
25
+ export function setDbMaxSizeMb(db, mb) {
26
+ setSetting(db, "db_max_size_mb", String(mb));
27
+ }
28
+ export function getLogTableMaxSizeMb(db) {
29
+ const val = getSetting(db, "log_table_max_size_mb");
30
+ return val ? parseInt(val, 10) : DEFAULT_LOG_TABLE_MAX_SIZE_MB;
31
+ }
32
+ export function setLogTableMaxSizeMb(db, mb) {
33
+ setSetting(db, "log_table_max_size_mb", String(mb));
34
+ }
35
+ export function getConfigSyncSource(db) {
36
+ const val = getSetting(db, "config_sync_source");
37
+ return val === "gitee" ? "gitee" : "github";
38
+ }
39
+ export function setConfigSyncSource(db, source) {
40
+ setSetting(db, "config_sync_source", source);
41
+ }
package/dist/index.d.ts CHANGED
@@ -2,10 +2,12 @@
2
2
  import { FastifyInstance } from "fastify";
3
3
  import { Config } from "./config.js";
4
4
  import { UsageWindowTracker } from "./proxy/usage-window-tracker.js";
5
+ import { CheckerOptions } from "./upgrade/checker.js";
5
6
  import Database from "better-sqlite3";
6
7
  export interface AppOptions {
7
8
  config?: Config;
8
9
  db?: Database.Database;
10
+ upgradeCheckerOptions?: CheckerOptions;
9
11
  }
10
12
  export declare function buildApp(options?: AppOptions): Promise<{
11
13
  app: FastifyInstance;
package/dist/index.js CHANGED
@@ -33,6 +33,8 @@ import { RequestTracker } from "./monitor/request-tracker.js";
33
33
  import { modelState } from "./proxy/model-state.js";
34
34
  import { UsageWindowTracker } from "./proxy/usage-window-tracker.js";
35
35
  import { scheduleLogCleanup } from "./db/log-cleaner.js";
36
+ import { scheduleDbSizeMonitor } from "./db/db-size-monitor.js";
37
+ import { startUpgradeChecker, stopUpgradeChecker } from "./admin/upgrade.js";
36
38
  import fastifyStatic from "@fastify/static";
37
39
  export async function buildApp(options) {
38
40
  const config = options?.config ?? getBaseConfig();
@@ -106,6 +108,7 @@ export async function buildApp(options) {
106
108
  return reply.code(status).send({ error: { message: fastifyError.message } });
107
109
  });
108
110
  loadRecommendedConfig();
111
+ startUpgradeChecker(options?.upgradeCheckerOptions);
109
112
  // 启动时回填:补齐回退老版本期间缺失的 metrics 冗余列
110
113
  if (shouldBackfill) {
111
114
  const backfilled = backfillMetricsFromRequestMetrics(db);
@@ -184,12 +187,17 @@ export async function buildApp(options) {
184
187
  return { status: "ok" };
185
188
  });
186
189
  const logCleanup = scheduleLogCleanup(db, app.log);
190
+ const dbSizeMonitor = scheduleDbSizeMonitor(db, config.DB_PATH, {
191
+ log: app.log,
192
+ });
187
193
  return {
188
194
  app,
189
195
  db,
190
196
  usageWindowTracker,
191
197
  close: async () => {
198
+ stopUpgradeChecker();
192
199
  logCleanup.stop();
200
+ dbSizeMonitor.stop();
193
201
  tracker.stopPushInterval();
194
202
  await app.close();
195
203
  db.close();
@@ -92,6 +92,10 @@ export class MetricsExtractor {
92
92
  else if (type === "message_delta") {
93
93
  const msg = parsed;
94
94
  this.outputTokens = msg.usage?.output_tokens ?? null;
95
+ // 第三方 Anthropic 兼容 API(如 OpenRouter、智谱)可能将 input_tokens 放在 message_delta 而非 message_start
96
+ if (this.inputTokens === null && msg.usage?.input_tokens) {
97
+ this.inputTokens = msg.usage.input_tokens;
98
+ }
95
99
  this.stopReason = msg.delta?.stop_reason ?? null;
96
100
  this.streamEndTime = Date.now();
97
101
  }
@@ -34,6 +34,8 @@ export declare class RequestTracker {
34
34
  getActive(): ActiveRequest[];
35
35
  getRecent(limit?: number): ActiveRequest[];
36
36
  get(id: string): ActiveRequest | undefined;
37
+ /** Public alias for API endpoint use — returns full request data including clientRequest */
38
+ getRequestById(id: string): ActiveRequest | undefined;
37
39
  getStats(): StatsSnapshot;
38
40
  getConcurrency(): ProviderConcurrencySnapshot[];
39
41
  getRuntime(): RuntimeMetrics;
@@ -135,6 +135,10 @@ export class RequestTracker {
135
135
  get(id) {
136
136
  return this.activeMap.get(id) ?? this.recentCompleted.find((r) => r.id === id);
137
137
  }
138
+ /** Public alias for API endpoint use — returns full request data including clientRequest */
139
+ getRequestById(id) {
140
+ return this.get(id);
141
+ }
138
142
  // --- Stats / monitoring ---
139
143
  getStats() {
140
144
  return this.statsAggregator.getStats();
@@ -195,7 +199,22 @@ export class RequestTracker {
195
199
  }
196
200
  }
197
201
  broadcast(event, data) {
198
- const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
202
+ // Strip clientRequest from broadcasts to reduce bandwidth;
203
+ // full data available on-demand via API endpoint
204
+ let payload = data;
205
+ if (event === "request_update" && Array.isArray(data)) {
206
+ payload = data.map((req) => {
207
+ const copy = { ...req };
208
+ delete copy.clientRequest;
209
+ return copy;
210
+ });
211
+ }
212
+ else if (event === "request_complete" && data && typeof data === "object") {
213
+ const copy = { ...data };
214
+ delete copy.clientRequest;
215
+ payload = copy;
216
+ }
217
+ const msg = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
199
218
  const clientCount = this.clients.size;
200
219
  let sentCount = 0;
201
220
  for (const client of this.clients) {
@@ -28,6 +28,7 @@ export interface ActiveRequest {
28
28
  streamContent?: StreamContentSnapshot;
29
29
  clientIp?: string;
30
30
  sessionId?: string;
31
+ clientRequest?: string;
31
32
  completedAt?: number;
32
33
  }
33
34
  export interface AttemptSnapshot {
@@ -24,6 +24,7 @@ export interface RequestLogParams extends LogRetryMeta {
24
24
  upHdrs: Record<string, string>;
25
25
  routerKeyId?: string | null;
26
26
  originalModel?: string | null;
27
+ sessionId?: string | null;
27
28
  }
28
29
  /** 插入成功请求日志,供 openai/anthropic 插件共享 */
29
30
  export declare function insertSuccessLog(db: Database.Database, params: RequestLogParams): void;
@@ -41,6 +42,7 @@ export interface RejectedLogParams extends LogRetryMeta {
41
42
  clientHeaders: RawHeaders;
42
43
  providerId?: string | null;
43
44
  originalModel?: string | null;
45
+ sessionId?: string | null;
44
46
  }
45
47
  /** Log a request rejected before reaching upstream */
46
48
  export declare function insertRejectedLog(params: RejectedLogParams): void;
@@ -1,7 +1,7 @@
1
1
  import { insertRequestLog } from "../db/index.js";
2
2
  /** 插入成功请求日志,供 openai/anthropic 插件共享 */
3
3
  export function insertSuccessLog(db, params) {
4
- const { id: logId, apiType, model, provider, isStream, startTime, clientReq, upstreamReq, status, respBody, upHdrs, isRetry = false, isFailover = false, originalRequestId = null, routerKeyId = null, originalModel = null } = params;
4
+ const { id: logId, apiType, model, provider, isStream, startTime, clientReq, upstreamReq, status, respBody, upHdrs, isRetry = false, isFailover = false, originalRequestId = null, routerKeyId = null, originalModel = null, sessionId = null } = params;
5
5
  insertRequestLog(db, {
6
6
  id: logId, api_type: apiType, model, provider_id: provider.id,
7
7
  status_code: status, latency_ms: Date.now() - startTime,
@@ -11,11 +11,12 @@ export function insertSuccessLog(db, params) {
11
11
  upstream_response: JSON.stringify({ statusCode: status, headers: upHdrs, body: respBody }),
12
12
  is_retry: isRetry ? 1 : 0, is_failover: isFailover ? 1 : 0, original_request_id: originalRequestId,
13
13
  router_key_id: routerKeyId, original_model: originalModel,
14
+ session_id: sessionId,
14
15
  });
15
16
  }
16
17
  /** Log a request rejected before reaching upstream */
17
18
  export function insertRejectedLog(params) {
18
- const { db, logId, apiType, model, statusCode, errorMessage, startTime, isStream, routerKeyId, originalBody, clientHeaders, providerId = null, isFailover = false, originalRequestId = null, originalModel = null } = params;
19
+ const { db, logId, apiType, model, statusCode, errorMessage, startTime, isStream, routerKeyId, originalBody, clientHeaders, providerId = null, isFailover = false, originalRequestId = null, originalModel = null, sessionId = null } = params;
19
20
  insertRequestLog(db, {
20
21
  id: logId,
21
22
  api_type: apiType,
@@ -31,5 +32,6 @@ export function insertRejectedLog(params) {
31
32
  original_request_id: originalRequestId,
32
33
  router_key_id: routerKeyId,
33
34
  original_model: originalModel,
35
+ session_id: sessionId,
34
36
  });
35
37
  }
@@ -4,6 +4,8 @@ export declare class ModelStateManager {
4
4
  private db;
5
5
  /** 单例注入 DB 实例,启动时调用一次 */
6
6
  init(db: Database.Database): void;
7
+ /** 清空所有内存缓存(导入配置后调用) */
8
+ clearAll(): void;
7
9
  /** 构造内存 Map 的 key:有 sessionId 时用复合键 */
8
10
  buildKey(routerKeyId: string | null, sessionId?: string): string;
9
11
  /**
@@ -10,6 +10,10 @@ export class ModelStateManager {
10
10
  init(db) {
11
11
  this.db = db;
12
12
  }
13
+ /** 清空所有内存缓存(导入配置后调用) */
14
+ clearAll() {
15
+ this.store.clear();
16
+ }
13
17
  /** 构造内存 Map 的 key:有 sessionId 时用复合键 */
14
18
  buildKey(routerKeyId, sessionId) {
15
19
  if (sessionId) {
@@ -23,6 +23,8 @@ export interface OrchestratorConfig {
23
23
  trackerId?: string;
24
24
  /** Claude Code 的 session ID,从 x-claude-code-session-id 请求头获取 */
25
25
  sessionId?: string;
26
+ /** 客户端请求的 JSON 字符串(headers + body),用于 Monitor 实时查看 */
27
+ clientRequest?: string;
26
28
  }
27
29
  export interface HandleContext {
28
30
  streamTimeoutMs?: number;
@@ -50,6 +50,7 @@ export class ProxyOrchestrator {
50
50
  attempts: [],
51
51
  clientIp: request.ip,
52
52
  sessionId: config.sessionId,
53
+ clientRequest: config.clientRequest,
53
54
  };
54
55
  }
55
56
  createAbortSignal(request) {
@@ -14,15 +14,49 @@ import { updateLogStreamContent } from "../db/index.js";
14
14
  import { callNonStream, callStream } from "./transport.js";
15
15
  import { insertRejectedLog } from "./log-helpers.js";
16
16
  const HTTP_ERROR_THRESHOLD = 400;
17
- const STREAM_CONTENT_MAX_RAW = 8192;
18
- const STREAM_CONTENT_MAX_TEXT = 4096;
17
+ const STREAM_CONTENT_MAX_RAW = 131072;
18
+ const STREAM_CONTENT_MAX_TEXT = 65536;
19
+ /** 将 tracker blocks 序列化为前端 tryDirectParse 可解析的 JSON */
20
+ function serializeBlocksForStorage(blocks, apiType) {
21
+ if (!blocks || blocks.length === 0)
22
+ return "";
23
+ if (apiType === "anthropic") {
24
+ const content = blocks.map(b => {
25
+ if (b.type === "thinking")
26
+ return { type: "thinking", thinking: b.content };
27
+ if (b.type === "tool_use") {
28
+ let input = {};
29
+ try {
30
+ input = JSON.parse(b.content || "{}");
31
+ }
32
+ catch { /* eslint-disable-line taste/no-silent-catch -- tool_use content 非合法 JSON 时保留空对象 */ }
33
+ return { type: "tool_use", name: b.name ?? "", input };
34
+ }
35
+ return { type: "text", text: b.content };
36
+ });
37
+ return JSON.stringify({ content });
38
+ }
39
+ const text = blocks.filter(b => b.type === "text").map(b => b.content).join("");
40
+ return JSON.stringify({ choices: [{ message: { content: text } }] });
41
+ }
42
+ function toStreamMetrics(m) {
43
+ return {
44
+ inputTokens: m.input_tokens,
45
+ outputTokens: m.output_tokens,
46
+ cacheReadTokens: m.cache_read_tokens,
47
+ ttftMs: m.ttft_ms,
48
+ tokensPerSecond: m.tokens_per_second,
49
+ stopReason: m.stop_reason,
50
+ isComplete: m.is_complete === 1,
51
+ };
52
+ }
19
53
  export async function handleProxyRequest(request, reply, apiType, upstreamPath, errors, deps, options) {
20
54
  request.raw.socket.on("error", (err) => request.log.debug({ err }, "client socket error"));
21
55
  const clientModel = request.body.model || "unknown";
22
56
  const sessionId = request.headers["x-claude-code-session-id"];
23
57
  const { effectiveModel, originalModel, interceptResponse } = applyEnhancement(deps.db, request, clientModel, sessionId);
24
58
  if (interceptResponse)
25
- return handleIntercept(deps.db, apiType, request, reply, interceptResponse, clientModel);
59
+ return handleIntercept(deps.db, apiType, request, reply, interceptResponse, clientModel, sessionId);
26
60
  const group = getMappingGroup(deps.db, effectiveModel);
27
61
  const isFailover = group?.strategy === "failover";
28
62
  const excludeTargets = [];
@@ -47,7 +81,7 @@ export async function handleProxyRequest(request, reply, apiType, upstreamPath,
47
81
  db: deps.db, logId, apiType, model: effectiveModel, statusCode: e.statusCode,
48
82
  errorMessage: `All failover targets exhausted (${excludeTargets.length} attempted)`,
49
83
  startTime, isStream, routerKeyId, originalBody, clientHeaders: cliHdrs, originalModel,
50
- isFailover: true, originalRequestId: rootLogId,
84
+ isFailover: true, originalRequestId: rootLogId, sessionId,
51
85
  });
52
86
  return reply.status(e.statusCode).send(e.body);
53
87
  }
@@ -56,7 +90,7 @@ export async function handleProxyRequest(request, reply, apiType, upstreamPath,
56
90
  db: deps.db, logId, apiType, model: effectiveModel, statusCode: e.statusCode,
57
91
  errorMessage: `No mapping found for model '${effectiveModel}'`, startTime, isStream,
58
92
  routerKeyId, originalBody, clientHeaders: cliHdrs, originalModel,
59
- isFailover: isFailoverIteration, originalRequestId: isFailoverIteration ? rootLogId : null,
93
+ isFailover: isFailoverIteration, originalRequestId: isFailoverIteration ? rootLogId : null, sessionId,
60
94
  });
61
95
  return reply.status(e.statusCode).send(e.body);
62
96
  }
@@ -71,7 +105,7 @@ export async function handleProxyRequest(request, reply, apiType, upstreamPath,
71
105
  db: deps.db, logId, apiType, model: effectiveModel, statusCode: e.statusCode,
72
106
  errorMessage: `Model '${resolved.backend_model}' not allowed`, startTime, isStream, routerKeyId,
73
107
  originalBody, clientHeaders: cliHdrs, providerId: resolved.provider_id, originalModel,
74
- isFailover: isFailoverIteration, originalRequestId: isFailoverIteration ? rootLogId : null,
108
+ isFailover: isFailoverIteration, originalRequestId: isFailoverIteration ? rootLogId : null, sessionId,
75
109
  });
76
110
  return reply.status(e.statusCode).send(e.body);
77
111
  }
@@ -88,7 +122,7 @@ export async function handleProxyRequest(request, reply, apiType, upstreamPath,
88
122
  db: deps.db, logId, apiType, model: effectiveModel, statusCode: e.statusCode,
89
123
  errorMessage: `Provider '${resolved.provider_id}' unavailable`, startTime, isStream, routerKeyId,
90
124
  originalBody, clientHeaders: cliHdrs, providerId: resolved.provider_id, originalModel,
91
- isFailover: isFailoverIteration, originalRequestId: isFailoverIteration ? rootLogId : null,
125
+ isFailover: isFailoverIteration, originalRequestId: isFailoverIteration ? rootLogId : null, sessionId,
92
126
  });
93
127
  return reply.status(e.statusCode).send(e.body);
94
128
  }
@@ -98,7 +132,7 @@ export async function handleProxyRequest(request, reply, apiType, upstreamPath,
98
132
  db: deps.db, logId, apiType, model: effectiveModel, statusCode: e.statusCode,
99
133
  errorMessage: `API type mismatch: expected '${apiType}'`, startTime, isStream, routerKeyId,
100
134
  originalBody, clientHeaders: cliHdrs, providerId: resolved.provider_id, originalModel,
101
- isFailover: isFailoverIteration, originalRequestId: isFailoverIteration ? rootLogId : null,
135
+ isFailover: isFailoverIteration, originalRequestId: isFailoverIteration ? rootLogId : null, sessionId,
102
136
  });
103
137
  return reply.status(e.statusCode).send(e.body);
104
138
  }
@@ -119,14 +153,7 @@ export async function handleProxyRequest(request, reply, apiType, upstreamPath,
119
153
  if (isStream) {
120
154
  const metricsTransform = new SSEMetricsTransform(apiType, startTime, {
121
155
  onMetrics: (m) => {
122
- deps.tracker?.update(logId, {
123
- streamMetrics: {
124
- inputTokens: m.input_tokens, outputTokens: m.output_tokens,
125
- cacheReadTokens: m.cache_read_tokens,
126
- ttftMs: m.ttft_ms, tokensPerSecond: m.tokens_per_second,
127
- stopReason: m.stop_reason, isComplete: m.is_complete === 1,
128
- },
129
- });
156
+ deps.tracker?.update(logId, { streamMetrics: toStreamMetrics(m) });
130
157
  },
131
158
  onChunk: (rawLine) => {
132
159
  deps.tracker?.appendStreamChunk(logId, rawLine, apiType, STREAM_CONTENT_MAX_RAW, STREAM_CONTENT_MAX_TEXT);
@@ -135,21 +162,19 @@ export async function handleProxyRequest(request, reply, apiType, upstreamPath,
135
162
  const checkEarlyError = deps.matcher
136
163
  ? (data) => deps.matcher.test(UPSTREAM_SUCCESS, data)
137
164
  : undefined;
138
- return callStream(provider, apiKey, body, cliHdrs, reply, deps.streamTimeoutMs, upstreamPath, buildUpstreamHeaders, metricsTransform, checkEarlyError);
165
+ const streamResult = await callStream(provider, apiKey, body, cliHdrs, reply, deps.streamTimeoutMs, upstreamPath, buildUpstreamHeaders, metricsTransform, checkEarlyError);
166
+ const m = (streamResult.kind === "stream_success" || streamResult.kind === "stream_abort")
167
+ ? streamResult.metrics : undefined;
168
+ if (m)
169
+ deps.tracker?.update(logId, { streamMetrics: toStreamMetrics(m) });
170
+ return streamResult;
139
171
  }
140
172
  const result = await callNonStream(provider, apiKey, body, cliHdrs, upstreamPath, buildUpstreamHeaders);
141
173
  // 非流式请求:从响应体提取指标并更新 tracker
142
174
  if (result.kind === "success") {
143
175
  const mr = MetricsExtractor.fromNonStreamResponse(apiType, result.body);
144
176
  if (mr) {
145
- deps.tracker?.update(logId, {
146
- streamMetrics: {
147
- inputTokens: mr.input_tokens, outputTokens: mr.output_tokens,
148
- cacheReadTokens: mr.cache_read_tokens,
149
- ttftMs: mr.ttft_ms, tokensPerSecond: mr.tokens_per_second,
150
- stopReason: mr.stop_reason, isComplete: mr.is_complete === 1,
151
- },
152
- });
177
+ deps.tracker?.update(logId, { streamMetrics: toStreamMetrics(mr) });
153
178
  }
154
179
  }
155
180
  // 非流式响应注入模型信息标签(模型映射场景)
@@ -168,19 +193,25 @@ export async function handleProxyRequest(request, reply, apiType, upstreamPath,
168
193
  return result;
169
194
  };
170
195
  try {
171
- const resilienceResult = await deps.orchestrator.handle(request, reply, apiType, { resolved, provider, clientModel: effectiveModel, isStream, trackerId: logId, sessionId }, { retryMaxAttempts: deps.retryMaxAttempts, retryBaseDelayMs: deps.retryBaseDelayMs, isFailover, ruleMatcher: deps.matcher, transportFn });
196
+ const resilienceResult = await deps.orchestrator.handle(request, reply, apiType, { resolved, provider, clientModel: effectiveModel, isStream, trackerId: logId, sessionId, clientRequest: clientReq }, { retryMaxAttempts: deps.retryMaxAttempts, retryBaseDelayMs: deps.retryBaseDelayMs, isFailover, ruleMatcher: deps.matcher, transportFn });
172
197
  const lastLogId = logResilienceResult(deps.db, {
173
198
  apiType, model: effectiveModel, providerId: provider.id, isStream,
174
- clientReq, upstreamReqBase, logId, routerKeyId, originalModel,
199
+ clientReq, upstreamReqBase, logId, routerKeyId, originalModel, sessionId,
175
200
  failover: { isFailoverIteration, rootLogId: rootLogId },
176
201
  }, resilienceResult.attempts, resilienceResult.result, startTime);
177
202
  collectTransportMetrics(deps.db, apiType, resilienceResult.result, isStream, lastLogId, provider.id, resolved.backend_model, request);
178
- // 流式请求:将 tracker 中累积的文本内容持久化到日志
203
+ // 流式请求:将 tracker 中累积的内容持久化到日志
204
+ // blocks 含非 text 类型时(thinking/tool_use)必须序列化为 JSON 以保留结构
205
+ // 注意:tracker 在原 logId 下累积内容,lastLogId 可能因 resilience 重试而指向不同记录
179
206
  if (isStream && deps.tracker) {
180
- const req = deps.tracker.get(lastLogId);
181
- const text = req?.streamContent?.textContent;
182
- if (text)
183
- updateLogStreamContent(deps.db, lastLogId, text);
207
+ const sc = deps.tracker.get(logId)?.streamContent;
208
+ const blocks = sc?.blocks;
209
+ const hasStructured = blocks && blocks.length > 0 && blocks.some(b => b.type !== "text");
210
+ const content = hasStructured
211
+ ? serializeBlocksForStorage(blocks, apiType)
212
+ : (sc?.textContent || "");
213
+ if (content)
214
+ updateLogStreamContent(deps.db, lastLogId, content);
184
215
  }
185
216
  // Failover: 单 provider 内重试已耗尽但仍失败,尝试下一个 target
186
217
  if (isFailover && !reply.raw.headersSent) {
@@ -216,7 +247,7 @@ export async function handleProxyRequest(request, reply, apiType, upstreamPath,
216
247
  errorMessage: `Concurrency queue full for provider '${provider.id}'`,
217
248
  startTime, isStream, routerKeyId, originalBody, clientHeaders: cliHdrs,
218
249
  providerId: provider.id, originalModel,
219
- isFailover: isFailoverIteration, originalRequestId: isFailoverIteration ? rootLogId : null,
250
+ isFailover: isFailoverIteration, originalRequestId: isFailoverIteration ? rootLogId : null, sessionId,
220
251
  });
221
252
  return reply.status(err.statusCode).send(err.body);
222
253
  }
@@ -227,7 +258,7 @@ export async function handleProxyRequest(request, reply, apiType, upstreamPath,
227
258
  errorMessage: `Concurrency wait timeout for provider '${provider.id}' (${e.timeoutMs}ms)`,
228
259
  startTime, isStream, routerKeyId, originalBody, clientHeaders: cliHdrs,
229
260
  providerId: provider.id, originalModel,
230
- isFailover: isFailoverIteration, originalRequestId: isFailoverIteration ? rootLogId : null,
261
+ isFailover: isFailoverIteration, originalRequestId: isFailoverIteration ? rootLogId : null, sessionId,
231
262
  });
232
263
  return reply.status(err.statusCode).send(err.body);
233
264
  }
@@ -240,6 +271,7 @@ export async function handleProxyRequest(request, reply, apiType, upstreamPath,
240
271
  client_request: clientReq, upstream_request: upstreamReqBase,
241
272
  is_failover: isFailoverIteration ? 1 : 0, original_request_id: isFailoverIteration ? rootLogId : null,
242
273
  router_key_id: routerKeyId, original_model: originalModel,
274
+ session_id: sessionId,
243
275
  });
244
276
  const err = errors.upstreamConnectionFailed();
245
277
  return reply.status(err.statusCode).send(err.body);
@@ -11,7 +11,7 @@ export declare function handleIntercept(db: Database.Database, apiType: "openai"
11
11
  statusCode: number;
12
12
  body: unknown;
13
13
  meta?: unknown;
14
- }, clientModel: string): import("fastify").FastifyReply;
14
+ }, clientModel: string, sessionId?: string): import("fastify").FastifyReply;
15
15
  export declare function logResilienceResult(db: Database.Database, params: {
16
16
  apiType: "openai" | "anthropic";
17
17
  model: string;
@@ -22,6 +22,7 @@ export declare function logResilienceResult(db: Database.Database, params: {
22
22
  logId: string;
23
23
  routerKeyId: string | null;
24
24
  originalModel: string | null;
25
+ sessionId?: string | null;
25
26
  failover?: FailoverContext;
26
27
  }, attempts: ResilienceAttempt[], result: TransportResult, startTime: number): string;
27
28
  export declare function collectTransportMetrics(db: Database.Database, apiType: "openai" | "anthropic", result: TransportResult, isStream: boolean, lastSuccessLogId: string, providerId: string, backendModel: string, request: FastifyRequest): void;