llm-simple-router 0.1.1 → 0.3.5

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 (155) hide show
  1. package/README.md +12 -14
  2. package/dist/admin/groups.js +25 -0
  3. package/dist/admin/monitor.d.ts +7 -0
  4. package/dist/admin/monitor.js +25 -0
  5. package/dist/admin/providers.d.ts +4 -1
  6. package/dist/admin/providers.js +68 -17
  7. package/dist/admin/proxy-enhancement.d.ts +7 -0
  8. package/dist/admin/proxy-enhancement.js +39 -0
  9. package/dist/admin/retry-rules.js +6 -3
  10. package/dist/admin/router-keys.d.ts +0 -1
  11. package/dist/admin/router-keys.js +17 -8
  12. package/dist/admin/routes.d.ts +4 -3
  13. package/dist/admin/routes.js +11 -4
  14. package/dist/admin/setup.d.ts +7 -0
  15. package/dist/admin/setup.js +47 -0
  16. package/dist/config.d.ts +1 -4
  17. package/dist/config.js +13 -13
  18. package/dist/db/index.d.ts +6 -3
  19. package/dist/db/index.js +4 -2
  20. package/dist/db/logs.d.ts +5 -2
  21. package/dist/db/logs.js +4 -4
  22. package/dist/db/mappings.d.ts +16 -0
  23. package/dist/db/mappings.js +76 -0
  24. package/dist/db/migrations/014_create_settings.sql +4 -0
  25. package/dist/db/migrations/015_add_original_model.sql +1 -0
  26. package/dist/db/migrations/016_create_session_model_tables.sql +24 -0
  27. package/dist/db/migrations/017_add_provider_concurrency.sql +3 -0
  28. package/dist/db/providers.d.ts +12 -1
  29. package/dist/db/providers.js +8 -3
  30. package/dist/db/retry-rules.js +4 -1
  31. package/dist/db/router-keys.js +3 -1
  32. package/dist/db/session-states.d.ts +40 -0
  33. package/dist/db/session-states.js +37 -0
  34. package/dist/db/settings.d.ts +4 -0
  35. package/dist/db/settings.js +10 -0
  36. package/dist/index.js +86 -16
  37. package/dist/metrics/sse-metrics-transform.d.ts +17 -1
  38. package/dist/metrics/sse-metrics-transform.js +33 -2
  39. package/dist/middleware/admin-auth.d.ts +2 -2
  40. package/dist/middleware/admin-auth.js +21 -8
  41. package/dist/middleware/auth.js +47 -1
  42. package/dist/monitor/request-tracker.d.ts +49 -0
  43. package/dist/monitor/request-tracker.js +279 -0
  44. package/dist/monitor/runtime-collector.d.ts +11 -0
  45. package/dist/monitor/runtime-collector.js +41 -0
  46. package/dist/monitor/stats-aggregator.d.ts +22 -0
  47. package/dist/monitor/stats-aggregator.js +166 -0
  48. package/dist/monitor/types.d.ts +84 -0
  49. package/dist/monitor/types.js +1 -0
  50. package/dist/proxy/anthropic.d.ts +4 -1
  51. package/dist/proxy/anthropic.js +10 -2
  52. package/dist/proxy/directive-parser.d.ts +7 -0
  53. package/dist/proxy/directive-parser.js +70 -0
  54. package/dist/proxy/enhancement-handler.d.ts +23 -0
  55. package/dist/proxy/enhancement-handler.js +169 -0
  56. package/dist/proxy/log-helpers.d.ts +41 -0
  57. package/dist/proxy/log-helpers.js +35 -0
  58. package/dist/proxy/mapping-resolver.js +43 -2
  59. package/dist/proxy/model-state.d.ts +28 -0
  60. package/dist/proxy/model-state.js +111 -0
  61. package/dist/proxy/openai.d.ts +4 -1
  62. package/dist/proxy/openai.js +12 -3
  63. package/dist/proxy/proxy-core.d.ts +15 -47
  64. package/dist/proxy/proxy-core.js +299 -337
  65. package/dist/proxy/response-cleaner.d.ts +5 -0
  66. package/dist/proxy/response-cleaner.js +60 -0
  67. package/dist/proxy/retry.d.ts +1 -1
  68. package/dist/proxy/retry.js +3 -2
  69. package/dist/proxy/semaphore.d.ts +27 -0
  70. package/dist/proxy/semaphore.js +125 -0
  71. package/dist/proxy/strategy/failover.d.ts +1 -1
  72. package/dist/proxy/strategy/failover.js +10 -2
  73. package/dist/proxy/strategy/random.d.ts +1 -1
  74. package/dist/proxy/strategy/random.js +8 -2
  75. package/dist/proxy/strategy/round-robin.d.ts +2 -1
  76. package/dist/proxy/strategy/round-robin.js +13 -2
  77. package/dist/proxy/strategy/targets-rule.d.ts +7 -0
  78. package/dist/proxy/strategy/targets-rule.js +14 -0
  79. package/dist/proxy/strategy/types.d.ts +5 -1
  80. package/dist/proxy/strategy/types.js +3 -0
  81. package/dist/proxy/upstream-call.d.ts +43 -0
  82. package/dist/proxy/upstream-call.js +208 -0
  83. package/dist/utils/password.d.ts +2 -0
  84. package/dist/utils/password.js +15 -0
  85. package/frontend-dist/assets/CardContent-B40ArIqh.js +1 -0
  86. package/frontend-dist/assets/{CardHeader-D5lVaeAA.js → CardHeader-BjkSQf27.js} +1 -1
  87. package/frontend-dist/assets/CardTitle-DjG2kSF3.js +1 -0
  88. package/frontend-dist/assets/Checkbox-Cw0rq2D9.js +1 -0
  89. package/frontend-dist/assets/CollapsibleTrigger-BvYqNbGA.js +1 -0
  90. package/frontend-dist/assets/Collection-CQ4pV54w.js +3 -0
  91. package/frontend-dist/assets/Dashboard-CsOTBnSa.js +3 -0
  92. package/frontend-dist/assets/DialogTitle-PS2W-IfG.js +1 -0
  93. package/frontend-dist/assets/Input-toxjzsir.js +1 -0
  94. package/frontend-dist/assets/Label-fZNDEQjf.js +1 -0
  95. package/frontend-dist/assets/LogResponseViewer-B9kSncNr.js +3 -0
  96. package/frontend-dist/assets/Login-DRm9DHq1.js +1 -0
  97. package/frontend-dist/assets/Logs-NHxebwmP.js +1 -0
  98. package/frontend-dist/assets/ModelMappings-DV0RPnO2.js +1 -0
  99. package/frontend-dist/assets/Monitor-B5TYWb2n.js +1 -0
  100. package/frontend-dist/assets/PopperContent-BvKlcZEO.js +1 -0
  101. package/frontend-dist/assets/Providers-D1Rauu-D.js +1 -0
  102. package/frontend-dist/assets/ProxyEnhancement-B2OliarO.js +5 -0
  103. package/frontend-dist/assets/RetryRules-BWu2gicT.js +1 -0
  104. package/frontend-dist/assets/RouterKeys-BP6XJCVa.js +1 -0
  105. package/frontend-dist/assets/RovingFocusItem-DHfpgdA0.js +1 -0
  106. package/frontend-dist/assets/SelectValue-CFf_mD9E.js +1 -0
  107. package/frontend-dist/assets/Setup-BMjCT-Tl.js +1 -0
  108. package/frontend-dist/assets/Switch-BGCQ7puL.js +1 -0
  109. package/frontend-dist/assets/TableHeader-DAOs6nSA.js +1 -0
  110. package/frontend-dist/assets/TabsTrigger-DBAYM66g.js +1 -0
  111. package/frontend-dist/assets/VisuallyHidden-Dh7svQf3.js +1 -0
  112. package/frontend-dist/assets/VisuallyHiddenInput-BOaRSEmd.js +1 -0
  113. package/frontend-dist/assets/alert-dialog-CUNSZqpB.js +1 -0
  114. package/frontend-dist/assets/button-CfQs66fX.js +1 -0
  115. package/frontend-dist/assets/client-DvdghFBq.js +12 -0
  116. package/frontend-dist/assets/createLucideIcon-DCD7INQf.js +1 -0
  117. package/frontend-dist/assets/dialog-DQFRGKR6.js +1 -0
  118. package/frontend-dist/assets/index--5JhZIwi.js +1 -0
  119. package/frontend-dist/assets/index-Bx15k8FA.css +1 -0
  120. package/frontend-dist/assets/lib-BJNsNHLO.js +1 -0
  121. package/frontend-dist/assets/ohash.D__AXeF1-CNudYmrX.js +1 -0
  122. package/frontend-dist/assets/useClipboard-aPMKfK25.js +1 -0
  123. package/frontend-dist/assets/useForwardExpose-u2vjohek.js +1 -0
  124. package/frontend-dist/assets/useNonce-ClXGIm-8.js +1 -0
  125. package/frontend-dist/assets/x-ILQhskuj.js +1 -0
  126. package/frontend-dist/index.html +7 -6
  127. package/package.json +5 -4
  128. package/.env.example +0 -13
  129. package/dist/admin/services.d.ts +0 -7
  130. package/dist/admin/services.js +0 -63
  131. package/frontend-dist/assets/CardContent-BE9fukPi.js +0 -1
  132. package/frontend-dist/assets/CardTitle-H-zwhi3Z.js +0 -1
  133. package/frontend-dist/assets/Checkbox--1gw0dYW.js +0 -1
  134. package/frontend-dist/assets/CollapsibleTrigger-D_ptA35Y.js +0 -1
  135. package/frontend-dist/assets/Dashboard-D4AwkULO.js +0 -3
  136. package/frontend-dist/assets/Label-GiPfoz7u.js +0 -1
  137. package/frontend-dist/assets/Login-BUet1sbM.js +0 -1
  138. package/frontend-dist/assets/Logs-yztb_F9t.js +0 -3
  139. package/frontend-dist/assets/ModelMappings-MbZhdPNv.js +0 -1
  140. package/frontend-dist/assets/Providers-BjsqH6A2.js +0 -1
  141. package/frontend-dist/assets/RetryRules-C2vvJvLr.js +0 -1
  142. package/frontend-dist/assets/RouterKeys-DavrgpAQ.js +0 -1
  143. package/frontend-dist/assets/RovingFocusItem-DnIa_lwH.js +0 -1
  144. package/frontend-dist/assets/SelectValue-BB0Ckbjh.js +0 -1
  145. package/frontend-dist/assets/TableHeader-D2GkiqRx.js +0 -1
  146. package/frontend-dist/assets/alert-dialog-CWjBke-O.js +0 -1
  147. package/frontend-dist/assets/badge-_ZHrMEpC.js +0 -3
  148. package/frontend-dist/assets/button-C4_mChkc.js +0 -1
  149. package/frontend-dist/assets/client-BWw0R36V.js +0 -12
  150. package/frontend-dist/assets/dialog-CUHMcTqp.js +0 -1
  151. package/frontend-dist/assets/index-DEl48bm9.css +0 -1
  152. package/frontend-dist/assets/index-UZK1BnPG.js +0 -1
  153. package/frontend-dist/assets/lib-Qs8xoTas.js +0 -1
  154. package/frontend-dist/assets/useForwardExpose-B-xauF1X.js +0 -1
  155. package/frontend-dist/assets/x-JBJB26JV.js +0 -1
