llm-simple-router 0.9.32 → 0.10.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 (143) hide show
  1. package/dist/admin/providers.js +1 -1
  2. package/dist/admin/routes.js +5 -0
  3. package/dist/admin/schedules.js +1 -1
  4. package/dist/admin/upgrade.js +3 -3
  5. package/dist/config/model-context.js +21 -9
  6. package/dist/config/recommended.js +2 -2
  7. package/dist/core/constants.d.ts +1 -0
  8. package/dist/core/constants.js +1 -0
  9. package/dist/core/container.d.ts +1 -0
  10. package/dist/core/container.js +1 -0
  11. package/dist/db/db-size-monitor.js +1 -1
  12. package/dist/db/index.js +1 -0
  13. package/dist/db/log-cleaner.js +1 -1
  14. package/dist/db/mappings.js +6 -15
  15. package/dist/db/providers.d.ts +1 -0
  16. package/dist/db/providers.js +11 -26
  17. package/dist/db/router-keys.js +4 -5
  18. package/dist/db/settings.js +3 -3
  19. package/dist/index.js +44 -8
  20. package/dist/proxy/format/adapters/anthropic.d.ts +2 -0
  21. package/dist/proxy/format/adapters/anthropic.js +18 -0
  22. package/dist/proxy/format/adapters/openai.d.ts +2 -0
  23. package/dist/proxy/format/adapters/openai.js +14 -0
  24. package/dist/proxy/format/adapters/responses.d.ts +2 -0
  25. package/dist/proxy/format/adapters/responses.js +9 -0
  26. package/dist/proxy/format/adapters/shared-error-meta.d.ts +9 -0
  27. package/dist/proxy/format/adapters/shared-error-meta.js +14 -0
  28. package/dist/proxy/format/converters/anthropic-openai.d.ts +1 -0
  29. package/dist/proxy/format/converters/anthropic-openai.js +11 -0
  30. package/dist/proxy/format/converters/anthropic-responses.d.ts +1 -0
  31. package/dist/proxy/format/converters/anthropic-responses.js +11 -0
  32. package/dist/proxy/format/converters/openai-anthropic.d.ts +1 -0
  33. package/dist/proxy/format/converters/openai-anthropic.js +11 -0
  34. package/dist/proxy/format/converters/openai-responses.d.ts +1 -0
  35. package/dist/proxy/format/converters/openai-responses.js +11 -0
  36. package/dist/proxy/format/converters/responses-anthropic.d.ts +1 -0
  37. package/dist/proxy/format/converters/responses-anthropic.js +11 -0
  38. package/dist/proxy/format/converters/responses-openai.d.ts +1 -0
  39. package/dist/proxy/format/converters/responses-openai.js +11 -0
  40. package/dist/proxy/format/registry.d.ts +17 -0
  41. package/dist/proxy/format/registry.js +50 -0
  42. package/dist/proxy/format/types.d.ts +27 -0
  43. package/dist/proxy/format/types.js +16 -0
  44. package/dist/proxy/handler/create-proxy-handler.d.ts +14 -0
  45. package/dist/proxy/handler/create-proxy-handler.js +235 -0
  46. package/dist/proxy/handler/failover-loop.d.ts +19 -0
  47. package/dist/proxy/handler/failover-loop.js +407 -0
  48. package/dist/proxy/handler/proxy-handler-utils.js +1 -1
  49. package/dist/proxy/hooks/builtin/allowed-models.d.ts +12 -0
  50. package/dist/proxy/hooks/builtin/allowed-models.js +37 -0
  51. package/dist/proxy/hooks/builtin/enhancement-preprocess.d.ts +2 -0
  52. package/dist/proxy/hooks/builtin/enhancement-preprocess.js +84 -0
  53. package/dist/proxy/hooks/builtin/error-logging.d.ts +2 -0
  54. package/dist/proxy/hooks/builtin/error-logging.js +86 -0
  55. package/dist/proxy/hooks/builtin/overflow-redirect.d.ts +2 -0
  56. package/dist/proxy/hooks/builtin/overflow-redirect.js +39 -0
  57. package/dist/proxy/hooks/builtin/plugin-request.d.ts +2 -0
  58. package/dist/proxy/hooks/builtin/plugin-request.js +49 -0
  59. package/dist/proxy/hooks/builtin/provider-patches.d.ts +2 -0
  60. package/dist/proxy/hooks/builtin/provider-patches.js +36 -0
  61. package/dist/proxy/hooks/builtin/request-logging.d.ts +2 -0
  62. package/dist/proxy/hooks/builtin/request-logging.js +72 -0
  63. package/dist/proxy/hooks/plugin-bridge.d.ts +7 -0
  64. package/dist/proxy/hooks/plugin-bridge.js +106 -0
  65. package/dist/proxy/hooks/sse-event-transform.d.ts +13 -0
  66. package/dist/proxy/hooks/sse-event-transform.js +59 -0
  67. package/dist/proxy/orchestration/resilience.js +2 -3
  68. package/dist/proxy/patch/deepseek/patch-orphan-tool-results.js +1 -1
  69. package/dist/proxy/patch/deepseek/utils.js +1 -1
  70. package/dist/proxy/pipeline/context.d.ts +3 -0
  71. package/dist/proxy/pipeline/context.js +31 -0
  72. package/dist/proxy/pipeline/hook-registry.d.ts +20 -0
  73. package/dist/proxy/pipeline/hook-registry.js +24 -0
  74. package/dist/proxy/pipeline/pipeline.d.ts +13 -0
  75. package/dist/proxy/pipeline/pipeline.js +26 -0
  76. package/dist/proxy/pipeline/register-hooks.d.ts +1 -0
  77. package/dist/proxy/pipeline/register-hooks.js +23 -0
  78. package/dist/proxy/pipeline/types.d.ts +63 -0
  79. package/dist/proxy/pipeline/types.js +10 -0
  80. package/dist/proxy/proxy-core.js +1 -1
  81. package/dist/proxy/routing/mapping-resolver.js +7 -16
  82. package/dist/proxy/transform/message-mapper.js +8 -8
  83. package/dist/proxy/transform/plugin-types.d.ts +47 -17
  84. package/dist/proxy/transform/request-bridge-responses.js +21 -21
  85. package/dist/proxy/transform/request-transform-responses.js +24 -24
  86. package/dist/proxy/transform/request-transform.js +1 -3
  87. package/dist/proxy/transform/response-bridge-responses.js +13 -13
  88. package/dist/proxy/transform/response-transform-responses.js +10 -10
  89. package/dist/proxy/transform/sanitize.js +5 -1
  90. package/dist/proxy/transform/stream-transform-base.js +2 -2
  91. package/dist/proxy/transport/transport-fn.js +0 -1
  92. package/frontend-dist/assets/{CardContent-D3x1v1V9.js → CardContent-eu5DgvQn.js} +1 -1
  93. package/frontend-dist/assets/{CardTitle-DIgY93n2.js → CardTitle-B8YCOfF5.js} +1 -1
  94. package/frontend-dist/assets/{Checkbox-BBj7YMSz.js → Checkbox-BMzIHy1r.js} +1 -1
  95. package/frontend-dist/assets/{CollapsibleContent-Cy8Zj7F7.js → CollapsibleContent-C_nFgqIi.js} +1 -1
  96. package/frontend-dist/assets/{CollapsibleTrigger-Vdxg8CPQ.js → CollapsibleTrigger-CPqhHT7X.js} +1 -1
  97. package/frontend-dist/assets/{Dashboard-BvUBgYaB.js → Dashboard-BLxKSSXs.js} +1 -1
  98. package/frontend-dist/assets/{Input-yOkodP1n.js → Input-DkWAdwBI.js} +1 -1
  99. package/frontend-dist/assets/{Label-XqpkYcVi.js → Label-CJ2IIzlx.js} +1 -1
  100. package/frontend-dist/assets/Login-Dw4f6v19.js +1 -0
  101. package/frontend-dist/assets/{Logs-bfeOLJad.js → Logs-Df503HB_.js} +1 -1
  102. package/frontend-dist/assets/{MappingEntryEditor-CMTbBK_d.js → MappingEntryEditor-Dg6zjLjk.js} +1 -1
  103. package/frontend-dist/assets/{ModelCard-C81l2a_5.js → ModelCard-Ct1-_CKo.js} +1 -1
  104. package/frontend-dist/assets/ModelMappings-QsbYO-BL.js +1 -0
  105. package/frontend-dist/assets/{Monitor-BGH6sjdr.js → Monitor-DIiuMy53.js} +1 -1
  106. package/frontend-dist/assets/{Providers-UK6LN6DF.js → Providers-NyDmz-Aa.js} +1 -1
  107. package/frontend-dist/assets/{ProxyEnhancement-MX0PRADd.js → ProxyEnhancement-DNtlZsDS.js} +1 -1
  108. package/frontend-dist/assets/QuickSetup-Ce3GmdB-.js +1 -0
  109. package/frontend-dist/assets/{RetryRules-gpvpTTSc.js → RetryRules-EqbyA2z0.js} +1 -1
  110. package/frontend-dist/assets/{RouterKeys-Dc8D6cEV.js → RouterKeys-DMKJn6FW.js} +1 -1
  111. package/frontend-dist/assets/{RovingFocusItem-B3BLvyzD.js → RovingFocusItem-CebAv0Zc.js} +1 -1
  112. package/frontend-dist/assets/Schedules-DxHuzar_.js +1 -0
  113. package/frontend-dist/assets/{Settings-BiYdwrHf.js → Settings-DwULwXPR.js} +2 -2
  114. package/frontend-dist/assets/{Setup-BVqlQCjs.js → Setup-DNhGIF-c.js} +1 -1
  115. package/frontend-dist/assets/{Switch-B7QaMMlY.js → Switch-ChIi3Y2O.js} +1 -1
  116. package/frontend-dist/assets/{TooltipTrigger-Bz3pu02g.js → TooltipTrigger-BcK9IvPW.js} +1 -1
  117. package/frontend-dist/assets/{TransformRulesForm-DXB1ChZ2.js → TransformRulesForm-CXJhbKc5.js} +1 -1
  118. package/frontend-dist/assets/{UnifiedRequestDialog-BbGcsFXP.js → UnifiedRequestDialog-I_J8h6X5.js} +1 -1
  119. package/frontend-dist/assets/{VisuallyHiddenInput-MTmHNjqA.js → VisuallyHiddenInput-awNWLd76.js} +1 -1
  120. package/frontend-dist/assets/{button-lv9v_6nd.js → button-C50lT23d.js} +2 -2
  121. package/frontend-dist/assets/{copy-Dwbg7SpV.js → copy-BHunr2eT.js} +1 -1
  122. package/frontend-dist/assets/{dialog-ZNKLyaHg.js → dialog-Bts2bGWX.js} +1 -1
  123. package/frontend-dist/assets/{index-CDEwS862.js → index-CGNAzHFe.js} +2 -2
  124. package/frontend-dist/assets/{trash-2-UQ53WECI.js → trash-2-CkUDjZuE.js} +1 -1
  125. package/frontend-dist/assets/{useClipboard-CmrKRWL_.js → useClipboard-C3rvTuuE.js} +1 -1
  126. package/frontend-dist/assets/useLogRetention-DL5cOZfI.js +1 -0
  127. package/frontend-dist/index.html +2 -2
  128. package/package.json +1 -1
  129. package/dist/proxy/handler/anthropic.d.ts +0 -7
  130. package/dist/proxy/handler/anthropic.js +0 -43
  131. package/dist/proxy/handler/openai.d.ts +0 -7
  132. package/dist/proxy/handler/openai.js +0 -131
  133. package/dist/proxy/handler/proxy-handler.d.ts +0 -15
  134. package/dist/proxy/handler/proxy-handler.js +0 -430
  135. package/dist/proxy/handler/responses.d.ts +0 -7
  136. package/dist/proxy/handler/responses.js +0 -48
  137. package/dist/proxy/transform/transform-coordinator.d.ts +0 -12
  138. package/dist/proxy/transform/transform-coordinator.js +0 -151
  139. package/frontend-dist/assets/Login-BGagonui.js +0 -1
  140. package/frontend-dist/assets/ModelMappings-CvDWSsDP.js +0 -1
  141. package/frontend-dist/assets/QuickSetup-CajStS5C.js +0 -1
  142. package/frontend-dist/assets/Schedules-Criu9-NO.js +0 -1
  143. package/frontend-dist/assets/useLogRetention-C59RXKSz.js +0 -1
