llm-simple-router 0.7.1 → 0.8.2

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 (153) hide show
  1. package/dist/admin/proxy-enhancement.js +3 -1
  2. package/dist/admin/routes.d.ts +1 -0
  3. package/dist/admin/routes.js +3 -1
  4. package/dist/admin/settings-import-export.d.ts +1 -0
  5. package/dist/admin/settings-import-export.js +7 -0
  6. package/dist/admin/transform-rules.d.ts +8 -0
  7. package/dist/admin/transform-rules.js +38 -0
  8. package/dist/admin/usage.js +1 -1
  9. package/dist/core/container.d.ts +1 -0
  10. package/dist/core/container.js +1 -0
  11. package/dist/db/migrations/034_create_provider_transform_rules.sql +11 -0
  12. package/dist/db/transform-rules.d.ts +16 -0
  13. package/dist/db/transform-rules.js +51 -0
  14. package/dist/index.js +30 -1
  15. package/dist/metrics/sse-parser.d.ts +2 -0
  16. package/dist/metrics/sse-parser.js +4 -0
  17. package/dist/monitor/request-tracker.d.ts +2 -0
  18. package/dist/monitor/request-tracker.js +22 -1
  19. package/dist/monitor/types.d.ts +1 -1
  20. package/dist/proxy/enhancement/response-cleaner.js +14 -6
  21. package/dist/proxy/handler/openai.js +13 -4
  22. package/dist/proxy/handler/proxy-handler-utils.js +2 -7
  23. package/dist/proxy/handler/proxy-handler.js +85 -18
  24. package/dist/proxy/patch/deepseek/index.d.ts +15 -3
  25. package/dist/proxy/patch/deepseek/index.js +29 -6
  26. package/dist/proxy/patch/deepseek/patch-cache-control.d.ts +6 -0
  27. package/dist/proxy/patch/deepseek/patch-cache-control.js +30 -0
  28. package/dist/proxy/patch/deepseek/patch-non-deepseek-tools.d.ts +16 -0
  29. package/dist/proxy/patch/deepseek/patch-non-deepseek-tools.js +74 -0
  30. package/dist/proxy/patch/deepseek/patch-orphan-tool-results.d.ts +10 -1
  31. package/dist/proxy/patch/deepseek/patch-orphan-tool-results.js +58 -15
  32. package/dist/proxy/patch/deepseek/patch-thinking-blocks.d.ts +5 -1
  33. package/dist/proxy/patch/deepseek/patch-thinking-blocks.js +37 -4
  34. package/dist/proxy/patch/deepseek/patch-thinking-param.d.ts +6 -0
  35. package/dist/proxy/patch/deepseek/patch-thinking-param.js +32 -0
  36. package/dist/proxy/patch/deepseek/utils.d.ts +8 -0
  37. package/dist/proxy/patch/deepseek/utils.js +38 -0
  38. package/dist/proxy/patch/index.d.ts +2 -2
  39. package/dist/proxy/patch/index.js +50 -4
  40. package/dist/proxy/patch/router-cleanup.js +1 -24
  41. package/dist/proxy/patch/safe-sse-parser.d.ts +9 -0
  42. package/dist/proxy/patch/safe-sse-parser.js +16 -0
  43. package/dist/proxy/patch/tool-round-limiter.d.ts +38 -0
  44. package/dist/proxy/patch/tool-round-limiter.js +115 -0
  45. package/dist/proxy/pipeline-snapshot.d.ts +4 -0
  46. package/dist/proxy/proxy-core.js +1 -0
  47. package/dist/proxy/proxy-logging.d.ts +1 -1
  48. package/dist/proxy/proxy-logging.js +3 -3
  49. package/dist/proxy/routing/enhancement-config.d.ts +1 -0
  50. package/dist/proxy/routing/enhancement-config.js +2 -0
  51. package/dist/proxy/transform/id-utils.d.ts +3 -0
  52. package/dist/proxy/transform/id-utils.js +9 -0
  53. package/dist/proxy/transform/message-mapper.d.ts +15 -0
  54. package/dist/proxy/transform/message-mapper.js +173 -0
  55. package/dist/proxy/transform/plugin-registry.d.ts +23 -0
  56. package/dist/proxy/transform/plugin-registry.js +130 -0
  57. package/dist/proxy/transform/plugin-types.d.ts +46 -0
  58. package/dist/proxy/transform/plugin-types.js +15 -0
  59. package/dist/proxy/transform/provider-meta.d.ts +29 -0
  60. package/dist/proxy/transform/provider-meta.js +72 -0
  61. package/dist/proxy/transform/request-transform.d.ts +4 -0
  62. package/dist/proxy/transform/request-transform.js +151 -0
  63. package/dist/proxy/transform/response-transform.d.ts +4 -0
  64. package/dist/proxy/transform/response-transform.js +99 -0
  65. package/dist/proxy/transform/sanitize.d.ts +3 -0
  66. package/dist/proxy/transform/sanitize.js +24 -0
  67. package/dist/proxy/transform/stream-ant2oa.d.ts +20 -0
  68. package/dist/proxy/transform/stream-ant2oa.js +200 -0
  69. package/dist/proxy/transform/stream-oa2ant.d.ts +25 -0
  70. package/dist/proxy/transform/stream-oa2ant.js +201 -0
  71. package/dist/proxy/transform/stream-transform-base.d.ts +19 -0
  72. package/dist/proxy/transform/stream-transform-base.js +61 -0
  73. package/dist/proxy/transform/thinking-mapper.d.ts +4 -0
  74. package/dist/proxy/transform/thinking-mapper.js +15 -0
  75. package/dist/proxy/transform/tool-mapper.d.ts +8 -0
  76. package/dist/proxy/transform/tool-mapper.js +67 -0
  77. package/dist/proxy/transform/transform-coordinator.d.ts +11 -0
  78. package/dist/proxy/transform/transform-coordinator.js +32 -0
  79. package/dist/proxy/transform/types.d.ts +43 -0
  80. package/dist/proxy/transform/types.js +1 -0
  81. package/dist/proxy/transform/usage-mapper.d.ts +8 -0
  82. package/dist/proxy/transform/usage-mapper.js +46 -0
  83. package/dist/proxy/transport/stream.d.ts +1 -1
  84. package/dist/proxy/transport/stream.js +19 -10
  85. package/dist/proxy/transport/transport-fn.d.ts +3 -0
  86. package/dist/proxy/transport/transport-fn.js +11 -4
  87. package/dist/storage/log-file-compressor.js +5 -6
  88. package/dist/storage/log-file-writer.js +11 -13
  89. package/dist/storage/types.d.ts +2 -0
  90. package/dist/storage/types.js +7 -0
  91. package/frontend-dist/assets/{CardContent-CxOF1feY.js → CardContent-BVMQ2_pg.js} +1 -1
  92. package/frontend-dist/assets/{CardTitle-BSEFcEOM.js → CardTitle-GLv7QyIY.js} +1 -1
  93. package/frontend-dist/assets/{CascadingModelSelect-DTwksDPZ.js → CascadingModelSelect-CBhqKFDX.js} +1 -1
  94. package/frontend-dist/assets/{Checkbox-RfsERG07.js → Checkbox-HPVDmEdV.js} +1 -1
  95. package/frontend-dist/assets/{CollapsibleTrigger-Dsjo7QlC.js → CollapsibleTrigger-DhxD9tpM.js} +1 -1
  96. package/frontend-dist/assets/{Collection-rQ4eIYfa.js → Collection-BRt7YxN8.js} +1 -1
  97. package/frontend-dist/assets/{Dashboard-YejfAPiB.js → Dashboard-D1Ys8Zog.js} +1 -1
  98. package/frontend-dist/assets/{DialogTitle-DeFTnmgC.js → DialogTitle-23q73lwF.js} +1 -1
  99. package/frontend-dist/assets/{Input-CENz_g9t.js → Input-CAnKUBBK.js} +1 -1
  100. package/frontend-dist/assets/{Label-BAciBrrd.js → Label-DWdYtVMI.js} +1 -1
  101. package/frontend-dist/assets/{Login-DQkYFq7R.js → Login-w5WFOinP.js} +1 -1
  102. package/frontend-dist/assets/{Logs-Dol8AX7z.js → Logs-C1F1ZmWF.js} +1 -1
  103. package/frontend-dist/assets/{ModelMappings-VEYW1TrW.js → ModelMappings-BzmecWEH.js} +1 -1
  104. package/frontend-dist/assets/{Monitor-C0r9WefB.js → Monitor-DrAZFTKR.js} +1 -1
  105. package/frontend-dist/assets/{PopoverTrigger-Cyqik5SE.js → PopoverTrigger-Bj65uUbv.js} +1 -1
  106. package/frontend-dist/assets/{PopperContent-B7IuAHeq.js → PopperContent-gzzf1XHe.js} +1 -1
  107. package/frontend-dist/assets/Providers-DSgf4mb6.js +1 -0
  108. package/frontend-dist/assets/ProxyEnhancement-Bb1cCP6d.js +5 -0
  109. package/frontend-dist/assets/{RetryRules-F0295m4_.js → RetryRules-BwPfEZtm.js} +1 -1
  110. package/frontend-dist/assets/{RouterKeys-CFbPtUE_.js → RouterKeys-CzTSq1Mx.js} +1 -1
  111. package/frontend-dist/assets/{RovingFocusItem-D291Vjh8.js → RovingFocusItem-CXM_Yfkm.js} +1 -1
  112. package/frontend-dist/assets/{Schedules-DWhF3uod.js → Schedules-DVilCXrC.js} +1 -1
  113. package/frontend-dist/assets/{SelectValue-BWlgUZa3.js → SelectValue-C0-LzGQY.js} +1 -1
  114. package/frontend-dist/assets/{Settings-BnIzEF_k.js → Settings-Bpk53zVX.js} +1 -1
  115. package/frontend-dist/assets/{Setup-BglKyQKq.js → Setup-Dn7EgC49.js} +1 -1
  116. package/frontend-dist/assets/{Switch-DyCR-CPu.js → Switch-BO8Ooae6.js} +1 -1
  117. package/frontend-dist/assets/{TableHeader-DVUlBL35.js → TableHeader-Bded9VTC.js} +1 -1
  118. package/frontend-dist/assets/{TabsTrigger-BU1DY-C8.js → TabsTrigger-BzKMi9AF.js} +1 -1
  119. package/frontend-dist/assets/{Teleport-BQgusr9g.js → Teleport-DizRK5O3.js} +1 -1
  120. package/frontend-dist/assets/{TooltipTrigger-Bv_QoBns.js → TooltipTrigger-EiIy2zn8.js} +1 -1
  121. package/frontend-dist/assets/{UnifiedRequestDialog-f_evI835.js → UnifiedRequestDialog-BABsTaGb.js} +1 -1
  122. package/frontend-dist/assets/{VisuallyHidden-Con10z4F.js → VisuallyHidden-5AozJQza.js} +1 -1
  123. package/frontend-dist/assets/{VisuallyHiddenInput-yrDtxucb.js → VisuallyHiddenInput-DdiZrV2i.js} +1 -1
  124. package/frontend-dist/assets/{alert-dialog-2Db6Z7JQ.js → alert-dialog-DlKUuTPe.js} +1 -1
  125. package/frontend-dist/assets/arrow-down-CxWKmZ2I.js +1 -0
  126. package/frontend-dist/assets/{badge-DEhZfeI0.js → badge-9KJEMa53.js} +1 -1
  127. package/frontend-dist/assets/button-Ul8WlrM5.js +12 -0
  128. package/frontend-dist/assets/check-7ahK--N4.js +1 -0
  129. package/frontend-dist/assets/{copy-CwqZSuIG.js → copy-DzU2pAMG.js} +1 -1
  130. package/frontend-dist/assets/{dialog-CVMKSdPr.js → dialog-B9j-FMrd.js} +1 -1
  131. package/frontend-dist/assets/{file-text-D0K8Hovo.js → file-text-Bj3ZIo-E.js} +1 -1
  132. package/frontend-dist/assets/index-Bz_ZaXNn.css +1 -0
  133. package/frontend-dist/assets/{index-Ct718O93.js → index-MedWZMHB.js} +1 -1
  134. package/frontend-dist/assets/{lib-H3YI7EK4.js → lib-Hhs3NqfD.js} +1 -1
  135. package/frontend-dist/assets/loader-circle-5TJUukEe.js +1 -0
  136. package/frontend-dist/assets/{useClipboard-Cd7k-5Yq.js → useClipboard-BmmsNSGV.js} +1 -1
  137. package/frontend-dist/assets/{useFocusGuards-luoLXnwV.js → useFocusGuards-A-9V2Y-b.js} +1 -1
  138. package/frontend-dist/assets/useFormControl-DEO19lRe.js +1 -0
  139. package/frontend-dist/assets/{useLogRetention-DB4Iu6o_.js → useLogRetention-BfnBFZ5K.js} +1 -1
  140. package/frontend-dist/assets/useNonce-BfwUJ1Ci.js +1 -0
  141. package/frontend-dist/assets/x-Cfopt3QL.js +1 -0
  142. package/frontend-dist/index.html +20 -20
  143. package/package.json +1 -1
  144. package/frontend-dist/assets/Providers-D8Z97edN.js +0 -1
  145. package/frontend-dist/assets/ProxyEnhancement-Kn8r2SN6.js +0 -5
  146. package/frontend-dist/assets/arrow-down-WyouvE7T.js +0 -1
  147. package/frontend-dist/assets/button-Cnkbp_6J.js +0 -12
  148. package/frontend-dist/assets/check-BuqB5Nyb.js +0 -1
  149. package/frontend-dist/assets/index-xjdbFKXJ.css +0 -1
  150. package/frontend-dist/assets/loader-circle-Be82FnVY.js +0 -1
  151. package/frontend-dist/assets/useFormControl-Da4ViGZF.js +0 -1
  152. package/frontend-dist/assets/useNonce-DvAdQ48J.js +0 -1
  153. package/frontend-dist/assets/x-DB22csQl.js +0 -1