package/README.md CHANGED
@@ -59,7 +59,7 @@ Router 根据模型映射找到后端供应商 -> 转发请求 -> 自动重试
59
59
  **方式一:shell alias(推荐)**
60
60
 
61
61
  ```bash
62
- alias clodedev='ANTHROPIC_AUTH_TOKEN="<your-router-key>" ANTHROPIC_BASE_URL="http://127.0.0.1:3000" claude'
62
+ alias clodedev='ANTHROPIC_AUTH_TOKEN="<your-router-key>" ANTHROPIC_BASE_URL="http://127.0.0.1:9981" claude'
63
63
  ```
64
64
 
65
65
  **方式二:~/.claude/settings.json**
@@ -68,7 +68,7 @@ alias clodedev='ANTHROPIC_AUTH_TOKEN="<your-router-key>" ANTHROPIC_BASE_URL="htt
68
68
  {
69
69
  "env": {
70
70
  "ANTHROPIC_AUTH_TOKEN": "sk-router-change-me",
71
- "ANTHROPIC_BASE_URL": "http://127.0.0.1:3000",
71
+ "ANTHROPIC_BASE_URL": "http://127.0.0.1:9981",
72
72
  "ANTHROPIC_MODEL": "some-model"
73
73
  }
74
74
  }
@@ -90,14 +90,13 @@ alias clodedev='ANTHROPIC_AUTH_TOKEN="<your-router-key>" ANTHROPIC_BASE_URL="htt
90
90
  ## 快速开始
