llm-simple-router 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +67 -18
  2. package/dist/admin/monitor.js +8 -0
  3. package/dist/admin/routes.js +2 -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/db/db-size-monitor.d.ts +22 -0
  8. package/dist/db/db-size-monitor.js +81 -0
  9. package/dist/db/index.d.ts +4 -1
  10. package/dist/db/index.js +4 -1
  11. package/dist/db/log-cleaner.js +16 -7
  12. package/dist/db/logs.d.ts +9 -1
  13. package/dist/db/logs.js +55 -5
  14. package/dist/db/migrations/022_add_session_id_and_incremental_vacuum.sql +5 -0
  15. package/dist/db/settings.d.ts +4 -0
  16. package/dist/db/settings.js +18 -1
  17. package/dist/index.js +5 -0
  18. package/dist/metrics/metrics-extractor.js +4 -0
  19. package/dist/monitor/request-tracker.d.ts +2 -0
  20. package/dist/monitor/request-tracker.js +20 -1
  21. package/dist/monitor/types.d.ts +1 -0
  22. package/dist/proxy/log-helpers.d.ts +2 -0
  23. package/dist/proxy/log-helpers.js +4 -2
  24. package/dist/proxy/model-state.d.ts +2 -0
  25. package/dist/proxy/model-state.js +4 -0
  26. package/dist/proxy/orchestrator.d.ts +2 -0
  27. package/dist/proxy/orchestrator.js +1 -0
  28. package/dist/proxy/proxy-handler.js +66 -34
  29. package/dist/proxy/proxy-logging.d.ts +2 -1
  30. package/dist/proxy/proxy-logging.js +5 -1
  31. package/dist/proxy/semaphore.d.ts +2 -0
  32. package/dist/proxy/semaphore.js +11 -0
  33. package/frontend-dist/assets/{CardContent-3ytnac7B.js → CardContent-CIO85eT6.js} +1 -1
  34. package/frontend-dist/assets/{CardTitle-BHZE8Rty.js → CardTitle-DiqIReMT.js} +1 -1
  35. package/frontend-dist/assets/Checkbox-C2u5pIp4.js +1 -0
  36. package/frontend-dist/assets/CollapsibleTrigger-RKFL41om.js +1 -0
  37. package/frontend-dist/assets/Collection-iiNnuTQj.js +1 -0
  38. package/frontend-dist/assets/{Dashboard-BJslVTg8.js → Dashboard-DOEqP6gF.js} +1 -1
  39. package/frontend-dist/assets/DialogTitle-CEqndrf6.js +1 -0
  40. package/frontend-dist/assets/{Input-JApdUstN.js → Input-l5ZurXX5.js} +1 -1
  41. package/frontend-dist/assets/{Label-IbQFgxLe.js → Label-PgGtS8v2.js} +1 -1
  42. package/frontend-dist/assets/{Login-BjuVvrPV.js → Login-DaN6ZcCx.js} +1 -1
  43. package/frontend-dist/assets/Logs-CleRQ7Xk.js +1 -0
  44. package/frontend-dist/assets/{ModelMappings-DWVmxMy6.js → ModelMappings-CacA_ua_.js} +1 -1
  45. package/frontend-dist/assets/{Monitor-BTEW0evp.js → Monitor-LSMFOBN2.js} +1 -1
  46. package/frontend-dist/assets/PopperContent-zLFHqQP0.js +1 -0
  47. package/frontend-dist/assets/{Providers-BqLSKXuv.js → Providers-NT5MUDU0.js} +1 -1
  48. package/frontend-dist/assets/{ProxyEnhancement-TAHOKnxW.js → ProxyEnhancement-DhOy8nNy.js} +1 -1
  49. package/frontend-dist/assets/{RetryRules-Cn6KHzgB.js → RetryRules-7arWa3jB.js} +1 -1
  50. package/frontend-dist/assets/{RouterKeys-CBgWAJ6-.js → RouterKeys-CdaZunRg.js} +1 -1
  51. package/frontend-dist/assets/SelectValue-CSg-MKW_.js +1 -0
  52. package/frontend-dist/assets/Settings-1ntV9XE3.js +6 -0
  53. package/frontend-dist/assets/{Setup-QKmeMDtB.js → Setup-CXLTDhYJ.js} +1 -1
  54. package/frontend-dist/assets/Switch-DivrIFE3.js +1 -0
  55. package/frontend-dist/assets/TableHeader-Bn0bodWx.js +1 -0
  56. package/frontend-dist/assets/TabsContent-MWvOH_LJ.js +1 -0
  57. package/frontend-dist/assets/TabsTrigger-WKkUfO2M.js +1 -0
  58. package/frontend-dist/assets/Teleport-B0PNXZbP.js +3 -0
  59. package/frontend-dist/assets/UnifiedRequestDialog-B2nt8nLl.css +1 -0
  60. package/frontend-dist/assets/UnifiedRequestDialog-Ba2e7YuJ.js +3 -0
  61. package/frontend-dist/assets/{VisuallyHidden-DPKPka_x.js → VisuallyHidden-BwwTtzb9.js} +1 -1
  62. package/frontend-dist/assets/{VisuallyHiddenInput-Bnglr6yR.js → VisuallyHiddenInput-EGZSP7s8.js} +1 -1
  63. package/frontend-dist/assets/alert-dialog-CS1yFhdV.js +1 -0
  64. package/frontend-dist/assets/{badge-BTjuxlp4.js → badge-C-QcC5n2.js} +1 -1
  65. package/frontend-dist/assets/{button-BKJB3nEQ.js → button-Dbz2Be22.js} +2 -2
  66. package/frontend-dist/assets/{createLucideIcon-igIAnu_Y.js → createLucideIcon-Biq59l_W.js} +1 -1
  67. package/frontend-dist/assets/dialog-Cr0YQlLW.js +1 -0
  68. package/frontend-dist/assets/{file-text-Ci7Mgh3F.js → file-text-DoRW0hQW.js} +1 -1
  69. package/frontend-dist/assets/index-0H2uCGbx.js +1 -0
  70. package/frontend-dist/assets/index-D-cdVNCb.css +1 -0
  71. package/frontend-dist/assets/{lib-BGW4QyKP.js → lib-B0lieqgg.js} +1 -1
  72. package/frontend-dist/assets/{ohash.D__AXeF1-CsY_LBk-.js → ohash.D__AXeF1-BGxYMs6k.js} +1 -1
  73. package/frontend-dist/assets/{useClipboard-wnGQAe3I.js → useClipboard-vaHkvJHw.js} +1 -1
  74. package/frontend-dist/assets/{useForwardExpose-bqtcPo63.js → useForwardExpose-C2_ks3sW.js} +1 -1
  75. package/frontend-dist/assets/useLogRetention-Cs_fiKql.js +1 -0
  76. package/frontend-dist/assets/useNonce-C9do0jOI.js +1 -0
  77. package/frontend-dist/assets/x-BlTnH_0_.js +1 -0
  78. package/frontend-dist/index.html +8 -8
  79. package/package.json +1 -1
  80. package/frontend-dist/assets/Checkbox-CMYgDuxw.js +0 -1
  81. package/frontend-dist/assets/CollapsibleTrigger-DooxvEnx.js +0 -1
  82. package/frontend-dist/assets/Collection-GDvpW_uY.js +0 -3
  83. package/frontend-dist/assets/DialogTitle-lj6NAA5R.js +0 -1
  84. package/frontend-dist/assets/Logs-J08HyZWA.js +0 -1
  85. package/frontend-dist/assets/PopperContent-ZhhkKJo0.js +0 -1
  86. package/frontend-dist/assets/SelectValue-DS4Z8y0u.js +0 -1
  87. package/frontend-dist/assets/Switch-BYebebrY.js +0 -1
  88. package/frontend-dist/assets/TableHeader-B2A48qgy.js +0 -1
  89. package/frontend-dist/assets/TabsContent-BcNBY5CB.js +0 -1
  90. package/frontend-dist/assets/TabsTrigger-8W_mNsGI.js +0 -1
  91. package/frontend-dist/assets/UnifiedRequestDialog-BmEamR1L.js +0 -3
  92. package/frontend-dist/assets/UnifiedRequestDialog-Dk3IIDDx.css +0 -1
  93. package/frontend-dist/assets/alert-dialog-BzyDZnoE.js +0 -1
  94. package/frontend-dist/assets/dialog-C0B-Xn-S.js +0 -1
  95. package/frontend-dist/assets/index-BrDOp_gc.js +0 -1
  96. package/frontend-dist/assets/index-DMdVJThL.css +0 -1
  97. package/frontend-dist/assets/useNonce-DN0Hrw3l.js +0 -1
  98. package/frontend-dist/assets/x-Cy_v5hrA.js +0 -1
  99. /package/frontend-dist/assets/{format-CPdJtjZ5.js → format-DOVIVsQC.js} +0 -0