@@ -418,7 +418,7 @@ export const adminProviderRoutes = (app, options, done) => {
418
418
  return reply.send(modelIds);
419
419
  }
420
420
  catch (err) {
421
- const message = err instanceof Error ? err.message : String(err);
421
+ const message = err instanceof Error ? err.message : err instanceof Error ? err.message : JSON.stringify(err);
422
422
  return reply.code(HTTP_BAD_REQUEST).send(apiError(API_CODE.BAD_REQUEST, `连接上游失败: ${message}`));
423
423
  }
424
424
  });
@@ -18,6 +18,7 @@ import { adminQuickSetupRoutes } from "./quick-setup.js";
18
18
  import { adminImportExportRoutes } from "./settings-import-export.js";
19
19
  import { adminTransformRuleRoutes } from "./transform-rules.js";
20
20
  import { adminScheduleRoutes } from "./schedules.js";
21
+ import { hookRegistry } from "../proxy/pipeline/hook-registry.js";
21
22
  export const adminRoutes = (app, options, done) => {
22
23
  // Setup 路由不需要 auth
23
24
  app.register(adminSetupRoutes, { db: options.db });
@@ -41,5 +42,9 @@ export const adminRoutes = (app, options, done) => {
41
42
  app.register(adminQuickSetupRoutes, { db: options.db, stateRegistry: options.stateRegistry, tracker: options.tracker, adaptiveController: options.adaptiveController });
42
43
  app.register(adminUpgradeRoutes, { db: options.db, closeFn: options.closeFn ?? (async () => { }) });
43
44
  app.register(adminTransformRuleRoutes, { db: options.db, pluginRegistry: options.pluginRegistry });
45
+ // Pipeline hooks 查询
46
+ app.get("/admin/api/pipeline/hooks", async () => {
47
+ return { hooks: hookRegistry.getAll() };
48
+ });
44
49
  done();
45
50
  };
@@ -95,7 +95,7 @@ function checkOverlap(db, groupId, excludeId, weekDays, startHour, endHour) {
95
95
  }
96
96
  const HOUR_PAD_WIDTH = 2;
97
97
  function formatHour(h) {
98
- return String(h).padStart(HOUR_PAD_WIDTH, "0") + ":00";
98
+ return h.toString().padStart(HOUR_PAD_WIDTH, "0") + ":00";
99
99
  }
100
100
  export const adminScheduleRoutes = (app, options, done) => {
101
101
  const { db } = options;
@@ -93,7 +93,7 @@ export const adminUpgradeRoutes = (app, options, done) => {
93
93
  return reply.send({ ok: true, version });
94
94
  }
95
95
  catch (err) {
96
- const msg = err instanceof Error ? err.message : String(err);
96
+ const msg = err instanceof Error ? err.message : err instanceof Error ? err.message : JSON.stringify(err);
97
97
  return reply.code(HTTP_INTERNAL_ERROR).send(apiError(API_CODE.INTERNAL_ERROR, `升级失败: ${msg}`));
98
98
  }
99
99
  });
