llm-simple-router 0.11.27 → 0.11.29

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 (81) hide show
  1. package/config/recommended-providers.json +23 -7
  2. package/dist/admin/providers.js +164 -69
  3. package/dist/config/recommended-providers.json +23 -7
  4. package/dist/config/recommended.d.ts +2 -0
  5. package/dist/core/types.d.ts +17 -0
  6. package/dist/db/logs.d.ts +4 -0
  7. package/dist/db/logs.js +6 -3
  8. package/dist/db/migrations/051_provider_endpoints.sql +15 -0
  9. package/dist/db/migrations/052_add_upstream_log_fields.sql +3 -0
  10. package/dist/db/providers.d.ts +9 -1
  11. package/dist/db/providers.js +32 -3
  12. package/dist/index.js +13 -0
  13. package/dist/proxy/handler/failover-loop.d.ts +136 -1
  14. package/dist/proxy/handler/failover-loop.js +395 -299
  15. package/dist/proxy/log-helpers.d.ts +4 -0
  16. package/dist/proxy/log-helpers.js +6 -2
  17. package/dist/proxy/patch/deepseek/patch-orphan-tool-results.d.ts +1 -1
  18. package/dist/proxy/patch/deepseek/patch-orphan-tool-results.js +24 -37
  19. package/dist/proxy/patch/index.js +3 -1
  20. package/dist/proxy/proxy-logging.d.ts +2 -0
  21. package/dist/proxy/proxy-logging.js +2 -0
  22. package/dist/proxy/routing/resolve-endpoint.d.ts +3 -0
  23. package/dist/proxy/routing/resolve-endpoint.js +38 -0
  24. package/dist/proxy/transform/types.d.ts +1 -2
  25. package/dist/proxy/transport/transport-fn.d.ts +1 -0
  26. package/dist/proxy/transport/transport-fn.js +1 -1
  27. package/frontend-dist/assets/{CardContent-BLlnppsG.js → CardContent-CPgJkYqm.js} +1 -1
  28. package/frontend-dist/assets/{CardTitle-8HHCqwln.js → CardTitle-DlmZKWtW.js} +1 -1
  29. package/frontend-dist/assets/{CascadingModelSelect-CrtNasp8.js → CascadingModelSelect-BwPgKnOc.js} +1 -1
  30. package/frontend-dist/assets/{Checkbox-B8Vw60jj.js → Checkbox-BV5EQ4YM.js} +1 -1
  31. package/frontend-dist/assets/{CollapsibleContent-CvvkOB4a.js → CollapsibleContent-urA_6kfp.js} +1 -1
  32. package/frontend-dist/assets/{CollapsibleTrigger-DeMItRLr.js → CollapsibleTrigger-DJRieUOm.js} +1 -1
  33. package/frontend-dist/assets/{Dashboard-9dJqc_2w.js → Dashboard-DSwRrmn8.js} +1 -1
  34. package/frontend-dist/assets/{Input-vc5GUwkU.js → Input-DERHUkRk.js} +1 -1
  35. package/frontend-dist/assets/{Label-FWcCWV4Q.js → Label-D5PFB0st.js} +1 -1
  36. package/frontend-dist/assets/{Login-9QkzUJm_.js → Login-KolKJnRj.js} +1 -1
  37. package/frontend-dist/assets/Logs-D4tcVJdx.js +1 -0
  38. package/frontend-dist/assets/{MappingEntryEditor-Cx85eUWy.js → MappingEntryEditor-D4Te5uWj.js} +1 -1
  39. package/frontend-dist/assets/{ModelMappings-rUvMpbjc.js → ModelMappings-CR92Hl4D.js} +1 -1
  40. package/frontend-dist/assets/{Monitor-DCo3DSbc.js → Monitor-Ccpd95Lb.js} +1 -1
  41. package/frontend-dist/assets/Providers-CN-w_3iZ.js +1 -0
  42. package/frontend-dist/assets/{ProxyEnhancement-Cn1rEBFB.js → ProxyEnhancement-aDCbph8B.js} +1 -1
  43. package/frontend-dist/assets/QuickSetup-BgrY5WgS.js +1 -0
  44. package/frontend-dist/assets/{RetryRules-DDJMY1IF.js → RetryRules-Ck7GDfvp.js} +1 -1
  45. package/frontend-dist/assets/{RouterKeys-DMyGtSxS.js → RouterKeys-KG3Hiqle.js} +1 -1
  46. package/frontend-dist/assets/{RovingFocusItem-Bx5uXXN2.js → RovingFocusItem-CwTuwFla.js} +1 -1
  47. package/frontend-dist/assets/{Schedules-CDDBsNpW.js → Schedules-BiuKtYN_.js} +1 -1
  48. package/frontend-dist/assets/{Settings-Cr9sYZKR.js → Settings-hbCMg8D_.js} +1 -1
  49. package/frontend-dist/assets/{Setup-LSmgyhc3.js → Setup-C-Sdhz4l.js} +1 -1
  50. package/frontend-dist/assets/{Switch-DyyEun5Q.js → Switch-CjhSCxdv.js} +1 -1
  51. package/frontend-dist/assets/{TooltipTrigger-DCldZMnb.js → TooltipTrigger-WXdFCeVq.js} +1 -1
  52. package/frontend-dist/assets/{TransformRulesForm-Gfvlk0Xk.js → TransformRulesForm-BXo7UEUm.js} +1 -1
  53. package/frontend-dist/assets/{UnifiedRequestDialog-BnrOpRXA.js → UnifiedRequestDialog-D9UChyhv.js} +3 -3
  54. package/frontend-dist/assets/{VisuallyHiddenInput-Bkpr-VfM.js → VisuallyHiddenInput-CkyFdj9S.js} +1 -1
  55. package/frontend-dist/assets/{button-CT60E_JD.js → button-BTtVoe27.js} +2 -2
  56. package/frontend-dist/assets/{copy-CVYlfrkY.js → copy-BRzaTo_t.js} +1 -1
  57. package/frontend-dist/assets/{dialog-B4JCV9g7.js → dialog-BO9QSeKn.js} +1 -1
  58. package/frontend-dist/assets/index-B_tA9Si9.css +1 -0
  59. package/frontend-dist/assets/{index-bGqHF9Js.js → index-CN2qglG7.js} +2 -2
  60. package/frontend-dist/assets/model-patches-9dYvtlVH.js +1 -0
  61. package/frontend-dist/assets/plus-D34D5D4i.js +1 -0
  62. package/frontend-dist/assets/providers-BaiCysMg.js +1 -0
  63. package/frontend-dist/assets/providers-DsC5Qd6m.js +1 -0
  64. package/frontend-dist/assets/requestDetail-C5px2Mru.js +1 -0
  65. package/frontend-dist/assets/requestDetail-Tozq3jwE.js +1 -0
  66. package/frontend-dist/assets/{sparkles-CbfRkkI9.js → sparkles-CC-Zd8Nr.js} +1 -1
  67. package/frontend-dist/assets/{trash-2-wGNSUXAY.js → trash-2-D475gyZP.js} +1 -1
  68. package/frontend-dist/assets/{useClipboard-Br_VVE9B.js → useClipboard-B553UCM8.js} +1 -1
  69. package/frontend-dist/assets/{useLogRetention-DOuufHTD.js → useLogRetention-DvyBBmK2.js} +1 -1
  70. package/frontend-dist/index.html +3 -3
  71. package/package.json +1 -1
  72. package/frontend-dist/assets/Logs-B2Ywp4Aj.js +0 -1
  73. package/frontend-dist/assets/Providers-CwvTWI9Z.js +0 -1
  74. package/frontend-dist/assets/QuickSetup-CdpVo45V.js +0 -1
  75. package/frontend-dist/assets/index-BaNw4aag.css +0 -1
  76. package/frontend-dist/assets/model-patches-C-_TDTXT.js +0 -1
  77. package/frontend-dist/assets/plus-C0-OMLZ3.js +0 -1
  78. package/frontend-dist/assets/providers-ChuD67aW.js +0 -1
  79. package/frontend-dist/assets/providers-DEOmviin.js +0 -1
  80. package/frontend-dist/assets/requestDetail-BI1tm09T.js +0 -1
  81. package/frontend-dist/assets/requestDetail-dJ1P4rgQ.js +0 -1
