llm-simple-router 0.5.0 → 0.5.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 (179) hide show
  1. package/config/recommended-providers.json +76 -0
  2. package/config/recommended-retry-rules.json +10 -0
  3. package/dist/admin/api-response.d.ts +27 -0
  4. package/dist/admin/api-response.js +40 -0
  5. package/dist/admin/constants.d.ts +0 -2
  6. package/dist/admin/constants.js +0 -3
  7. package/dist/admin/groups.js +9 -5
  8. package/dist/admin/logs.js +3 -2
  9. package/dist/admin/mappings.js +7 -6
  10. package/dist/admin/metrics.js +23 -5
  11. package/dist/admin/monitor.js +2 -1
  12. package/dist/admin/providers.js +13 -4
  13. package/dist/admin/proxy-enhancement.js +11 -6
  14. package/dist/admin/recommended.js +1 -9
  15. package/dist/admin/retry-rules.js +8 -4
  16. package/dist/admin/router-keys.js +5 -1
  17. package/dist/admin/routes.js +2 -0
  18. package/dist/admin/settings-import-export.js +3 -2
  19. package/dist/admin/settings.js +7 -5
  20. package/dist/admin/setup.js +3 -2
  21. package/dist/admin/stats.js +20 -3
  22. package/dist/admin/upgrade.d.ts +13 -0
  23. package/dist/admin/upgrade.js +114 -0
  24. package/dist/admin/usage.js +12 -24
  25. package/dist/config.d.ts +1 -1
  26. package/dist/config.js +1 -1
  27. package/dist/constants.d.ts +3 -0
  28. package/dist/constants.js +11 -0
  29. package/dist/db/index.d.ts +3 -3
  30. package/dist/db/index.js +2 -2
  31. package/dist/db/mappings.js +5 -8
  32. package/dist/db/metrics.js +3 -4
  33. package/dist/db/providers.d.ts +8 -0
  34. package/dist/db/providers.js +6 -0
  35. package/dist/db/retry-rules.d.ts +1 -0
  36. package/dist/db/retry-rules.js +3 -0
  37. package/dist/db/settings.d.ts +2 -0
  38. package/dist/db/settings.js +7 -0
  39. package/dist/db/stats.d.ts +1 -2
  40. package/dist/db/stats.js +7 -11
  41. package/dist/index.d.ts +2 -0
  42. package/dist/index.js +55 -34
  43. package/dist/metrics/metrics-extractor.js +1 -1
  44. package/dist/metrics/sse-parser.js +2 -0
  45. package/dist/middleware/admin-auth.js +6 -5
  46. package/dist/middleware/auth.js +1 -10
  47. package/dist/monitor/request-tracker.d.ts +1 -0
  48. package/dist/monitor/request-tracker.js +9 -45
  49. package/dist/monitor/runtime-collector.js +1 -1
  50. package/dist/monitor/stream-content-accumulator.d.ts +14 -0
  51. package/dist/monitor/stream-content-accumulator.js +58 -0
  52. package/dist/proxy/anthropic.d.ts +2 -1
  53. package/dist/proxy/anthropic.js +3 -3
  54. package/dist/proxy/enhancement/directive-parser.d.ts +18 -0
  55. package/dist/proxy/{directive-parser.js → enhancement/directive-parser.js} +44 -0
  56. package/dist/proxy/{enhancement-handler.js → enhancement/enhancement-handler.js} +152 -32
  57. package/dist/proxy/enhancement/index.d.ts +3 -0
  58. package/dist/proxy/enhancement/index.js +3 -0
  59. package/dist/proxy/{response-cleaner.js → enhancement/response-cleaner.js} +14 -0
  60. package/dist/proxy/log-helpers.d.ts +1 -1
  61. package/dist/proxy/mapping-resolver.js +4 -4
  62. package/dist/proxy/openai.d.ts +2 -1
  63. package/dist/proxy/openai.js +4 -4
  64. package/dist/proxy/orchestrator.d.ts +0 -1
  65. package/dist/proxy/orchestrator.js +1 -3
  66. package/dist/proxy/proxy-core.d.ts +0 -4
  67. package/dist/proxy/proxy-core.js +0 -2
  68. package/dist/proxy/proxy-handler.d.ts +1 -1
  69. package/dist/proxy/proxy-handler.js +52 -132
  70. package/dist/proxy/proxy-logging.d.ts +0 -2
  71. package/dist/proxy/proxy-logging.js +1 -3
  72. package/dist/proxy/resilience.d.ts +5 -2
  73. package/dist/proxy/resilience.js +16 -7
  74. package/dist/proxy/strategy/failover.js +2 -7
  75. package/dist/proxy/strategy/random.js +2 -2
  76. package/dist/proxy/strategy/round-robin.js +2 -2
  77. package/dist/proxy/strategy/scheduled.js +1 -8
  78. package/dist/proxy/strategy/targets-rule.d.ts +1 -0
  79. package/dist/proxy/strategy/targets-rule.js +5 -0
  80. package/dist/proxy/transport-fn.d.ts +25 -0
  81. package/dist/proxy/transport-fn.js +55 -0
  82. package/dist/proxy/transport.d.ts +0 -25
  83. package/dist/proxy/transport.js +0 -38
  84. package/dist/upgrade/checker.d.ts +25 -0
  85. package/dist/upgrade/checker.js +120 -0
  86. package/dist/upgrade/deployment.d.ts +2 -0
  87. package/dist/upgrade/deployment.js +20 -0
  88. package/dist/upgrade/version.d.ts +1 -0
  89. package/dist/upgrade/version.js +13 -0
  90. package/dist/utils/password.js +4 -2
  91. package/dist/utils/time-range.d.ts +9 -0
  92. package/dist/utils/time-range.js +40 -0
  93. package/frontend-dist/assets/CardContent-WrBnGhTg.js +1 -0
  94. package/frontend-dist/assets/CardTitle-BcDYk7cq.js +1 -0
  95. package/frontend-dist/assets/Checkbox-MZf0YsDG.js +1 -0
  96. package/frontend-dist/assets/CollapsibleTrigger-CrOH9HlW.js +1 -0
  97. package/frontend-dist/assets/Collection-DcTx_Y54.js +1 -0
  98. package/frontend-dist/assets/Dashboard-D0oDrSLr.js +3 -0
  99. package/frontend-dist/assets/DialogTitle-Cl5Cd7QH.js +1 -0
  100. package/frontend-dist/assets/{Input-l5ZurXX5.js → Input-O0ebU-Va.js} +1 -1
  101. package/frontend-dist/assets/Label-C_S0y7Um.js +1 -0
  102. package/frontend-dist/assets/Login-DGY7uF8P.js +1 -0
  103. package/frontend-dist/assets/Logs-ls8pv89b.js +1 -0
  104. package/frontend-dist/assets/ModelMappings-DGlf0S4s.js +1 -0
  105. package/frontend-dist/assets/Monitor-BSI87grz.js +1 -0
  106. package/frontend-dist/assets/PopperContent-C6Q7hDmf.js +1 -0
  107. package/frontend-dist/assets/Providers-ZkRpj8_m.js +1 -0
  108. package/frontend-dist/assets/ProxyEnhancement-DFPI1W6Z.js +5 -0
  109. package/frontend-dist/assets/RetryRules-DtM31qsl.js +1 -0
  110. package/frontend-dist/assets/RouterKeys-D63tRFKm.js +1 -0
  111. package/frontend-dist/assets/RovingFocusItem-BJoylAKU.js +1 -0
  112. package/frontend-dist/assets/SelectValue-CLp5z6_I.js +1 -0
  113. package/frontend-dist/assets/Settings-DSgRKbTQ.js +6 -0
  114. package/frontend-dist/assets/Setup-BDmj6CRk.js +1 -0
  115. package/frontend-dist/assets/Switch-Wz-t_zkv.js +1 -0
  116. package/frontend-dist/assets/TableHeader-DGtcqGkw.js +1 -0
  117. package/frontend-dist/assets/TabsTrigger-CPCi2HIa.js +1 -0
  118. package/frontend-dist/assets/Teleport-DdjYHlNK.js +3 -0
  119. package/frontend-dist/assets/TooltipTrigger-H_QoPY1n.js +1 -0
  120. package/frontend-dist/assets/UnifiedRequestDialog-BAAfMJJl.js +3 -0
  121. package/frontend-dist/assets/{VisuallyHidden-BwwTtzb9.js → VisuallyHidden-Cyk-jWwh.js} +1 -1
  122. package/frontend-dist/assets/VisuallyHiddenInput-CYjNe_H8.js +1 -0
  123. package/frontend-dist/assets/alert-dialog-Bi3dliLl.js +1 -0
  124. package/frontend-dist/assets/badge-Kkta3e9W.js +1 -0
  125. package/frontend-dist/assets/button-BQ3s7yNh.js +12 -0
  126. package/frontend-dist/assets/{createLucideIcon-Biq59l_W.js → createLucideIcon-D1tkPDOQ.js} +1 -1
  127. package/frontend-dist/assets/dialog-DoIATUYw.js +1 -0
  128. package/frontend-dist/assets/{file-text-DoRW0hQW.js → file-text-Dt6QP1bZ.js} +1 -1
  129. package/frontend-dist/assets/index-BY0E7CHR.js +1 -0
  130. package/frontend-dist/assets/index-Bnrh1mFY.css +1 -0
  131. package/frontend-dist/assets/lib-CxwxnlwW.js +1 -0
  132. package/frontend-dist/assets/{ohash.D__AXeF1-BGxYMs6k.js → ohash.D__AXeF1-b0PiKZB_.js} +1 -1
  133. package/frontend-dist/assets/{useClipboard-vaHkvJHw.js → useClipboard-Cnnz6AAN.js} +1 -1
  134. package/frontend-dist/assets/useLogRetention-DYP5LOAc.js +1 -0
  135. package/frontend-dist/assets/useNonce-DKbOCfgM.js +1 -0
  136. package/frontend-dist/assets/x-CAoitXRt.js +1 -0
  137. package/frontend-dist/index.html +18 -9
  138. package/package.json +2 -1
  139. package/dist/proxy/directive-parser.d.ts +0 -7
  140. package/frontend-dist/assets/CardContent-CIO85eT6.js +0 -1
  141. package/frontend-dist/assets/CardTitle-DiqIReMT.js +0 -1
  142. package/frontend-dist/assets/Checkbox-C2u5pIp4.js +0 -1
  143. package/frontend-dist/assets/CollapsibleTrigger-RKFL41om.js +0 -1
  144. package/frontend-dist/assets/Collection-iiNnuTQj.js +0 -1
  145. package/frontend-dist/assets/Dashboard-DOEqP6gF.js +0 -3
  146. package/frontend-dist/assets/DialogTitle-CEqndrf6.js +0 -1
  147. package/frontend-dist/assets/Label-PgGtS8v2.js +0 -1
  148. package/frontend-dist/assets/Login-DaN6ZcCx.js +0 -1
  149. package/frontend-dist/assets/Logs-CleRQ7Xk.js +0 -1
  150. package/frontend-dist/assets/ModelMappings-CacA_ua_.js +0 -1
  151. package/frontend-dist/assets/Monitor-LSMFOBN2.js +0 -1
  152. package/frontend-dist/assets/PopperContent-zLFHqQP0.js +0 -1
  153. package/frontend-dist/assets/Providers-NT5MUDU0.js +0 -1
  154. package/frontend-dist/assets/ProxyEnhancement-DhOy8nNy.js +0 -5
  155. package/frontend-dist/assets/RetryRules-7arWa3jB.js +0 -1
  156. package/frontend-dist/assets/RouterKeys-CdaZunRg.js +0 -1
  157. package/frontend-dist/assets/SelectValue-CSg-MKW_.js +0 -1
  158. package/frontend-dist/assets/Settings-1ntV9XE3.js +0 -6
  159. package/frontend-dist/assets/Setup-CXLTDhYJ.js +0 -1
  160. package/frontend-dist/assets/Switch-DivrIFE3.js +0 -1
  161. package/frontend-dist/assets/TableHeader-Bn0bodWx.js +0 -1
  162. package/frontend-dist/assets/TabsContent-MWvOH_LJ.js +0 -1
  163. package/frontend-dist/assets/TabsTrigger-WKkUfO2M.js +0 -1
  164. package/frontend-dist/assets/Teleport-B0PNXZbP.js +0 -3
  165. package/frontend-dist/assets/UnifiedRequestDialog-Ba2e7YuJ.js +0 -3
  166. package/frontend-dist/assets/VisuallyHiddenInput-EGZSP7s8.js +0 -1
  167. package/frontend-dist/assets/alert-dialog-CS1yFhdV.js +0 -1
  168. package/frontend-dist/assets/badge-C-QcC5n2.js +0 -1
  169. package/frontend-dist/assets/button-Dbz2Be22.js +0 -12
  170. package/frontend-dist/assets/dialog-Cr0YQlLW.js +0 -1
  171. package/frontend-dist/assets/index-0H2uCGbx.js +0 -1
  172. package/frontend-dist/assets/index-D-cdVNCb.css +0 -1
  173. package/frontend-dist/assets/lib-B0lieqgg.js +0 -1
  174. package/frontend-dist/assets/useForwardExpose-C2_ks3sW.js +0 -1
  175. package/frontend-dist/assets/useLogRetention-Cs_fiKql.js +0 -1
  176. package/frontend-dist/assets/useNonce-C9do0jOI.js +0 -1
  177. package/frontend-dist/assets/x-BlTnH_0_.js +0 -1
  178. /package/dist/proxy/{enhancement-handler.d.ts → enhancement/enhancement-handler.d.ts} +0 -0
  179. /package/dist/proxy/{response-cleaner.d.ts → enhancement/response-cleaner.d.ts} +0 -0