91
91
 
92
92
  ```bash
93
- npm install
94
- cp .env.example .env
95
- # 编辑 .env,设置 ADMIN_PASSWORD、ENCRYPTION_KEY、JWT_SECRET
96
- npm run dev
97
- # 访问 http://localhost:3000/admin
93
+ # 一行命令启动
94
+ npx llm-simple-router
95
+ # 访问 http://localhost:9981/admin
96
+ # 首次访问会进入 Setup 页面设置管理员密码
98
97
  ```
99
98
 
100
- 生成随机密钥:`openssl rand -hex 32`
99
+ 无需任何环境变量。数据默认存储在 `~/.llm-simple-router/`。
101
100
 
102
101
  ## Docker 部署
103
102
 
@@ -107,15 +106,14 @@ docker compose up -d
107
106
 
108
107
  ## 环境变量
109
108
 
109
+ 所有密钥(管理员密码、加密密钥、JWT 密钥)通过首次启动的 Setup 页面设置,无需环境变量。
110
+
110
111
  | 变量 | 必需 | 默认值 | 说明 |
111
112
  |------|------|--------|------|
112
- | `ADMIN_PASSWORD` | Yes | -- | 管理后台密码 |
113
- | `ENCRYPTION_KEY` | Yes | -- | AES-256-GCM 密钥(64字符 hex) |
114
- | `JWT_SECRET` | Yes | -- | JWT 签名密钥(64字符 hex) |
115
- | `PORT` | No | `3000` | 服务端口 |
116
- | `DB_PATH` | No | `./data/router.db` | SQLite 数据库路径 |
113
+ | `PORT` | No | `9981` | 服务端口 |
114
+ | `DB_PATH` | No | `~/.llm-simple-router/router.db` | SQLite 数据库路径 |
117
115
  | `LOG_LEVEL` | No | `info` | 日志级别 |
118
- | `TZ` | No | -- | 时区设置 |
116
+ | `TZ` | No | `Asia/Shanghai` | 时区设置 |
119
117
  | `STREAM_TIMEOUT_MS` | No | `3000000` | 流式代理空闲超时(ms) |
120
118
  | `RETRY_MAX_ATTEMPTS` | No | `3` | 最大重试次数 |
121
119
  | `RETRY_BASE_DELAY_MS` | No | `1000` | 重试基础延迟(ms) |
@@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox";
2
2
  import { getAllMappingGroups, createMappingGroup, updateMappingGroup, deleteMappingGroup, getProviderById, getMappingGroupById, } from "../db/index.js";
3
3
  import { STRATEGY_NAMES } from "../proxy/strategy/types.js";
4
4
  import { HTTP_BAD_REQUEST, HTTP_CREATED, HTTP_CONFLICT } from "./constants.js";