@@ -94,12 +94,12 @@
94
94
  "group": "智谱",
95
95
  "presets": [
96
96
  {
97
- "plan": "Coding Plan",
98
- "presetName": "zhipu-coding-plan",
99
- "apiType": "openai",
100
- "baseUrl": "https://open.bigmodel.cn",
101
- "upstreamPath": "/api/coding/paas/v4/chat/completions",
102
- "modelsEndpoint": "/v1/models",
97
+ "plan": "Coding Plan",
98
+ "presetName": "zhipu-coding-plan",
99
+ "apiType": "openai",
100
+ "baseUrl": "https://open.bigmodel.cn",
101
+ "upstreamPath": "/api/coding/paas/v4/chat/completions",
102
+ "modelsEndpoint": "/v1/models",
103
103
  "models": [
104
104
  "glm-5.1",
105
105
  "glm-5",
@@ -108,6 +108,22 @@
108
108
  "glm-4.5-air"
109
109
  ]
110
110
  },
111
+ {
112
+ "plan": "Coding Plan (Anthropic)",
113
+ "presetName": "zhipu-coding-plan-anthropic",
114
+ "apiType": "anthropic",
115
+ "baseUrl": "https://open.bigmodel.cn",
116
+ "upstreamPath": "/api/anthropic/v1/messages",
117
+ "modelsEndpoint": "/api/anthropic/v1/models",
118
+ "models": [
119
+ "glm-5.1",
120
+ "glm-5",
121
+ "glm-5-turbo",
122
+ "glm-4.7",
123
+ "glm-4.5-air"
124
+ ],
125
+ "hidden": true
126
+ },
111
127
  {
112
128
  "plan": "API",
113
129
  "presetName": "zhipu",
@@ -352,4 +368,4 @@
352
368
  }
353
369
  ]
354
370
  }
355
- ]
371
+ ]
@@ -1,5 +1,6 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import { getAllProviders, getProviderById, createProvider, updateProvider, deleteProvider, getAllMappingGroups, updateMappingGroup, PROVIDER_CONCURRENCY_DEFAULTS } from "../db/index.js";
3
+ import { parseEndpoints, serializeEndpoints } from "../db/providers.js";
3
4
  import { encrypt, decrypt } from "../utils/crypto.js";
4
5
  import { getSetting } from "../db/settings.js";
5
6
  import { HTTP_CREATED, HTTP_NOT_FOUND, HTTP_CONFLICT, HTTP_BAD_REQUEST, HTTP_OK } from "./constants.js";