package/dist/index.js CHANGED
@@ -5,19 +5,10 @@ import { existsSync } from "node:fs";
5
5
  import { randomUUID } from "crypto";
6
6
  import Fastify from "fastify";
7
7
  import { insertRequestLog } from "./db/logs.js";
8
- import { HTTP_NOT_FOUND, HTTP_INTERNAL_ERROR, HTTP_BAD_REQUEST } from "./constants.js";
8
+ import { HTTP_NOT_FOUND, HTTP_INTERNAL_ERROR, getProxyApiType } from "./constants.js";
9
+ import { API_CODE, apiError, isAdminApiResponse, statusToApiCode } from "./admin/api-response.js";
9
10
  const PROVIDER_DEFAULT_QUEUE_TIMEOUT_MS = 5000;
10
11
  const PROVIDER_DEFAULT_MAX_QUEUE_SIZE = 100;
11
- // 代理路由路径 → api_type,用于在全局 hook/errorHandler 中识别代理请求
12
- const PROXY_API_TYPES = {
13
- "/v1/chat/completions": "openai",
14
- "/v1/messages": "anthropic",
15
- "/v1/models": "openai",
16
- };
17
- function getProxyApiType(url) {
18
- const path = url.split("?")[0];
19
- return PROXY_API_TYPES[path] ?? null;
20
- }
21
12
  const __filename = fileURLToPath(import.meta.url);