@@ -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 });
@@ -10,6 +10,7 @@ interface AdminRoutesOptions {
10
10
  adaptiveController?: AdaptiveConcurrencyController;
11
11
  logFileWriter?: import("../storage/log-file-writer.js").LogFileWriter | null;
12
12
  logsDir?: string;
13
+ pluginRegistry?: import("../proxy/transform/plugin-registry.js").PluginRegistry;
13
14
  closeFn?: () => Promise<void>;
14
15
  }
15
16
  export declare const adminRoutes: FastifyPluginCallback<AdminRoutesOptions>;
@@ -15,6 +15,7 @@ import { adminRecommendedRoutes } from "./recommended.js";
15
15
  import { adminUsageRoutes } from "./usage.js";
16
16
  import { adminUpgradeRoutes } from "./upgrade.js";
17
17
  import { adminImportExportRoutes } from "./settings-import-export.js";
18
+ import { adminTransformRuleRoutes } from "./transform-rules.js";
18
19
  import { adminScheduleRoutes } from "./schedules.js";
19
20
  export const adminRoutes = (app, options, done) => {
20
21
  // Setup 路由不需要 auth
@@ -33,9 +34,10 @@ export const adminRoutes = (app, options, done) => {
33
34
  app.register(adminProxyEnhancementRoutes, { db: options.db, stateRegistry: options.stateRegistry });
34
35
  app.register(adminMonitorRoutes, { tracker: options.tracker });
35
36
  app.register(adminSettingsRoutes, { db: options.db, logsDir: options.logsDir });
36
- app.register(adminImportExportRoutes, { db: options.db, stateRegistry: options.stateRegistry });
37
+ app.register(adminImportExportRoutes, { db: options.db, stateRegistry: options.stateRegistry, pluginRegistry: options.pluginRegistry });
37
38
  app.register(adminRecommendedRoutes, { db: options.db });
38
39
  app.register(adminUsageRoutes, { db: options.db });
39
40
  app.register(adminUpgradeRoutes, { db: options.db, closeFn: options.closeFn ?? (async () => { }) });
41
+ app.register(adminTransformRuleRoutes, { db: options.db, pluginRegistry: options.pluginRegistry });
40
42
  done();
41
43
  };
@@ -4,6 +4,7 @@ import type { StateRegistry } from "../core/registry.js";
4
4
  interface ImportExportOptions {
5
5
  db: Database.Database;
6
6
  stateRegistry: StateRegistry;
7
+ pluginRegistry?: import("../proxy/transform/plugin-registry.js").PluginRegistry;
7
8
  }
8
9
  export declare const adminImportExportRoutes: FastifyPluginCallback<ImportExportOptions>;
9
10
  export {};
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "crypto";
2
+ import { resolve } from "path";
2
3
  import { encrypt, decrypt } from "../utils/crypto.js";
3
4
  import { getSetting } from "../db/settings.js";
4
5
  import { API_CODE, apiError } from "./api-response.js";
@@ -12,6 +13,7 @@ const CONFIG_TABLES = [
12
13
  "schedules",
13
14
  "provider_model_info",
14
15
  "session_model_history",
16
+ "provider_transform_rules",
15
17
  ];
16
18
  // settings 表按 key 列的值过滤,不覆盖本地安全敏感配置
17
19
  const PROTECTED_SETTING_KEYS = new Set(["admin_password_hash", "jwt_secret", "encryption_key"]);
@@ -129,6 +131,11 @@ export const adminImportExportRoutes = (app, options, done) => {
129
131
  stateRegistry.removeAllProviders();
130
132
  stateRegistry.clearModelState();
131
133
  stateRegistry.reinitializeProviders();
134
+ // 刷新 transform plugin 缓存(从 DB 重新加载规则 + 扫描插件目录)
135
+ if (options.pluginRegistry) {
136
+ const pluginsDir = resolve(process.cwd(), "plugins/transform");
137
+ options.pluginRegistry.reload(options.db, pluginsDir);
138
+ }
132
139
  return reply.send(counts);
133
140
  });
