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.
Files changed (133) hide show
  1. package/config/recommended-providers.json +33 -9
  2. package/config/recommended-retry-rules.json +9 -8
  3. package/dist/admin/providers.js +11 -9
  4. package/dist/admin/proxy-enhancement.js +3 -1
  5. package/dist/admin/quick-setup.d.ts +13 -0
  6. package/dist/admin/quick-setup.js +169 -0
  7. package/dist/admin/recommended.js +5 -1
  8. package/dist/admin/routes.js +2 -0
  9. package/dist/config/model-context.d.ts +8 -2
  10. package/dist/config/model-context.js +17 -5
  11. package/dist/config/recommended.d.ts +1 -0
  12. package/dist/config/recommended.js +5 -9
  13. package/dist/index.js +22 -0
  14. package/dist/monitor/request-tracker.d.ts +2 -0
  15. package/dist/monitor/request-tracker.js +14 -0
  16. package/dist/proxy/handler/proxy-handler.js +21 -5
  17. package/dist/proxy/patch/index.d.ts +3 -0
  18. package/dist/proxy/patch/index.js +28 -0
  19. package/dist/proxy/patch/tool-round-limiter.d.ts +38 -0
  20. package/dist/proxy/patch/tool-round-limiter.js +115 -0
  21. package/dist/proxy/pipeline-snapshot.d.ts +4 -0
  22. package/dist/proxy/routing/enhancement-config.d.ts +1 -0
  23. package/dist/proxy/routing/enhancement-config.js +2 -0
  24. package/dist/storage/log-file-compressor.js +5 -6
  25. package/dist/storage/log-file-writer.js +11 -13
  26. package/dist/storage/types.d.ts +2 -0
  27. package/dist/storage/types.js +7 -0
  28. package/frontend-dist/assets/CardContent-F3K9pZNP.js +1 -0
  29. package/frontend-dist/assets/CardTitle-13anASyk.js +1 -0
  30. package/frontend-dist/assets/CascadingModelSelect-BmW89GUP.js +1 -0
  31. package/frontend-dist/assets/Checkbox-C2oSHNgP.js +1 -0
  32. package/frontend-dist/assets/CollapsibleContent-CdeCo0Ko.js +1 -0
  33. package/frontend-dist/assets/CollapsibleTrigger-CMd4wTNY.js +1 -0
  34. package/frontend-dist/assets/Collection-BulopTxo.js +1 -0
  35. package/frontend-dist/assets/Dashboard-BahJSTKV.js +3 -0
  36. package/frontend-dist/assets/DialogTitle-CnqbO2hx.js +1 -0
  37. package/frontend-dist/assets/{Input-B56t8UfI.js → Input-RyuwzbNx.js} +1 -1
  38. package/frontend-dist/assets/Label-73u_Os4X.js +1 -0
  39. package/frontend-dist/assets/Login-CoQSrVLo.js +1 -0
  40. package/frontend-dist/assets/Logs-C2b6MPXL.js +1 -0
  41. package/frontend-dist/assets/MappingList-m2ebUmJ9.js +1 -0
  42. package/frontend-dist/assets/ModelCard-B0pjEq6W.js +1 -0
  43. package/frontend-dist/assets/ModelMappings-BazKS9T4.js +1 -0
  44. package/frontend-dist/assets/Monitor-8B_tm1NO.js +1 -0
  45. package/frontend-dist/assets/{PopoverTrigger-srlKRM2q.js → PopoverTrigger-DSmA2dE4.js} +1 -1
  46. package/frontend-dist/assets/{PopperContent-9j4ZA5oc.js → PopperContent-Bd_mpt_D.js} +1 -1
  47. package/frontend-dist/assets/Providers-TI83sF2T.js +1 -0
  48. package/frontend-dist/assets/ProxyEnhancement-CWLh-YlM.js +5 -0
  49. package/frontend-dist/assets/QuickSetup-DUZNdIvp.js +1 -0
  50. package/frontend-dist/assets/RetryRules-CzhCNQ0R.js +1 -0
  51. package/frontend-dist/assets/RouterKeys-B_C-Wp_I.js +1 -0
  52. package/frontend-dist/assets/RovingFocusItem-DwGTruuB.js +1 -0
  53. package/frontend-dist/assets/Schedules-BMB6RX9e.js +1 -0
  54. package/frontend-dist/assets/SelectValue-DRc1qira.js +1 -0
  55. package/frontend-dist/assets/Settings-Ck8CoUJC.js +6 -0
  56. package/frontend-dist/assets/Setup-dwKkHGrB.js +1 -0
  57. package/frontend-dist/assets/Switch-BE8DAylK.js +1 -0
  58. package/frontend-dist/assets/TableHeader-BqYT-eO-.js +1 -0
  59. package/frontend-dist/assets/Teleport-CLw1Jxrb.js +3 -0
  60. package/frontend-dist/assets/TooltipTrigger-bqCyq9MU.js +1 -0
  61. package/frontend-dist/assets/UnifiedRequestDialog-BDNR1wzi.js +3 -0
  62. package/frontend-dist/assets/UnifiedRequestDialog-DmpjVK9n.css +1 -0
  63. package/frontend-dist/assets/VisuallyHidden-BpDuyh8-.js +1 -0
  64. package/frontend-dist/assets/VisuallyHiddenInput-CCL5ykZW.js +1 -0
  65. package/frontend-dist/assets/alert-dialog-gprnWn1b.js +1 -0
  66. package/frontend-dist/assets/badge-CpT5q-jI.js +1 -0
  67. package/frontend-dist/assets/{button-CLKo-tBF.js → button-zud8Qspb.js} +7 -7
  68. package/frontend-dist/assets/check-CRv7NpkT.js +1 -0
  69. package/frontend-dist/assets/constants-ncbNnOLM.js +1 -0
  70. package/frontend-dist/assets/copy-CIHn6HDL.js +1 -0
  71. package/frontend-dist/assets/dialog-Da8YFS7g.js +1 -0
  72. package/frontend-dist/assets/file-text-LfP0_JRK.js +1 -0
  73. package/frontend-dist/assets/index-BfXK7SYr.js +1 -0
  74. package/frontend-dist/assets/index-CDtb1WVq.css +1 -0
  75. package/frontend-dist/assets/lib-xfvPneK8.js +1 -0
  76. package/frontend-dist/assets/loader-circle-D8BaqxEc.js +1 -0
  77. package/frontend-dist/assets/sun-n4cC12ho.js +1 -0
  78. package/frontend-dist/assets/trash-2-oDWBOuqK.js +1 -0
  79. package/frontend-dist/assets/{useClipboard-tSRRbabN.js → useClipboard-C2i7YvJ-.js} +1 -1
  80. package/frontend-dist/assets/{useFocusGuards-BxD_AgQe.js → useFocusGuards-DORIgNd9.js} +1 -1
  81. package/frontend-dist/assets/useFormControl-OyxyVR_M.js +1 -0
  82. package/frontend-dist/assets/{useLogRetention-DeCxyOiV.js → useLogRetention-DE7zYGFK.js} +1 -1
  83. package/frontend-dist/assets/useNonce-D_84NiFG.js +1 -0
  84. package/frontend-dist/assets/useTheme-BFhy-DAX.js +1 -0
  85. package/frontend-dist/assets/x-BN5AHIVk.js +1 -0
  86. package/frontend-dist/index.html +22 -20
  87. package/package.json +1 -1
  88. package/frontend-dist/assets/CardContent-WAXChVto.js +0 -1
  89. package/frontend-dist/assets/CardTitle-2TP7C40C.js +0 -1
  90. package/frontend-dist/assets/CascadingModelSelect-CBe9pEx_.js +0 -1
  91. package/frontend-dist/assets/Checkbox-Bbf909ia.js +0 -1
  92. package/frontend-dist/assets/CollapsibleTrigger-H2GGomkv.js +0 -1
  93. package/frontend-dist/assets/Collection-BdkMCE5V.js +0 -1
  94. package/frontend-dist/assets/Dashboard-BQvc6U98.js +0 -3
  95. package/frontend-dist/assets/DialogTitle-CS0Nuvko.js +0 -1
  96. package/frontend-dist/assets/Label-CXRQoDIZ.js +0 -1
  97. package/frontend-dist/assets/Login-DRNqP0bt.js +0 -1
  98. package/frontend-dist/assets/Logs-DeJosCWl.js +0 -1
  99. package/frontend-dist/assets/ModelMappings-DV-NmdF7.js +0 -1
  100. package/frontend-dist/assets/Monitor-DwqkpkuK.js +0 -1
  101. package/frontend-dist/assets/Providers-oYOUgEsH.js +0 -1
  102. package/frontend-dist/assets/ProxyEnhancement-3tQzXNGn.js +0 -5
  103. package/frontend-dist/assets/RetryRules-rlrPpTd0.js +0 -1
  104. package/frontend-dist/assets/RouterKeys-COpe69A8.js +0 -1
  105. package/frontend-dist/assets/RovingFocusItem-DbXUYGXA.js +0 -1
  106. package/frontend-dist/assets/Schedules-gszIRN_S.js +0 -1
  107. package/frontend-dist/assets/SelectValue-CpY2uWSk.js +0 -1
  108. package/frontend-dist/assets/Settings-Xu6V0Sve.js +0 -6
  109. package/frontend-dist/assets/Setup-BfcLFnBR.js +0 -1
  110. package/frontend-dist/assets/Switch-DVfy7Q4A.js +0 -1
  111. package/frontend-dist/assets/TableHeader-BqZo28x_.js +0 -1
  112. package/frontend-dist/assets/TabsTrigger-0L00h4oy.js +0 -1
  113. package/frontend-dist/assets/Teleport-Czq5P0IN.js +0 -3
  114. package/frontend-dist/assets/TooltipTrigger-ChkMBqtC.js +0 -1
  115. package/frontend-dist/assets/UnifiedRequestDialog-B3GxGgwz.js +0 -3
  116. package/frontend-dist/assets/UnifiedRequestDialog-BjEigSaR.css +0 -1
  117. package/frontend-dist/assets/VisuallyHidden-DAFM-4dn.js +0 -1
  118. package/frontend-dist/assets/VisuallyHiddenInput-BwfFtaqi.js +0 -1
  119. package/frontend-dist/assets/alert-dialog-vdmYFjPE.js +0 -1
  120. package/frontend-dist/assets/arrow-down-Da-mukXD.js +0 -1
  121. package/frontend-dist/assets/badge-0gUIxSR9.js +0 -1
  122. package/frontend-dist/assets/check-CEd3A-kB.js +0 -1
  123. package/frontend-dist/assets/constants-D_0jiLjw.js +0 -1
  124. package/frontend-dist/assets/copy-7NlsO7pN.js +0 -1
  125. package/frontend-dist/assets/dialog-DUkre9Rw.js +0 -1
  126. package/frontend-dist/assets/file-text-Di1QQ-B6.js +0 -1
  127. package/frontend-dist/assets/index-Be9MymBh.js +0 -1
  128. package/frontend-dist/assets/index-Bz_ZaXNn.css +0 -1
  129. package/frontend-dist/assets/lib-CweCSowO.js +0 -1
  130. package/frontend-dist/assets/loader-circle-Cb19pB9Z.js +0 -1
  131. package/frontend-dist/assets/useFormControl-DP5JWFRS.js +0 -1
  132. package/frontend-dist/assets/useNonce-DTHUE2m9.js +0 -1
  133. 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": ["deepseek-chat", "deepseek-reasoner"]
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": ["deepseek-chat", "deepseek-reasoner"]
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": ["glm-5.1", "glm-5", "glm-4.7", "glm-4.5-air"]
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": "KIMI",
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": ["kimi-for-coding", "kimi-k2.5"]
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": ["MiniMax-M2.7"]
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": ["deepseek-v4-pro", "deepseek-v4-flash", "minimax-m2.7", "minimax-m2.5"]
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": ["step-3.5-flash-2603", "step-3.5-flash"]
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
  ]
@@ -58,9 +58,11 @@ function cascadeProviderDisable(db, providerId) {
58
58
  return result;
59
59
  }
60
60
  function extractModelOverrides(models) {
61
- const names = models.map(m => typeof m === "string" ? m : m.name);
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 { names, overrides };
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 modelNames = parseModels(s.models || "[]");
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(modelNames, overrides),
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 { names: normalizedModels, overrides: contextOverrides } = extractModelOverrides((body.models ?? []));
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 { names, overrides } = extractModelOverrides(body.models);
194
- fields.models = JSON.stringify(names);
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
- return reply.send(rules.filter((r) => !existing.has(r.name)));
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();
@@ -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): string[];
10
- export declare function buildModelInfoList(modelNames: string[], overrides: Map<string, number>): ModelInfo[];
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) => typeof item === 'string' ? item : item?.name ?? '').filter(Boolean);
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(modelNames, overrides) {
101
- return modelNames.map(name => ({
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 cachedProviders;
8
+ return loadJson('recommended-providers.json');
13
9
  }
14
10
  export function getRecommendedRetryRules() {
15
- return cachedRetryRules;
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)