package/README.md CHANGED
@@ -60,56 +60,105 @@ npx llm-simple-router
60
60
 
61
61
  ### 3. 配置模型映射
62
62
 
63
- 管理后台 > 模型映射页面。示例配置:
63
+ 管理后台 > 模型映射页面。
64
+
65
+ **核心概念:** 客户端请求携带模型名 A,Router 根据映射规则将其替换为后端 Provider 支持的模型名 B,然后转发请求:
66
+
67
+ ```
68
+ Claude Code (模型 A) → Router (A → B) → Provider API (模型 B)
69
+ ```
70
+
71
+ 只需在映射表中配置「客户端模型 = A,后端模型 = B,选择供应商」即可。
72
+
73
+ #### Claude Code 默认模型名
74
+
75
+ Claude Code 未设置环境变量时,默认使用以下模型名:`opus`、`sonnet`、`haiku`。如果后端是智谱 Coding Plan,映射配置如下:
64
76
 
65
77
  | 客户端模型 | 后端模型 | 供应商 | 时间窗口 |
66
78
  |-----------|---------|--------|---------|
67
- | sonnet | glm-5.1 | 智谱 | 全天 |
68
- | sonnet | kimi-for-coding | Moonshot | 14:00-18:00 |
79
+ | opus | glm-5.1 | 智谱 Coding Plan | 全天 |
80
+ | sonnet | glm-5.1 | 智谱 Coding Plan | 全天 |
81
+ | haiku | glm-5-turbo | 智谱 Coding Plan | 全天 |
69
82
 