134
141
  done();
@@ -0,0 +1,8 @@
1
+ import type { FastifyPluginCallback } from "fastify";
2
+ import Database from "better-sqlite3";
3
+ interface TransformRuleOptions {
4
+ db: Database.Database;
5
+ pluginRegistry?: import("../proxy/transform/plugin-registry.js").PluginRegistry;
6
+ }
7
+ export declare const adminTransformRuleRoutes: FastifyPluginCallback<TransformRuleOptions>;
8
+ export {};
@@ -0,0 +1,38 @@
1
+ import { resolve } from "path";
2
+ import { getTransformRule, upsertTransformRule, deleteTransformRule, getAllActiveRules } from "../db/transform-rules.js";
3
+ const ALLOWED_FIELDS = new Set([
4
+ "inject_headers", "request_defaults", "drop_fields", "field_overrides", "plugin_name", "is_active",
5
+ ]);
6
+ export const adminTransformRuleRoutes = (app, options, done) => {
7
+ const { db } = options;
8
+ app.get("/admin/api/transform-rules/:providerId", async (req) => {
9
+ const { providerId } = req.params;
10
+ const rule = getTransformRule(db, providerId);
11
+ return { code: 0, message: "ok", data: rule };
12
+ });
13
+ app.put("/admin/api/transform-rules/:providerId", async (req) => {
14
+ const { providerId } = req.params;
15
+ const updates = {};
16
+ for (const [key, val] of Object.entries(req.body)) {
17
+ if (ALLOWED_FIELDS.has(key))
18
+ updates[key] = val;
19
+ }
20
+ upsertTransformRule(db, providerId, updates);
21
+ return { code: 0, message: "ok", data: { success: true } };
22
+ });
23
+ app.delete("/admin/api/transform-rules/:providerId", async (req) => {
24
+ const { providerId } = req.params;
25
+ deleteTransformRule(db, providerId);
26
+ return { code: 0, message: "ok", data: { success: true } };
27
+ });
28
+ app.post("/admin/api/transform-rules/reload", async () => {
29
+ if (options.pluginRegistry) {
30
+ const pluginsDir = resolve(process.cwd(), "plugins/transform");
31
+ const result = options.pluginRegistry.reload(options.db, pluginsDir);
32
+ return { code: 0, message: "ok", data: result };
33
+ }
34
+ const rules = getAllActiveRules(db);
35
+ return { code: 0, message: "ok", data: { loadedPlugins: [], rulesCount: rules.length } };
36
+ });
37
+ done();
38
+ };
@@ -1,6 +1,6 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import { getWindowsInRange, getWindowUsage } from "../db/usage-windows.js";
3
- import { getProviderById } from "../db/providers.js";
3
+ import { getProviderById } from "../db/index.js";
4
4
  import { resolveTimeRange } from "../utils/time-range.js";