@@ -26,10 +27,11 @@ function cascadeProviderDisable(db, providerId) {
26
27
  let modified = false;
27
28
  let shouldDisable = false;
28
29
  // 归一化旧格式 { default, windows } → { targets }(向后兼容 migration 026 前数据)
29
- // eslint-disable-next-line taste/no-deprecated-rule-format
30
- if (!Array.isArray(rule.targets) && typeof rule.default === "object" && rule.default !== null) {
31
- // eslint-disable-next-line taste/no-deprecated-rule-format
32
- rule.targets = [rule.default];
30
+ if (!Array.isArray(rule.targets)) {
31
+ const legacyDefault = rule["default"];
32
+ if (typeof legacyDefault === "object" && legacyDefault !== null) {
33
+ rule.targets = [legacyDefault];
34
+ }
33
35
  }
34
36
  const targets = rule.targets;
35
37
  if (Array.isArray(targets)) {
@@ -95,12 +97,42 @@ function isValidHttpUrl(str) {
95
97
  return false;
96
98
  }
97
99
  }
100
+ /** 校验 endpoints 数组并加密 api_key。成功返回处理后的数组,失败返回错误信息 */
101
+ function validateAndEncryptEndpoints(endpoints, encryptionKey) {
102
+ const apiTypes = endpoints.map(e => e.api_type);
103
+ if (new Set(apiTypes).size !== apiTypes.length) {
104
+ return { ok: false, message: "endpoints 中存在重复的 api_type" };
105
+ }
106
+ for (const ep of endpoints) {
107
+ if (!isValidHttpUrl(ep.base_url)) {
108
+ return { ok: false, message: `endpoint (${ep.api_type}) base_url 格式无效,必须是以 http:// 或 https:// 开头的合法 URL` };
109
+ }
110
+ }
111
+ const processed = endpoints.map(e => ({
112
+ api_type: e.api_type,
113
+ base_url: e.base_url,
114
+ upstream_path: e.upstream_path ?? null,
115
+ api_key: e.api_key ? encrypt(e.api_key, encryptionKey) : null,
116
+ }));
117
+ return { ok: true, processed };
118
+ }
119
+ const EndpointSchema = Type.Object({
120
+ api_type: Type.Union([
121
+ Type.Literal("openai"),
122
+ Type.Literal("openai-responses"),
123
+ Type.Literal("anthropic"),
124
+ ]),
125
+ base_url: Type.String({ minLength: 1 }),
126
+ upstream_path: Type.Optional(Type.Union([Type.String({ minLength: 1 }), Type.Null()])),
127
+ api_key: Type.Optional(Type.Union([Type.String({ minLength: 1 }), Type.Null()])),
128
+ });
98
129
  const CreateProviderSchema = Type.Object({
99
130
  name: Type.String({ minLength: 1 }),
100
- api_type: Type.Union([Type.Literal("openai"), Type.Literal("anthropic")]),
101
- base_url: Type.String({ minLength: 1 }),
131
+ api_type: Type.Optional(Type.Union([Type.Literal("openai"), Type.Literal("anthropic"), Type.Literal("openai-responses")])),
132
+ base_url: Type.Optional(Type.String({ minLength: 1 })),
102
133
  upstream_path: Type.Optional(Type.String({ minLength: 1 })),
103
- api_key: Type.String({ minLength: 1 }),
134
+ api_key: Type.Optional(Type.String({ minLength: 1 })),
135
+ endpoints: Type.Optional(Type.Array(EndpointSchema, { minItems: 1 })),
104
136
  models: Type.Optional(Type.Array(Type.Union([
105
137
  Type.String(),
106
138
  Type.Object({ name: Type.String(), context_window: Type.Optional(Type.Number()), patches: Type.Optional(Type.Array(Type.String())), stream_timeout_ms: Type.Optional(Type.Number({ minimum: 0, maximum: 86_400_000 })), capabilities: Type.Optional(Type.Array(Type.String())) }),
@@ -118,10 +150,11 @@ const CreateProviderSchema = Type.Object({
118
150
  });
119
151
  const UpdateProviderSchema = Type.Object({
120
152
  name: Type.Optional(Type.String({ minLength: 1 })),
121
- api_type: Type.Optional(Type.Union([Type.Literal("openai"), Type.Literal("anthropic")])),
153
+ api_type: Type.Optional(Type.Union([Type.Literal("openai"), Type.Literal("anthropic"), Type.Literal("openai-responses")])),
122
154
  base_url: Type.Optional(Type.String({ minLength: 1 })),
123
155
  upstream_path: Type.Optional(Type.String({ minLength: 1 })),
124
156
  api_key: Type.Optional(Type.String({ minLength: 1 })),
157
+ endpoints: Type.Optional(Type.Array(EndpointSchema, { minItems: 1 })),
125
158
  models: Type.Optional(Type.Array(Type.Union([
126
159
  Type.String(),
127
160
  Type.Object({ name: Type.String(), context_window: Type.Optional(Type.Number()), patches: Type.Optional(Type.Array(Type.String())), stream_timeout_ms: Type.Optional(Type.Number({ minimum: 0, maximum: 86_400_000 })), capabilities: Type.Optional(Type.Array(Type.String())) }),
@@ -137,6 +170,104 @@ const UpdateProviderSchema = Type.Object({
137
170
  proxy_username: Type.Optional(Type.Union([Type.String(), Type.Null()])),
138
171
  proxy_password: Type.Optional(Type.Union([Type.String(), Type.Null()])),
139
172
  });
173
+ async function handleCreateProvider(body, reply, deps) {
174
+ const { db, stateRegistry, tracker, adaptiveController } = deps;
175
+ if (!PROVIDER_NAME_RE.test(body.name)) {
176
+ return reply.code(HTTP_BAD_REQUEST).send(apiError(API_CODE.VALIDATION_FAILED, "Provider 名称仅允许英文大小写字母、数字、横线和下划线"));
177
+ }
178
+ const existing = db.prepare("SELECT id FROM providers WHERE name = ?").get(body.name);
179
+ if (existing) {
180
+ return reply.code(HTTP_CONFLICT).send(apiError(API_CODE.CONFLICT_NAME, `Provider 名称 '${body.name}' 已存在`));
181
+ }
182
+ const encryptionKey = getSetting(db, "encryption_key");
183
+ // ---- endpoints 解析 ----
184
+ let legacyApiType;
185
+ let legacyBaseUrl;
186
+ let legacyUpstreamPath;
187
+ let legacyApiKeyPlain;
188
+ let endpointsSerialized;
189
+ if (body.endpoints && body.endpoints.length > 0) {
190
+ const result = validateAndEncryptEndpoints(body.endpoints, encryptionKey);
191
+ if (!result.ok) {
192
+ return reply.code(HTTP_BAD_REQUEST).send(apiError(API_CODE.VALIDATION_FAILED, result.message));
193
+ }
194
+ endpointsSerialized = serializeEndpoints(result.processed);
195
+ legacyApiType = body.endpoints[0].api_type;
196
+ legacyBaseUrl = body.endpoints[0].base_url;
197
+ legacyUpstreamPath = body.endpoints[0].upstream_path ?? null;
198
+ legacyApiKeyPlain = body.endpoints[0].api_key ?? body.api_key ?? "";
199
+ if (!legacyApiKeyPlain) {
200
+ return reply.code(HTTP_BAD_REQUEST).send(apiError(API_CODE.VALIDATION_FAILED, "第一个 endpoint 需要提供 api_key 或使用旧 api_key 字段"));
201
+ }
202
+ }
203
+ else {
204
+ if (!body.api_type || !body.base_url || !body.api_key) {
205
+ return reply.code(HTTP_BAD_REQUEST).send(apiError(API_CODE.VALIDATION_FAILED, "缺少 endpoints 时,api_type/base_url/api_key 为必填"));
206
+ }
207
+ if (!isValidHttpUrl(body.base_url)) {
208
+ return reply.code(HTTP_BAD_REQUEST).send(apiError(API_CODE.VALIDATION_FAILED, "base_url 格式无效,必须是以 http:// 或 https:// 开头的合法 URL"));
209
+ }
210
+ legacyApiType = body.api_type;
211
+ legacyBaseUrl = body.base_url;
212
+ legacyUpstreamPath = body.upstream_path ?? null;
213
+ legacyApiKeyPlain = body.api_key;
214
+ endpointsSerialized = serializeEndpoints([{
215
+ api_type: body.api_type,
216
+ base_url: body.base_url,
217
+ upstream_path: body.upstream_path ?? null,
218
+ api_key: null,
219
+ }]);
220
+ }
221
+ const encryptedKey = encrypt(legacyApiKeyPlain, encryptionKey);
222
+ const { entries: normalizedModels, overrides: contextOverrides } = extractModelOverrides((body.models ?? []));
223
+ const isAdaptiveEnabled = body.adaptive_enabled ?? 0;
224
+ const effectiveProxyType = body.proxy_url?.trim() ? body.proxy_type : null;
225
+ const effectiveProxyUrl = body.proxy_url?.trim() || null;
226
+ const encryptedProxyUsername = (effectiveProxyType && body.proxy_username) ? encrypt(body.proxy_username, encryptionKey) : null;
227
+ const encryptedProxyPassword = (effectiveProxyType && body.proxy_password) ? encrypt(body.proxy_password, encryptionKey) : null;
228
+ const id = createProvider(db, {
229
+ name: body.name,
230
+ api_type: legacyApiType,
231
+ base_url: legacyBaseUrl,
232
+ upstream_path: legacyUpstreamPath,
233
+ api_key: encryptedKey,
234
+ api_key_preview: legacyApiKeyPlain.length > API_KEY_PREVIEW_MIN_LENGTH ? `${legacyApiKeyPlain.slice(0, API_KEY_PREVIEW_PREFIX_LEN)}...${legacyApiKeyPlain.slice(-API_KEY_PREVIEW_PREFIX_LEN)}` : "****",
235
+ models: JSON.stringify(normalizedModels),
236
+ is_active: body.is_active ?? 1,
237
+ max_concurrency: body.max_concurrency ?? PROVIDER_CONCURRENCY_DEFAULTS.max_concurrency,
238
+ queue_timeout_ms: body.queue_timeout_ms ?? PROVIDER_CONCURRENCY_DEFAULTS.queue_timeout_ms,
239
+ max_queue_size: body.max_queue_size ?? PROVIDER_CONCURRENCY_DEFAULTS.max_queue_size,
240
+ adaptive_enabled: isAdaptiveEnabled,
241
+ proxy_type: effectiveProxyType,
242
+ proxy_url: effectiveProxyUrl,
243
+ proxy_username: encryptedProxyUsername,
244
+ proxy_password: encryptedProxyPassword,
245
+ endpoints: endpointsSerialized,
246
+ });
247
+ if (contextOverrides.length > 0) {
248
+ setModelInfoForProvider(db, id, contextOverrides.map(o => ({ model_name: o.name, context_window: o.context_window })));
249
+ }
250
+ if (!isAdaptiveEnabled) {
251
+ stateRegistry?.updateProviderConcurrency(id, {
252
+ maxConcurrency: body.max_concurrency ?? PROVIDER_CONCURRENCY_DEFAULTS.max_concurrency,
253
+ queueTimeoutMs: body.queue_timeout_ms ?? PROVIDER_CONCURRENCY_DEFAULTS.queue_timeout_ms,
254
+ maxQueueSize: body.max_queue_size ?? PROVIDER_CONCURRENCY_DEFAULTS.max_queue_size,
255
+ });
256
+ }
257
+ adaptiveController?.syncProvider(id, {
258
+ adaptive_enabled: isAdaptiveEnabled,
259
+ max_concurrency: body.max_concurrency ?? PROVIDER_CONCURRENCY_DEFAULTS.max_concurrency,
260
+ queue_timeout_ms: body.queue_timeout_ms ?? PROVIDER_CONCURRENCY_DEFAULTS.queue_timeout_ms,
261
+ max_queue_size: body.max_queue_size ?? PROVIDER_CONCURRENCY_DEFAULTS.max_queue_size,
262
+ });
263
+ tracker?.updateProviderConfig(id, {
264
+ name: body.name,
265
+ maxConcurrency: body.max_concurrency ?? PROVIDER_CONCURRENCY_DEFAULTS.max_concurrency,
266
+ queueTimeoutMs: body.queue_timeout_ms ?? PROVIDER_CONCURRENCY_DEFAULTS.queue_timeout_ms,
267
+ maxQueueSize: body.max_queue_size ?? PROVIDER_CONCURRENCY_DEFAULTS.max_queue_size,
268
+ });
269
+ return reply.code(HTTP_CREATED).send({ id });
270
+ }
140
271
  export const adminProviderRoutes = (app, options, done) => {
141
272
  const { db, stateRegistry, tracker, adaptiveController, proxyAgentFactory } = options;
142
273
  app.get("/admin/api/providers", async (_request, reply) => {
@@ -162,6 +293,12 @@ export const adminProviderRoutes = (app, options, done) => {
162
293
  proxy_url: s.proxy_url,
163
294
  proxy_username: s.proxy_username ? decrypt(s.proxy_username, encryptionKey) : null,
164
295
  proxy_password: s.proxy_password ? decrypt(s.proxy_password, encryptionKey) : null,
296
+ endpoints: parseEndpoints(s.endpoints).map(ep => ({
297
+ api_type: ep.api_type,
298
+ base_url: ep.base_url,
299
+ upstream_path: ep.upstream_path ?? null,
300
+ api_key: ep.api_key ? decrypt(ep.api_key, encryptionKey) : "",
301
+ })),
165
302
  concurrency_status: stateRegistry?.getProviderStatus(s.id) ?? { active: 0, queued: 0 },
166
303
  created_at: s.created_at,
167
304
  updated_at: s.updated_at,
@@ -169,67 +306,7 @@ export const adminProviderRoutes = (app, options, done) => {
169
306
  }));
170
307
  });
171
308
  app.post("/admin/api/providers", { schema: { body: CreateProviderSchema } }, async (request, reply) => {
172
- const body = request.body;
173
- if (!PROVIDER_NAME_RE.test(body.name)) {
174
- return reply.code(HTTP_BAD_REQUEST).send(apiError(API_CODE.VALIDATION_FAILED, "Provider 名称仅允许英文大小写字母、数字、横线和下划线"));
175
- }
176
- const existing = db.prepare("SELECT id FROM providers WHERE name = ?").get(body.name);
177
- if (existing) {
178
- return reply.code(HTTP_CONFLICT).send(apiError(API_CODE.CONFLICT_NAME, `Provider 名称 '${body.name}' 已存在`));
179
- }
180
- if (!isValidHttpUrl(body.base_url)) {
181
- return reply.code(HTTP_BAD_REQUEST).send(apiError(API_CODE.VALIDATION_FAILED, "base_url 格式无效,必须是以 http:// 或 https:// 开头的合法 URL"));
182
- }
183
- const encryptedKey = encrypt(body.api_key, getSetting(db, "encryption_key"));
184
- const { entries: normalizedModels, overrides: contextOverrides } = extractModelOverrides((body.models ?? []));
185
- const isAdaptiveEnabled = body.adaptive_enabled ?? 0;
186
- // 将空 proxy_url 视为不使用代理
187
- const effectiveProxyType = body.proxy_url?.trim() ? body.proxy_type : null;
188
- const effectiveProxyUrl = body.proxy_url?.trim() || null;
189
- const encryptedProxyUsername = (effectiveProxyType && body.proxy_username) ? encrypt(body.proxy_username, getSetting(db, "encryption_key")) : null;
190
- const encryptedProxyPassword = (effectiveProxyType && body.proxy_password) ? encrypt(body.proxy_password, getSetting(db, "encryption_key")) : null;
191
- const id = createProvider(db, {
192
- name: body.name,
193
- api_type: body.api_type,
194
- base_url: body.base_url,
195
- upstream_path: body.upstream_path ?? null,
196
- api_key: encryptedKey,
197
- 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)}` : "****",
198
- models: JSON.stringify(normalizedModels),
199
- is_active: body.is_active ?? 1,
200
- max_concurrency: body.max_concurrency ?? PROVIDER_CONCURRENCY_DEFAULTS.max_concurrency,
201
- queue_timeout_ms: body.queue_timeout_ms ?? PROVIDER_CONCURRENCY_DEFAULTS.queue_timeout_ms,
202
- max_queue_size: body.max_queue_size ?? PROVIDER_CONCURRENCY_DEFAULTS.max_queue_size,
203
- adaptive_enabled: isAdaptiveEnabled,
204
- proxy_type: effectiveProxyType,
205
- proxy_url: effectiveProxyUrl,
206
- proxy_username: encryptedProxyUsername,
207
- proxy_password: encryptedProxyPassword,
208
- });
209
- if (contextOverrides.length > 0) {
210
- setModelInfoForProvider(db, id, contextOverrides.map(o => ({ model_name: o.name, context_window: o.context_window })));
211
- }
212
- // 当 adaptive 启用时,由 syncProvider 全权管理信号量(避免重复调用 updateConfig)
213
- if (!isAdaptiveEnabled) {
214
- stateRegistry?.updateProviderConcurrency(id, {
215
- maxConcurrency: body.max_concurrency ?? PROVIDER_CONCURRENCY_DEFAULTS.max_concurrency,
216
- queueTimeoutMs: body.queue_timeout_ms ?? PROVIDER_CONCURRENCY_DEFAULTS.queue_timeout_ms,
217
- maxQueueSize: body.max_queue_size ?? PROVIDER_CONCURRENCY_DEFAULTS.max_queue_size,
218
- });
219
- }
220
- adaptiveController?.syncProvider(id, {
221
- adaptive_enabled: isAdaptiveEnabled,
222
- max_concurrency: body.max_concurrency ?? PROVIDER_CONCURRENCY_DEFAULTS.max_concurrency,
223
- queue_timeout_ms: body.queue_timeout_ms ?? PROVIDER_CONCURRENCY_DEFAULTS.queue_timeout_ms,
224
- max_queue_size: body.max_queue_size ?? PROVIDER_CONCURRENCY_DEFAULTS.max_queue_size,
225
- });
226
- tracker?.updateProviderConfig(id, {
227
- name: body.name,
228
- maxConcurrency: body.max_concurrency ?? PROVIDER_CONCURRENCY_DEFAULTS.max_concurrency,
229
- queueTimeoutMs: body.queue_timeout_ms ?? PROVIDER_CONCURRENCY_DEFAULTS.queue_timeout_ms,
230
- maxQueueSize: body.max_queue_size ?? PROVIDER_CONCURRENCY_DEFAULTS.max_queue_size,
231
- });
232
- return reply.code(HTTP_CREATED).send({ id });
309
+ return handleCreateProvider(request.body, reply, { db, stateRegistry, tracker, adaptiveController });
233
310
  });
234
311
  app.put("/admin/api/providers/:id", { schema: { body: UpdateProviderSchema } }, async (request, reply) => {
235
312
  const { id } = request.params;
@@ -295,6 +372,24 @@ export const adminProviderRoutes = (app, options, done) => {
295
372
  if (body.proxy_password !== undefined && effectiveProxyType) {
296
373
  fields.proxy_password = body.proxy_password ? encrypt(body.proxy_password, getSetting(db, "encryption_key")) : null;
297
374
  }
375
+ // ---- endpoints 处理 ----
376
+ if (body.endpoints) {
377
+ const ek = getSetting(db, "encryption_key");
378
+ const result = validateAndEncryptEndpoints(body.endpoints, ek);
379
+ if (!result.ok) {
380
+ return reply.code(HTTP_BAD_REQUEST).send(apiError(API_CODE.VALIDATION_FAILED, result.message));
381
+ }
382
+ fields.endpoints = serializeEndpoints(result.processed);
383
+ // 同步旧字段 = endpoints[0]
384
+ fields.api_type = body.endpoints[0].api_type;
385
+ fields.base_url = body.endpoints[0].base_url;
386
+ fields.upstream_path = body.endpoints[0].upstream_path ?? null;
387
+ const primaryApiKey = body.endpoints[0].api_key;
388
+ if (primaryApiKey) {
389
+ fields.api_key = encrypt(primaryApiKey, ek);
390
+ fields.api_key_preview = primaryApiKey.length > API_KEY_PREVIEW_MIN_LENGTH ? `${primaryApiKey.slice(0, API_KEY_PREVIEW_PREFIX_LEN)}...${primaryApiKey.slice(-API_KEY_PREVIEW_PREFIX_LEN)}` : "****";
391
+ }
392
+ }
298
393
  updateProvider(db, id, fields);
299
394
  proxyAgentFactory?.invalidate(id);
300
395
  const updated = getProviderById(db, id);
@@ -94,12 +94,12 @@
94
94
  "group": "智谱",
95
95
  "presets": [
96
96
  {
97
- "plan": "Coding Plan",
98
- "presetName": "zhipu-coding-plan",
99
- "apiType": "openai",
100
- "baseUrl": "https://open.bigmodel.cn",
101
- "upstreamPath": "/api/coding/paas/v4/chat/completions",
102
- "modelsEndpoint": "/v1/models",
97
+ "plan": "Coding Plan",
98
+ "presetName": "zhipu-coding-plan",
99
+ "apiType": "openai",
100
+ "baseUrl": "https://open.bigmodel.cn",
101
+ "upstreamPath": "/api/coding/paas/v4/chat/completions",
102
+ "modelsEndpoint": "/v1/models",
103
103
  "models": [
104
104
  "glm-5.1",
105
105
  "glm-5",
@@ -108,6 +108,22 @@
108
108
  "glm-4.5-air"
109
109
  ]
110
110
  },
111
+ {
112
+ "plan": "Coding Plan (Anthropic)",
113
+ "presetName": "zhipu-coding-plan-anthropic",
114
+ "apiType": "anthropic",
115
+ "baseUrl": "https://open.bigmodel.cn",
116
+ "upstreamPath": "/api/anthropic/v1/messages",
117
+ "modelsEndpoint": "/api/anthropic/v1/models",
118
+ "models": [
119
+ "glm-5.1",
120
+ "glm-5",
121
+ "glm-5-turbo",
122
+ "glm-4.7",
123
+ "glm-4.5-air"
124
+ ],
125
+ "hidden": true
126
+ },
111
127
  {
112
128
  "plan": "API",
113
129
  "presetName": "zhipu",
@@ -352,4 +368,4 @@
352
368
  }
353
369
  ]
354
370
  }
355
- ]
371
+ ]
@@ -9,6 +9,8 @@ export interface ProviderPreset {
9
9
  models: string[];
10
10
  /** 由 API handler 补充:模型名 → capabilities 映射 */
11
11
  modelCapabilities?: Record<string, string[]>;
12
+ /** 隐藏 preset,不在 plan 下拉菜单显示,但 endpoints 生成仍遍历 */
13
+ hidden?: boolean;
12
14
  }
13
15
  export interface ProviderGroup {
14
16
  group: string;
@@ -57,6 +57,8 @@ export interface MetricsResult {
57
57
  text_tokens: number | null;
58
58
  tool_use_tokens: number | null;
59
59
  }
60
+ /** Provider endpoint API 类型(openai / openai-responses / anthropic) */
61
+ export type ApiType = "openai" | "openai-responses" | "anthropic";
60
62
  export type RawHeaders = Record<string, string | string[] | undefined>;
61
63
  export type TransportResult = {
62
64
  kind: "success";
@@ -119,5 +121,20 @@ export interface ResilienceAttempt {
119
121
  /** response headers 是否已发送,影响重试/failover 决策 */
120
122
  headers_sent?: boolean | null;
121
123
  }
124
+ /** Provider endpoint — stored in providers.endpoints JSON field */
125
+ export interface ProviderEndpoint {
126
+ api_type: ApiType;
127
+ base_url: string;
128
+ upstream_path?: string | null;
129
+ api_key?: string | null;
130
+ }
131
+ /** resolveEndpoint() 的输出 — 所有下游消费者只消费此对象 */
132
+ export interface ResolvedEndpoint {
133
+ api_type: ApiType;
134
+ base_url: string;
135
+ upstream_path: string | null;
136
+ api_key: string;
137
+ needs_transform: boolean;
138
+ }
122
139
  /** 流式传输阶段状态 */
123
140
  export type StreamState = "BUFFERING" | "STREAMING" | "COMPLETED" | "EARLY_ERROR" | "ABORTED";
package/dist/db/logs.d.ts CHANGED
@@ -21,6 +21,8 @@ export interface RequestLog {
21
21
  original_model: string | null;
22
22
  stream_text_content: string | null;
23
23
  session_id: string | null;
24
+ upstream_api_type: string | null;
25
+ upstream_base_url: string | null;
24
26
  }
25
27
  /** 列表查询扩展字段:JOIN providers 获得 provider_name */
26
28
  export interface RequestLogListRow extends RequestLog {
@@ -57,6 +59,8 @@ export interface RequestLogInsert {
57
59
  resilience_reason?: string | null;
58
60
  mapping_reason?: string | null;
59
61
  failover_trigger?: string | null;
62
+ upstream_api_type?: string | null;
63
+ upstream_base_url?: string | null;
60
64
  }
61
65
  export interface LogWriteContext {
62
66
  matcher?: RetryMatcher | null;
package/dist/db/logs.js CHANGED
@@ -9,6 +9,7 @@ const LOG_LIST_SELECT = `rl.id, rl.api_type, rl.model, rl.provider_id, rl.status
9
9
  rm.tokens_per_second, rm.stop_reason, rm.backend_model, rm.is_complete AS metrics_complete,
10
10
  rm.input_tokens_estimated, rm.client_type, rm.cache_read_tokens_estimated,
11
11
  COALESCE(p.name, rl.provider_id) AS provider_name,
12
+ rl.upstream_api_type, rl.upstream_base_url,
12
13
  CASE
13
14
  WHEN rl.client_request IS NULL THEN 'off'
14
15
  WHEN rl.api_type = 'anthropic' THEN COALESCE(json_extract(rl.client_request, '$.body.thinking.type'), 'off')
@@ -22,9 +23,10 @@ function rawInsertRequestLog(db, log, writeContext) {
22
23
  getCachedStmt(db, `INSERT INTO request_logs (id, api_type, model, provider_id, status_code, client_status_code, latency_ms,
23
24
  is_stream, error_message, created_at, client_request, upstream_request, upstream_response,
24
25
  is_retry, is_failover, original_request_id, router_key_id, original_model, session_id, pipeline_snapshot,
25
- transport_kind, abort_reason, error_code, headers_sent, resilience_action, resilience_reason, mapping_reason, failover_trigger)
26
+ transport_kind, abort_reason, error_code, headers_sent, resilience_action, resilience_reason, mapping_reason, failover_trigger,
27
+ upstream_api_type, upstream_base_url)
26
28
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
27
- ?, ?, ?, ?, ?, ?, ?, ?)`).run(log.id, log.api_type, log.model, log.provider_id, log.status_code, log.client_status_code ?? null, log.latency_ms, log.is_stream, log.error_message, log.created_at, log.client_request ?? null, preserveDetail ? (log.upstream_request ?? null) : null, preserveDetail ? (log.upstream_response ?? null) : null, log.is_retry ?? 0, log.is_failover ?? 0, log.original_request_id ?? null, log.router_key_id ?? null, log.original_model ?? null, log.session_id ?? null, log.pipeline_snapshot ?? null, log.transport_kind ?? null, log.abort_reason ?? null, log.error_code ?? null, log.headers_sent ?? null, log.resilience_action ?? null, log.resilience_reason ?? null, log.mapping_reason ?? null, log.failover_trigger ?? null);
29
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(log.id, log.api_type, log.model, log.provider_id, log.status_code, log.client_status_code ?? null, log.latency_ms, log.is_stream, log.error_message, log.created_at, log.client_request ?? null, preserveDetail ? (log.upstream_request ?? null) : null, preserveDetail ? (log.upstream_response ?? null) : null, log.is_retry ?? 0, log.is_failover ?? 0, log.original_request_id ?? null, log.router_key_id ?? null, log.original_model ?? null, log.session_id ?? null, log.pipeline_snapshot ?? null, log.transport_kind ?? null, log.abort_reason ?? null, log.error_code ?? null, log.headers_sent ?? null, log.resilience_action ?? null, log.resilience_reason ?? null, log.mapping_reason ?? null, log.failover_trigger ?? null, log.upstream_api_type ?? null, log.upstream_base_url ?? null);
28
30
  }
29
31
  export function insertRequestLog(db, log, writeContext) {
30
32
  // 文件写入:始终同步调用(WriteStream 内部异步,不阻塞事件循环)
@@ -110,7 +112,8 @@ export function getRequestLogById(db, id) {
110
112
  return db.prepare(`SELECT rl.*, rm.input_tokens, rm.output_tokens, rm.cache_read_tokens, rm.ttft_ms,
111
113
  rm.tokens_per_second, rm.stop_reason, rm.backend_model, rm.is_complete AS metrics_complete,
112
114
  rm.input_tokens_estimated, rm.client_type, rm.cache_read_tokens_estimated,
113
- COALESCE(p.name, rl.provider_id) AS provider_name${LOG_DETAIL_THINKING_LEVEL}
115
+ COALESCE(p.name, rl.provider_id) AS provider_name${LOG_DETAIL_THINKING_LEVEL},
116
+ rl.upstream_api_type, rl.upstream_base_url
114
117
  FROM request_logs rl
115
118
  LEFT JOIN providers p ON p.id = rl.provider_id
116
119
  LEFT JOIN request_metrics rm ON rm.request_log_id = rl.id
@@ -0,0 +1,15 @@
1
+ -- 051: Add endpoints column to providers, migrate existing data to endpoints JSON
2
+ ALTER TABLE providers ADD COLUMN endpoints TEXT DEFAULT NULL;
3
+
4
+ UPDATE providers
5
+ SET endpoints = json_array(
6
+ json_object(
7
+ 'api_type', api_type,
8
+ 'base_url', base_url,
9
+ 'upstream_path', CASE WHEN upstream_path IS NULL THEN json('null') ELSE upstream_path END,
10
+ 'api_key', CASE WHEN api_key IS NULL THEN json('null') ELSE api_key END
11
+ )
12
+ )
13
+ WHERE endpoints IS NULL
14
+ AND api_type IS NOT NULL
15
+ AND base_url IS NOT NULL;
@@ -0,0 +1,3 @@
1
+ -- 052: Add upstream endpoint fields to request_logs for multi-API-type logging
2
+ ALTER TABLE request_logs ADD COLUMN upstream_api_type TEXT DEFAULT NULL;
3
+ ALTER TABLE request_logs ADD COLUMN upstream_base_url TEXT DEFAULT NULL;
@@ -1,4 +1,5 @@
1
1
  import Database from "better-sqlite3";
2
+ import type { ProviderEndpoint } from "../core/types.js";
2
3
  export interface Provider {
3
4
  id: string;
4
5
  name: string;
@@ -18,6 +19,8 @@ export interface Provider {
18
19
  proxy_url: string | null;
19
20
  proxy_username: string | null;
20
21
  proxy_password: string | null;
22
+ /** @internal 原始 JSON 文本,业务层用 parseEndpoints() 解析 */
23
+ endpoints?: string;
21
24
  created_at: string;
22
25
  updated_at: string;
23
26
  }
@@ -30,6 +33,10 @@ export declare const PROVIDER_CONCURRENCY_DEFAULTS: {
30
33
  readonly queue_timeout_ms: 0;
31
34
  readonly max_queue_size: 100;
32
35
  };
36
+ /** 解析 endpoints JSON 文本为类型安全的数组 */
37
+ export declare function parseEndpoints(endpointsJson: string | null | undefined): ProviderEndpoint[];
38
+ /** 将 endpoints 数组序列化为 JSON 文本(用于 DB 写入) */
39
+ export declare function serializeEndpoints(endpoints: ProviderEndpoint[]): string;
33
40
  export declare function getActiveProviders(db: Database.Database, apiType: "openai" | "openai-responses" | "anthropic"): Provider[];
34
41
  export declare function getAllProviders(db: Database.Database): Provider[];
35
42
  export declare function getProviderById(db: Database.Database, id: string): Provider | undefined;
@@ -50,8 +57,9 @@ export declare function createProvider(db: Database.Database, provider: {
50
57
  proxy_url?: string | null;
51
58
  proxy_username?: string | null;
52
59
  proxy_password?: string | null;
60
+ endpoints?: string;
53
61
  }): string;
54
- export declare function updateProvider(db: Database.Database, id: string, fields: Partial<Pick<Provider, "name" | "api_type" | "base_url" | "upstream_path" | "api_key" | "api_key_preview" | "models" | "is_active" | "max_concurrency" | "queue_timeout_ms" | "max_queue_size" | "adaptive_enabled" | "proxy_type" | "proxy_url" | "proxy_username" | "proxy_password">>): void;
62
+ export declare function updateProvider(db: Database.Database, id: string, fields: Partial<Pick<Provider, "name" | "api_type" | "base_url" | "upstream_path" | "api_key" | "api_key_preview" | "models" | "is_active" | "max_concurrency" | "queue_timeout_ms" | "max_queue_size" | "adaptive_enabled" | "proxy_type" | "proxy_url" | "proxy_username" | "proxy_password" | "endpoints">>): void;
55
63
  export declare function deleteProvider(db: Database.Database, id: string): void;
56
64
  export declare function getActiveProviderByName(db: Database.Database, name: string): {
57
65
  id: string;
@@ -22,8 +22,37 @@ export const PROVIDER_CONCURRENCY_DEFAULTS = {
22
22
  max_queue_size: 100,
23
23
  };
24
24
  const PROVIDER_FIELDS = new Set([
25
- "name", "api_type", "base_url", "upstream_path", "api_key", "api_key_preview", "models", "is_active", "max_concurrency", "queue_timeout_ms", "max_queue_size", "adaptive_enabled", "proxy_type", "proxy_url", "proxy_username", "proxy_password",
25
+ "name", "api_type", "base_url", "upstream_path", "api_key", "api_key_preview", "models", "is_active", "max_concurrency", "queue_timeout_ms", "max_queue_size", "adaptive_enabled", "proxy_type", "proxy_url", "proxy_username", "proxy_password", "endpoints",
26
26
  ]);
27
+ const VALID_API_TYPES = new Set(["openai", "openai-responses", "anthropic"]);
28
+ /** 解析 endpoints JSON 文本为类型安全的数组 */
29
+ export function parseEndpoints(endpointsJson) {
30
+ if (!endpointsJson)
31
+ return [];
32
+ const parsed = JSON.parse(endpointsJson);
33
+ if (!Array.isArray(parsed)) {
34
+ throw new Error(`Invalid endpoints JSON: not an array`);
35
+ }
36
+ // Validate every element is a non-null object with required fields
37
+ for (let i = 0; i < parsed.length; i++) {
38
+ const item = parsed[i];
39
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
40
+ throw new Error(`Invalid endpoints JSON: element [${i}] is not an object`);
41
+ }
42
+ const obj = item;
43
+ if (typeof obj.api_type !== "string" || !VALID_API_TYPES.has(obj.api_type)) {
44
+ throw new Error(`Invalid endpoints JSON: element [${i}] has invalid api_type '${typeof obj.api_type === "string" ? obj.api_type : JSON.stringify(obj.api_type)}', must be one of: openai, openai-responses, anthropic`);
45
+ }
46
+ if (typeof obj.base_url !== "string" || obj.base_url.trim() === "") {
47
+ throw new Error(`Invalid endpoints JSON: element [${i}] has invalid base_url, must be a non-empty string`);
48
+ }
49
+ }
50
+ return parsed;
51
+ }
52
+ /** 将 endpoints 数组序列化为 JSON 文本(用于 DB 写入) */
53
+ export function serializeEndpoints(endpoints) {
54
+ return JSON.stringify(endpoints);
55
+ }
27
56
  export function getActiveProviders(db, apiType) {
28
57
  return db
29
58
  .prepare("SELECT * FROM providers WHERE api_type = ? AND is_active = 1")
@@ -38,8 +67,8 @@ export function getProviderById(db, id) {
38
67
  export function createProvider(db, provider) {
39
68
  const id = randomUUID();
40
69
  const now = new Date().toISOString();
41
- db.prepare(`INSERT INTO providers (id, name, api_type, base_url, upstream_path, api_key, api_key_preview, models, is_active, max_concurrency, queue_timeout_ms, max_queue_size, adaptive_enabled, proxy_type, proxy_url, proxy_username, proxy_password, created_at, updated_at)
42
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, provider.name, provider.api_type, provider.base_url, provider.upstream_path ?? null, provider.api_key, provider.api_key_preview ?? null, provider.models ?? "[]", provider.is_active ?? 1, provider.max_concurrency ?? PROVIDER_CONCURRENCY_DEFAULTS.max_concurrency, provider.queue_timeout_ms ?? PROVIDER_CONCURRENCY_DEFAULTS.queue_timeout_ms, provider.max_queue_size ?? PROVIDER_CONCURRENCY_DEFAULTS.max_queue_size, provider.adaptive_enabled ?? 0, provider.proxy_type ?? null, provider.proxy_url ?? null, provider.proxy_username ?? null, provider.proxy_password ?? null, now, now);
70
+ db.prepare(`INSERT INTO providers (id, name, api_type, base_url, upstream_path, api_key, api_key_preview, models, is_active, max_concurrency, queue_timeout_ms, max_queue_size, adaptive_enabled, proxy_type, proxy_url, proxy_username, proxy_password, endpoints, created_at, updated_at)
71
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, provider.name, provider.api_type, provider.base_url, provider.upstream_path ?? null, provider.api_key, provider.api_key_preview ?? null, provider.models ?? "[]", provider.is_active ?? 1, provider.max_concurrency ?? PROVIDER_CONCURRENCY_DEFAULTS.max_concurrency, provider.queue_timeout_ms ?? PROVIDER_CONCURRENCY_DEFAULTS.queue_timeout_ms, provider.max_queue_size ?? PROVIDER_CONCURRENCY_DEFAULTS.max_queue_size, provider.adaptive_enabled ?? 0, provider.proxy_type ?? null, provider.proxy_url ?? null, provider.proxy_username ?? null, provider.proxy_password ?? null, provider.endpoints ?? null, now, now);
43
72
  return id;
44
73
  }
45
74
  export function updateProvider(db, id, fields) {
package/dist/index.js CHANGED
@@ -408,6 +408,19 @@ export async function main() {
408
408
  const config = getConfig();
409
409
  // 全局兜底:防止未捕获异常导致进程崩溃
410
410
  process.on("uncaughtException", (err) => {
411
+ const code = err.code;
412
+ // EPIPE/ECONNRESET 是客户端断连后的正常网络错误,不影响服务稳定性
413
+ if (code === "EPIPE" || code === "ECONNRESET") {
414
+ try {
415
+ app.log.warn({ err }, "Client disconnected (EPIPE/ECONNRESET)");
416
+ /* eslint-disable taste/no-silent-catch -- app.log 可能已崩溃 */
417
+ }
418
+ catch {
419
+ console.warn("Client disconnected:", err.message);
420
+ }
421
+ /* eslint-enable taste/no-silent-catch */
422
+ return;
423
+ }
411
424
  try {
412
425
  app.log.fatal({ err }, "Uncaught exception");
413
426
  /* eslint-disable taste/no-silent-catch -- app.log 可能已崩溃,console 是最后手段 */