5
+ const MIN_FAILOVER_TARGETS = 2;
5
6
  const CreateGroupSchema = Type.Object({
6
7
  client_model: Type.String({ minLength: 1 }),
7
8
  strategy: Type.String({ minLength: 1 }),
@@ -13,6 +14,10 @@ const UpdateGroupSchema = Type.Object({
13
14
  rule: Type.Optional(Type.String()),
14
15
  });
15
16
  async function validateRule(db, strategy, ruleJson) {
17
+ const VALID_STRATEGIES = new Set(Object.values(STRATEGY_NAMES));
18
+ if (!VALID_STRATEGIES.has(strategy)) {
19
+ return `Unknown strategy '${strategy}'. Valid: ${[...VALID_STRATEGIES].join(", ")}`;
20
+ }
16
21
  let rule;
17
22
  try {
18
23
  rule = JSON.parse(ruleJson);
@@ -45,6 +50,26 @@ async function validateRule(db, strategy, ruleJson) {
45
50
  }
46
51
  }
47
52
  }
53
+ if (strategy === STRATEGY_NAMES.ROUND_ROBIN || strategy === STRATEGY_NAMES.RANDOM || strategy === STRATEGY_NAMES.FAILOVER) {
54
+ const r = rule;
55
+ if (!Array.isArray(r.targets) || r.targets.length === 0) {
56
+ return "rule.targets must be a non-empty array";
57
+ }
58
+ const minTargets = strategy === STRATEGY_NAMES.FAILOVER ? MIN_FAILOVER_TARGETS : 1;
59
+ if (r.targets.length < minTargets) {
60
+ return `strategy '${strategy}' requires at least ${minTargets} target(s)`;
61
+ }
62
+ for (let i = 0; i < r.targets.length; i++) {
63
+ const t = r.targets[i];
64
+ if (!t.backend_model || !t.provider_id) {
65
+ return `targets[${i}] missing backend_model or provider_id`;
66
+ }
67
+ const p = getProviderById(db, t.provider_id);
68
+ if (!p) {
69
+ return `targets[${i}] provider_id '${t.provider_id}' not found`;
70
+ }
71
+ }
72
+ }
48
73
  return undefined;
49
74
  }
50
75
  export const adminGroupRoutes = (app, options, done) => {
@@ -0,0 +1,7 @@
1
+ import { FastifyPluginCallback } from "fastify";
2
+ import type { RequestTracker } from "../monitor/request-tracker.js";
3
+ interface MonitorRoutesOptions {
4
+ tracker?: RequestTracker;
5
+ }
6
+ export declare const adminMonitorRoutes: FastifyPluginCallback<MonitorRoutesOptions>;
7
+ export {};
@@ -0,0 +1,25 @@
1
+ const HTTP_OK = 200;
2
+ export const adminMonitorRoutes = (app, options, done) => {
3
+ const { tracker } = options;
4
+ if (!tracker) {
5
+ done();
6
+ return;
7
+ }
8
+ app.get("/admin/api/monitor/active", async () => tracker.getActive());
9
+ app.get("/admin/api/monitor/recent", async () => tracker.getRecent());
10
+ app.get("/admin/api/monitor/stats", async () => tracker.getStats());
11
+ app.get("/admin/api/monitor/concurrency", async () => tracker.getConcurrency());
12
+ app.get("/admin/api/monitor/runtime", async () => tracker.getRuntime());
13
+ app.get("/admin/api/monitor/stream", (request, reply) => {
14
+ reply.raw.writeHead(HTTP_OK, {
15
+ "Content-Type": "text/event-stream",
16
+ "Cache-Control": "no-cache",
17
+ Connection: "keep-alive",
18
+ });
19
+ tracker.addClient(reply.raw);
20
+ request.raw.on("close", () => {
21
+ tracker.removeClient(reply.raw);
22
+ });
23
+ });
24
+ done();
25
+ };
@@ -1,8 +1,11 @@
1
1
  import { FastifyPluginCallback } from "fastify";
2
2
  import Database from "better-sqlite3";
3
+ import { ProviderSemaphoreManager } from "../proxy/semaphore.js";
4
+ import type { RequestTracker } from "../monitor/request-tracker.js";
3
5
  interface ProviderRoutesOptions {
4
6
  db: Database.Database;
5
- encryptionKey: string;
7
+ semaphoreManager?: ProviderSemaphoreManager;
8
+ tracker?: RequestTracker;
6
9
  }
7
10
  export declare const adminProviderRoutes: FastifyPluginCallback<ProviderRoutesOptions>;
8
11
  export {};
@@ -1,9 +1,11 @@
1
1
  import { Type } from "@sinclair/typebox";
2
- import { getAllProviders, getProviderById, createProvider, updateProvider, deleteProvider, getAllMappingGroups } from "../db/index.js";
3
- import { encrypt } from "../utils/crypto.js";
4
- import { HTTP_CREATED, HTTP_NOT_FOUND, HTTP_CONFLICT } from "./constants.js";
5
- const API_KEY_PREVIEW_MIN_LEN = 8;
2
+ import { getAllProviders, getProviderById, createProvider, updateProvider, deleteProvider, getAllMappingGroups, PROVIDER_CONCURRENCY_DEFAULTS } from "../db/index.js";
3
+ import { encrypt, decrypt } from "../utils/crypto.js";
4
+ import { getSetting } from "../db/settings.js";
5
+ import { HTTP_CREATED, HTTP_NOT_FOUND, HTTP_CONFLICT, HTTP_BAD_REQUEST } from "./constants.js";
6
+ const API_KEY_PREVIEW_MIN_LENGTH = 8;
6
7
  const API_KEY_PREVIEW_PREFIX_LEN = 4;
8
+ const PROVIDER_NAME_RE = /^[a-zA-Z0-9_-]+$/;
7
9
  const CreateProviderSchema = Type.Object({
8
10
  name: Type.String({ minLength: 1 }),
9
11
  api_type: Type.Union([Type.Literal("openai"), Type.Literal("anthropic")]),
@@ -11,6 +13,9 @@ const CreateProviderSchema = Type.Object({
11
13
  api_key: Type.String({ minLength: 1 }),
12
14
  models: Type.Optional(Type.Array(Type.String())),
13
15
  is_active: Type.Optional(Type.Number()),
16
+ max_concurrency: Type.Optional(Type.Integer({ minimum: 0 })),
17
+ queue_timeout_ms: Type.Optional(Type.Integer({ minimum: 0 })),
18
+ max_queue_size: Type.Optional(Type.Integer({ minimum: 1 })),
14
19
  });
15
20
  const UpdateProviderSchema = Type.Object({
16
21
  name: Type.Optional(Type.String({ minLength: 1 })),
@@ -19,40 +24,59 @@ const UpdateProviderSchema = Type.Object({
19
24
  api_key: Type.Optional(Type.String({ minLength: 1 })),
20
25
  models: Type.Optional(Type.Array(Type.String())),
21
26
  is_active: Type.Optional(Type.Number()),
27
+ max_concurrency: Type.Optional(Type.Integer({ minimum: 0 })),
28
+ queue_timeout_ms: Type.Optional(Type.Integer({ minimum: 0 })),
29
+ max_queue_size: Type.Optional(Type.Integer({ minimum: 1 })),
22
30
  });
23
- function computeApiKeyPreview(apiKey) {
24
- if (apiKey.length <= API_KEY_PREVIEW_MIN_LEN)
25
- return "****";
26
- return `${apiKey.slice(0, API_KEY_PREVIEW_PREFIX_LEN)}...${apiKey.slice(-API_KEY_PREVIEW_PREFIX_LEN)}`;
27
- }
28
31
  export const adminProviderRoutes = (app, options, done) => {
29
- const { db, encryptionKey } = options;
32
+ const { db, semaphoreManager, tracker } = options;
30
33
  app.get("/admin/api/providers", async (_request, reply) => {
34
+ const encryptionKey = getSetting(db, "encryption_key");
31
35
  const providers = getAllProviders(db);
32
36
  return reply.send(providers.map((s) => ({
33
37
  id: s.id,
34
38
  name: s.name,
35
39
  api_type: s.api_type,
36
40
  base_url: s.base_url,
37
- api_key_preview: s.api_key_preview || "****",
41
+ api_key: s.api_key ? decrypt(s.api_key, encryptionKey) : "",
38
42
  models: JSON.parse(s.models || "[]"),
39
43
  is_active: s.is_active,
44
+ max_concurrency: s.max_concurrency,
45
+ queue_timeout_ms: s.queue_timeout_ms,
46
+ max_queue_size: s.max_queue_size,
47
+ concurrency_status: semaphoreManager?.getStatus(s.id) ?? { active: 0, queued: 0 },
40
48
  created_at: s.created_at,
41
49
  updated_at: s.updated_at,
42
50
  })));
43
51
  });
44
52
  app.post("/admin/api/providers", { schema: { body: CreateProviderSchema } }, async (request, reply) => {
45
53
  const body = request.body;
46
- const encryptedKey = encrypt(body.api_key, encryptionKey);
47
- const apiKeyPreview = computeApiKeyPreview(body.api_key);
54
+ if (!PROVIDER_NAME_RE.test(body.name)) {
55
+ return reply.status(HTTP_BAD_REQUEST).send({ error: { message: "Provider 名称仅允许英文大小写字母、数字、横线和下划线" } });
56
+ }
57
+ const encryptedKey = encrypt(body.api_key, getSetting(db, "encryption_key"));
48
58
  const id = createProvider(db, {
49
59
  name: body.name,
50
60
  api_type: body.api_type,
51
61
  base_url: body.base_url,
52
62
  api_key: encryptedKey,
53
- api_key_preview: apiKeyPreview,
63
+ api_key_preview: body.api_key.length > API_KEY_PREVIEW_MIN_LENGTH ? `${body.api_key.slice(0, API_KEY_PREVIEW_PREFIX_LEN)}...${body.api_key.slice(-API_KEY_PREVIEW_PREFIX_LEN)}` : "****",
54
64
  models: JSON.stringify(body.models ?? []),
55
65
  is_active: body.is_active ?? 1,
66
+ max_concurrency: body.max_concurrency ?? PROVIDER_CONCURRENCY_DEFAULTS.max_concurrency,
67
+ queue_timeout_ms: body.queue_timeout_ms ?? PROVIDER_CONCURRENCY_DEFAULTS.queue_timeout_ms,
68
+ max_queue_size: body.max_queue_size ?? PROVIDER_CONCURRENCY_DEFAULTS.max_queue_size,
69
+ });
70
+ semaphoreManager?.updateConfig(id, {
71
+ maxConcurrency: body.max_concurrency ?? PROVIDER_CONCURRENCY_DEFAULTS.max_concurrency,
72
+ queueTimeoutMs: body.queue_timeout_ms ?? PROVIDER_CONCURRENCY_DEFAULTS.queue_timeout_ms,
73
+ maxQueueSize: body.max_queue_size ?? PROVIDER_CONCURRENCY_DEFAULTS.max_queue_size,
74
+ });
75
+ tracker?.updateProviderConfig(id, {
76
+ name: body.name,
77
+ maxConcurrency: body.max_concurrency ?? PROVIDER_CONCURRENCY_DEFAULTS.max_concurrency,
78
+ queueTimeoutMs: body.queue_timeout_ms ?? PROVIDER_CONCURRENCY_DEFAULTS.queue_timeout_ms,
79
+ maxQueueSize: body.max_queue_size ?? PROVIDER_CONCURRENCY_DEFAULTS.max_queue_size,
56
80
  });
57
81
  return reply.code(HTTP_CREATED).send({ id });
58
82
  });
@@ -63,6 +87,9 @@ export const adminProviderRoutes = (app, options, done) => {
63
87
  return reply.code(HTTP_NOT_FOUND).send({ error: { message: "Provider not found" } });
64
88
  }
65
89
  const body = request.body;
90
+ if (body.name !== undefined && !PROVIDER_NAME_RE.test(body.name)) {
91
+ return reply.status(HTTP_BAD_REQUEST).send({ error: { message: "Provider 名称仅允许英文大小写字母、数字、横线和下划线" } });
92
+ }
66
93
  const fields = {};
67
94
  if (body.name !== undefined)
68
95
  fields.name = body.name;
@@ -74,11 +101,31 @@ export const adminProviderRoutes = (app, options, done) => {
74
101
  fields.is_active = body.is_active;
75
102
  if (body.models !== undefined)
76
103
  fields.models = JSON.stringify(body.models);
104
+ if (body.max_concurrency !== undefined)
105
+ fields.max_concurrency = body.max_concurrency;
106
+ if (body.queue_timeout_ms !== undefined)
107
+ fields.queue_timeout_ms = body.queue_timeout_ms;
108
+ if (body.max_queue_size !== undefined)
109
+ fields.max_queue_size = body.max_queue_size;
77
110
  if (body.api_key) {
78
- fields.api_key = encrypt(body.api_key, encryptionKey);
79
- fields.api_key_preview = computeApiKeyPreview(body.api_key);
111
+ fields.api_key = encrypt(body.api_key, getSetting(db, "encryption_key"));
112
+ fields.api_key_preview = body.api_key.length > API_KEY_PREVIEW_MIN_LENGTH ? `${body.api_key.slice(0, API_KEY_PREVIEW_PREFIX_LEN)}...${body.api_key.slice(-API_KEY_PREVIEW_PREFIX_LEN)}` : "****";
80
113
  }
81
114
  updateProvider(db, id, fields);
115
+ const updated = getProviderById(db, id);
116
+ if (body.max_concurrency !== undefined || body.queue_timeout_ms !== undefined || body.max_queue_size !== undefined) {
117
+ semaphoreManager?.updateConfig(id, {
118
+ maxConcurrency: updated.max_concurrency,
119
+ queueTimeoutMs: updated.queue_timeout_ms,
120
+ maxQueueSize: updated.max_queue_size,
121
+ });
122
+ }
123
+ tracker?.updateProviderConfig(id, {
124
+ name: body.name ?? existing.name,
125
+ maxConcurrency: updated.max_concurrency,
126
+ queueTimeoutMs: updated.queue_timeout_ms,
127
+ maxQueueSize: updated.max_queue_size,
128
+ });
82
129
  return reply.send({ success: true });
83
130
  });
84
131
  app.delete("/admin/api/providers/:id", async (request, reply) => {
@@ -92,9 +139,13 @@ export const adminProviderRoutes = (app, options, done) => {
92
139
  return reply.code(HTTP_CONFLICT).send({ error: { message: `Provider is referenced by mapping group '${g.client_model}'` } });
93
140
  }
94
141
  }
95
- catch { /* rule format invalid, skip */ }
142
+ catch {
143
+ continue;
144
+ }
96
145
  }
97
146
  deleteProvider(db, id);
147
+ semaphoreManager?.remove(id);
148
+ tracker?.removeProviderConfig(id);
98
149
  return reply.send({ success: true });
99
150
  });
100
151
  done();
@@ -0,0 +1,7 @@
1
+ import { FastifyPluginCallback } from "fastify";
2
+ import Database from "better-sqlite3";
3
+ interface ProxyEnhancementOptions {
4
+ db: Database.Database;
5
+ }
6
+ export declare const adminProxyEnhancementRoutes: FastifyPluginCallback<ProxyEnhancementOptions>;
7
+ export {};
@@ -0,0 +1,39 @@
1
+ import { getSetting, setSetting } from "../db/settings.js";
2
+ import { getSessionStates, getSessionHistory, } from "../db/session-states.js";
3
+ import { modelState } from "../proxy/model-state.js";
4
+ export const adminProxyEnhancementRoutes = (app, options, done) => {
5
+ const { db } = options;
6
+ app.get("/admin/api/proxy-enhancement", async (_req, reply) => {
7
+ const raw = getSetting(db, "proxy_enhancement");
8
+ const config = raw
9
+ ? JSON.parse(raw)
10
+ : { claude_code_enabled: false };
11
+ return reply.send(config);
12
+ });
13
+ app.put("/admin/api/proxy-enhancement", async (req, reply) => {
14
+ const body = req.body;
15
+ if (typeof body.claude_code_enabled !== "boolean") {
16
+ return reply.status(400).send({ error: "claude_code_enabled must be a boolean" }); // eslint-disable-line no-magic-numbers
17
+ }
18
+ const config = {
19
+ claude_code_enabled: body.claude_code_enabled,
20
+ };
21
+ setSetting(db, "proxy_enhancement", JSON.stringify(config));
22
+ return reply.send({ success: true });
23
+ });
24
+ app.get("/admin/api/session-states", async (_req, reply) => {
25
+ const states = getSessionStates(db);
26
+ return reply.send(states);
27
+ });
28
+ app.get("/admin/api/session-states/:keyId/:sessionId/history", async (req, reply) => {
29
+ const { keyId, sessionId } = req.params;
30
+ const history = getSessionHistory(db, keyId, sessionId);
31
+ return reply.send(history);
32
+ });
33
+ app.delete("/admin/api/session-states/:keyId/:sessionId", async (req, reply) => {
34
+ const { keyId, sessionId } = req.params;
35
+ modelState.delete(keyId, sessionId);
36
+ return reply.send({ success: true });
37
+ });
38
+ done();
39
+ };
@@ -1,6 +1,9 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import { getAllRetryRules, createRetryRule, updateRetryRule, deleteRetryRule, } from "../db/index.js";
3
3
  import { HTTP_BAD_REQUEST, HTTP_CREATED } from "./constants.js";
4
+ const DEFAULT_RETRY_DELAY_MS = 5000;
5
+ const DEFAULT_MAX_RETRIES = 10;
6
+ const DEFAULT_MAX_DELAY_MS = 60000;
4
7
  const CreateRetryRuleSchema = Type.Object({
5
8
  name: Type.String({ minLength: 1 }),
6
9
  status_code: Type.Number({ minimum: 100, maximum: 599 }),
@@ -52,9 +55,9 @@ export const adminRetryRuleRoutes = (app, options, done) => {
52
55
  body_pattern: body.body_pattern,
53
56
  is_active: body.is_active ?? 1,
54
57
  retry_strategy: body.retry_strategy ?? "exponential",
55
- retry_delay_ms: body.retry_delay_ms ?? 5000,
56
- max_retries: body.max_retries ?? 10,
57
- max_delay_ms: body.max_delay_ms ?? 60000,
58
+ retry_delay_ms: body.retry_delay_ms ?? DEFAULT_RETRY_DELAY_MS,
59
+ max_retries: body.max_retries ?? DEFAULT_MAX_RETRIES,
60
+ max_delay_ms: body.max_delay_ms ?? DEFAULT_MAX_DELAY_MS,
58
61
  });
59
62
  refreshMatcher(matcher, db);
60
63
  return reply.code(HTTP_CREATED).send({ id });
@@ -2,7 +2,6 @@ import { FastifyPluginCallback } from "fastify";
2
2
  import Database from "better-sqlite3";
3
3
  interface RouterKeyRoutesOptions {
4
4
  db: Database.Database;
5
- encryptionKey: string;
6
5
  }
7
6
  export declare const adminRouterKeyRoutes: FastifyPluginCallback<RouterKeyRoutesOptions>;
8
7
  export {};
@@ -2,10 +2,18 @@ import { randomBytes, createHash } from "crypto";
2
2
  import { Type } from "@sinclair/typebox";
3
3
  import { encrypt, decrypt } from "../utils/crypto.js";
4
4
  import { getAllRouterKeys, getRouterKeyById, createRouterKey, updateRouterKey, deleteRouterKey, getAvailableModels, } from "../db/index.js";
5
+ import { getSetting } from "../db/settings.js";
5
6
  const HTTP_CREATED = 201;
6
7
  const HTTP_NOT_FOUND = 404;
7
8
  const KEY_RANDOM_BYTES = 32;
8
9
  const KEY_PREFIX_LENGTH = 8;
10
+ /** 归一化 allowed_models:null/空数组/仅含空字符串 → null(允许所有模型) */
11
+ function normalizeAllowedModels(val) {
12
+ if (!val)
13
+ return null;
14
+ const filtered = val.filter((m) => m.trim() !== "");
15
+ return filtered.length > 0 ? JSON.stringify(filtered) : null;
16
+ }
9
17
  const CreateRouterKeySchema = Type.Object({
10
18
  name: Type.String({ minLength: 1 }),
11
19
  allowed_models: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Null()])),
@@ -15,14 +23,15 @@ const UpdateRouterKeySchema = Type.Object({
15
23
  allowed_models: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Null()])),
16
24
  is_active: Type.Optional(Type.Number()),
17
25
  });
18
- function generateRouterKey(encryptionKey) {
26
+ function generateRouterKey(db) {
19
27
  const key = `sk-router-${randomBytes(KEY_RANDOM_BYTES).toString("hex")}`;
20
28
  const hash = createHash("sha256").update(key).digest("hex");
21
29
  const prefix = key.slice(0, KEY_PREFIX_LENGTH);
22
- const encrypted = encrypt(key, encryptionKey);
30
+ const encrypted = encrypt(key, getSetting(db, "encryption_key"));
23
31
  return { key, hash, prefix, encrypted };
24
32
  }
25
- function toPublicRouterKey(rk, encryptionKey) {
33
+ function toPublicRouterKey(rk, db) {
34
+ const encryptionKey = getSetting(db, "encryption_key");
26
35
  return {
27
36
  id: rk.id,
28
37
  name: rk.name,
@@ -35,15 +44,15 @@ function toPublicRouterKey(rk, encryptionKey) {
35
44
  };
36
45
  }
37
46
  export const adminRouterKeyRoutes = (app, options, done) => {
38
- const { db, encryptionKey } = options;
47
+ const { db } = options;
39
48
  app.get("/admin/api/router-keys", async (_request, reply) => {
40
49
  const keys = getAllRouterKeys(db);
41
- return reply.send(keys.map((rk) => toPublicRouterKey(rk, encryptionKey)));
50
+ return reply.send(keys.map((rk) => toPublicRouterKey(rk, db)));
42
51
  });
43
52
  app.post("/admin/api/router-keys", { schema: { body: CreateRouterKeySchema } }, async (request, reply) => {
44
53
  const body = request.body;
45
- const { key, hash, prefix, encrypted } = generateRouterKey(encryptionKey);
46
- const allowedModels = body.allowed_models ? JSON.stringify(body.allowed_models) : null;
54
+ const { key, hash, prefix, encrypted } = generateRouterKey(db);
55
+ const allowedModels = normalizeAllowedModels(body.allowed_models);
47
56
  const id = createRouterKey(db, { name: body.name, key_hash: hash, key_prefix: prefix, key_encrypted: encrypted, allowed_models: allowedModels });
48
57
  return reply.code(HTTP_CREATED).send({
49
58
  id,
@@ -66,7 +75,7 @@ export const adminRouterKeyRoutes = (app, options, done) => {
66
75
  if (body.name !== undefined)
67
76
  fields.name = body.name;
68
77
  if (body.allowed_models !== undefined)
69
- fields.allowed_models = JSON.stringify(body.allowed_models);
78
+ fields.allowed_models = normalizeAllowedModels(body.allowed_models);
70
79
  if (body.is_active !== undefined)
71
80
  fields.is_active = body.is_active;
72
81
  updateRouterKey(db, id, fields);
@@ -1,12 +1,13 @@
1
1
  import { FastifyPluginCallback } from "fastify";
2
2
  import Database from "better-sqlite3";
3
3
  import { RetryRuleMatcher } from "../proxy/retry-rules.js";
4
+ import type { RequestTracker } from "../monitor/request-tracker.js";
5
+ import { ProviderSemaphoreManager } from "../proxy/semaphore.js";
4
6
  interface AdminRoutesOptions {
5
7
  db: Database.Database;
6
- adminPassword: string;
7
- jwtSecret: string;
8
- encryptionKey: string;
9
8
  matcher: RetryRuleMatcher | null;
9
+ tracker?: RequestTracker;
10
+ semaphoreManager?: ProviderSemaphoreManager;
10
11
  }
11
12
  export declare const adminRoutes: FastifyPluginCallback<AdminRoutesOptions>;
12
13
  export {};
@@ -6,17 +6,24 @@ import { adminRetryRuleRoutes } from "./retry-rules.js";
6
6
  import { adminLogRoutes } from "./logs.js";
7
7
  import { adminStatsRoutes } from "./stats.js";
8
8
  import { adminMetricsRoutes } from "./metrics.js";
9
+ import { adminProxyEnhancementRoutes } from "./proxy-enhancement.js";
9
10
  import { adminRouterKeyRoutes } from "./router-keys.js";
11
+ import { adminSetupRoutes } from "./setup.js";
12
+ import { adminMonitorRoutes } from "./monitor.js";
10
13
  export const adminRoutes = (app, options, done) => {
11
- app.register(adminAuthPlugin, { adminPassword: options.adminPassword, jwtSecret: options.jwtSecret });
12
- app.register(adminLoginRoutes, { adminPassword: options.adminPassword, jwtSecret: options.jwtSecret });
13
- app.register(adminProviderRoutes, { db: options.db, encryptionKey: options.encryptionKey });
14
+ // Setup 路由不需要 auth
15
+ app.register(adminSetupRoutes, { db: options.db });
16
+ app.register(adminAuthPlugin, { db: options.db });
17
+ app.register(adminLoginRoutes, { db: options.db });
18
+ app.register(adminProviderRoutes, { db: options.db, semaphoreManager: options.semaphoreManager, tracker: options.tracker });
14
19
  app.register(adminMappingRoutes, { db: options.db });
15
20
  app.register(adminGroupRoutes, { db: options.db });
16
21
  app.register(adminRetryRuleRoutes, { db: options.db, matcher: options.matcher });
17
22
  app.register(adminLogRoutes, { db: options.db });
18
- app.register(adminRouterKeyRoutes, { db: options.db, encryptionKey: options.encryptionKey });
23
+ app.register(adminRouterKeyRoutes, { db: options.db });
19
24
  app.register(adminStatsRoutes, { db: options.db });
20
25
  app.register(adminMetricsRoutes, { db: options.db });
26
+ app.register(adminProxyEnhancementRoutes, { db: options.db });
27
+ app.register(adminMonitorRoutes, { tracker: options.tracker });
21
28
  done();
22
29
  };
@@ -0,0 +1,7 @@
1
+ import { FastifyPluginCallback } from "fastify";
2
+ import Database from "better-sqlite3";
3
+ interface SetupOptions {
4
+ db: Database.Database;
5
+ }
6
+ export declare const adminSetupRoutes: FastifyPluginCallback<SetupOptions>;
7
+ export {};
@@ -0,0 +1,47 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import jwt from "jsonwebtoken";
3
+ import { getSetting, setSetting, isInitialized } from "../db/settings.js";
4
+ import { hashPassword } from "../utils/password.js";
5
+ import { HTTP_BAD_REQUEST, HTTP_CONFLICT } from "./constants.js";
6
+ const CRYPTO_BYTES_LENGTH = 32;
7
+ const MIN_PASSWORD_LENGTH = 6;
8
+ export const adminSetupRoutes = (app, options, done) => {
9
+ const { db } = options;
10
+ app.get("/admin/api/setup/status", async () => {
11
+ return { initialized: isInitialized(db) };
12
+ });
13
+ app.post("/admin/api/setup/initialize", async (request, reply) => {
14
+ const { password } = request.body;
15
+ if (!password || password.length < MIN_PASSWORD_LENGTH) {
16
+ return reply.code(HTTP_BAD_REQUEST).send({ error: { message: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` } });
17
+ }
18
+ // 事务中原子检查防竞态
19
+ const alreadyInitialized = db.transaction(() => {
20
+ if (isInitialized(db))
21
+ return true;
22
+ const encryptionKey = randomBytes(CRYPTO_BYTES_LENGTH).toString("hex");
23
+ const jwtSecret = randomBytes(CRYPTO_BYTES_LENGTH).toString("hex");
24
+ setSetting(db, "admin_password_hash", hashPassword(password));
25
+ setSetting(db, "encryption_key", encryptionKey);
26
+ setSetting(db, "jwt_secret", jwtSecret);
27
+ setSetting(db, "initialized", "true");
28
+ return false;
29
+ })();
30
+ if (alreadyInitialized) {
31
+ return reply.code(HTTP_CONFLICT).send({ error: { message: "Already initialized" } });
32
+ }
33
+ // 自动登录:签发 JWT
34
+ const TOKEN_EXPIRY_SECONDS = 172800; // 48 hours,与 admin-auth 保持一致
35
+ const secret = getSetting(db, "jwt_secret");
36
+ const token = jwt.sign({ role: "admin" }, secret, { expiresIn: TOKEN_EXPIRY_SECONDS });
37
+ reply.setCookie("admin_token", token, {
38
+ path: "/admin",
39
+ httpOnly: true,
40
+ secure: process.env.NODE_ENV === "production",
41
+ sameSite: "lax",
42
+ maxAge: TOKEN_EXPIRY_SECONDS,
43
+ });
44
+ return { success: true };
45
+ });
46
+ done();
47
+ };
package/dist/config.d.ts CHANGED
@@ -1,8 +1,4 @@
1
- import "dotenv/config";
2
1
  export interface Config {
3
- ADMIN_PASSWORD: string;
4
- ENCRYPTION_KEY: string;
5
- JWT_SECRET: string;
6
2
  PORT: number;
7
3
  DB_PATH: string;
8
4
  LOG_LEVEL: string;
@@ -12,4 +8,5 @@ export interface Config {
12
8
  RETRY_BASE_DELAY_MS: number;
13
9
  }
14
10
  export declare function resetConfig(): void;
11
+ export declare function getBaseConfig(): Config;
15
12
  export declare function getConfig(): Config;
package/dist/config.js CHANGED
@@ -1,23 +1,20 @@
1
- import "dotenv/config";
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
2
3
  let cachedConfig = null;
4
+ function getDefaultDbPath() {
5
+ if (process.env.DB_PATH)
6
+ return process.env.DB_PATH;
7
+ return join(homedir(), ".llm-simple-router", "router.db");
8
+ }
3
9
  export function resetConfig() {
4
10
  cachedConfig = null;
5
11
  }
6
- export function getConfig() {
12
+ export function getBaseConfig() {
7
13
  if (cachedConfig)
8
14
  return cachedConfig;
9
- const requiredVars = ["ADMIN_PASSWORD", "ENCRYPTION_KEY", "JWT_SECRET"];
10
- for (const name of requiredVars) {
11
- if (!process.env[name]) {
12
- throw new Error(`Missing required environment variable: ${name}`);
13
- }
14
- }
15
15
  cachedConfig = {
16
- ADMIN_PASSWORD: process.env.ADMIN_PASSWORD,
17
- ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
18
- JWT_SECRET: process.env.JWT_SECRET,
19
- PORT: parseInt(process.env.PORT || "3000", 10),
20
- DB_PATH: process.env.DB_PATH || "./data/router.db",
16
+ PORT: parseInt(process.env.PORT || "9981", 10),
17
+ DB_PATH: getDefaultDbPath(),
21
18
  LOG_LEVEL: process.env.LOG_LEVEL || "info",
22
19
  TZ: process.env.TZ || "Asia/Shanghai",
23
20
  STREAM_TIMEOUT_MS: parseInt(process.env.STREAM_TIMEOUT_MS || "3000000", 10),
@@ -26,3 +23,6 @@ export function getConfig() {
26
23
  };
27
24
  return cachedConfig;
28
25
  }
26
+ export function getConfig() {
27
+ return getBaseConfig();
28
+ }
@@ -1,9 +1,9 @@
1
1
  import Database from "better-sqlite3";
2
2
  export declare function initDatabase(dbPath: string): Database.Database;
3
- export { getActiveProviders, getAllProviders, getProviderById, createProvider, updateProvider, deleteProvider, } from "./providers.js";
3
+ export { getActiveProviders, getAllProviders, getProviderById, createProvider, updateProvider, deleteProvider, PROVIDER_CONCURRENCY_DEFAULTS, } from "./providers.js";
4
4
  export type { Provider } from "./providers.js";
5
- export { getModelMapping, getAllModelMappings, createModelMapping, updateModelMapping, deleteModelMapping, getMappingGroup, getMappingGroupById, getAllMappingGroups, createMappingGroup, updateMappingGroup, deleteMappingGroup, } from "./mappings.js";
6
- export type { ModelMapping, MappingGroup } from "./mappings.js";
5
+ export { getModelMapping, getAllModelMappings, createModelMapping, updateModelMapping, deleteModelMapping, getMappingGroup, getMappingGroupById, getAllMappingGroups, createMappingGroup, updateMappingGroup, deleteMappingGroup, getActiveProviderModels, resolveByProviderModel, } from "./mappings.js";
6
+ export type { ModelMapping, MappingGroup, ProviderModelEntry } from "./mappings.js";
7
7
  export { getActiveRetryRules, getAllRetryRules, createRetryRule, updateRetryRule, deleteRetryRule, seedDefaultRules, } from "./retry-rules.js";
8
8
  export type { RetryRule } from "./retry-rules.js";
9
9
  export { insertRequestLog, getRequestLogs, getRequestLogById, deleteLogsBefore, insertMetrics, } from "./logs.js";
@@ -14,3 +14,6 @@ export { getMetricsSummary, getMetricsTimeseries } from "./metrics.js";
14
14
  export type { MetricsSummaryRow, MetricsTimeseriesRow, MetricsPeriod, MetricsMetric } from "./metrics.js";
15
15
  export { getStats } from "./stats.js";
16
16
  export type { Stats, StatsPeriod } from "./stats.js";
17
+ export { getSetting, setSetting, isInitialized } from "./settings.js";
18
+ export { getSessionStates, getSessionState, getSessionHistory, upsertSessionState, insertSessionHistory, deleteSessionState, } from "./session-states.js";
19
+ export type { SessionModelState, SessionModelHistory, UpsertSessionStateInput, InsertSessionHistoryInput } from "./session-states.js";