5
5
  const UsageQuerySchema = Type.Object({
6
6
  router_key_id: Type.Optional(Type.String()),
@@ -7,6 +7,7 @@ export declare const SERVICE_KEYS: {
7
7
  readonly usageWindowTracker: "usageWindowTracker";
8
8
  readonly sessionTracker: "sessionTracker";
9
9
  readonly adaptiveController: "adaptiveController";
10
+ readonly pluginRegistry: "pluginRegistry";
10
11
  readonly logFileWriter: "logFileWriter";
11
12
  };
12
13
  export type ServiceKey = (typeof SERVICE_KEYS)[keyof typeof SERVICE_KEYS];
@@ -7,6 +7,7 @@ export const SERVICE_KEYS = {
7
7
  usageWindowTracker: "usageWindowTracker",
8
8
  sessionTracker: "sessionTracker",
9
9
  adaptiveController: "adaptiveController",
10
+ pluginRegistry: "pluginRegistry",
10
11
  logFileWriter: "logFileWriter",
11
12
  };
12
13
  /**
@@ -0,0 +1,11 @@
1
+ CREATE TABLE IF NOT EXISTS provider_transform_rules (
2
+ provider_id TEXT PRIMARY KEY REFERENCES providers(id) ON DELETE CASCADE,
3
+ inject_headers TEXT,
4
+ request_defaults TEXT,
5
+ drop_fields TEXT,
6
+ field_overrides TEXT,
7
+ plugin_name TEXT,
8
+ is_active INTEGER DEFAULT 1,
9
+ created_at TEXT DEFAULT (datetime('now')),
10
+ updated_at TEXT DEFAULT (datetime('now'))
11
+ );
@@ -0,0 +1,16 @@
1
+ import Database from "better-sqlite3";
2
+ export interface TransformRules {
3
+ provider_id: string;
4
+ inject_headers: Record<string, string> | null;
5
+ request_defaults: Record<string, unknown> | null;
6
+ drop_fields: string[] | null;
7
+ field_overrides: Record<string, unknown> | null;
8
+ plugin_name: string | null;
9
+ is_active: number;
10
+ created_at?: string;
11
+ updated_at?: string;
12
+ }
13
+ export declare function getTransformRule(db: Database.Database, providerId: string): TransformRules | null;
14
+ export declare function upsertTransformRule(db: Database.Database, providerId: string, rules: Partial<Omit<TransformRules, "provider_id">>): void;
15
+ export declare function deleteTransformRule(db: Database.Database, providerId: string): void;
16
+ export declare function getAllActiveRules(db: Database.Database): TransformRules[];
@@ -0,0 +1,51 @@
1
+ const JSON_COLUMNS = ["inject_headers", "request_defaults", "drop_fields", "field_overrides"];
2
+ function parseJsonColumns(row) {
3
+ const result = { ...row };
4
+ for (const col of JSON_COLUMNS) {
5
+ if (result[col]) {
6
+ try {
7
+ result[col] = JSON.parse(result[col]);
8
+ }
9
+ catch {
10
+ console.error(`[transform-rules] Failed to parse JSON column "${col}", keeping raw value`);
11
+ }
12
+ }
13
+ }
14
+ return result;
15
+ }
16
+ export function getTransformRule(db, providerId) {
17
+ const row = db.prepare("SELECT * FROM provider_transform_rules WHERE provider_id = ?").get(providerId);
18
+ if (!row)
19
+ return null;
20
+ return parseJsonColumns(row);
21
+ }
22
+ export function upsertTransformRule(db, providerId, rules) {
23
+ const existing = db.prepare("SELECT provider_id FROM provider_transform_rules WHERE provider_id = ?").get(providerId);
24
+ if (existing) {
25
+ const fields = [];
26
+ const values = [];
27
+ const jsonFields = new Set(["inject_headers", "request_defaults", "drop_fields", "field_overrides"]);
28
+ for (const [key, val] of Object.entries(rules)) {
29
+ if (key === "provider_id")
30
+ continue;
31
+ fields.push(`${key} = ?`);
32
+ values.push(jsonFields.has(key) && val ? JSON.stringify(val) : val);
33
+ }
34
+ if (fields.length === 0)
35
+ return;
36
+ fields.push("updated_at = datetime('now')");
37
+ values.push(providerId);
38
+ db.prepare(`UPDATE provider_transform_rules SET ${fields.join(", ")} WHERE provider_id = ?`).run(...values);
39
+ }
40
+ else {
41
+ db.prepare(`INSERT INTO provider_transform_rules (provider_id, inject_headers, request_defaults, drop_fields, field_overrides, plugin_name, is_active)
42
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(providerId, rules.inject_headers ? JSON.stringify(rules.inject_headers) : null, rules.request_defaults ? JSON.stringify(rules.request_defaults) : null, rules.drop_fields ? JSON.stringify(rules.drop_fields) : null, rules.field_overrides ? JSON.stringify(rules.field_overrides) : null, rules.plugin_name ?? null, rules.is_active ?? 1);
43
+ }
44
+ }
45
+ export function deleteTransformRule(db, providerId) {
46
+ db.prepare("DELETE FROM provider_transform_rules WHERE provider_id = ?").run(providerId);
47
+ }
48
+ export function getAllActiveRules(db) {
49
+ const rows = db.prepare("SELECT * FROM provider_transform_rules WHERE is_active = 1").all();
50
+ return rows.map(parseJsonColumns);
51
+ }
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ import { openaiProxy } from "./proxy/handler/openai.js";
19
19
  import { anthropicProxy } from "./proxy/handler/anthropic.js";
20
20
  import { adminRoutes } from "./admin/routes.js";
21
21
  import { RetryRuleMatcher } from "./proxy/orchestration/retry-rules.js";
22
+ import { PluginRegistry } from "./proxy/transform/plugin-registry.js";
22
23
  import { ProviderSemaphoreManager } from "./proxy/orchestration/semaphore.js";
23
24
  import { AdaptiveConcurrencyController } from "./proxy/adaptive-controller.js";
24
25
  import { loadEnhancementConfig } from "./proxy/routing/enhancement-config.js";
@@ -210,6 +211,12 @@ export async function buildApp(options) {
210
211
  const ac = new AdaptiveConcurrencyController(c.resolve(SERVICE_KEYS.semaphoreManager), app.log);
211
212
  return ac;
212
213
  });
214
+ // 注册 PluginRegistry(从 DB 和 plugins 目录加载转换插件)
215
+ const pluginRegistry = new PluginRegistry();
216
+ pluginRegistry.loadFromDB(db);
217
+ const pluginsDir = path.resolve(__dirname, "../plugins/transform");
218
+ pluginRegistry.scanPluginsDir(pluginsDir);
219
+ container.register(SERVICE_KEYS.pluginRegistry, () => pluginRegistry);
213
220
  // 从容器解析所有服务
214
221
  const matcher = container.resolve(SERVICE_KEYS.matcher);
215
222
  const semaphoreManager = container.resolve(SERVICE_KEYS.semaphoreManager);
@@ -244,7 +251,7 @@ export async function buildApp(options) {
244
251
  // Late-bound close ref — close 函数在 adminRoutes 注册之后才定义,
245
252
  // 但 restart API 需要在运行时调用它
246
253
  const closeRef = { fn: async () => { } };
247
- app.register(adminRoutes, { db, stateRegistry, tracker, adaptiveController, logFileWriter, logsDir, closeFn: () => closeRef.fn() });
254
+ app.register(adminRoutes, { db, stateRegistry, tracker, adaptiveController, logFileWriter, logsDir, closeFn: () => closeRef.fn(), pluginRegistry });
248
255
  // 前端静态文件服务(生产环境)
249
256
  const frontendDist = path.resolve(process.env.FRONTEND_DIST || path.join(__dirname, "../frontend-dist"));
250
257
  if (existsSync(frontendDist)) {
@@ -272,11 +279,17 @@ export async function buildApp(options) {
272
279
  const dbSizeMonitor = scheduleDbSizeMonitor(db, config.DB_PATH, {
273
280
  log: app.log,
274
281
  });
282
+ let closed = false;
275
283
  let close = async () => {
284
+ if (closed)
285
+ return;
286
+ closed = true;
276
287
  stopUpgradeChecker();
277
288
  logCleanup.stop();
278
289
  dbSizeMonitor.stop();
279
290
  tracker.stopPushInterval();
291
+ // 关闭所有 SSE 长连接,防止 app.close() 因 hijack 的连接无限等待
292
+ tracker.closeAllClients();
280
293
  modelState.clearAll();
281
294
  semaphoreManager.removeAll();
282
295
  const sessionTracker = container.resolve(SERVICE_KEYS.sessionTracker);
@@ -332,7 +345,22 @@ export async function main() {
332
345
  /* eslint-enable taste/no-silent-catch */
333
346
  });