@@ -125,7 +125,7 @@ export const adminUpgradeRoutes = (app, options, done) => {
125
125
  process.exit(0);
126
126
  }
127
127
  catch (err) {
128
- const msg = err instanceof Error ? err.message : String(err);
128
+ const msg = err instanceof Error ? err.message : err instanceof Error ? err.message : JSON.stringify(err);
129
129
  req.log.error({ err }, `Restart failed: ${msg}`);
130
130
  process.exit(1);
131
131
  }
@@ -162,7 +162,7 @@ export const adminUpgradeRoutes = (app, options, done) => {
162
162
  return reply.send({ ok: true });
163
163
  }
164
164
  catch (err) {
165
- const msg = err instanceof Error ? err.message : String(err);
165
+ const msg = err instanceof Error ? err.message : err instanceof Error ? err.message : JSON.stringify(err);
166
166
  return reply.code(HTTP_INTERNAL_ERROR).send(apiError(API_CODE.INTERNAL_ERROR, `同步失败: ${msg}`));
167
167
  }
168
168
  });
@@ -88,6 +88,22 @@ export function lookupContextWindow(modelName) {
88
88
  export function normalizePatchName(name) {
89
89
  return name.replace(/-/g, "_");
90
90
  }
91
+ /**
92
+ * 解析 providers.models 的 JSON 文本。
93
+ *
94
+ * 这是解析 providers.models 字段的唯一合法入口。
95
+ * 禁止直接 JSON.parse(provider.models) —— 数据格式已从 string[] 演进为 ModelEntry[],
96
+ * 直接 JSON.parse 会得到对象数组而非字符串数组,导致运行时错误。
97
+ *
98
+ * ESLint 规则 taste/no-raw-json-parse-models 会强制执行此约束。
99
+ */
100
+ /** 旧 patch ID 到新 patch ID 的迁移映射 */
101
+ const PATCH_ID_MIGRATION = {
102
+ thinking_param: "thinking_consistency",
103
+ thinking_blocks: "thinking_consistency",
104
+ non_ds_tools: "thinking_consistency",
105
+ cache_control: "thinking_consistency",
106
+ };
91
107
  export function parseModels(raw) {
92
108
  if (!raw)
93
109
  return [];
@@ -100,20 +116,16 @@ export function parseModels(raw) {
100
116
  return item ? { name: item, patches: [] } : null;
101
117
  }
102
118
  const obj = item;
103
- if (!obj || !obj.name)
119
+ if (!obj)
120
+ return null;
121
+ const modelName = obj.name ?? obj.id;
122
+ if (!modelName)
104
123
  return null;
105
- /** 旧 patch ID 到新 patch ID 的运行时迁移映射 */
106
- const PATCH_ID_MIGRATION = {
107
- thinking_param: "thinking_consistency",
108
- thinking_blocks: "thinking_consistency",
109
- non_ds_tools: "thinking_consistency",
110
- cache_control: "thinking_consistency",
111
- };
112
124
  const rawPatches = (obj.patches ?? []).map(normalizePatchName);
113
125
  const migrated = rawPatches.map(p => PATCH_ID_MIGRATION[p] ?? p);
114
126
  const patches = [...new Set(migrated)];
115
127
  const result = {
116
- name: obj.name,
128
+ name: modelName,
117
129
  patches,
118
130
  };
119
131
  if (obj.stream_timeout_ms != null)
@@ -12,7 +12,7 @@ function loadJson(filename) {
12
12
  return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
13
13
  }
14
14
  catch (err) {
15
- process.stderr.write(`[recommended] 加载 ${filename} 失败: ${err instanceof Error ? err.message : String(err)}\n`);
15
+ process.stderr.write(`[recommended] 加载 ${filename} 失败: ${err instanceof Error ? err.message : err instanceof Error ? err.message : JSON.stringify(err)}\n`);
16
16
  return [];
17
17
  }
18
18
  }
@@ -31,7 +31,7 @@ export function getConfigVersions() {
31
31
  return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
32
32
  }
33
33
  catch (err) {
34
- process.stderr.write(`[recommended] 加载 version.json 失败: ${err instanceof Error ? err.message : String(err)}\n`);
34
+ process.stderr.write(`[recommended] 加载 version.json 失败: ${err instanceof Error ? err.message : err instanceof Error ? err.message : JSON.stringify(err)}\n`);
35
35
  return { providers: 0, retryRules: 0 };
36
36
  }
37
37
  }
