llm-simple-router 0.8.0 → 0.9.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.
- package/config/recommended-providers.json +33 -9
- package/config/recommended-retry-rules.json +9 -8
- package/dist/admin/providers.js +11 -9
- package/dist/admin/proxy-enhancement.js +3 -1
- package/dist/admin/quick-setup.d.ts +13 -0
- package/dist/admin/quick-setup.js +169 -0
- package/dist/admin/recommended.js +5 -1
- package/dist/admin/routes.js +2 -0
- package/dist/config/model-context.d.ts +8 -2
- package/dist/config/model-context.js +17 -5
- package/dist/config/recommended.d.ts +1 -0
- package/dist/config/recommended.js +5 -9
- package/dist/index.js +22 -0
- package/dist/monitor/request-tracker.d.ts +2 -0
- package/dist/monitor/request-tracker.js +14 -0
- package/dist/proxy/handler/proxy-handler.js +21 -5
- package/dist/proxy/patch/index.d.ts +3 -0
- package/dist/proxy/patch/index.js +28 -0
- package/dist/proxy/patch/tool-round-limiter.d.ts +38 -0
- package/dist/proxy/patch/tool-round-limiter.js +115 -0
- package/dist/proxy/pipeline-snapshot.d.ts +4 -0
- package/dist/proxy/routing/enhancement-config.d.ts +1 -0
- package/dist/proxy/routing/enhancement-config.js +2 -0
- package/dist/storage/log-file-compressor.js +5 -6
- package/dist/storage/log-file-writer.js +11 -13
- package/dist/storage/types.d.ts +2 -0
- package/dist/storage/types.js +7 -0
- package/frontend-dist/assets/CardContent-F3K9pZNP.js +1 -0
- package/frontend-dist/assets/CardTitle-13anASyk.js +1 -0
- package/frontend-dist/assets/CascadingModelSelect-BmW89GUP.js +1 -0
- package/frontend-dist/assets/Checkbox-C2oSHNgP.js +1 -0
- package/frontend-dist/assets/CollapsibleContent-CdeCo0Ko.js +1 -0
- package/frontend-dist/assets/CollapsibleTrigger-CMd4wTNY.js +1 -0
- package/frontend-dist/assets/Collection-BulopTxo.js +1 -0
- package/frontend-dist/assets/Dashboard-BahJSTKV.js +3 -0
- package/frontend-dist/assets/DialogTitle-CnqbO2hx.js +1 -0
- package/frontend-dist/assets/{Input-B56t8UfI.js → Input-RyuwzbNx.js} +1 -1
- package/frontend-dist/assets/Label-73u_Os4X.js +1 -0
- package/frontend-dist/assets/Login-CoQSrVLo.js +1 -0
- package/frontend-dist/assets/Logs-C2b6MPXL.js +1 -0
- package/frontend-dist/assets/MappingList-m2ebUmJ9.js +1 -0
- package/frontend-dist/assets/ModelCard-B0pjEq6W.js +1 -0
- package/frontend-dist/assets/ModelMappings-BazKS9T4.js +1 -0
- package/frontend-dist/assets/Monitor-8B_tm1NO.js +1 -0
- package/frontend-dist/assets/{PopoverTrigger-srlKRM2q.js → PopoverTrigger-DSmA2dE4.js} +1 -1
- package/frontend-dist/assets/{PopperContent-9j4ZA5oc.js → PopperContent-Bd_mpt_D.js} +1 -1
- package/frontend-dist/assets/Providers-TI83sF2T.js +1 -0
- package/frontend-dist/assets/ProxyEnhancement-CWLh-YlM.js +5 -0
- package/frontend-dist/assets/QuickSetup-DUZNdIvp.js +1 -0
- package/frontend-dist/assets/RetryRules-CzhCNQ0R.js +1 -0
- package/frontend-dist/assets/RouterKeys-B_C-Wp_I.js +1 -0
- package/frontend-dist/assets/RovingFocusItem-DwGTruuB.js +1 -0
- package/frontend-dist/assets/Schedules-BMB6RX9e.js +1 -0
- package/frontend-dist/assets/SelectValue-DRc1qira.js +1 -0
- package/frontend-dist/assets/Settings-Ck8CoUJC.js +6 -0
- package/frontend-dist/assets/Setup-dwKkHGrB.js +1 -0
- package/frontend-dist/assets/Switch-BE8DAylK.js +1 -0
- package/frontend-dist/assets/TableHeader-BqYT-eO-.js +1 -0
- package/frontend-dist/assets/Teleport-CLw1Jxrb.js +3 -0
- package/frontend-dist/assets/TooltipTrigger-bqCyq9MU.js +1 -0
- package/frontend-dist/assets/UnifiedRequestDialog-BDNR1wzi.js +3 -0
- package/frontend-dist/assets/UnifiedRequestDialog-DmpjVK9n.css +1 -0
- package/frontend-dist/assets/VisuallyHidden-BpDuyh8-.js +1 -0
- package/frontend-dist/assets/VisuallyHiddenInput-CCL5ykZW.js +1 -0
- package/frontend-dist/assets/alert-dialog-gprnWn1b.js +1 -0
- package/frontend-dist/assets/badge-CpT5q-jI.js +1 -0
- package/frontend-dist/assets/{button-CLKo-tBF.js → button-zud8Qspb.js} +7 -7
- package/frontend-dist/assets/check-CRv7NpkT.js +1 -0
- package/frontend-dist/assets/constants-ncbNnOLM.js +1 -0
- package/frontend-dist/assets/copy-CIHn6HDL.js +1 -0
- package/frontend-dist/assets/dialog-Da8YFS7g.js +1 -0
- package/frontend-dist/assets/file-text-LfP0_JRK.js +1 -0
- package/frontend-dist/assets/index-BfXK7SYr.js +1 -0
- package/frontend-dist/assets/index-CDtb1WVq.css +1 -0
- package/frontend-dist/assets/lib-xfvPneK8.js +1 -0
- package/frontend-dist/assets/loader-circle-D8BaqxEc.js +1 -0
- package/frontend-dist/assets/sun-n4cC12ho.js +1 -0
- package/frontend-dist/assets/trash-2-oDWBOuqK.js +1 -0
- package/frontend-dist/assets/{useClipboard-tSRRbabN.js → useClipboard-C2i7YvJ-.js} +1 -1
- package/frontend-dist/assets/{useFocusGuards-BxD_AgQe.js → useFocusGuards-DORIgNd9.js} +1 -1
- package/frontend-dist/assets/useFormControl-OyxyVR_M.js +1 -0
- package/frontend-dist/assets/{useLogRetention-DeCxyOiV.js → useLogRetention-DE7zYGFK.js} +1 -1
- package/frontend-dist/assets/useNonce-D_84NiFG.js +1 -0
- package/frontend-dist/assets/useTheme-BFhy-DAX.js +1 -0
- package/frontend-dist/assets/x-BN5AHIVk.js +1 -0
- package/frontend-dist/index.html +22 -20
- package/package.json +1 -1
- package/frontend-dist/assets/CardContent-WAXChVto.js +0 -1
- package/frontend-dist/assets/CardTitle-2TP7C40C.js +0 -1
- package/frontend-dist/assets/CascadingModelSelect-CBe9pEx_.js +0 -1
- package/frontend-dist/assets/Checkbox-Bbf909ia.js +0 -1
- package/frontend-dist/assets/CollapsibleTrigger-H2GGomkv.js +0 -1
- package/frontend-dist/assets/Collection-BdkMCE5V.js +0 -1
- package/frontend-dist/assets/Dashboard-BQvc6U98.js +0 -3
- package/frontend-dist/assets/DialogTitle-CS0Nuvko.js +0 -1
- package/frontend-dist/assets/Label-CXRQoDIZ.js +0 -1
- package/frontend-dist/assets/Login-DRNqP0bt.js +0 -1
- package/frontend-dist/assets/Logs-DeJosCWl.js +0 -1
- package/frontend-dist/assets/ModelMappings-DV-NmdF7.js +0 -1
- package/frontend-dist/assets/Monitor-DwqkpkuK.js +0 -1
- package/frontend-dist/assets/Providers-oYOUgEsH.js +0 -1
- package/frontend-dist/assets/ProxyEnhancement-3tQzXNGn.js +0 -5
- package/frontend-dist/assets/RetryRules-rlrPpTd0.js +0 -1
- package/frontend-dist/assets/RouterKeys-COpe69A8.js +0 -1
- package/frontend-dist/assets/RovingFocusItem-DbXUYGXA.js +0 -1
- package/frontend-dist/assets/Schedules-gszIRN_S.js +0 -1
- package/frontend-dist/assets/SelectValue-CpY2uWSk.js +0 -1
- package/frontend-dist/assets/Settings-Xu6V0Sve.js +0 -6
- package/frontend-dist/assets/Setup-BfcLFnBR.js +0 -1
- package/frontend-dist/assets/Switch-DVfy7Q4A.js +0 -1
- package/frontend-dist/assets/TableHeader-BqZo28x_.js +0 -1
- package/frontend-dist/assets/TabsTrigger-0L00h4oy.js +0 -1
- package/frontend-dist/assets/Teleport-Czq5P0IN.js +0 -3
- package/frontend-dist/assets/TooltipTrigger-ChkMBqtC.js +0 -1
- package/frontend-dist/assets/UnifiedRequestDialog-B3GxGgwz.js +0 -3
- package/frontend-dist/assets/UnifiedRequestDialog-BjEigSaR.css +0 -1
- package/frontend-dist/assets/VisuallyHidden-DAFM-4dn.js +0 -1
- package/frontend-dist/assets/VisuallyHiddenInput-BwfFtaqi.js +0 -1
- package/frontend-dist/assets/alert-dialog-vdmYFjPE.js +0 -1
- package/frontend-dist/assets/arrow-down-Da-mukXD.js +0 -1
- package/frontend-dist/assets/badge-0gUIxSR9.js +0 -1
- package/frontend-dist/assets/check-CEd3A-kB.js +0 -1
- package/frontend-dist/assets/constants-D_0jiLjw.js +0 -1
- package/frontend-dist/assets/copy-7NlsO7pN.js +0 -1
- package/frontend-dist/assets/dialog-DUkre9Rw.js +0 -1
- package/frontend-dist/assets/file-text-Di1QQ-B6.js +0 -1
- package/frontend-dist/assets/index-Be9MymBh.js +0 -1
- package/frontend-dist/assets/index-Bz_ZaXNn.css +0 -1
- package/frontend-dist/assets/lib-CweCSowO.js +0 -1
- package/frontend-dist/assets/loader-circle-Cb19pB9Z.js +0 -1
- package/frontend-dist/assets/useFormControl-DP5JWFRS.js +0 -1
- package/frontend-dist/assets/useNonce-DTHUE2m9.js +0 -1
- package/frontend-dist/assets/x-ztwrQbIz.js +0 -1
|
@@ -7,14 +7,20 @@
|
|
|
7
7
|
"presetName": "deepseek",
|
|
8
8
|
"apiType": "anthropic",
|
|
9
9
|
"baseUrl": "https://api.deepseek.com/anthropic",
|
|
10
|
-
"models": [
|
|
10
|
+
"models": [
|
|
11
|
+
"deepseek-v4-flash",
|
|
12
|
+
"deepseek-v4-pro"
|
|
13
|
+
]
|
|
11
14
|
},
|
|
12
15
|
{
|
|
13
16
|
"plan": "OpenAI",
|
|
14
17
|
"presetName": "deepseek-openai",
|
|
15
18
|
"apiType": "openai",
|
|
16
19
|
"baseUrl": "https://api.deepseek.com",
|
|
17
|
-
"models": [
|
|
20
|
+
"models": [
|
|
21
|
+
"deepseek-v4-flash",
|
|
22
|
+
"deepseek-v4-pro"
|
|
23
|
+
]
|
|
18
24
|
}
|
|
19
25
|
]
|
|
20
26
|
},
|
|
@@ -86,7 +92,12 @@
|
|
|
86
92
|
"presetName": "zhipu-coding-plan",
|
|
87
93
|
"apiType": "anthropic",
|
|
88
94
|
"baseUrl": "https://open.bigmodel.cn/api/anthropic",
|
|
89
|
-
"models": [
|
|
95
|
+
"models": [
|
|
96
|
+
"glm-5.1",
|
|
97
|
+
"glm-5",
|
|
98
|
+
"glm-4.7",
|
|
99
|
+
"glm-4.5-air"
|
|
100
|
+
]
|
|
90
101
|
},
|
|
91
102
|
{
|
|
92
103
|
"plan": "API",
|
|
@@ -105,14 +116,17 @@
|
|
|
105
116
|
]
|
|
106
117
|
},
|
|
107
118
|
{
|
|
108
|
-
"group": "
|
|
119
|
+
"group": "月之暗面",
|
|
109
120
|
"presets": [
|
|
110
121
|
{
|
|
111
122
|
"plan": "Coding Plan",
|
|
112
123
|
"presetName": "kimi-coding-plan",
|
|
113
124
|
"apiType": "anthropic",
|
|
114
125
|
"baseUrl": "https://api.kimi.com/coding",
|
|
115
|
-
"models": [
|
|
126
|
+
"models": [
|
|
127
|
+
"kimi-for-coding",
|
|
128
|
+
"kimi-k2.5"
|
|
129
|
+
]
|
|
116
130
|
},
|
|
117
131
|
{
|
|
118
132
|
"plan": "API",
|
|
@@ -137,7 +151,9 @@
|
|
|
137
151
|
"presetName": "minimax-token-plan",
|
|
138
152
|
"apiType": "anthropic",
|
|
139
153
|
"baseUrl": "https://api.minimaxi.com/anthropic",
|
|
140
|
-
"models": [
|
|
154
|
+
"models": [
|
|
155
|
+
"MiniMax-M2.7"
|
|
156
|
+
]
|
|
141
157
|
},
|
|
142
158
|
{
|
|
143
159
|
"plan": "API",
|
|
@@ -258,7 +274,12 @@
|
|
|
258
274
|
"presetName": "opencode-go-anthropic",
|
|
259
275
|
"apiType": "anthropic",
|
|
260
276
|
"baseUrl": "https://opencode.ai/zen/go/v1/messages",
|
|
261
|
-
"models": [
|
|
277
|
+
"models": [
|
|
278
|
+
"deepseek-v4-pro",
|
|
279
|
+
"deepseek-v4-flash",
|
|
280
|
+
"minimax-m2.7",
|
|
281
|
+
"minimax-m2.5"
|
|
282
|
+
]
|
|
262
283
|
}
|
|
263
284
|
]
|
|
264
285
|
},
|
|
@@ -270,7 +291,10 @@
|
|
|
270
291
|
"presetName": "stepfun-step-plan",
|
|
271
292
|
"apiType": "anthropic",
|
|
272
293
|
"baseUrl": "https://api.stepfun.com/step_plan",
|
|
273
|
-
"models": [
|
|
294
|
+
"models": [
|
|
295
|
+
"step-3.5-flash-2603",
|
|
296
|
+
"step-3.5-flash"
|
|
297
|
+
]
|
|
274
298
|
},
|
|
275
299
|
{
|
|
276
300
|
"plan": "API",
|
|
@@ -288,4 +312,4 @@
|
|
|
288
312
|
}
|
|
289
313
|
]
|
|
290
314
|
}
|
|
291
|
-
]
|
|
315
|
+
]
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
-
{ "name": "429 Too Many Requests", "status_code": 429, "body_pattern": ".*", "retry_strategy": "exponential", "retry_delay_ms": 5000, "max_retries": 10, "max_delay_ms": 60000 },
|
|
3
|
-
{ "name": "503 Service Unavailable", "status_code": 503, "body_pattern": ".*", "retry_strategy": "exponential", "retry_delay_ms": 5000, "max_retries": 10, "max_delay_ms": 60000 },
|
|
4
|
-
{ "name": "ZAI 网络错误 (code 1234)", "status_code": 400, "body_pattern": "\"type\"\\s*:\\s*\"error\".*\"code\"\\s*:\\s*\"1234\"", "retry_strategy": "exponential", "retry_delay_ms": 5000, "max_retries": 10, "max_delay_ms": 60000 },
|
|
5
|
-
{ "name": "ZAI 临时不可用", "status_code": 400, "body_pattern": "\"type\"\\s*:\\s*\"error\".*请稍后重试", "retry_strategy": "exponential", "retry_delay_ms": 5000, "max_retries": 10, "max_delay_ms": 60000 },
|
|
6
|
-
{ "name": "ZAI 操作失败 (code 500)", "status_code": 400, "body_pattern": "\"type\"\\s*:\\s*\"error\".*\"code\"\\s*:\\s*\"500\"", "retry_strategy": "exponential", "retry_delay_ms": 5000, "max_retries": 10, "max_delay_ms": 60000 },
|
|
7
|
-
{ "name": "ZAI 速率限制 (HTTP 200, code 1302)", "status_code": 200, "body_pattern": "\"error\".*\"code\"\\s*:\\s*\"1302\"", "retry_strategy": "exponential", "retry_delay_ms": 5000, "max_retries": 10, "max_delay_ms": 60000 },
|
|
8
|
-
{ "name": "ZAI SSE 错误 (HTTP 200, code 500)", "status_code": 200, "body_pattern": "\"error\".*\"code\"\\s*:\\s*\"500\"", "retry_strategy": "exponential", "retry_delay_ms": 5000, "max_retries": 10, "max_delay_ms": 60000 },
|
|
9
|
-
{ "name": "ZAI SSE 错误 (HTTP 200, code 1234)", "status_code": 200, "body_pattern": "\"error\".*\"code\"\\s*:\\s*\"1234\"", "retry_strategy": "exponential", "retry_delay_ms": 5000, "max_retries": 10, "max_delay_ms": 60000 }
|
|
2
|
+
{ "name": "429 Too Many Requests", "status_code": 429, "body_pattern": ".*", "retry_strategy": "exponential", "retry_delay_ms": 5000, "max_retries": 10, "max_delay_ms": 60000, "providers": [] },
|
|
3
|
+
{ "name": "503 Service Unavailable", "status_code": 503, "body_pattern": ".*", "retry_strategy": "exponential", "retry_delay_ms": 5000, "max_retries": 10, "max_delay_ms": 60000, "providers": [] },
|
|
4
|
+
{ "name": "ZAI 网络错误 (code 1234)", "status_code": 400, "body_pattern": "\"type\"\\s*:\\s*\"error\".*\"code\"\\s*:\\s*\"1234\"", "retry_strategy": "exponential", "retry_delay_ms": 5000, "max_retries": 10, "max_delay_ms": 60000, "providers": ["智谱"] },
|
|
5
|
+
{ "name": "ZAI 临时不可用", "status_code": 400, "body_pattern": "\"type\"\\s*:\\s*\"error\".*请稍后重试", "retry_strategy": "exponential", "retry_delay_ms": 5000, "max_retries": 10, "max_delay_ms": 60000, "providers": ["智谱"] },
|
|
6
|
+
{ "name": "ZAI 操作失败 (code 500)", "status_code": 400, "body_pattern": "\"type\"\\s*:\\s*\"error\".*\"code\"\\s*:\\s*\"500\"", "retry_strategy": "exponential", "retry_delay_ms": 5000, "max_retries": 10, "max_delay_ms": 60000, "providers": ["智谱"] },
|
|
7
|
+
{ "name": "ZAI 速率限制 (HTTP 200, code 1302)", "status_code": 200, "body_pattern": "\"error\".*\"code\"\\s*:\\s*\"1302\"", "retry_strategy": "exponential", "retry_delay_ms": 5000, "max_retries": 10, "max_delay_ms": 60000, "providers": ["智谱"] },
|
|
8
|
+
{ "name": "ZAI SSE 错误 (HTTP 200, code 500)", "status_code": 200, "body_pattern": "\"error\".*\"code\"\\s*:\\s*\"500\"", "retry_strategy": "exponential", "retry_delay_ms": 5000, "max_retries": 10, "max_delay_ms": 60000, "providers": ["智谱"] },
|
|
9
|
+
{ "name": "ZAI SSE 错误 (HTTP 200, code 1234)", "status_code": 200, "body_pattern": "\"error\".*\"code\"\\s*:\\s*\"1234\"", "retry_strategy": "exponential", "retry_delay_ms": 5000, "max_retries": 10, "max_delay_ms": 60000, "providers": ["智谱"] },
|
|
10
|
+
{ "name": "KIMI 401 认证错误", "status_code": 401, "body_pattern": ".*authentication_error.*", "retry_strategy": "exponential", "retry_delay_ms": 5000, "max_retries": 3, "max_delay_ms": 60000, "providers": ["月之暗面"] }
|
|
10
11
|
]
|
package/dist/admin/providers.js
CHANGED
|
@@ -58,9 +58,11 @@ function cascadeProviderDisable(db, providerId) {
|
|
|
58
58
|
return result;
|
|
59
59
|
}
|
|
60
60
|
function extractModelOverrides(models) {
|
|
61
|
-
const
|
|
61
|
+
const entries = models.map(m => typeof m === "string"
|
|
62
|
+
? { name: m, patches: [] }
|
|
63
|
+
: { name: m.name, context_window: m.context_window, patches: m.patches ?? [] });
|
|
62
64
|
const overrides = models.filter((m) => typeof m !== "string" && m.context_window != null);
|
|
63
|
-
return {
|
|
65
|
+
return { entries, overrides };
|
|
64
66
|
}
|
|
65
67
|
const API_KEY_PREVIEW_PREFIX_LEN = 4;
|
|
66
68
|
const PROVIDER_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
|
@@ -71,7 +73,7 @@ const CreateProviderSchema = Type.Object({
|
|
|
71
73
|
api_key: Type.String({ minLength: 1 }),
|
|
72
74
|
models: Type.Optional(Type.Array(Type.Union([
|
|
73
75
|
Type.String(),
|
|
74
|
-
Type.Object({ name: Type.String(), context_window: Type.Optional(Type.Number()) })
|
|
76
|
+
Type.Object({ name: Type.String(), context_window: Type.Optional(Type.Number()), patches: Type.Optional(Type.Array(Type.String())) })
|
|
75
77
|
]))),
|
|
76
78
|
is_active: Type.Optional(Type.Number()),
|
|
77
79
|
max_concurrency: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
@@ -86,7 +88,7 @@ const UpdateProviderSchema = Type.Object({
|
|
|
86
88
|
api_key: Type.Optional(Type.String({ minLength: 1 })),
|
|
87
89
|
models: Type.Optional(Type.Array(Type.Union([
|
|
88
90
|
Type.String(),
|
|
89
|
-
Type.Object({ name: Type.String(), context_window: Type.Optional(Type.Number()) })
|
|
91
|
+
Type.Object({ name: Type.String(), context_window: Type.Optional(Type.Number()), patches: Type.Optional(Type.Array(Type.String())) })
|
|
90
92
|
]))),
|
|
91
93
|
is_active: Type.Optional(Type.Number()),
|
|
92
94
|
max_concurrency: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
@@ -100,7 +102,7 @@ export const adminProviderRoutes = (app, options, done) => {
|
|
|
100
102
|
const encryptionKey = getSetting(db, "encryption_key");
|
|
101
103
|
const providers = getAllProviders(db);
|
|
102
104
|
return reply.send(providers.map((s) => {
|
|
103
|
-
const
|
|
105
|
+
const modelEntries = parseModels(s.models || "[]");
|
|
104
106
|
const overrides = new Map(getModelInfoForProvider(db, s.id).map(m => [m.model_name, m.context_window]));
|
|
105
107
|
return {
|
|
106
108
|
id: s.id,
|
|
@@ -108,7 +110,7 @@ export const adminProviderRoutes = (app, options, done) => {
|
|
|
108
110
|
api_type: s.api_type,
|
|
109
111
|
base_url: s.base_url,
|
|
110
112
|
api_key: s.api_key ? decrypt(s.api_key, encryptionKey) : "",
|
|
111
|
-
models: buildModelInfoList(
|
|
113
|
+
models: buildModelInfoList(modelEntries, overrides),
|
|
112
114
|
is_active: s.is_active,
|
|
113
115
|
max_concurrency: s.max_concurrency,
|
|
114
116
|
queue_timeout_ms: s.queue_timeout_ms,
|
|
@@ -130,7 +132,7 @@ export const adminProviderRoutes = (app, options, done) => {
|
|
|
130
132
|
return reply.code(HTTP_CONFLICT).send(apiError(API_CODE.CONFLICT_NAME, `Provider 名称 '${body.name}' 已存在`));
|
|
131
133
|
}
|
|
132
134
|
const encryptedKey = encrypt(body.api_key, getSetting(db, "encryption_key"));
|
|
133
|
-
const {
|
|
135
|
+
const { entries: normalizedModels, overrides: contextOverrides } = extractModelOverrides((body.models ?? []));
|
|
134
136
|
const isAdaptiveEnabled = body.adaptive_enabled ?? 0;
|
|
135
137
|
const id = createProvider(db, {
|
|
136
138
|
name: body.name,
|
|
@@ -190,8 +192,8 @@ export const adminProviderRoutes = (app, options, done) => {
|
|
|
190
192
|
if (body.is_active !== undefined)
|
|
191
193
|
fields.is_active = body.is_active;
|
|
192
194
|
if (body.models !== undefined) {
|
|
193
|
-
const {
|
|
194
|
-
fields.models = JSON.stringify(
|
|
195
|
+
const { entries, overrides } = extractModelOverrides(body.models);
|
|
196
|
+
fields.models = JSON.stringify(entries);
|
|
195
197
|
if (overrides.length > 0) {
|
|
196
198
|
setModelInfoForProvider(db, id, overrides.map(o => ({ model_name: o.name, context_window: o.context_window })));
|
|
197
199
|
}
|
|
@@ -4,6 +4,7 @@ const UpdateProxyEnhancementSchema = Type.Object({
|
|
|
4
4
|
claude_code_enabled: Type.Boolean(),
|
|
5
5
|
tool_call_loop_enabled: Type.Boolean(),
|
|
6
6
|
stream_loop_enabled: Type.Boolean(),
|
|
7
|
+
tool_round_limit_enabled: Type.Boolean(),
|
|
7
8
|
});
|
|
8
9
|
const SessionParamsSchema = Type.Object({
|
|
9
10
|
keyId: Type.String(),
|
|
@@ -13,7 +14,7 @@ import { getSessionStates, getSessionHistory, } from "../db/session-states.js";
|
|
|
13
14
|
export const adminProxyEnhancementRoutes = (app, options, done) => {
|
|
14
15
|
const { db, stateRegistry } = options;
|
|
15
16
|
app.get("/admin/api/proxy-enhancement", async (_request, reply) => {
|
|
16
|
-
const config = stateRegistry?.getEnhancementConfig() ?? { claude_code_enabled: false, tool_call_loop_enabled: false, stream_loop_enabled: false };
|
|
17
|
+
const config = stateRegistry?.getEnhancementConfig() ?? { claude_code_enabled: false, tool_call_loop_enabled: false, stream_loop_enabled: false, tool_round_limit_enabled: true };
|
|
17
18
|
return reply.send(config);
|
|
18
19
|
});
|
|
19
20
|
app.put("/admin/api/proxy-enhancement", { schema: { body: UpdateProxyEnhancementSchema } }, async (request, reply) => {
|
|
@@ -22,6 +23,7 @@ export const adminProxyEnhancementRoutes = (app, options, done) => {
|
|
|
22
23
|
claude_code_enabled: body.claude_code_enabled,
|
|
23
24
|
tool_call_loop_enabled: body.tool_call_loop_enabled,
|
|
24
25
|
stream_loop_enabled: body.stream_loop_enabled,
|
|
26
|
+
tool_round_limit_enabled: body.tool_round_limit_enabled,
|
|
25
27
|
};
|
|
26
28
|
setSetting(db, "proxy_enhancement", JSON.stringify(config));
|
|
27
29
|
return reply.send({ success: true });
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { FastifyPluginCallback } from "fastify";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import type { StateRegistry } from "../core/registry.js";
|
|
4
|
+
import type { RequestTracker } from "../monitor/request-tracker.js";
|
|
5
|
+
import type { AdaptiveConcurrencyController } from "../proxy/adaptive-controller.js";
|
|
6
|
+
interface QuickSetupRoutesOptions {
|
|
7
|
+
db: Database.Database;
|
|
8
|
+
stateRegistry?: StateRegistry;
|
|
9
|
+
tracker?: RequestTracker;
|
|
10
|
+
adaptiveController?: AdaptiveConcurrencyController;
|
|
11
|
+
}
|
|
12
|
+
export declare const adminQuickSetupRoutes: FastifyPluginCallback<QuickSetupRoutesOptions>;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import { createProvider } from "../db/providers.js";
|
|
3
|
+
import { createMappingGroup, updateMappingGroup } from "../db/mappings.js";
|
|
4
|
+
import { createRetryRule } from "../db/retry-rules.js";
|
|
5
|
+
import { upsertTransformRule } from "../db/transform-rules.js";
|
|
6
|
+
import { encrypt } from "../utils/crypto.js";
|
|
7
|
+
import { getSetting } from "../db/settings.js";
|
|
8
|
+
import { HTTP_CREATED, HTTP_BAD_REQUEST, HTTP_CONFLICT } from "./constants.js";
|
|
9
|
+
import { API_CODE, apiError } from "./api-response.js";
|
|
10
|
+
import { PROVIDER_CONCURRENCY_DEFAULTS } from "../db/providers.js";
|
|
11
|
+
const PROVIDER_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
|
12
|
+
const API_KEY_PREVIEW_MIN_LENGTH = 8;
|
|
13
|
+
const API_KEY_PREVIEW_PREFIX_LEN = 4;
|
|
14
|
+
const QuickSetupProviderSchema = Type.Object({
|
|
15
|
+
name: Type.String({ minLength: 1 }),
|
|
16
|
+
api_type: Type.Union([Type.Literal("openai"), Type.Literal("anthropic")]),
|
|
17
|
+
base_url: Type.String({ minLength: 1 }),
|
|
18
|
+
api_key: Type.String({ minLength: 1 }),
|
|
19
|
+
models: Type.Array(Type.Object({
|
|
20
|
+
name: Type.String(),
|
|
21
|
+
context_window: Type.Optional(Type.Number()),
|
|
22
|
+
patches: Type.Optional(Type.Array(Type.String())),
|
|
23
|
+
})),
|
|
24
|
+
concurrency_mode: Type.Optional(Type.Union([Type.Literal("auto"), Type.Literal("manual"), Type.Literal("none")])),
|
|
25
|
+
max_concurrency: Type.Optional(Type.Number()),
|
|
26
|
+
queue_timeout_ms: Type.Optional(Type.Number()),
|
|
27
|
+
max_queue_size: Type.Optional(Type.Number()),
|
|
28
|
+
});
|
|
29
|
+
const QuickSetupMappingSchema = Type.Object({
|
|
30
|
+
client_model: Type.String({ minLength: 1 }),
|
|
31
|
+
backend_model: Type.String({ minLength: 1 }),
|
|
32
|
+
});
|
|
33
|
+
const QuickSetupRetryRuleSchema = Type.Object({
|
|
34
|
+
name: Type.String({ minLength: 1 }),
|
|
35
|
+
status_code: Type.Number({ minimum: 100, maximum: 599 }),
|
|
36
|
+
body_pattern: Type.String({ minLength: 1 }),
|
|
37
|
+
retry_strategy: Type.Union([Type.Literal("fixed"), Type.Literal("exponential")]),
|
|
38
|
+
retry_delay_ms: Type.Number({ minimum: 100 }),
|
|
39
|
+
max_retries: Type.Number({ minimum: 0, maximum: 100 }),
|
|
40
|
+
max_delay_ms: Type.Number({ minimum: 100 }),
|
|
41
|
+
});
|
|
42
|
+
const QuickSetupTransformSchema = Type.Object({
|
|
43
|
+
inject_headers: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
44
|
+
request_defaults: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
45
|
+
drop_fields: Type.Optional(Type.Array(Type.String())),
|
|
46
|
+
});
|
|
47
|
+
const QuickSetupSchema = Type.Object({
|
|
48
|
+
provider: QuickSetupProviderSchema,
|
|
49
|
+
mappings: Type.Array(QuickSetupMappingSchema),
|
|
50
|
+
retry_rules: Type.Array(QuickSetupRetryRuleSchema),
|
|
51
|
+
transform_rules: Type.Optional(QuickSetupTransformSchema),
|
|
52
|
+
});
|
|
53
|
+
export const adminQuickSetupRoutes = (app, options, done) => {
|
|
54
|
+
const { db, stateRegistry, tracker, adaptiveController } = options;
|
|
55
|
+
app.post("/admin/api/quick-setup", { schema: { body: QuickSetupSchema } }, async (request, reply) => {
|
|
56
|
+
const body = request.body;
|
|
57
|
+
// 1. Validate provider name
|
|
58
|
+
if (!PROVIDER_NAME_RE.test(body.provider.name)) {
|
|
59
|
+
return reply.code(HTTP_BAD_REQUEST).send(apiError(API_CODE.VALIDATION_FAILED, "Provider 名称仅允许英文大小写字母、数字、横线和下划线"));
|
|
60
|
+
}
|
|
61
|
+
// 2. Check no duplicate provider name
|
|
62
|
+
const existing = db.prepare("SELECT id FROM providers WHERE name = ?").get(body.provider.name);
|
|
63
|
+
if (existing) {
|
|
64
|
+
return reply.code(HTTP_CONFLICT).send(apiError(API_CODE.CONFLICT_NAME, `Provider 名称 '${body.provider.name}' 已存在`));
|
|
65
|
+
}
|
|
66
|
+
// 3. Validate retry rule body_pattern regex
|
|
67
|
+
for (const rule of body.retry_rules) {
|
|
68
|
+
try {
|
|
69
|
+
new RegExp(rule.body_pattern);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return reply.code(HTTP_BAD_REQUEST).send(apiError(API_CODE.INVALID_REGEX, `重试规则「${rule.name}」的 body_pattern 不是有效的正则表达式`));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// 4. Start transaction
|
|
76
|
+
const encryptionKey = getSetting(db, "encryption_key");
|
|
77
|
+
const createAll = db.transaction(() => {
|
|
78
|
+
// 5. Create provider with models JSON
|
|
79
|
+
const encryptedKey = encrypt(body.provider.api_key, encryptionKey);
|
|
80
|
+
const modelEntries = body.provider.models.map(m => ({
|
|
81
|
+
name: m.name,
|
|
82
|
+
...(m.context_window != null ? { context_window: m.context_window } : {}),
|
|
83
|
+
...(m.patches && m.patches.length > 0 ? { patches: m.patches } : {}),
|
|
84
|
+
}));
|
|
85
|
+
const adaptiveEnabled = body.provider.concurrency_mode === 'auto' ? 1 : 0;
|
|
86
|
+
const maxConcurrency = body.provider.max_concurrency ?? PROVIDER_CONCURRENCY_DEFAULTS.max_concurrency;
|
|
87
|
+
const queueTimeoutMs = body.provider.queue_timeout_ms ?? PROVIDER_CONCURRENCY_DEFAULTS.queue_timeout_ms;
|
|
88
|
+
const maxQueueSize = body.provider.max_queue_size ?? PROVIDER_CONCURRENCY_DEFAULTS.max_queue_size;
|
|
89
|
+
const providerId = createProvider(db, {
|
|
90
|
+
name: body.provider.name,
|
|
91
|
+
api_type: body.provider.api_type,
|
|
92
|
+
base_url: body.provider.base_url,
|
|
93
|
+
api_key: encryptedKey,
|
|
94
|
+
api_key_preview: body.provider.api_key.length > API_KEY_PREVIEW_MIN_LENGTH
|
|
95
|
+
? `${body.provider.api_key.slice(0, API_KEY_PREVIEW_PREFIX_LEN)}...${body.provider.api_key.slice(-API_KEY_PREVIEW_PREFIX_LEN)}`
|
|
96
|
+
: "****",
|
|
97
|
+
models: JSON.stringify(modelEntries),
|
|
98
|
+
is_active: 1,
|
|
99
|
+
max_concurrency: maxConcurrency,
|
|
100
|
+
queue_timeout_ms: queueTimeoutMs,
|
|
101
|
+
max_queue_size: maxQueueSize,
|
|
102
|
+
adaptive_enabled: adaptiveEnabled,
|
|
103
|
+
});
|
|
104
|
+
// 6. Upsert mapping groups
|
|
105
|
+
for (const m of body.mappings) {
|
|
106
|
+
const existing = db.prepare('SELECT id FROM mapping_groups WHERE client_model = ?').get(m.client_model);
|
|
107
|
+
const ruleJson = JSON.stringify({
|
|
108
|
+
targets: [{ backend_model: m.backend_model, provider_id: providerId }],
|
|
109
|
+
});
|
|
110
|
+
if (existing) {
|
|
111
|
+
updateMappingGroup(db, existing.id, {
|
|
112
|
+
client_model: m.client_model,
|
|
113
|
+
rule: ruleJson,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
createMappingGroup(db, {
|
|
118
|
+
client_model: m.client_model,
|
|
119
|
+
rule: ruleJson,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// 7. Create retry rules
|
|
124
|
+
for (const r of body.retry_rules) {
|
|
125
|
+
createRetryRule(db, {
|
|
126
|
+
name: r.name,
|
|
127
|
+
status_code: r.status_code,
|
|
128
|
+
body_pattern: r.body_pattern,
|
|
129
|
+
is_active: 1,
|
|
130
|
+
retry_strategy: r.retry_strategy,
|
|
131
|
+
retry_delay_ms: r.retry_delay_ms,
|
|
132
|
+
max_retries: r.max_retries,
|
|
133
|
+
max_delay_ms: r.max_delay_ms,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
// 8. Create transform rules
|
|
137
|
+
if (body.transform_rules) {
|
|
138
|
+
upsertTransformRule(db, providerId, {
|
|
139
|
+
inject_headers: body.transform_rules.inject_headers ?? null,
|
|
140
|
+
request_defaults: body.transform_rules.request_defaults ?? null,
|
|
141
|
+
drop_fields: body.transform_rules.drop_fields ?? null,
|
|
142
|
+
is_active: 1,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return providerId;
|
|
146
|
+
});
|
|
147
|
+
// 8. Execute transaction
|
|
148
|
+
const providerId = createAll();
|
|
149
|
+
// 9. Sync concurrency state
|
|
150
|
+
const finalAdaptiveEnabled = body.provider.concurrency_mode === 'auto' ? 1 : 0;
|
|
151
|
+
const finalMaxConcurrency = body.provider.max_concurrency ?? PROVIDER_CONCURRENCY_DEFAULTS.max_concurrency;
|
|
152
|
+
const finalQueueTimeoutMs = body.provider.queue_timeout_ms ?? PROVIDER_CONCURRENCY_DEFAULTS.queue_timeout_ms;
|
|
153
|
+
const finalMaxQueueSize = body.provider.max_queue_size ?? PROVIDER_CONCURRENCY_DEFAULTS.max_queue_size;
|
|
154
|
+
adaptiveController?.syncProvider(providerId, {
|
|
155
|
+
adaptive_enabled: finalAdaptiveEnabled,
|
|
156
|
+
max_concurrency: finalMaxConcurrency,
|
|
157
|
+
queue_timeout_ms: finalQueueTimeoutMs,
|
|
158
|
+
max_queue_size: finalMaxQueueSize,
|
|
159
|
+
});
|
|
160
|
+
tracker?.updateProviderConfig(providerId, {
|
|
161
|
+
name: body.provider.name,
|
|
162
|
+
maxConcurrency: finalMaxConcurrency,
|
|
163
|
+
queueTimeoutMs: finalQueueTimeoutMs,
|
|
164
|
+
maxQueueSize: finalMaxQueueSize,
|
|
165
|
+
});
|
|
166
|
+
return reply.code(HTTP_CREATED).send({ success: true, provider_id: providerId });
|
|
167
|
+
});
|
|
168
|
+
done();
|
|
169
|
+
};
|
|
@@ -7,7 +7,11 @@ export const adminRecommendedRoutes = (app, options, done) => {
|
|
|
7
7
|
app.get("/admin/api/recommended/retry-rules", async (_req, reply) => {
|
|
8
8
|
const rules = getRecommendedRetryRules();
|
|
9
9
|
const existing = new Set(db.prepare("SELECT name FROM retry_rules").all().map((r) => r.name));
|
|
10
|
-
|
|
10
|
+
// Return all rules with `exists` flag, so the frontend can show all and mark existing ones
|
|
11
|
+
return reply.send(rules.map(r => ({
|
|
12
|
+
...r,
|
|
13
|
+
exists: existing.has(r.name),
|
|
14
|
+
})));
|
|
11
15
|
});
|
|
12
16
|
app.post("/admin/api/recommended/reload", async (_req, reply) => {
|
|
13
17
|
reloadConfig();
|
package/dist/admin/routes.js
CHANGED
|
@@ -14,6 +14,7 @@ import { adminSettingsRoutes } from "./settings.js";
|
|
|
14
14
|
import { adminRecommendedRoutes } from "./recommended.js";
|
|
15
15
|
import { adminUsageRoutes } from "./usage.js";
|
|
16
16
|
import { adminUpgradeRoutes } from "./upgrade.js";
|
|
17
|
+
import { adminQuickSetupRoutes } from "./quick-setup.js";
|
|
17
18
|
import { adminImportExportRoutes } from "./settings-import-export.js";
|
|
18
19
|
import { adminTransformRuleRoutes } from "./transform-rules.js";
|
|
19
20
|
import { adminScheduleRoutes } from "./schedules.js";
|
|
@@ -37,6 +38,7 @@ export const adminRoutes = (app, options, done) => {
|
|
|
37
38
|
app.register(adminImportExportRoutes, { db: options.db, stateRegistry: options.stateRegistry, pluginRegistry: options.pluginRegistry });
|
|
38
39
|
app.register(adminRecommendedRoutes, { db: options.db });
|
|
39
40
|
app.register(adminUsageRoutes, { db: options.db });
|
|
41
|
+
app.register(adminQuickSetupRoutes, { db: options.db, stateRegistry: options.stateRegistry, tracker: options.tracker, adaptiveController: options.adaptiveController });
|
|
40
42
|
app.register(adminUpgradeRoutes, { db: options.db, closeFn: options.closeFn ?? (async () => { }) });
|
|
41
43
|
app.register(adminTransformRuleRoutes, { db: options.db, pluginRegistry: options.pluginRegistry });
|
|
42
44
|
done();
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
export interface ModelInfo {
|
|
2
2
|
name: string;
|
|
3
3
|
context_window: number | null;
|
|
4
|
+
patches: string[];
|
|
5
|
+
}
|
|
6
|
+
export interface ModelEntry {
|
|
7
|
+
name: string;
|
|
8
|
+
context_window?: number;
|
|
9
|
+
patches?: string[];
|
|
4
10
|
}
|
|
5
11
|
export declare const MODEL_CONTEXT_WINDOWS: Record<string, number>;
|
|
6
12
|
export declare const DEFAULT_CONTEXT_WINDOW = 200000;
|
|
7
13
|
export declare const OVERFLOW_THRESHOLD = 1000000;
|
|
8
14
|
export declare function lookupContextWindow(modelName: string): number;
|
|
9
|
-
export declare function parseModels(raw: string):
|
|
10
|
-
export declare function buildModelInfoList(
|
|
15
|
+
export declare function parseModels(raw: string): ModelEntry[];
|
|
16
|
+
export declare function buildModelInfoList(modelEntries: ModelEntry[], overrides: Map<string, number>): ModelInfo[];
|
|
@@ -91,15 +91,27 @@ export function parseModels(raw) {
|
|
|
91
91
|
const parsed = JSON.parse(raw);
|
|
92
92
|
if (!Array.isArray(parsed))
|
|
93
93
|
return [];
|
|
94
|
-
return parsed.map((item) =>
|
|
94
|
+
return parsed.map((item) => {
|
|
95
|
+
if (typeof item === 'string') {
|
|
96
|
+
return item ? { name: item, patches: [] } : null;
|
|
97
|
+
}
|
|
98
|
+
const obj = item;
|
|
99
|
+
if (!obj || !obj.name)
|
|
100
|
+
return null;
|
|
101
|
+
return {
|
|
102
|
+
name: obj.name,
|
|
103
|
+
patches: obj.patches ?? [],
|
|
104
|
+
};
|
|
105
|
+
}).filter((e) => e !== null);
|
|
95
106
|
}
|
|
96
107
|
catch {
|
|
97
108
|
return [];
|
|
98
109
|
}
|
|
99
110
|
}
|
|
100
|
-
export function buildModelInfoList(
|
|
101
|
-
return
|
|
102
|
-
name,
|
|
103
|
-
context_window: overrides.get(name) ?? lookupContextWindow(name),
|
|
111
|
+
export function buildModelInfoList(modelEntries, overrides) {
|
|
112
|
+
return modelEntries.map(entry => ({
|
|
113
|
+
name: entry.name,
|
|
114
|
+
context_window: overrides.get(entry.name) ?? lookupContextWindow(entry.name),
|
|
115
|
+
patches: entry.patches ?? [],
|
|
104
116
|
}));
|
|
105
117
|
}
|
|
@@ -17,6 +17,7 @@ export interface RecommendedRetryRule {
|
|
|
17
17
|
retry_delay_ms: number;
|
|
18
18
|
max_retries: number;
|
|
19
19
|
max_delay_ms: number;
|
|
20
|
+
providers?: string[];
|
|
20
21
|
}
|
|
21
22
|
export declare function loadRecommendedConfig(dir?: string): void;
|
|
22
23
|
export declare function getRecommendedProviders(): ProviderGroup[];
|
|
@@ -1,22 +1,18 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
let cachedProviders = [];
|
|
4
|
-
let cachedRetryRules = [];
|
|
5
3
|
let configDir = '';
|
|
6
4
|
export function loadRecommendedConfig(dir) {
|
|
7
5
|
configDir = dir ?? path.resolve(process.cwd(), 'config');
|
|
8
|
-
cachedProviders = loadJson('recommended-providers.json');
|
|
9
|
-
cachedRetryRules = loadJson('recommended-retry-rules.json');
|
|
10
6
|
}
|
|
11
7
|
export function getRecommendedProviders() {
|
|
12
|
-
return
|
|
8
|
+
return loadJson('recommended-providers.json');
|
|
13
9
|
}
|
|
14
10
|
export function getRecommendedRetryRules() {
|
|
15
|
-
return
|
|
16
|
-
}
|
|
17
|
-
export function reloadConfig() {
|
|
18
|
-
loadRecommendedConfig(configDir);
|
|
11
|
+
return loadJson('recommended-retry-rules.json');
|
|
19
12
|
}
|
|
13
|
+
// No-op: kept for backward compat (reload endpoint, upgrade flow)
|
|
14
|
+
// Config is now always read from disk, no caching.
|
|
15
|
+
export function reloadConfig() { }
|
|
20
16
|
function loadJson(filename) {
|
|
21
17
|
const filePath = path.join(configDir, filename);
|
|
22
18
|
try {
|
package/dist/index.js
CHANGED
|
@@ -279,11 +279,17 @@ export async function buildApp(options) {
|
|
|
279
279
|
const dbSizeMonitor = scheduleDbSizeMonitor(db, config.DB_PATH, {
|
|
280
280
|
log: app.log,
|
|
281
281
|
});
|
|
282
|
+
let closed = false;
|
|
282
283
|
let close = async () => {
|
|
284
|
+
if (closed)
|
|
285
|
+
return;
|
|
286
|
+
closed = true;
|
|
283
287
|
stopUpgradeChecker();
|
|
284
288
|
logCleanup.stop();
|
|
285
289
|
dbSizeMonitor.stop();
|
|
286
290
|
tracker.stopPushInterval();
|
|
291
|
+
// 关闭所有 SSE 长连接,防止 app.close() 因 hijack 的连接无限等待
|
|
292
|
+
tracker.closeAllClients();
|
|
287
293
|
modelState.clearAll();
|
|
288
294
|
semaphoreManager.removeAll();
|
|
289
295
|
const sessionTracker = container.resolve(SERVICE_KEYS.sessionTracker);
|
|
@@ -339,7 +345,22 @@ export async function main() {
|
|
|
339
345
|
/* eslint-enable taste/no-silent-catch */
|
|
340
346
|
});
|
|
341
347
|
// 优雅关闭:SIGTERM(systemd/docker stop)和 SIGINT(Ctrl+C)
|
|
348
|
+
let isShuttingDown = false;
|
|
349
|
+
const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 10_000;
|
|
342
350
|
const shutdown = async (signal) => {
|
|
351
|
+
// 防止重复触发:多次 Ctrl+C 只执行一次关闭
|
|
352
|
+
if (isShuttingDown) {
|
|
353
|
+
app.log.info(`Received ${signal} again, already shutting down...`);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
isShuttingDown = true;
|
|
357
|
+
// 强制退出兜底:优雅关闭超过 N 秒则强制退出
|
|
358
|
+
const forceTimer = setTimeout(() => {
|
|
359
|
+
app.log.error("Graceful shutdown timed out, forcing exit");
|
|
360
|
+
process.exit(1);
|
|
361
|
+
}, GRACEFUL_SHUTDOWN_TIMEOUT_MS);
|
|
362
|
+
// 不阻止进程退出
|
|
363
|
+
forceTimer.unref();
|
|
343
364
|
try {
|
|
344
365
|
app.log.info(`Received ${signal}, shutting down gracefully...`);
|
|
345
366
|
await close();
|
|
@@ -348,6 +369,7 @@ export async function main() {
|
|
|
348
369
|
catch (err) {
|
|
349
370
|
app.log.error({ err }, "Error during shutdown");
|
|
350
371
|
}
|
|
372
|
+
clearTimeout(forceTimer);
|
|
351
373
|
process.exit(0);
|
|
352
374
|
};
|
|
353
375
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
@@ -52,6 +52,8 @@ export declare class RequestTracker {
|
|
|
52
52
|
/** 向单个客户端发送当前活跃请求快照(保留 clientRequest 以便前端即时展示) */
|
|
53
53
|
private sendInitialSnapshot;
|
|
54
54
|
removeClient(res: ServerResponse): void;
|
|
55
|
+
/** 主动关闭所有 SSE 客户端连接,确保 app.close() 不会因长连接阻塞 */
|
|
56
|
+
closeAllClients(): void;
|
|
55
57
|
startPushInterval(): void;
|
|
56
58
|
stopPushInterval(): void;
|
|
57
59
|
broadcast(event: string, data: unknown): void;
|
|
@@ -188,6 +188,20 @@ export class RequestTracker {
|
|
|
188
188
|
removeClient(res) {
|
|
189
189
|
this.clients.delete(res);
|
|
190
190
|
}
|
|
191
|
+
/** 主动关闭所有 SSE 客户端连接,确保 app.close() 不会因长连接阻塞 */
|
|
192
|
+
closeAllClients() {
|
|
193
|
+
const clients = [...this.clients];
|
|
194
|
+
this.clients.clear();
|
|
195
|
+
for (const client of clients) {
|
|
196
|
+
try {
|
|
197
|
+
if (!client.writableEnded)
|
|
198
|
+
client.end();
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// 忽略已关闭的连接
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
191
205
|
// --- Push interval ---
|
|
192
206
|
startPushInterval() {
|
|
193
207
|
if (this.pushTimer)
|