334
347
  // 优雅关闭:SIGTERM(systemd/docker stop)和 SIGINT(Ctrl+C)
348
+ let isShuttingDown = false;
349
+ const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 10_000;
335
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();
336
364
  try {
337
365
  app.log.info(`Received ${signal}, shutting down gracefully...`);
338
366
  await close();
@@ -341,6 +369,7 @@ export async function main() {
341
369
  catch (err) {
342
370
  app.log.error({ err }, "Error during shutdown");
343
371
  }
372
+ clearTimeout(forceTimer);
344
373
  process.exit(0);
345
374
  };
346
375
  process.on("SIGTERM", () => shutdown("SIGTERM"));
@@ -11,6 +11,8 @@ export interface SSEEvent {
11
11
  export declare class SSEParser {
12
12
  private buffer;
13
13
  isDone: boolean;
14
+ /** 当前未解析缓冲区长度,子类可据此做内存保护 */
15
+ get bufferLength(): number;
14
16
  feed(chunk: string): SSEEvent[];
15
17
  flush(): SSEEvent[];
16
18
  private drainEvents;
@@ -1,6 +1,10 @@
1
1
  export class SSEParser {
2
2
  buffer = "";
3
3
  isDone = false;
4
+ /** 当前未解析缓冲区长度,子类可据此做内存保护 */
5
+ get bufferLength() {
6
+ return this.buffer.length;
7
+ }
4
8
  feed(chunk) {
5
9
  if (this.isDone)
6
10
  return [];
@@ -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)
@@ -224,7 +238,14 @@ export class RequestTracker {
224
238
  // request_start: 无需处理,已是原始数据
225
239
  // request_complete: strip clientRequest(完成后从 DB 加载详情)
226
240
  let payload = data;
227
- if (event === "request_complete" && data && typeof data === "object") {
241
+ if (event === "request_update" && Array.isArray(data)) {
242
+ payload = data.map((req) => {
243
+ const copy = { ...req };
244
+ delete copy.clientRequest;
245
+ return copy;
246
+ });
247
+ }
248
+ else if ((event === "request_complete" || event === "request_start") && data && typeof data === "object") {
228
249
  const copy = { ...data };
229
250
  delete copy.clientRequest;
230
251
  payload = copy;
@@ -1,5 +1,5 @@
1
1
  export interface ContentBlock {
2
- type: 'thinking' | 'text' | 'tool_use';
2
+ type: 'thinking' | 'text' | 'tool_use' | 'tool_result';
3
3
  content: string;
4
4
  name?: string;
5
5
  }
@@ -40,19 +40,27 @@ export function cleanRouterResponses(body) {
40
40
  }
41
41
  if (msg.role === "assistant") {
42
42
  const blocks = Array.isArray(msg.content) ? msg.content : [msg.content];
43
- // 清理 router synthetic AskUserQuestion 的 tool_use 消息
43
+ // 清理 router synthetic AskUserQuestion 的 tool_use 消息(Anthropic content blocks)
44
44
  const toolUseBlocks = blocks.filter((b) => b && typeof b === "object" && b.type === "tool_use"
45
45
  && b.name === "AskUserQuestion"
46
46
  && typeof b.id === "string"
47
47
  && b.id.startsWith(TOOL_USE_ID_PREFIX));
48
48
  if (toolUseBlocks.length > 0 && toolUseBlocks.length === blocks.length)
49
49
  return false;
50
- const texts = blocks
51
- .filter((b) => b?.type === "text" && typeof b.text === "string")
52
- .map((b) => b.text);
53
- const combined = texts.join("");
50
+ // 提取文本:兼容 Anthropic content blocks 和 OpenAI 纯字符串
51
+ const textParts = [];
52
+ for (const b of blocks) {
53
+ if (typeof b === "string") {
54
+ textParts.push(b);
55
+ }
56
+ else if (b && typeof b === "object" && b.type === "text" && typeof b.text === "string") {
57
+ textParts.push(b.text);
58
+ }
59
+ }
60
+ const combined = textParts.join("");
54
61
  const stripped = combined.replace(RE_ROUTER_RESPONSE, "").trim();
55
- if (!stripped)
62
+ // 有 tool_calls 的消息即使 text 为空也保留(OpenAI 格式)
63
+ if (!stripped && !msg.tool_calls)
56
64
  return false;
57
65
  }
58
66
  return true;
@@ -10,6 +10,9 @@ import { HTTP_NOT_FOUND, HTTP_BAD_GATEWAY } from "../../core/constants.js";
10
10
  import { SERVICE_KEYS } from "../../core/container.js";
11
11
  const CHAT_COMPLETIONS_PATH = "/v1/chat/completions";
12
12
  const MODELS_PATH = "/v1/models";
13
+ /** OpenAI 兼容路径(不带 /v1 前缀),供部分客户端使用 */
14
+ const CHAT_COMPLETIONS_COMPAT_PATH = "/chat/completions";
15
+ const MODELS_COMPAT_PATH = "/models";
13
16
  const OPENAI_ERROR_META = {
14
17
  modelNotFound: { type: "invalid_request_error", code: "model_not_found" },
15
18
  modelNotAllowed: { type: "invalid_request_error", code: "model_not_allowed" },
@@ -27,7 +30,7 @@ function sendError(reply, e) {
27
30
  const openaiProxyRaw = (app, opts, done) => {
28
31
  const { db, container } = opts;
29
32
  const orchestrator = createOrchestrator(container.resolve(SERVICE_KEYS.semaphoreManager), container.resolve(SERVICE_KEYS.tracker), container.resolve(SERVICE_KEYS.adaptiveController));
30
- app.post(CHAT_COMPLETIONS_PATH, async (request, reply) => {
33
+ const handleChatCompletions = async (request, reply) => {
31
34
  if (!orchestrator) {
32
35
  const body = request.body;
33
36
  insertRequestLog(db, {
@@ -48,8 +51,11 @@ const openaiProxyRaw = (app, opts, done) => {
48
51
  }
49
52
  },
50
53
  });
51
- });
52
- app.get(MODELS_PATH, async (request, reply) => {
54
+ };
55
+ // 规范路径 + 兼容路径(不带 /v1 前缀)
56
+ app.post(CHAT_COMPLETIONS_PATH, handleChatCompletions);
57
+ app.post(CHAT_COMPLETIONS_COMPAT_PATH, handleChatCompletions);
58
+ const handleModels = async (request, reply) => {
53
59
  const startTime = Date.now();
54
60
  const providers = getActiveProviders(db, "openai");
55
61
  if (providers.length === 0) {
@@ -97,7 +103,10 @@ const openaiProxyRaw = (app, opts, done) => {
97
103
  body: { error: { message: "Failed to reach backend service", type: "server_error", code: "upstream_error" } },
98
104
  });
99
105
  }
100
- });
106
+ };
107
+ // 规范路径 + 兼容路径
108
+ app.get(MODELS_PATH, handleModels);
109
+ app.get(MODELS_COMPAT_PATH, handleModels);
101
110
  done();
102
111
  };
103
112
  export const openaiProxy = fp(openaiProxyRaw, { name: "openai-proxy" });
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "crypto";
2
+ import { parseToolArguments } from "../transform/sanitize.js";
2
3
  const HASH_DIGEST_LENGTH = 16;
3
4
  /** 从 TransportResult 中提取最终 HTTP status code */
4
5
  export function getTransportStatusCode(result) {
@@ -17,13 +18,7 @@ export function serializeBlocksForStorage(blocks, apiType) {
17
18
  if (b.type === "thinking")
18
19
  return { type: "thinking", thinking: b.content };
19
20
  if (b.type === "tool_use") {
20
- let input = {};
21
- // eslint-disable-next-line taste/no-silent-catch
22
- try {
23
- input = JSON.parse(b.content || "{}");
24
- }
25
- catch { /* tool_use content 非合法 JSON 时保留空对象 */ }
26
- return { type: "tool_use", name: b.name ?? "", input };
21
+ return { type: "tool_use", name: b.name ?? "", input: parseToolArguments(b.content) };
27
22
  }
28
23
  return { type: "text", text: b.content };
29
24
  });