22
13
  const __dirname = path.dirname(__filename);
23
14
  import { getConfig } from "./config.js";
@@ -34,6 +25,7 @@ import { modelState } from "./proxy/model-state.js";
34
25
  import { UsageWindowTracker } from "./proxy/usage-window-tracker.js";
35
26
  import { scheduleLogCleanup } from "./db/log-cleaner.js";
36
27
  import { scheduleDbSizeMonitor } from "./db/db-size-monitor.js";
28
+ import { startUpgradeChecker, stopUpgradeChecker } from "./admin/upgrade.js";
37
29
  import fastifyStatic from "@fastify/static";
38
30
  export async function buildApp(options) {
39
31
  const config = options?.config ?? getBaseConfig();
@@ -79,34 +71,62 @@ export async function buildApp(options) {
79
71
  .join("; ");
80
72
  return new Error(message);
81
73
  });
82
- // 统一 schema validation 错误响应格式,代理路由的错误也记录到 request_logs
74
+ // 统一错误处理:代理路由保持 {error:{message}},Admin API 使用信封格式
83
75
  app.setErrorHandler((error, request, reply) => {
84
76
  const fastifyError = error;
85
77
  const status = fastifyError.statusCode ?? HTTP_INTERNAL_ERROR;
86
- const proxyApiType = getProxyApiType(request.url);
87
- if (proxyApiType) {
88
- request.log.error({ statusCode: status, err: error }, `Proxy request error: ${fastifyError.message}`);
89
- const body = request.body;
90
- insertRequestLog(db, {
91
- id: randomUUID(),
92
- api_type: proxyApiType,
93
- model: body?.model || null,
94
- provider_id: null,
95
- status_code: status,
96
- latency_ms: 0,
97
- is_stream: 0,
98
- error_message: fastifyError.message,
99
- created_at: new Date().toISOString(),
100
- client_request: JSON.stringify({ headers: request.headers }),
101
- router_key_id: request.routerKey?.id ?? null,
102
- });
78
+ // 代理路由保持原有格式,并记录到 request_logs
79
+ if (!isAdminApiResponse(request.url)) {
80
+ const proxyApiType = getProxyApiType(request.url);
81
+ if (proxyApiType) {
82
+ request.log.error({ statusCode: status, err: error }, `Proxy request error: ${fastifyError.message}`);
83
+ const body = request.body;
84
+ insertRequestLog(db, {
85
+ id: randomUUID(),
86
+ api_type: proxyApiType,
87
+ model: body?.model || null,
88
+ provider_id: null,
89
+ status_code: status,
90
+ latency_ms: 0,
91
+ is_stream: 0,
92
+ error_message: fastifyError.message,
93
+ created_at: new Date().toISOString(),
94
+ client_request: JSON.stringify({ headers: request.headers }),
95
+ router_key_id: request.routerKey?.id ?? null,
96
+ });
97
+ }
98
+ return reply.code(status).send({ error: { message: fastifyError.message } });
103
99
  }
104
- if (status === HTTP_BAD_REQUEST && fastifyError.validation) {
105
- return reply.code(HTTP_BAD_REQUEST).send({ error: { message: fastifyError.message } });
100
+ // Admin API 统一信封错误格式
101
+ const code = statusToApiCode(status);
102
+ return reply.code(status).send(apiError(code, fastifyError.message));
103
+ });
104
+ // onSend hook:自动包装 Admin API 成功响应为信封格式
105
+ app.addHook('onSend', async (request, reply, payload) => {
106
+ if (!isAdminApiResponse(request.url, reply.getHeader('content-type'))) {
107
+ return payload;
106
108
  }
107
- return reply.code(status).send({ error: { message: fastifyError.message } });
109
+ // 已是错误信封(errorHandler 已包装)或已是信封格式 跳过
110
+ if (typeof payload === 'string') {
111
+ try {
112
+ const parsed = JSON.parse(payload);
113
+ if ('code' in parsed)
114
+ return payload; // errorHandler 或路由已手动包装
115
+ }
116
+ catch {
117
+ return payload;
118
+ }
119
+ }
120
+ // 包装成功响应
121
+ const wrapped = {
122
+ code: API_CODE.SUCCESS,
123
+ message: 'ok',
124
+ data: typeof payload === 'string' ? JSON.parse(payload) : payload,
125
+ };
126
+ return JSON.stringify(wrapped);
108
127
  });
109
128
  loadRecommendedConfig();
129
+ startUpgradeChecker(options?.upgradeCheckerOptions);
110
130
  // 启动时回填:补齐回退老版本期间缺失的 metrics 冗余列
111
131
  if (shouldBackfill) {
112
132
  const backfilled = backfillMetricsFromRequestMetrics(db);
@@ -145,20 +165,20 @@ export async function buildApp(options) {
145
165
  app.register(openaiProxy, {
146
166
  db,
147
167
  streamTimeoutMs: config.STREAM_TIMEOUT_MS,
148
- retryMaxAttempts: config.RETRY_MAX_ATTEMPTS,
149
168
  retryBaseDelayMs: config.RETRY_BASE_DELAY_MS,
150
169
  matcher,
151
170
  semaphoreManager,
152
171
  tracker,
172
+ usageWindowTracker,
153
173
  });
154
174
  app.register(anthropicProxy, {
155
175
  db,
156
176
  streamTimeoutMs: config.STREAM_TIMEOUT_MS,
157
- retryMaxAttempts: config.RETRY_MAX_ATTEMPTS,
158
177
  retryBaseDelayMs: config.RETRY_BASE_DELAY_MS,
159
178
  matcher,
160
179
  semaphoreManager,
161
180
  tracker,
181
+ usageWindowTracker,
162
182
  });
163
183
  app.register(adminRoutes, { db, matcher, tracker, semaphoreManager });
164
184
  // 前端静态文件服务(生产环境)
@@ -193,6 +213,7 @@ export async function buildApp(options) {
193
213
  db,
194
214
  usageWindowTracker,
195
215
  close: async () => {
216
+ stopUpgradeChecker();
196
217
  logCleanup.stop();
197
218
  dbSizeMonitor.stop();
198
219
  tracker.stopPushInterval();
@@ -1,4 +1,4 @@
1
- const MS_PER_SECOND = 1000;
1
+ import { MS_PER_SECOND } from "../constants.js";
2
2
  export class MetricsExtractor {
3
3
  apiType;
4
4
  requestStartTime;
@@ -5,6 +5,8 @@ export class SSEParser {
5
5
  if (this.isDone)
6
6
  return [];
7
7
  this.buffer += chunk;
8
+ // SSE 规范允许 \r\n 行尾,统一为 \n
9
+ this.buffer = this.buffer.replace(/\r\n/g, "\n");
8
10
  return this.drainEvents();
9
11
  }
10
12
  flush() {
@@ -3,6 +3,7 @@ import cookie from "@fastify/cookie";
3
3
  import jwt from "jsonwebtoken";
4
4
  import { isInitialized, getSetting } from "../db/settings.js";
5
5
  import { verifyPassword } from "../utils/password.js";
6
+ import { API_CODE, apiError } from "../admin/api-response.js";
6
7
  const HTTP_UNAUTHORIZED = 401;
7
8
  const adminAuthRaw = (app, options, done) => {
8
9
  app.register(cookie);
@@ -19,11 +20,11 @@ const adminAuthRaw = (app, options, done) => {
19
20
  return;
20
21
  // 未初始化时返回 needsSetup
21
22
  if (!isInitialized(options.db)) {
22
- return reply.code(HTTP_UNAUTHORIZED).send({ error: { message: "Not initialized", needsSetup: true } });
23
+ return reply.code(HTTP_UNAUTHORIZED).send(apiError(API_CODE.NOT_INITIALIZED, "Not initialized"));
23
24
  }
24
25
  const token = request.cookies["admin_token"];
25
26
  if (!token) {
26
- reply.code(HTTP_UNAUTHORIZED).send({ error: { message: "Not authenticated" } });
27
+ reply.code(HTTP_UNAUTHORIZED).send(apiError(API_CODE.TOKEN_INVALID, "Not authenticated"));
27
28
  return reply;
28
29
  }
29
30
  const secret = getSetting(options.db, "jwt_secret");
@@ -32,7 +33,7 @@ const adminAuthRaw = (app, options, done) => {
32
33
  }
33
34
  catch (err) {
34
35
  request.log.debug({ err }, "invalid JWT token");
35
- reply.code(HTTP_UNAUTHORIZED).send({ error: { message: "Invalid or expired token" } });
36
+ reply.code(HTTP_UNAUTHORIZED).send(apiError(API_CODE.TOKEN_INVALID, "Invalid or expired token"));
36
37
  return reply;
37
38
  }
38
39
  });
@@ -44,12 +45,12 @@ export const adminLoginRoutes = (app, options, done) => {
44
45
  app.post("/admin/api/login", async (request, reply) => {
45
46
  const { password } = request.body;
46
47
  if (!password) {
47
- return reply.code(HTTP_UNAUTHORIZED).send({ error: { message: "Invalid password" } });
48
+ return reply.code(HTTP_UNAUTHORIZED).send(apiError(API_CODE.WRONG_PASSWORD, "Invalid password"));
48
49
  }
49
50
  // DB 模式:scrypt hash 验证
50
51
  const hash = getSetting(options.db, "admin_password_hash");
51
52
  if (!hash || !verifyPassword(password, hash)) {
52
- return reply.code(HTTP_UNAUTHORIZED).send({ error: { message: "Invalid password" } });
53
+ return reply.code(HTTP_UNAUTHORIZED).send(apiError(API_CODE.WRONG_PASSWORD, "Invalid password"));
53
54
  }
54
55
  const secret = getSetting(options.db, "jwt_secret");
55
56
  const token = jwt.sign({ role: "admin" }, secret, { expiresIn: TOKEN_EXPIRY_SECONDS });
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from "crypto";
2
2
  import fp from "fastify-plugin";
3
3
  import { isInitialized } from "../db/settings.js";
4
4
  import { insertRequestLog } from "../db/logs.js";
5
+ import { getProxyApiType } from "../constants.js";
5
6
  const SKIP_PATHS = ["/health", "/admin"];
6
7
  const HTTP_UNAUTHORIZED = 401;
7
8
  const HTTP_SERVICE_UNAVAILABLE = 503;
@@ -19,16 +20,6 @@ function unauthorizedReply(reply) {
19
20
  },
20
21
  });
21
22
  }
22
- // 代理路由路径 → api_type 映射,用于记录被认证拒绝的请求
23
- const PROXY_API_TYPES = {
24
- "/v1/chat/completions": "openai",
25
- "/v1/messages": "anthropic",
26
- "/v1/models": "openai",
27
- };
28
- function getProxyApiType(url) {
29
- const path = url.split("?")[0];
30
- return PROXY_API_TYPES[path] ?? null;
31
- }
32
23
  function logRejectedAuth(db, apiType, statusCode, errorMessage, request) {
33
24
  insertRequestLog(db, {
34
25
  id: randomUUID(),
@@ -15,6 +15,7 @@ export declare class RequestTracker {
15
15
  private providerConfigCache;
16
16
  private pushTimer;
17
17
  private tickCount;
18
+ private streamAccumulators;
18
19
  /** Visible for testing */
19
20
  readonly statsAggregator: StatsAggregator;
20
21
  readonly runtimeCollector: RuntimeCollector;
@@ -1,6 +1,6 @@
1
1
  import { StatsAggregator } from "./stats-aggregator.js";
2
2
  import { RuntimeCollector } from "./runtime-collector.js";
3
- import { extractStreamText } from "./stream-extractor.js";
3
+ import { StreamContentAccumulator } from "./stream-content-accumulator.js";
4
4
  const RUNTIME_PUSH_TICK_INTERVAL = 2;
5
5
  const RECENT_COMPLETED_MAX = 200;
6
6
  const RECENT_TTL_MS = 5 * 60 * 1000; // eslint-disable-line no-magic-numbers
@@ -14,6 +14,7 @@ export class RequestTracker {
14
14
  providerConfigCache = new Map();
15
15
  pushTimer = null;
16
16
  tickCount = 0;
17
+ streamAccumulators = new Map();
17
18
  /** Visible for testing */
18
19
  statsAggregator;
19
20
  runtimeCollector;
@@ -48,51 +49,13 @@ export class RequestTracker {
48
49
  const req = this.activeMap.get(id);
49
50
  if (!req)
50
51
  return;
51
- if (!req.streamContent) {
52
- req.streamContent = { rawChunks: "", textContent: "", totalChars: 0, blocks: [] };
53
- }
54
- const sc = req.streamContent;
55
- sc.totalChars += rawLine.length;
56
- // 环形缓冲区:超过限制时截断保留尾部
57
- sc.rawChunks += rawLine + "\n";
58
- if (sc.rawChunks.length > maxRaw) {
59
- sc.rawChunks = sc.rawChunks.slice(-maxRaw);
60
- }
61
- // 初始化 blocks 数组
62
- if (!sc.blocks) {
63
- sc.blocks = [];
64
- }
65
- const extracted = extractStreamText(rawLine, apiType);
66
- // 拼接纯文本(text 和 text_delta)
67
- if (extracted.text) {
68
- sc.textContent += extracted.text;
69
- if (sc.textContent.length > maxText) {
70
- sc.textContent = sc.textContent.slice(-maxText);
71
- }
72
- }
73
- // 维护结构化内容块
74
- if (extracted.block) {
75
- const { index, type, content, name } = extracted.block;
76
- while (sc.blocks.length <= index) {
77
- sc.blocks.push({ type: 'text', content: '' });
78
- }
79
- if (name) {
80
- sc.blocks[index].name = name;
81
- }
82
- if (content === '' && type !== 'text') {
83
- sc.blocks[index].type = type;
84
- }
85
- else if (content) {
86
- sc.blocks[index].content += content;
87
- sc.blocks[index].type = type;
88
- }
89
- const MAX_BLOCK_CONTENT = maxText;
90
- for (const block of sc.blocks) {
91
- if (block.content.length > MAX_BLOCK_CONTENT) {
92
- block.content = block.content.slice(-MAX_BLOCK_CONTENT);
93
- }
94
- }
52
+ let acc = this.streamAccumulators.get(id);
53
+ if (!acc) {
54
+ acc = new StreamContentAccumulator(maxRaw, maxText);
55
+ this.streamAccumulators.set(id, acc);
95
56
  }
57
+ acc.append(rawLine, apiType);
58
+ req.streamContent = acc.getSnapshot();
96
59
  }
97
60
  complete(id, result) {
98
61
  const req = this.activeMap.get(id);
@@ -112,6 +75,7 @@ export class RequestTracker {
112
75
  completedAt: now,
113
76
  };
114
77
  this.activeMap.delete(id);
78
+ this.streamAccumulators.delete(id);
115
79
  this.recentCompleted.unshift(completed);
116
80
  if (this.recentCompleted.length > RECENT_COMPLETED_MAX) {
117
81
  this.recentCompleted.length = RECENT_COMPLETED_MAX;
@@ -1,5 +1,5 @@
1
1
  import { performance } from "node:perf_hooks";
2
- const MS_PER_SECOND = 1000;
2
+ import { MS_PER_SECOND } from "../constants.js";
3
3
  const NS_PER_MS = 1e6;
4
4
  const perf = performance;
5
5
  const proc = process;
@@ -0,0 +1,14 @@
1
+ import type { StreamContentSnapshot } from "./types.js";
2
+ export declare const DEFAULT_MAX_RAW = 131072;
3
+ export declare const DEFAULT_MAX_TEXT = 65536;
4
+ export declare class StreamContentAccumulator {
5
+ private readonly maxRaw;
6
+ private readonly maxText;
7
+ private rawChunks;
8
+ private textContent;
9
+ private totalChars;
10
+ private blocks;
11
+ constructor(maxRaw?: number, maxText?: number);
12
+ append(rawLine: string, apiType: "openai" | "anthropic"): void;
13
+ getSnapshot(): StreamContentSnapshot;
14
+ }
@@ -0,0 +1,58 @@
1
+ import { extractStreamText } from "./stream-extractor.js";
2
+ export const DEFAULT_MAX_RAW = 131072;
3
+ export const DEFAULT_MAX_TEXT = 65536;
4
+ export class StreamContentAccumulator {
5
+ maxRaw;
6
+ maxText;
7
+ rawChunks = "";
8
+ textContent = "";
9
+ totalChars = 0;
10
+ blocks = [];
11
+ constructor(maxRaw = DEFAULT_MAX_RAW, maxText = DEFAULT_MAX_TEXT) {
12
+ this.maxRaw = maxRaw;
13
+ this.maxText = maxText;
14
+ }
15
+ append(rawLine, apiType) {
16
+ this.totalChars += rawLine.length;
17
+ this.rawChunks += rawLine + "\n";
18
+ if (this.rawChunks.length > this.maxRaw) {
19
+ this.rawChunks = this.rawChunks.slice(-this.maxRaw);
20
+ }
21
+ const extracted = extractStreamText(rawLine, apiType);
22
+ if (extracted.text) {
23
+ this.textContent += extracted.text;
24
+ if (this.textContent.length > this.maxText) {
25
+ this.textContent = this.textContent.slice(-this.maxText);
26
+ }
27
+ }
28
+ if (extracted.block) {
29
+ const { index, type, content, name } = extracted.block;
30
+ while (this.blocks.length <= index) {
31
+ this.blocks.push({ type: "text", content: "" });
32
+ }
33
+ if (name) {
34
+ this.blocks[index].name = name;
35
+ }
36
+ if (content === "" && type !== "text") {
37
+ this.blocks[index].type = type;
38
+ }
39
+ else if (content) {
40
+ this.blocks[index].content += content;
41
+ this.blocks[index].type = type;
42
+ }
43
+ for (const block of this.blocks) {
44
+ if (block.content.length > this.maxText) {
45
+ block.content = block.content.slice(-this.maxText);
46
+ }
47
+ }
48
+ }
49
+ }
50
+ getSnapshot() {
51
+ return {
52
+ rawChunks: this.rawChunks,
53
+ textContent: this.textContent,
54
+ totalChars: this.totalChars,
55
+ blocks: this.blocks.length > 0 ? this.blocks : undefined,
56
+ };
57
+ }
58
+ }
@@ -3,13 +3,14 @@ import type { FastifyPluginCallback } from "fastify";
3
3
  import { RetryRuleMatcher } from "./retry-rules.js";
4
4
  import { ProviderSemaphoreManager } from "./semaphore.js";
5
5
  import type { RequestTracker } from "../monitor/request-tracker.js";
6
+ import type { UsageWindowTracker } from "./usage-window-tracker.js";
6
7
  export interface AnthropicProxyOptions {
7
8
  db: Database.Database;
8
9
  streamTimeoutMs: number;
9
- retryMaxAttempts: number;
10
10
  retryBaseDelayMs: number;
11
11
  matcher?: RetryRuleMatcher;
12
12
  semaphoreManager?: ProviderSemaphoreManager;
13
13
  tracker?: RequestTracker;
14
+ usageWindowTracker?: UsageWindowTracker;
14
15
  }
15
16
  export declare const anthropicProxy: FastifyPluginCallback<AnthropicProxyOptions>;
@@ -14,14 +14,14 @@ const ANTHROPIC_ERROR_TYPE = {
14
14
  };
15
15
  const anthropicErrors = createErrorFormatter((kind, message) => ({ type: "error", error: { type: ANTHROPIC_ERROR_TYPE[kind], message } }));
16
16
  const anthropicProxyRaw = (app, opts, done) => {
17
- const { db, streamTimeoutMs, retryMaxAttempts, retryBaseDelayMs, matcher, semaphoreManager, tracker } = opts;
17
+ const { db, streamTimeoutMs, retryBaseDelayMs, matcher, semaphoreManager, tracker, usageWindowTracker } = opts;
18
18
  const orchestrator = createOrchestrator(semaphoreManager, tracker);
19
19
  app.post(MESSAGES_PATH, async (request, reply) => {
20
20
  if (!orchestrator) {
21
21
  const e = anthropicErrors.providerUnavailable();
22
- return reply.status(e.statusCode).send(e.body);
22
+ return reply.code(e.statusCode).send(e.body);
23
23
  }
24
- const deps = { db, streamTimeoutMs, retryMaxAttempts, retryBaseDelayMs, matcher, tracker, orchestrator };
24
+ const deps = { db, streamTimeoutMs, retryBaseDelayMs, matcher, tracker, orchestrator, usageWindowTracker };
25
25
  return handleProxyRequest(request, reply, "anthropic", MESSAGES_PATH, anthropicErrors, deps);
26
26
  });
27
27
  done();
@@ -0,0 +1,18 @@
1
+ /** synthetic tool_use 的 ID 前缀,用于识别我们的 AskUserQuestion 响应 */
2
+ export declare const TOOL_USE_ID_PREFIX = "toolu_router_";
3
+ export interface DirectiveParseResult {
4
+ modelName: string | null;
5
+ command: string | null;
6
+ cleanedBody: Record<string, unknown>;
7
+ isCommandMessage: boolean;
8
+ }
9
+ export declare function parseDirective(body: Record<string, unknown>): DirectiveParseResult;
10
+ export interface ToolResultParseResult {
11
+ isRouterToolResult: boolean;
12
+ selectedModel: string | null;
13
+ }
14
+ /**
15
+ * 检测请求中是否包含对 router synthetic AskUserQuestion 的 tool_result 回调,
16
+ * 如果是,从中提取用户选择的模型名。
17
+ */
18
+ export declare function parseToolResult(body: Record<string, unknown>): ToolResultParseResult;
@@ -1,5 +1,12 @@
1
1
  const MODEL_MAX_LEN = 128;
2
2
  const MODEL_RE = /^[a-zA-Z0-9][a-zA-Z0-9._:-]*$/;
3
+ /** synthetic tool_use 的 ID 前缀,用于识别我们的 AskUserQuestion 响应 */
4
+ export const TOOL_USE_ID_PREFIX = "toolu_router_";
5
+ /**
6
+ * 从 Claude Code AskUserQuestion 的 tool_result content 文本中提取用户选择。
7
+ * 格式:User has answered your questions: "question"="answer". ...
8
+ */
9
+ const RE_TOOL_RESULT_ANSWER = /="([^"]+)"\./;
3
10
  function isValidModelName(name) {
4
11
  return name.length <= MODEL_MAX_LEN && MODEL_RE.test(name) && !/^\d+$/.test(name);
5
12
  }
@@ -68,3 +75,40 @@ export function parseDirective(body) {
68
75
  }
69
76
  return { modelName, command, cleanedBody, isCommandMessage };
70
77
  }
78
+ /**
79
+ * 检测请求中是否包含对 router synthetic AskUserQuestion 的 tool_result 回调,
80
+ * 如果是,从中提取用户选择的模型名。
81
+ */
82
+ export function parseToolResult(body) {
83
+ const messages = body.messages;
84
+ if (!messages?.length)
85
+ return { isRouterToolResult: false, selectedModel: null };
86
+ let lastUserIdx = -1;
87
+ for (let i = messages.length - 1; i >= 0; i--) {
88
+ if (messages[i].role === "user") {
89
+ lastUserIdx = i;
90
+ break;
91
+ }
92
+ }
93
+ if (lastUserIdx < 0)
94
+ return { isRouterToolResult: false, selectedModel: null };
95
+ const lastUser = messages[lastUserIdx];
96
+ const blocks = Array.isArray(lastUser.content) ? lastUser.content : [lastUser.content];
97
+ for (const block of blocks) {
98
+ if (!block || typeof block !== "object")
99
+ continue;
100
+ const b = block;
101
+ if (b.type !== "tool_result" || typeof b.tool_use_id !== "string")
102
+ continue;
103
+ if (!b.tool_use_id.startsWith(TOOL_USE_ID_PREFIX))
104
+ continue;
105
+ const text = typeof b.content === "string" ? b.content : "";
106
+ const match = RE_TOOL_RESULT_ANSWER.exec(text);
107
+ if (match?.[1]) {
108
+ return { isRouterToolResult: true, selectedModel: match[1] };
109
+ }
110
+ // tool_result 匹配前缀但无法提取模型名
111
+ return { isRouterToolResult: true, selectedModel: null };
112
+ }
113
+ return { isRouterToolResult: false, selectedModel: null };
114
+ }