@@ -7,6 +7,7 @@ export declare const HTTP_CONFLICT = 409;
7
7
  export declare const HTTP_UNPROCESSABLE_ENTITY = 422;
8
8
  export declare const HTTP_INTERNAL_ERROR = 500;
9
9
  export declare const HTTP_BAD_GATEWAY = 502;
10
+ export declare const HTTP_CLIENT_CLOSED = 499;
10
11
  export declare const HTTP_SERVICE_UNAVAILABLE = 503;
11
12
  export declare const PROXY_API_TYPES: Record<string, string>;
12
13
  export declare function getProxyApiType(url: string): string | null;
@@ -9,6 +9,7 @@ export const HTTP_CONFLICT = 409;
9
9
  export const HTTP_UNPROCESSABLE_ENTITY = 422;
10
10
  export const HTTP_INTERNAL_ERROR = 500;
11
11
  export const HTTP_BAD_GATEWAY = 502;
12
+ export const HTTP_CLIENT_CLOSED = 499; // nginx convention: client disconnected
12
13
  export const HTTP_SERVICE_UNAVAILABLE = 503;
13
14
  // api_type 路由映射:proxy path → api type,用于全局 hook/errorHandler 中识别代理请求
14
15
  export const PROXY_API_TYPES = {
@@ -8,6 +8,7 @@ export declare const SERVICE_KEYS: {
8
8
  readonly sessionTracker: "sessionTracker";
9
9
  readonly adaptiveController: "adaptiveController";
10
10
  readonly pluginRegistry: "pluginRegistry";
11
+ readonly formatRegistry: "formatRegistry";
11
12
  readonly logFileWriter: "logFileWriter";
12
13
  readonly proxyAgentFactory: "proxyAgentFactory";
13
14
  };
@@ -8,6 +8,7 @@ export const SERVICE_KEYS = {
8
8
  sessionTracker: "sessionTracker",
9
9
  adaptiveController: "adaptiveController",
10
10
  pluginRegistry: "pluginRegistry",
11
+ formatRegistry: "formatRegistry",
11
12
  logFileWriter: "logFileWriter",
12
13
  proxyAgentFactory: "proxyAgentFactory",
13
14
  };
@@ -57,7 +57,7 @@ export function scheduleDbSizeMonitor(db, dbPath, options) {
57
57
  }
58
58
  catch (e) {
59
59
  // DB 可能已关闭(测试清理、进程关闭等)
60
- options.log.info(`Size monitor check skipped: ${e instanceof Error ? e.message : String(e)}`);
60
+ options.log.info(`Size monitor check skipped: ${e instanceof Error ? e.message : e instanceof Error ? e.message : JSON.stringify(e)}`);
61
61
  }
62
62
  finally {
63
63
  running = false;
package/dist/db/index.js CHANGED
@@ -105,6 +105,7 @@ function runApplicationMigrations(db) {
105
105
  db.transaction(() => {
106
106
  for (const p of providers) {
107
107
  try {
108
+ // eslint-disable-next-line taste/no-raw-json-parse-models -- 迁移代码需要操作原始 JSON 结构,parseModels() 会过滤非标准字段
108
109
  const raw = JSON.parse(p.models);
109
110
  if (!Array.isArray(raw) || raw.length === 0)
110
111
  continue;
@@ -29,7 +29,7 @@ export function scheduleLogCleanup(db, log) {
29
29
  }
30
30
  catch (e) {
31
31
  // DB 可能已关闭(测试清理、进程关闭等)
32
- log.info(`Log cleanup skipped: ${e instanceof Error ? e.message : String(e)}`);
32
+ log.info(`Log cleanup skipped: ${e instanceof Error ? e.message : e instanceof Error ? e.message : JSON.stringify(e)}`);
33
33
  }
34
34
  finally {
35
35
  cleaning = false;
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "crypto";
2
2
  import { buildUpdateQuery, deleteById } from "./helpers.js";
3
+ import { parseModels } from "../config/model-context.js";
3
4
  const GROUP_FIELDS = new Set(["client_model", "strategy", "rule", "is_active"]);
4
5
  // --- MappingGroups CRUD ---
5
6
  export function getMappingGroup(db, clientModel) {
@@ -35,14 +36,9 @@ export function getActiveProviderModels(db) {
35
36
  const providers = db.prepare("SELECT name, models, is_active FROM providers WHERE is_active = 1").all();
36
37
  const results = [];
37
38
  for (const p of providers) {
38
- try {
39
- const models = JSON.parse(p.models);
40
- for (const m of models) {
41
- results.push({ provider_name: p.name, backend_model: m });
42
- }
43
- }
44
- catch {
45
- continue;
39
+ const modelEntries = parseModels(p.models);
40
+ for (const m of modelEntries) {
41
+ results.push({ provider_name: p.name, backend_model: m.name });
46
42
  }
47
43
  }
48
44
  return results;
@@ -83,14 +79,9 @@ export function resolveByProviderModel(db, providerName, backendModel) {
83
79
  const providerRow = db.prepare("SELECT id, models FROM providers WHERE name = ? AND is_active = 1").get(providerName);
84
80
  if (!providerRow)
85
81
  return null;
86
- try {
87
- const models = JSON.parse(providerRow.models);
88
- if (!models.includes(backendModel))
89
- return null;
90
- }
91
- catch {
82
+ const modelEntries = parseModels(providerRow.models);
83
+ if (!modelEntries.some(m => m.name === backendModel))
92
84
  return null;
93
- }
94
85
  // 尝试从 mapping_groups 找到包含此 provider+backend_model 的 client_model
95
86
  const groups = db.prepare("SELECT client_model, rule FROM mapping_groups").all();
96
87
  for (const g of groups) {
@@ -7,6 +7,7 @@ export interface Provider {
7
7
  upstream_path: string | null;
8
8
  api_key: string;
9
9
  api_key_preview?: string;
10
+ /** @internal 原始 JSON 文本,业务层请使用 parseModels() 解析,禁止直接 JSON.parse */
10
11
  models: string;
11
12
  is_active: number;
12
13
  max_concurrency: number;
@@ -1,35 +1,20 @@
1
1
  import { randomUUID } from "crypto";
2
2
  import { buildUpdateQuery, deleteById } from "./helpers.js";
3
+ import { parseModels } from "../config/model-context.js";
3
4
  /** 默认流式超时 10 分钟 */
4
5
  export const DEFAULT_STREAM_TIMEOUT_MS = 600_000;
5
6
  /** 从 provider 的 models JSON 中查找指定模型的超时值 */
6
7
  export function getModelStreamTimeout(provider, backendModel) {
7
- try {
8
- const raw = JSON.parse(provider.models);
9
- if (!Array.isArray(raw))
10
- return DEFAULT_STREAM_TIMEOUT_MS;
11
- for (const m of raw) {
12
- if (typeof m === "string") {
13
- if (m === backendModel)
14
- return DEFAULT_STREAM_TIMEOUT_MS;
15
- continue;
16
- }
17
- const obj = m;
18
- if (!obj || typeof obj !== "object")
19
- continue;
20
- const modelId = (obj.name ?? obj.id);
21
- if (modelId === backendModel) {
22
- const timeout = obj.stream_timeout_ms;
23
- // stream_timeout_ms: 0 表示禁用超时,返回 Infinity;
24
- // undefined/null/未设置 表示使用默认值
25
- if (timeout === 0)
26
- return Number.POSITIVE_INFINITY;
27
- return timeout ?? DEFAULT_STREAM_TIMEOUT_MS;
28
- }
29
- }
30
- }
31
- catch { /* ignore parse errors — models field may be empty or invalid */ } // eslint-disable-line taste/no-silent-catch
32
- return DEFAULT_STREAM_TIMEOUT_MS;
8
+ const entries = parseModels(provider.models);
9
+ const entry = entries.find(m => m.name === backendModel);
10
+ if (!entry)
11
+ return DEFAULT_STREAM_TIMEOUT_MS;
12
+ const timeout = entry.stream_timeout_ms;
13
+ // stream_timeout_ms: 0 表示禁用超时,返回 Infinity;
14
+ // undefined/null/未设置 表示使用默认值
15
+ if (timeout === 0)
16
+ return Number.POSITIVE_INFINITY;
17
+ return timeout ?? DEFAULT_STREAM_TIMEOUT_MS;
33
18
  }
34
19
  export const PROVIDER_CONCURRENCY_DEFAULTS = {
35
20
  max_concurrency: 0,
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "crypto";
2
2
  import { buildUpdateQuery, deleteById } from "./helpers.js";
3
+ import { parseModels } from "../config/model-context.js";
3
4
  export function getRouterKeyByHash(db, hash) {
4
5
  return db.prepare("SELECT id, name, allowed_models FROM router_keys WHERE key_hash = ? AND is_active = 1").get(hash);
5
6
  }
@@ -27,11 +28,9 @@ export function getAvailableModels(db) {
27
28
  const rows = db.prepare("SELECT models FROM providers WHERE is_active = 1").all();
28
29
  const set = new Set();
29
30
  for (const r of rows) {
30
- try {
31
- JSON.parse(r.models || "[]").forEach((m) => set.add(m));
32
- }
33
- catch {
34
- continue;
31
+ const entries = parseModels(r.models);
32
+ for (const m of entries) {
33
+ set.add(m.name);
35
34
  }
36
35
  }
37
36
  return [...set].sort();
@@ -14,7 +14,7 @@ export function getLogRetentionDays(db) {
14
14
  return val ? parseInt(val, 10) : DEFAULT_LOG_RETENTION_DAYS;
15
15
  }
16
16
  export function setLogRetentionDays(db, days) {
17
- setSetting(db, "log_retention_days", String(days));
17
+ setSetting(db, "log_retention_days", days.toString());
18
18
  }
19
19
  const DEFAULT_DB_MAX_SIZE_MB = 1024;
20
20
  const DEFAULT_LOG_TABLE_MAX_SIZE_MB = 800;
@@ -23,14 +23,14 @@ export function getDbMaxSizeMb(db) {
23
23
  return val ? parseInt(val, 10) : DEFAULT_DB_MAX_SIZE_MB;
24
24
  }
25
25
  export function setDbMaxSizeMb(db, mb) {
26
- setSetting(db, "db_max_size_mb", String(mb));
26
+ setSetting(db, "db_max_size_mb", mb.toString());
27
27
  }
28
28
  export function getLogTableMaxSizeMb(db) {
29
29
  const val = getSetting(db, "log_table_max_size_mb");
30
30
  return val ? parseInt(val, 10) : DEFAULT_LOG_TABLE_MAX_SIZE_MB;
31
31
  }
32
32
  export function setLogTableMaxSizeMb(db, mb) {
33
- setSetting(db, "log_table_max_size_mb", String(mb));
33
+ setSetting(db, "log_table_max_size_mb", mb.toString());
34
34
  }
35
35
  export function getConfigSyncSource(db) {
36
36
  const val = getSetting(db, "config_sync_source");
package/dist/index.js CHANGED
@@ -15,12 +15,20 @@ import { getConfig, getBaseConfig } from "./config/index.js";
15
15
  import { initDatabase, getAllProviders } from "./db/index.js";
16
16
  import { loadRecommendedConfig } from "./config/recommended.js";
17
17
  import { authMiddleware } from "./middleware/auth.js";
18
- import { openaiProxy } from "./proxy/handler/openai.js";
19
- import { anthropicProxy } from "./proxy/handler/anthropic.js";
20
- import { responsesProxy } from "./proxy/handler/responses.js";
18
+ import { createProxyHandler } from "./proxy/handler/create-proxy-handler.js";
21
19
  import { adminRoutes } from "./admin/routes.js";
22
20
  import { RetryRuleMatcher } from "./proxy/orchestration/retry-rules.js";
23
21
  import { PluginRegistry } from "./proxy/transform/plugin-registry.js";
22
+ import { FormatRegistry } from "./proxy/format/registry.js";
23
+ import { openaiAdapter } from "./proxy/format/adapters/openai.js";
24
+ import { anthropicAdapter } from "./proxy/format/adapters/anthropic.js";
25
+ import { responsesAdapter } from "./proxy/format/adapters/responses.js";
26
+ import { openaiToAnthropicConverter } from "./proxy/format/converters/openai-anthropic.js";
27
+ import { anthropicToOpenAIConverter } from "./proxy/format/converters/anthropic-openai.js";
28
+ import { openaiToResponsesConverter } from "./proxy/format/converters/openai-responses.js";
29
+ import { responsesToOpenAIConverter } from "./proxy/format/converters/responses-openai.js";
30
+ import { responsesToAnthropicConverter } from "./proxy/format/converters/responses-anthropic.js";
31
+ import { anthropicToResponsesConverter } from "./proxy/format/converters/anthropic-responses.js";
24
32
  import { SemaphoreManager, AdaptiveController } from "@llm-router/core/concurrency";
25
33
  import { RequestTracker } from "@llm-router/core/monitor";
26
34
  import { UsageWindowTracker } from "./proxy/routing/usage-window-tracker.js";
@@ -32,6 +40,7 @@ import fastifyStatic from "@fastify/static";
32
40
  import { ServiceContainer, SERVICE_KEYS } from "./core/container.js";
33
41
  import { LogFileWriter } from "./storage/log-file-writer.js";
34
42
  import { ProxyAgentFactory } from "./proxy/transport/proxy-agent.js";
43
+ import { registerBuiltinHooks } from "./proxy/pipeline/register-hooks.js";
35
44
  import { scheduleLogFileMaintenance } from "./storage/log-file-compressor.js";
36
45
  import { getDetailLogEnabled, getLogFileRetentionDays } from "./db/settings.js";
37
46
  import { dirname, join } from "node:path";
@@ -216,6 +225,18 @@ export async function buildApp(options) {
216
225
  const pluginsDir = path.resolve(__dirname, "../plugins/transform");
217
226
  pluginRegistry.scanPluginsDir(pluginsDir);
218
227
  container.register(SERVICE_KEYS.pluginRegistry, () => pluginRegistry);
228
+ // 注册 FormatRegistry(3 adapters + 6 converters 覆盖所有格式转换)
229
+ const formatRegistry = new FormatRegistry();
230
+ formatRegistry.registerAdapter(openaiAdapter);
231
+ formatRegistry.registerAdapter(anthropicAdapter);
232
+ formatRegistry.registerAdapter(responsesAdapter);
233
+ formatRegistry.registerConverter(openaiToAnthropicConverter);
234
+ formatRegistry.registerConverter(anthropicToOpenAIConverter);
235
+ formatRegistry.registerConverter(openaiToResponsesConverter);
236
+ formatRegistry.registerConverter(responsesToOpenAIConverter);
237
+ formatRegistry.registerConverter(responsesToAnthropicConverter);
238
+ formatRegistry.registerConverter(anthropicToResponsesConverter);
239
+ container.register(SERVICE_KEYS.formatRegistry, () => formatRegistry);
219
240
  // 注册 ProxyAgentFactory
220
241
  container.register(SERVICE_KEYS.proxyAgentFactory, () => new ProxyAgentFactory());
221
242
  // 从容器解析所有服务
@@ -230,9 +251,24 @@ export async function buildApp(options) {
230
251
  // 从 DB 读取已有 provider 的并发配置,初始化信号量/adaptive/tracker(共享逻辑)
231
252
  initializeProviderState(db, semaphoreManager, adaptiveController, tracker);
232
253
  app.register(authMiddleware, { db });
233
- app.register(openaiProxy, { db, container });
234
- app.register(anthropicProxy, { db, container });
235
- app.register(responsesProxy, { db, container });
254
+ // 注册内置 hooks hookRegistry(供 Admin API 查询)
255
+ registerBuiltinHooks();
256
+ // --- New pipeline-based proxy handlers (Phase 3) ---
257
+ const openaiHandler = createProxyHandler({
258
+ apiType: "openai",
259
+ paths: ["/v1/chat/completions", "/chat/completions"],
260
+ });
261
+ const anthropicHandler = createProxyHandler({
262
+ apiType: "anthropic",
263
+ paths: ["/v1/messages"],
264
+ });
265
+ const responsesHandler = createProxyHandler({
266
+ apiType: "openai-responses",
267
+ paths: ["/v1/responses", "/responses"],
268
+ });
269
+ app.register(openaiHandler, { db, container });
270
+ app.register(anthropicHandler, { db, container });
271
+ app.register(responsesHandler, { db, container });
236
272
  // StateRegistry — Admin 层通过此接口触发 proxy 层状态刷新,消除 admin→proxy 依赖
237
273
  const stateRegistry = {
238
274
  refreshRetryRules: () => matcher.load(db),
@@ -262,7 +298,7 @@ export async function buildApp(options) {
262
298
  });
263
299
  // SPA fallback: /admin/ 下非 API 路径返回 index.html
264
300
  app.setNotFoundHandler((request, reply) => {
265
- if (request.url.startsWith("/admin") &&
301
+ if ((request.url.startsWith("/admin/") || request.url === "/admin") &&
266
302
  !request.url.startsWith("/admin/api")) {
267
303
  return reply.sendFile("index.html");
268
304
  }
@@ -347,7 +383,7 @@ export async function main() {
347
383
  });
348
384
  process.on("unhandledRejection", (reason) => {
349
385
  try {
350
- app.log.error({ err: reason instanceof Error ? reason : new Error(String(reason)) }, "Unhandled rejection");
386
+ app.log.error({ err: reason instanceof Error ? reason : new Error(typeof reason === 'string' ? reason : JSON.stringify(reason)) }, "Unhandled rejection");
351
387
  /* eslint-disable taste/no-silent-catch -- app.log 可能已崩溃,console 是最后手段 */
352
388
  }
353
389
  catch {
@@ -0,0 +1,2 @@
1
+ import type { FormatAdapter } from "../types.js";
2
+ export declare const anthropicAdapter: FormatAdapter;
@@ -0,0 +1,18 @@
1
+ const ANTHROPIC_ERROR_META = {
2
+ modelNotFound: { type: "not_found_error", code: "model_not_found" },
3
+ modelNotAllowed: { type: "forbidden_error", code: "model_not_allowed" },
4
+ providerUnavailable: { type: "api_error", code: "provider_unavailable" },
5
+ providerTypeMismatch: { type: "api_error", code: "provider_type_mismatch" },
6
+ upstreamConnectionFailed: { type: "upstream_error", code: "upstream_connection_failed" },
7
+ concurrencyQueueFull: { type: "api_error", code: "concurrency_queue_full" },
8
+ concurrencyTimeout: { type: "api_error", code: "concurrency_timeout" },
9
+ promptTooLong: { type: "invalid_request_error", code: "context_window_exceeded" },
10
+ };
11
+ export const anthropicAdapter = {
12
+ apiType: "anthropic",
13
+ defaultPath: "/v1/messages",
14
+ errorMeta: ANTHROPIC_ERROR_META,
15
+ formatError(message) {
16
+ return { type: "error", error: { type: "api_error", message } };
17
+ },
18
+ };
@@ -0,0 +1,2 @@
1
+ import type { FormatAdapter } from "../types.js";
2
+ export declare const openaiAdapter: FormatAdapter;
@@ -0,0 +1,14 @@
1
+ import { OPENAI_FAMILY_ERROR_META } from "./shared-error-meta.js";
2
+ export const openaiAdapter = {
3
+ apiType: "openai",
4
+ defaultPath: "/v1/chat/completions",
5
+ errorMeta: OPENAI_FAMILY_ERROR_META,
6
+ beforeSendProxy(body, isStream) {
7
+ if (isStream && !body.stream_options) {
8
+ body.stream_options = { include_usage: true };
9
+ }
10
+ },
11
+ formatError(message, code) {
12
+ return { error: { message, type: "upstream_error", code: code ?? "upstream_error" } };
13
+ },
14
+ };
@@ -0,0 +1,2 @@
1
+ import type { FormatAdapter } from "../types.js";
2
+ export declare const responsesAdapter: FormatAdapter;
@@ -0,0 +1,9 @@
1
+ import { OPENAI_FAMILY_ERROR_META } from "./shared-error-meta.js";
2
+ export const responsesAdapter = {
3
+ apiType: "openai-responses",
4
+ defaultPath: "/v1/responses",
5
+ errorMeta: OPENAI_FAMILY_ERROR_META,
6
+ formatError(message, code) {
7
+ return { error: { message, type: "invalid_request_error", code: code ?? "upstream_error" } };
8
+ },
9
+ };
@@ -0,0 +1,9 @@
1
+ import type { ErrorKind } from "../types.js";
2
+ /**
3
+ * OpenAI 和 Responses API 共用的错误元数据。
4
+ * 两者 error code/type 完全相同,仅 apiType/defaultPath/formatError 不同。
5
+ */
6
+ export declare const OPENAI_FAMILY_ERROR_META: Record<ErrorKind, {
7
+ type: string;
8
+ code: string;
9
+ }>;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * OpenAI 和 Responses API 共用的错误元数据。
3
+ * 两者 error code/type 完全相同,仅 apiType/defaultPath/formatError 不同。
4
+ */
5
+ export const OPENAI_FAMILY_ERROR_META = {
6
+ modelNotFound: { type: "invalid_request_error", code: "model_not_found" },
7
+ modelNotAllowed: { type: "invalid_request_error", code: "model_not_allowed" },
8
+ providerUnavailable: { type: "server_error", code: "provider_unavailable" },
9
+ providerTypeMismatch: { type: "server_error", code: "provider_type_mismatch" },
10
+ upstreamConnectionFailed: { type: "upstream_error", code: "upstream_connection_failed" },
11
+ concurrencyQueueFull: { type: "server_error", code: "concurrency_queue_full" },
12
+ concurrencyTimeout: { type: "server_error", code: "concurrency_timeout" },
13
+ promptTooLong: { type: "invalid_request_error", code: "context_window_exceeded" },
14
+ };
@@ -0,0 +1 @@
1
+ export declare const anthropicToOpenAIConverter: import("../types.js").FormatConverter;
@@ -0,0 +1,11 @@
1
+ import { createConverter } from "../types.js";
2
+ import { anthropicToOpenAIRequest } from "../../transform/request-transform.js";
3
+ import { anthropicResponseToOpenAI } from "../../transform/response-transform.js";
4
+ import { AnthropicToOpenAITransform } from "../../transform/stream-ant2oa.js";
5
+ export const anthropicToOpenAIConverter = createConverter({
6
+ sourceType: "anthropic",
7
+ targetType: "openai",
8
+ requestTransform: anthropicToOpenAIRequest,
9
+ responseTransform: anthropicResponseToOpenAI,
10
+ streamTransformClass: AnthropicToOpenAITransform,
11
+ });
@@ -0,0 +1 @@
1
+ export declare const anthropicToResponsesConverter: import("../types.js").FormatConverter;
@@ -0,0 +1,11 @@
1
+ import { createConverter } from "../types.js";
2
+ import { anthropicToResponsesRequest } from "../../transform/request-transform-responses.js";
3
+ import { anthropicToResponsesResponse } from "../../transform/response-transform-responses.js";
4
+ import { AnthropicToResponsesTransform } from "../../transform/stream-ant2resp.js";
5
+ export const anthropicToResponsesConverter = createConverter({
6
+ sourceType: "anthropic",
7
+ targetType: "openai-responses",
8
+ requestTransform: anthropicToResponsesRequest,
9
+ responseTransform: anthropicToResponsesResponse,
10
+ streamTransformClass: AnthropicToResponsesTransform,
11
+ });
@@ -0,0 +1 @@
1
+ export declare const openaiToAnthropicConverter: import("../types.js").FormatConverter;
@@ -0,0 +1,11 @@
1
+ import { createConverter } from "../types.js";
2
+ import { openaiToAnthropicRequest } from "../../transform/request-transform.js";
3
+ import { openaiResponseToAnthropic } from "../../transform/response-transform.js";
4
+ import { OpenAIToAnthropicTransform } from "../../transform/stream-oa2ant.js";
5
+ export const openaiToAnthropicConverter = createConverter({
6
+ sourceType: "openai",
7
+ targetType: "anthropic",
8
+ requestTransform: openaiToAnthropicRequest,
9
+ responseTransform: openaiResponseToAnthropic,
10
+ streamTransformClass: OpenAIToAnthropicTransform,
11
+ });
@@ -0,0 +1 @@
1
+ export declare const openaiToResponsesConverter: import("../types.js").FormatConverter;
@@ -0,0 +1,11 @@
1
+ import { createConverter } from "../types.js";
2
+ import { chatToResponsesRequest } from "../../transform/request-bridge-responses.js";
3
+ import { chatToResponsesResponse } from "../../transform/response-bridge-responses.js";
4
+ import { ChatToResponsesBridgeTransform } from "../../transform/stream-bridge-chat2resp.js";
5
+ export const openaiToResponsesConverter = createConverter({
6
+ sourceType: "openai",
7
+ targetType: "openai-responses",
8
+ requestTransform: chatToResponsesRequest,
9
+ responseTransform: chatToResponsesResponse,
10
+ streamTransformClass: ChatToResponsesBridgeTransform,
11
+ });
@@ -0,0 +1 @@
1
+ export declare const responsesToAnthropicConverter: import("../types.js").FormatConverter;
@@ -0,0 +1,11 @@
1
+ import { createConverter } from "../types.js";
2
+ import { responsesToAnthropicRequest } from "../../transform/request-transform-responses.js";
3
+ import { responsesToAnthropicResponse } from "../../transform/response-transform-responses.js";
4
+ import { ResponsesToAnthropicTransform } from "../../transform/stream-resp2ant.js";
5
+ export const responsesToAnthropicConverter = createConverter({
6
+ sourceType: "openai-responses",
7
+ targetType: "anthropic",
8
+ requestTransform: responsesToAnthropicRequest,
9
+ responseTransform: responsesToAnthropicResponse,
10
+ streamTransformClass: ResponsesToAnthropicTransform,
11
+ });
@@ -0,0 +1 @@
1
+ export declare const responsesToOpenAIConverter: import("../types.js").FormatConverter;
@@ -0,0 +1,11 @@
1
+ import { createConverter } from "../types.js";
2
+ import { responsesToChatRequest } from "../../transform/request-bridge-responses.js";
3
+ import { responsesToChatResponse } from "../../transform/response-bridge-responses.js";
4
+ import { ResponsesToChatBridgeTransform } from "../../transform/stream-bridge-resp2chat.js";
5
+ export const responsesToOpenAIConverter = createConverter({
6
+ sourceType: "openai-responses",
7
+ targetType: "openai",
8
+ requestTransform: responsesToChatRequest,
9
+ responseTransform: responsesToChatResponse,
10
+ streamTransformClass: ResponsesToChatBridgeTransform,
11
+ });