70
- 客户端模型是指 Claude Code 实际请求的模型名(由 `ANTHROPIC_MODEL` 等环境变量决定)。
83
+ 也可以利用分时段功能实现高峰期自动切换:
84
+
85
+ | 客户端模型 | 后端模型 | 供应商 | 时间窗口 |
86
+ |-----------|---------|--------|---------|
87
+ | sonnet | glm-5.1 | 智谱 Coding Plan | 00:00-14:00 |
88
+ | sonnet | kimi-for-coding | Moonshot | 14:00-18:00 |
89
+ | sonnet | glm-5.1 | 智谱 Coding Plan | 18:00-24:00 |
71
90
 
72
91
  ### 4. 配置 Claude Code
73
92
 
74
- 在管理后台创建 Router API 密钥,然后选择一种方式配置:
93
+ 在管理后台创建 Router API 密钥,然后选择一种方式配置。**两种方式只需选其一。**
75
94
 
76
95
  **方式一:shell alias(推荐)**
77
96
 
97
+ 最小配置,Claude Code 使用默认模型名(opus / sonnet / haiku),Router 通过映射表转换为后端模型:
98
+
78
99
  ```bash
79
100
  alias clode='\
80
101
  export ANTHROPIC_AUTH_TOKEN="<your-router-key>" && \
81
102
  export ANTHROPIC_BASE_URL="http://127.0.0.1:9981" && \
82
- export ANTHROPIC_MODEL="<your-default-model>" && \
83
- export ANTHROPIC_DEFAULT_OPUS_MODEL="<your-opus-model>" && \
84
- export ANTHROPIC_DEFAULT_SONNET_MODEL="<your-sonnet-model>" && \
85
- export ANTHROPIC_DEFAULT_HAIKU_MODEL="<your-haiku-model>" && \
86
- export ANTHROPIC_SMALL_FAST_MODEL="<your-fast-model>" && \
87
103
  claude'
88
104
  ```
89
105
 
106
+ 也可以通过环境变量直接指定模型名,绕过 Router 映射:
107
+
108
+ ```bash
109
+ alias clode='\
110
+ export ANTHROPIC_AUTH_TOKEN="sk-router-xxxxxxxx" && \
111
+ export ANTHROPIC_BASE_URL="http://192.168.1.111:9981" && \
112
+ export ANTHROPIC_MODEL="glm-5" && \
113
+ export ANTHROPIC_DEFAULT_OPUS_MODEL="glm-5.1" && \
114
+ export ANTHROPIC_DEFAULT_SONNET_MODEL="glm-5" && \
115
+ export ANTHROPIC_DEFAULT_HAIKU_MODEL="glm-5-turbo" && \
116
+ export ANTHROPIC_SMALL_FAST_MODEL="glm-5-turbo" && \
117
+ claude'
118
+ ```
119
+
120
+ > 调试时可加参数:`claude --dangerously-skip-permissions --verbose --debug`,或设置 `export DEBUG=claude:*` 查看详细日志。
121
+
90
122
  **方式二:~/.claude/settings.json**
91
123
 
124
+ 在 `~/.claude/settings.json` 的 `env` 字段中配置,效果与 export 环境变量相同:
125
+
126
+ 最小配置:
127
+
92
128
  ```json
93
129
  {
94
130
  "env": {
95
131
  "ANTHROPIC_AUTH_TOKEN": "<your-router-key>",
96
- "ANTHROPIC_BASE_URL": "http://127.0.0.1:9981",
97
- "ANTHROPIC_MODEL": "<your-default-model>",
98
- "ANTHROPIC_DEFAULT_OPUS_MODEL": "<your-opus-model>",
99
- "ANTHROPIC_DEFAULT_SONNET_MODEL": "<your-sonnet-model>",
100
- "ANTHROPIC_DEFAULT_HAIKU_MODEL": "<your-haiku-model>",
101
- "ANTHROPIC_SMALL_FAST_MODEL": "<your-fast-model>"
132
+ "ANTHROPIC_BASE_URL": "http://127.0.0.1:9981"
102
133
  }
103
134
  }
104
135
  ```
105
136
 
137
+ 覆盖模型名:
138
+
139
+ ```json
140
+ {
141
+ "env": {
142
+ "ANTHROPIC_AUTH_TOKEN": "sk-router-xxxxxxxx",
143
+ "ANTHROPIC_BASE_URL": "http://192.168.1.111:9981",
144
+ "ANTHROPIC_MODEL": "glm-5",
145
+ "ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-5.1",
146
+ "ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-5",
147
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL": "glm-5-turbo",
148
+ "ANTHROPIC_SMALL_FAST_MODEL": "glm-5-turbo"
149
+ }
150
+ }
151
+ ```
152
+
153
+ > settings.json 中的环境变量对所有项目生效。如果只想对当前项目生效,可放在 `.claude/settings.json`(项目根目录下)。
154
+
106
155
  ### 5. 使用
107
156
 
108
157
  ```bash
109
- # 方式一用户直接用 alias
158
+ # 方式一(shell alias
110
159
  clode
111
160
 
112
- # 方式二用户正常启动 claude
161
+ # 方式二(settings.json)
113
162
  claude
114
163
  ```
115
164
 
@@ -1,3 +1,4 @@
1
+ import { HTTP_NOT_FOUND } from "./constants.js";
1
2
  const HTTP_OK = 200;
2
3
  export const adminMonitorRoutes = (app, options, done) => {
3
4
  const { tracker } = options;
@@ -21,5 +22,12 @@ export const adminMonitorRoutes = (app, options, done) => {
21
22
  tracker.removeClient(reply.raw);
22
23
  });
23
24
  });
25
+ app.get("/admin/api/monitor/request/:id", async (request, reply) => {
26
+ const { id } = request.params;
27
+ const req = tracker.getRequestById(id);
28
+ if (!req)
29
+ return reply.status(HTTP_NOT_FOUND).send({ error: "Not found" });
30
+ return req;
31
+ });
24
32
  done();
25
33
  };
@@ -13,6 +13,7 @@ import { adminMonitorRoutes } from "./monitor.js";
13
13
  import { adminSettingsRoutes } from "./settings.js";
14
14
  import { adminRecommendedRoutes } from "./recommended.js";
15
15
  import { adminUsageRoutes } from "./usage.js";
16
+ import { adminImportExportRoutes } from "./settings-import-export.js";
16
17
  export const adminRoutes = (app, options, done) => {
17
18
  // Setup 路由不需要 auth
18
19
  app.register(adminSetupRoutes, { db: options.db });
@@ -29,6 +30,7 @@ export const adminRoutes = (app, options, done) => {
29
30
  app.register(adminProxyEnhancementRoutes, { db: options.db });
30
31
  app.register(adminMonitorRoutes, { tracker: options.tracker });
31
32
  app.register(adminSettingsRoutes, { db: options.db });
33
+ app.register(adminImportExportRoutes, { db: options.db, matcher: options.matcher, semaphoreManager: options.semaphoreManager });
32
34
  app.register(adminRecommendedRoutes, { db: options.db });
33
35
  app.register(adminUsageRoutes, { db: options.db });
34
36
  done();
@@ -0,0 +1,11 @@
1
+ import { FastifyPluginCallback } from "fastify";
2
+ import Database from "better-sqlite3";
3
+ import { RetryRuleMatcher } from "../proxy/retry-rules.js";
4
+ import { ProviderSemaphoreManager } from "../proxy/semaphore.js";
5
+ interface ImportExportOptions {
6
+ db: Database.Database;
7
+ matcher: RetryRuleMatcher | null;
8
+ semaphoreManager?: ProviderSemaphoreManager;
9
+ }
10
+ export declare const adminImportExportRoutes: FastifyPluginCallback<ImportExportOptions>;
11
+ export {};
@@ -0,0 +1,144 @@
1
+ import { createHash } from "crypto";
2
+ import { getAllProviders, PROVIDER_CONCURRENCY_DEFAULTS } from "../db/index.js";
3
+ import { encrypt, decrypt } from "../utils/crypto.js";
4
+ import { getSetting } from "../db/settings.js";
5
+ import { modelState } from "../proxy/model-state.js";
6
+ const CONFIG_TABLES = [
7
+ "providers",
8
+ "mapping_groups",
9
+ "retry_rules",
10
+ "router_keys",
11
+ "settings",
12
+ "session_model_states",
13
+ ];
14
+ // settings 表按 key 列的值过滤,不覆盖本地安全敏感配置
15
+ const PROTECTED_SETTING_KEYS = new Set(["admin_password_hash", "jwt_secret", "encryption_key"]);
16
+ const EXPORT_VERSION = 1;
17
+ const ISO_DATE_LENGTH = 10;
18
+ const BAD_REQUEST = 400;
19
+ const KEY_PREFIX_LENGTH = 8;
20
+ export const adminImportExportRoutes = (app, options, done) => {
21
+ const { db, matcher, semaphoreManager } = options;
22
+ app.get("/admin/api/settings/export", async (_request, reply) => {
23
+ const encryptionKey = getSetting(db, "encryption_key");
24
+ const data = {};
25
+ for (const table of CONFIG_TABLES) {
26
+ data[table] = db.prepare(`SELECT * FROM ${table}`).all();
27
+ }
28
+ // 导出时解密敏感字段,确保跨实例可移植
29
+ if (encryptionKey) {
30
+ for (const row of (data.providers || [])) {
31
+ if (typeof row.api_key === "string" && row.api_key) {
32
+ try {
33
+ row.api_key = decrypt(row.api_key, encryptionKey);
34
+ }
35
+ catch { /* eslint-disable-line taste/no-silent-catch -- 无法解密则保留原值 */ }
36
+ }
37
+ }
38
+ for (const row of (data.router_keys || [])) {
39
+ if (typeof row.key_encrypted === "string") {
40
+ try {
41
+ row.key = decrypt(row.key_encrypted, encryptionKey);
42
+ delete row.key_encrypted;
43
+ delete row.key_hash;
44
+ delete row.key_prefix;
45
+ }
46
+ catch { /* eslint-disable-line taste/no-silent-catch -- 无法解密则保留加密数据 */ }
47
+ }
48
+ }
49
+ }
50
+ const date = new Date().toISOString().slice(0, ISO_DATE_LENGTH);
51
+ reply.header("Content-Disposition", `attachment; filename="router-config-${date}.json"`);
52
+ return reply.send({
53
+ version: EXPORT_VERSION,
54
+ exportedAt: new Date().toISOString(),
55
+ data,
56
+ });
57
+ });
58
+ app.post("/admin/api/settings/import", async (request, reply) => {
59
+ const body = request.body;
60
+ if (typeof body.version !== "number" || body.version !== EXPORT_VERSION) {
61
+ return reply.code(BAD_REQUEST).send({ error: { message: `Unsupported version. Expected ${EXPORT_VERSION}.` } });
62
+ }
63
+ if (!body.data || typeof body.data !== "object") {
64
+ return reply.code(BAD_REQUEST).send({ error: { message: "Missing or invalid data field" } });
65
+ }
66
+ const counts = {};
67
+ const importData = body.data;
68
+ const encryptionKey = getSetting(db, "encryption_key");
69
+ // 导入时用本地密钥重新加密敏感字段
70
+ if (encryptionKey) {
71
+ for (const row of (importData.providers || [])) {
72
+ if (typeof row.api_key === "string" && row.api_key) {
73
+ row.api_key = encrypt(row.api_key, encryptionKey);
74
+ }
75
+ }
76
+ for (const row of (importData.router_keys || [])) {
77
+ if (typeof row.key === "string") {
78
+ row.key_hash = createHash("sha256").update(row.key).digest("hex");
79
+ row.key_prefix = row.key.slice(0, KEY_PREFIX_LENGTH);
80
+ row.key_encrypted = encrypt(row.key, encryptionKey);
81
+ delete row.key;
82
+ }
83
+ }
84
+ }
85
+ db.transaction(() => {
86
+ // 临时关闭外键检查,避免删除顺序导致约束冲突
87
+ const prevFk = db.pragma("foreign_keys", { simple: true });
88
+ db.pragma("foreign_keys = OFF");
89
+ // 导入前先备份受保护配置,导入后恢复
90
+ const protectedRows = db
91
+ .prepare(`SELECT * FROM settings WHERE key IN (${[...PROTECTED_SETTING_KEYS].map(() => "?").join(", ")})`)
92
+ .all(...PROTECTED_SETTING_KEYS);
93
+ for (const table of CONFIG_TABLES) {
94
+ const rows = importData[table];
95
+ if (!Array.isArray(rows))
96
+ continue;
97
+ db.exec(`DELETE FROM ${table}`);
98
+ // 用 PRAGMA table_info 获取合法列名,防止用户 JSON 注入非法列
99
+ const validCols = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name));
100
+ for (const row of rows) {
101
+ const entries = Object.entries(row).filter(([k]) => validCols.has(k));
102
+ if (entries.length === 0)
103
+ continue;
104
+ if (table === "settings") {
105
+ const keyValue = entries.find(([k]) => k === "key")?.[1];
106
+ if (keyValue && PROTECTED_SETTING_KEYS.has(keyValue)) {
107
+ continue;
108
+ }
109
+ }
110
+ const cols = entries.map(([k]) => k).join(", ");
111
+ const vals = entries.map(() => "?").join(", ");
112
+ db.prepare(`INSERT INTO ${table} (${cols}) VALUES (${vals})`).run(...entries.map(([, v]) => v));
113
+ }
114
+ counts[table] = rows.length;
115
+ }
116
+ // 恢复受保护配置
117
+ const upsertStmt = db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)");
118
+ for (const row of protectedRows) {
119
+ upsertStmt.run(row.key, row.value);
120
+ }
121
+ // 恢复外键设置
122
+ db.pragma(`foreign_keys = ${prevFk}`);
123
+ })();
124
+ // 导入成功后刷新内存缓存
125
+ if (matcher)
126
+ matcher.load(db);
127
+ if (semaphoreManager) {
128
+ // 清除旧的 semaphore 配置,按导入后的 providers 表重建
129
+ semaphoreManager.removeAll();
130
+ const providers = getAllProviders(db);
131
+ for (const p of providers) {
132
+ semaphoreManager.updateConfig(p.id, {
133
+ maxConcurrency: p.max_concurrency ?? PROVIDER_CONCURRENCY_DEFAULTS.max_concurrency,
134
+ queueTimeoutMs: p.queue_timeout_ms ?? PROVIDER_CONCURRENCY_DEFAULTS.queue_timeout_ms,
135
+ maxQueueSize: p.max_queue_size ?? PROVIDER_CONCURRENCY_DEFAULTS.max_queue_size,
136
+ });
137
+ }
138
+ }
139
+ // session_model_states 已通过 DB 导入,内存缓存会在读取时自然回填
140
+ modelState.clearAll();
141
+ return reply.send(counts);
142
+ });
143
+ done();
144
+ };
@@ -1,4 +1,4 @@
1
- import { getLogRetentionDays, setLogRetentionDays } from "../db/settings.js";
1
+ import { getLogRetentionDays, setLogRetentionDays, getDbMaxSizeMb, setDbMaxSizeMb, getLogTableMaxSizeMb, setLogTableMaxSizeMb, getSetting, } from "../db/settings.js";
2
2
  export const adminSettingsRoutes = (app, options, done) => {
3
3
  const { db } = options;
4
4
  app.get("/admin/api/settings/log-retention", async () => {
@@ -6,11 +6,49 @@ export const adminSettingsRoutes = (app, options, done) => {
6
6
  });
7
7
  app.put("/admin/api/settings/log-retention", async (request) => {
8
8
  const { days } = request.body;
9
- if (!Number.isInteger(days) || days < 0 || days > 90) {
9
+ const MAX_LOG_RETENTION_DAYS = 90;
10
+ if (!Number.isInteger(days) || days < 0 || days > MAX_LOG_RETENTION_DAYS) {
10
11
  throw { statusCode: 400, message: "days must be integer 0-90" };
11
12
  }
12
13
  setLogRetentionDays(db, days);
13
14
  return { days };
14
15
  });
16
+ app.get("/admin/api/settings/db-size", async () => {
17
+ const DEFAULT_SIZE_INFO = { totalBytes: 0, logTableBytes: 0, logCount: 0, lastChecked: null };
18
+ const raw = getSetting(db, "db_size_info");
19
+ let sizeInfo = DEFAULT_SIZE_INFO;
20
+ if (raw) {
21
+ try {
22
+ sizeInfo = JSON.parse(raw);
23
+ }
24
+ catch { /* eslint-disable-line taste/no-silent-catch -- 损坏的缓存值,回退默认 */ }
25
+ }
26
+ return {
27
+ ...sizeInfo,
28
+ thresholds: {
29
+ dbMaxSizeMb: getDbMaxSizeMb(db),
30
+ logTableMaxSizeMb: getLogTableMaxSizeMb(db),
31
+ },
32
+ };
33
+ });
34
+ app.put("/admin/api/settings/db-size-thresholds", async (request) => {
35
+ const body = request.body;
36
+ if (body.dbMaxSizeMb !== undefined) {
37
+ if (!Number.isFinite(body.dbMaxSizeMb) || body.dbMaxSizeMb < 1) {
38
+ throw { statusCode: 400, message: "dbMaxSizeMb must be a positive number" };
39
+ }
40
+ setDbMaxSizeMb(db, Math.round(body.dbMaxSizeMb));
41
+ }
42
+ if (body.logTableMaxSizeMb !== undefined) {
43
+ if (!Number.isFinite(body.logTableMaxSizeMb) || body.logTableMaxSizeMb < 1) {
44
+ throw { statusCode: 400, message: "logTableMaxSizeMb must be a positive number" };
45
+ }
46
+ setLogTableMaxSizeMb(db, Math.round(body.logTableMaxSizeMb));
47
+ }
48
+ return {
49
+ dbMaxSizeMb: getDbMaxSizeMb(db),
50
+ logTableMaxSizeMb: getLogTableMaxSizeMb(db),
51
+ };
52
+ });
15
53
  done();
16
54
  };
@@ -0,0 +1,22 @@
1
+ import Database from "better-sqlite3";
2
+ export interface DbSizeInfo {
3
+ totalBytes: number;
4
+ logTableBytes: number;
5
+ logCount: number;
6
+ lastChecked: string;
7
+ }
8
+ export interface SizeThresholds {
9
+ dbMaxSizeMb: number;
10
+ logTableMaxSizeMb: number;
11
+ }
12
+ export declare function collectDbSizeInfo(db: Database.Database, dbPath: string): DbSizeInfo;
13
+ export declare function runSizeBasedCleanup(db: Database.Database, dbPath: string, thresholds: SizeThresholds): number;
14
+ export interface DbSizeMonitorHandle {
15
+ stop: () => void;
16
+ }
17
+ export declare function scheduleDbSizeMonitor(db: Database.Database, dbPath: string, options: {
18
+ intervalMs?: number;
19
+ log: {
20
+ info: (msg: string) => void;
21
+ };
22
+ }): DbSizeMonitorHandle;
@@ -0,0 +1,81 @@
1
+ import { statSync } from "fs";
2
+ import { setSetting, getDbMaxSizeMb, getLogTableMaxSizeMb } from "./settings.js";
3
+ import { estimateLogTableSize, deleteOldestLogs, getLogCount } from "./logs.js";
4
+ const BYTES_PER_MB = 1_048_576;
5
+ const DEFAULT_INTERVAL_MS = 1_800_000; // 30 分钟
6
+ const CLEANUP_TARGET_RATIO = 0.8;
7
+ const DEFAULT_ROW_BYTES = 500;
8
+ export function collectDbSizeInfo(db, dbPath) {
9
+ let totalBytes = 0;
10
+ if (dbPath !== ":memory:") {
11
+ try {
12
+ totalBytes = statSync(dbPath).size;
13
+ }
14
+ catch { // eslint-disable-line taste/no-silent-catch -- DB 文件可能尚未创建(CI 内存测试、首次启动等)
15
+ }
16
+ }
17
+ const logTableBytes = estimateLogTableSize(db);
18
+ const logCount = getLogCount(db);
19
+ const info = {
20
+ totalBytes,
21
+ logTableBytes,
22
+ logCount,
23
+ lastChecked: new Date().toISOString(),
24
+ };
25
+ setSetting(db, "db_size_info", JSON.stringify(info));
26
+ return info;
27
+ }
28
+ export function runSizeBasedCleanup(db, dbPath, thresholds) {
29
+ const info = collectDbSizeInfo(db, dbPath);
30
+ const logOverThreshold = info.logTableBytes > thresholds.logTableMaxSizeMb * BYTES_PER_MB;
31
+ const dbOverThreshold = info.totalBytes > thresholds.dbMaxSizeMb * BYTES_PER_MB;
32
+ if (!logOverThreshold && !dbOverThreshold)
33
+ return 0;
34
+ const targetBytes = thresholds.logTableMaxSizeMb * BYTES_PER_MB * CLEANUP_TARGET_RATIO;
35
+ const avgRowBytes = info.logCount > 0 ? info.logTableBytes / info.logCount : DEFAULT_ROW_BYTES;
36
+ const keepCount = Math.max(0, Math.floor(targetBytes / avgRowBytes));
37
+ return deleteOldestLogs(db, keepCount);
38
+ }
39
+ export function scheduleDbSizeMonitor(db, dbPath, options) {
40
+ const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
41
+ let running = false;
42
+ let initialTimer = null;
43
+ let intervalTimer = null;
44
+ const doCheck = () => {
45
+ if (running)
46
+ return;
47
+ running = true;
48
+ try {
49
+ // 每次检查时从 DB 读取最新阈值,而非使用启动时的缓存值
50
+ const thresholds = {
51
+ dbMaxSizeMb: getDbMaxSizeMb(db),
52
+ logTableMaxSizeMb: getLogTableMaxSizeMb(db),
53
+ };
54
+ const deleted = runSizeBasedCleanup(db, dbPath, thresholds);
55
+ if (deleted > 0)
56
+ options.log.info(`Size-based cleanup: deleted ${deleted} log records`);
57
+ }
58
+ catch (e) {
59
+ // DB 可能已关闭(测试清理、进程关闭等)
60
+ options.log.info(`Size monitor check skipped: ${e instanceof Error ? e.message : String(e)}`);
61
+ }
62
+ finally {
63
+ running = false;
64
+ }
65
+ };
66
+ // 推迟到下一个事件循环 tick,避免阻塞服务器启动(与 log-cleaner 保持一致)
67
+ initialTimer = setTimeout(doCheck, 0);
68
+ intervalTimer = setInterval(doCheck, intervalMs);
69
+ return {
70
+ stop: () => {
71
+ if (initialTimer) {
72
+ clearTimeout(initialTimer);
73
+ initialTimer = null;
74
+ }
75
+ if (intervalTimer) {
76
+ clearInterval(intervalTimer);
77
+ intervalTimer = null;
78
+ }
79
+ },
80
+ };
81
+ }
@@ -6,7 +6,7 @@ export { getModelMapping, getAllModelMappings, createModelMapping, updateModelMa
6
6
  export type { ModelMapping, MappingGroup, ProviderModelEntry } from "./mappings.js";
7
7
  export { getActiveRetryRules, getAllRetryRules, createRetryRule, updateRetryRule, deleteRetryRule, } from "./retry-rules.js";
8
8
  export type { RetryRule } from "./retry-rules.js";
9
- export { insertRequestLog, getRequestLogs, getRequestLogById, deleteLogsBefore, getRequestLogChildren, getRequestLogsGrouped, updateLogMetrics, updateLogStreamContent, backfillMetricsFromRequestMetrics, } from "./logs.js";
9
+ export { insertRequestLog, getRequestLogs, getRequestLogById, deleteLogsBefore, getRequestLogChildren, getRequestLogsGrouped, updateLogMetrics, updateLogStreamContent, backfillMetricsFromRequestMetrics, estimateLogTableSize, deleteOldestLogs, getLogCount, } from "./logs.js";
10
10
  export type { RequestLog, RequestLogGroupedRow, RequestLogListRow } from "./logs.js";
11
11
  export { getRouterKeyByHash, getAllRouterKeys, getRouterKeyById, createRouterKey, updateRouterKey, deleteRouterKey, getAvailableModels, } from "./router-keys.js";
12
12
  export type { RouterKey } from "./router-keys.js";
@@ -15,7 +15,10 @@ export type { MetricsSummaryRow, MetricsTimeseriesRow, MetricsPeriod, MetricsMet
15
15
  export { getStats } from "./stats.js";
16
16
  export type { Stats, StatsPeriod } from "./stats.js";
17
17
  export { getSetting, setSetting, isInitialized } from "./settings.js";
18
+ export { getDbMaxSizeMb, setDbMaxSizeMb, getLogTableMaxSizeMb, setLogTableMaxSizeMb, } from "./settings.js";
18
19
  export { getSessionStates, getSessionState, getSessionHistory, upsertSessionState, insertSessionHistory, deleteSessionState, } from "./session-states.js";
19
20
  export type { SessionModelState, SessionModelHistory, UpsertSessionStateInput, InsertSessionHistoryInput } from "./session-states.js";
20
21
  export { insertWindow, getLatestWindow, getWindowsInRange, getWindowUsage, } from "./usage-windows.js";
21
22
  export type { UsageWindow, WindowUsage } from "./usage-windows.js";
23
+ export { collectDbSizeInfo, runSizeBasedCleanup, scheduleDbSizeMonitor, } from "./db-size-monitor.js";
24
+ export type { DbSizeInfo, SizeThresholds, DbSizeMonitorHandle } from "./db-size-monitor.js";
package/dist/db/index.js CHANGED
@@ -15,6 +15,7 @@ export function initDatabase(dbPath) {
15
15
  }
16
16
  const db = new Database(dbPath);
17
17
  db.pragma("journal_mode = WAL");
18
+ db.pragma("auto_vacuum = INCREMENTAL");
18
19
  db.pragma("foreign_keys = ON");
19
20
  db.exec(`
20
21
  CREATE TABLE IF NOT EXISTS migrations (
@@ -55,10 +56,12 @@ export function initDatabase(dbPath) {
55
56
  export { getActiveProviders, getAllProviders, getProviderById, createProvider, updateProvider, deleteProvider, PROVIDER_CONCURRENCY_DEFAULTS, } from "./providers.js";
56
57
  export { getModelMapping, getAllModelMappings, createModelMapping, updateModelMapping, deleteModelMapping, getMappingGroup, getMappingGroupById, getAllMappingGroups, createMappingGroup, updateMappingGroup, deleteMappingGroup, getActiveProviderModels, resolveByProviderModel, } from "./mappings.js";
57
58
  export { getActiveRetryRules, getAllRetryRules, createRetryRule, updateRetryRule, deleteRetryRule, } from "./retry-rules.js";
58
- export { insertRequestLog, getRequestLogs, getRequestLogById, deleteLogsBefore, getRequestLogChildren, getRequestLogsGrouped, updateLogMetrics, updateLogStreamContent, backfillMetricsFromRequestMetrics, } from "./logs.js";
59
+ export { insertRequestLog, getRequestLogs, getRequestLogById, deleteLogsBefore, getRequestLogChildren, getRequestLogsGrouped, updateLogMetrics, updateLogStreamContent, backfillMetricsFromRequestMetrics, estimateLogTableSize, deleteOldestLogs, getLogCount, } from "./logs.js";
59
60
  export { getRouterKeyByHash, getAllRouterKeys, getRouterKeyById, createRouterKey, updateRouterKey, deleteRouterKey, getAvailableModels, } from "./router-keys.js";
60
61
  export { getMetricsSummary, getMetricsTimeseries, insertMetrics } from "./metrics.js";
61
62
  export { getStats } from "./stats.js";
62
63
  export { getSetting, setSetting, isInitialized } from "./settings.js";
64
+ export { getDbMaxSizeMb, setDbMaxSizeMb, getLogTableMaxSizeMb, setLogTableMaxSizeMb, } from "./settings.js";
63
65
  export { getSessionStates, getSessionState, getSessionHistory, upsertSessionState, insertSessionHistory, deleteSessionState, } from "./session-states.js";
64
66
  export { insertWindow, getLatestWindow, getWindowsInRange, getWindowUsage, } from "./usage-windows.js";
67
+ export { collectDbSizeInfo, runSizeBasedCleanup, scheduleDbSizeMonitor, } from "./db-size-monitor.js";
@@ -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 {