llm-simple-router 0.2.0 → 0.3.5

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 (112) hide show
  1. package/dist/admin/monitor.d.ts +7 -0
  2. package/dist/admin/monitor.js +25 -0
  3. package/dist/admin/providers.d.ts +4 -0
  4. package/dist/admin/providers.js +57 -9
  5. package/dist/admin/retry-rules.js +6 -3
  6. package/dist/admin/routes.d.ts +4 -0
  7. package/dist/admin/routes.js +3 -1
  8. package/dist/admin/setup.js +8 -5
  9. package/dist/cli.js +0 -0
  10. package/dist/db/index.d.ts +1 -1
  11. package/dist/db/index.js +1 -1
  12. package/dist/db/mappings.js +6 -2
  13. package/dist/db/migrations/017_add_provider_concurrency.sql +3 -0
  14. package/dist/db/providers.d.ts +12 -1
  15. package/dist/db/providers.js +8 -3
  16. package/dist/db/retry-rules.js +4 -1
  17. package/dist/db/router-keys.js +3 -1
  18. package/dist/index.js +36 -5
  19. package/dist/metrics/sse-metrics-transform.d.ts +17 -1
  20. package/dist/metrics/sse-metrics-transform.js +33 -2
  21. package/dist/middleware/auth.js +5 -4
  22. package/dist/monitor/request-tracker.d.ts +49 -0
  23. package/dist/monitor/request-tracker.js +279 -0
  24. package/dist/monitor/runtime-collector.d.ts +11 -0
  25. package/dist/monitor/runtime-collector.js +41 -0
  26. package/dist/monitor/stats-aggregator.d.ts +22 -0
  27. package/dist/monitor/stats-aggregator.js +166 -0
  28. package/dist/monitor/types.d.ts +84 -0
  29. package/dist/monitor/types.js +1 -0
  30. package/dist/proxy/anthropic.d.ts +4 -0
  31. package/dist/proxy/anthropic.js +10 -2
  32. package/dist/proxy/enhancement-handler.js +3 -1
  33. package/dist/proxy/mapping-resolver.js +6 -2
  34. package/dist/proxy/openai.d.ts +4 -0
  35. package/dist/proxy/openai.js +10 -2
  36. package/dist/proxy/proxy-core.d.ts +6 -0
  37. package/dist/proxy/proxy-core.js +176 -85
  38. package/dist/proxy/retry.d.ts +1 -1
  39. package/dist/proxy/retry.js +3 -2
  40. package/dist/proxy/semaphore.d.ts +27 -0
  41. package/dist/proxy/semaphore.js +125 -0
  42. package/dist/utils/password.js +2 -1
  43. package/frontend-dist/assets/CardContent-B40ArIqh.js +1 -0
  44. package/frontend-dist/assets/{CardHeader-D5lVaeAA.js → CardHeader-BjkSQf27.js} +1 -1
  45. package/frontend-dist/assets/CardTitle-DjG2kSF3.js +1 -0
  46. package/frontend-dist/assets/Checkbox-Cw0rq2D9.js +1 -0
  47. package/frontend-dist/assets/CollapsibleTrigger-BvYqNbGA.js +1 -0
  48. package/frontend-dist/assets/Collection-CQ4pV54w.js +3 -0
  49. package/frontend-dist/assets/Dashboard-CsOTBnSa.js +3 -0
  50. package/frontend-dist/assets/DialogTitle-PS2W-IfG.js +1 -0
  51. package/frontend-dist/assets/Input-toxjzsir.js +1 -0
  52. package/frontend-dist/assets/Label-fZNDEQjf.js +1 -0
  53. package/frontend-dist/assets/LogResponseViewer-B9kSncNr.js +3 -0
  54. package/frontend-dist/assets/Login-DRm9DHq1.js +1 -0
  55. package/frontend-dist/assets/Logs-NHxebwmP.js +1 -0
  56. package/frontend-dist/assets/ModelMappings-DV0RPnO2.js +1 -0
  57. package/frontend-dist/assets/Monitor-B5TYWb2n.js +1 -0
  58. package/frontend-dist/assets/PopperContent-BvKlcZEO.js +1 -0
  59. package/frontend-dist/assets/Providers-D1Rauu-D.js +1 -0
  60. package/frontend-dist/assets/ProxyEnhancement-B2OliarO.js +5 -0
  61. package/frontend-dist/assets/RetryRules-BWu2gicT.js +1 -0
  62. package/frontend-dist/assets/RouterKeys-BP6XJCVa.js +1 -0
  63. package/frontend-dist/assets/RovingFocusItem-DHfpgdA0.js +1 -0
  64. package/frontend-dist/assets/SelectValue-CFf_mD9E.js +1 -0
  65. package/frontend-dist/assets/Setup-BMjCT-Tl.js +1 -0
  66. package/frontend-dist/assets/Switch-BGCQ7puL.js +1 -0
  67. package/frontend-dist/assets/TableHeader-DAOs6nSA.js +1 -0
  68. package/frontend-dist/assets/TabsTrigger-DBAYM66g.js +1 -0
  69. package/frontend-dist/assets/VisuallyHidden-Dh7svQf3.js +1 -0
  70. package/frontend-dist/assets/VisuallyHiddenInput-BOaRSEmd.js +1 -0
  71. package/frontend-dist/assets/alert-dialog-CUNSZqpB.js +1 -0
  72. package/frontend-dist/assets/button-CfQs66fX.js +1 -0
  73. package/frontend-dist/assets/client-DvdghFBq.js +12 -0
  74. package/frontend-dist/assets/createLucideIcon-DCD7INQf.js +1 -0
  75. package/frontend-dist/assets/dialog-DQFRGKR6.js +1 -0
  76. package/frontend-dist/assets/index--5JhZIwi.js +1 -0
  77. package/frontend-dist/assets/index-Bx15k8FA.css +1 -0
  78. package/frontend-dist/assets/lib-BJNsNHLO.js +1 -0
  79. package/frontend-dist/assets/ohash.D__AXeF1-CNudYmrX.js +1 -0
  80. package/frontend-dist/assets/useClipboard-aPMKfK25.js +1 -0
  81. package/frontend-dist/assets/useForwardExpose-u2vjohek.js +1 -0
  82. package/frontend-dist/assets/useNonce-ClXGIm-8.js +1 -0
  83. package/frontend-dist/assets/x-ILQhskuj.js +1 -0
  84. package/frontend-dist/index.html +7 -6
  85. package/package.json +1 -1
  86. package/dist/admin/services.d.ts +0 -7
  87. package/dist/admin/services.js +0 -63
  88. package/frontend-dist/assets/CardContent-BE9fukPi.js +0 -1
  89. package/frontend-dist/assets/CardTitle-H-zwhi3Z.js +0 -1
  90. package/frontend-dist/assets/Checkbox--1gw0dYW.js +0 -1
  91. package/frontend-dist/assets/CollapsibleTrigger-D_ptA35Y.js +0 -1
  92. package/frontend-dist/assets/Dashboard-D4AwkULO.js +0 -3
  93. package/frontend-dist/assets/Label-GiPfoz7u.js +0 -1
  94. package/frontend-dist/assets/Login-BUet1sbM.js +0 -1
  95. package/frontend-dist/assets/Logs-yztb_F9t.js +0 -3
  96. package/frontend-dist/assets/ModelMappings-MbZhdPNv.js +0 -1
  97. package/frontend-dist/assets/Providers-BjsqH6A2.js +0 -1
  98. package/frontend-dist/assets/RetryRules-C2vvJvLr.js +0 -1
  99. package/frontend-dist/assets/RouterKeys-DavrgpAQ.js +0 -1
  100. package/frontend-dist/assets/RovingFocusItem-DnIa_lwH.js +0 -1
  101. package/frontend-dist/assets/SelectValue-BB0Ckbjh.js +0 -1
  102. package/frontend-dist/assets/TableHeader-D2GkiqRx.js +0 -1
  103. package/frontend-dist/assets/alert-dialog-CWjBke-O.js +0 -1
  104. package/frontend-dist/assets/badge-_ZHrMEpC.js +0 -3
  105. package/frontend-dist/assets/button-C4_mChkc.js +0 -1
  106. package/frontend-dist/assets/client-BWw0R36V.js +0 -12
  107. package/frontend-dist/assets/dialog-CUHMcTqp.js +0 -1
  108. package/frontend-dist/assets/index-DEl48bm9.css +0 -1
  109. package/frontend-dist/assets/index-UZK1BnPG.js +0 -1
  110. package/frontend-dist/assets/lib-Qs8xoTas.js +0 -1
  111. package/frontend-dist/assets/useForwardExpose-B-xauF1X.js +0 -1
  112. package/frontend-dist/assets/x-JBJB26JV.js +0 -1
@@ -0,0 +1,7 @@
1
+ import { FastifyPluginCallback } from "fastify";
2
+ import type { RequestTracker } from "../monitor/request-tracker.js";
3
+ interface MonitorRoutesOptions {
4
+ tracker?: RequestTracker;
5
+ }
6
+ export declare const adminMonitorRoutes: FastifyPluginCallback<MonitorRoutesOptions>;
7
+ export {};
@@ -0,0 +1,25 @@
1
+ const HTTP_OK = 200;
2
+ export const adminMonitorRoutes = (app, options, done) => {
3
+ const { tracker } = options;
4
+ if (!tracker) {
5
+ done();
6
+ return;
7
+ }
8
+ app.get("/admin/api/monitor/active", async () => tracker.getActive());
9
+ app.get("/admin/api/monitor/recent", async () => tracker.getRecent());
10
+ app.get("/admin/api/monitor/stats", async () => tracker.getStats());
11
+ app.get("/admin/api/monitor/concurrency", async () => tracker.getConcurrency());
12
+ app.get("/admin/api/monitor/runtime", async () => tracker.getRuntime());
13
+ app.get("/admin/api/monitor/stream", (request, reply) => {
14
+ reply.raw.writeHead(HTTP_OK, {
15
+ "Content-Type": "text/event-stream",
16
+ "Cache-Control": "no-cache",
17
+ Connection: "keep-alive",
18
+ });
19
+ tracker.addClient(reply.raw);
20
+ request.raw.on("close", () => {
21
+ tracker.removeClient(reply.raw);
22
+ });
23
+ });
24
+ done();
25
+ };
@@ -1,7 +1,11 @@
1
1
  import { FastifyPluginCallback } from "fastify";
2
2
  import Database from "better-sqlite3";
3
+ import { ProviderSemaphoreManager } from "../proxy/semaphore.js";
4
+ import type { RequestTracker } from "../monitor/request-tracker.js";
3
5
  interface ProviderRoutesOptions {
4
6
  db: Database.Database;
7
+ semaphoreManager?: ProviderSemaphoreManager;
8
+ tracker?: RequestTracker;
5
9
  }
6
10
  export declare const adminProviderRoutes: FastifyPluginCallback<ProviderRoutesOptions>;
7
11
  export {};
@@ -1,9 +1,9 @@
1
1
  import { Type } from "@sinclair/typebox";
2
- import { getAllProviders, getProviderById, createProvider, updateProvider, deleteProvider, getAllMappingGroups } from "../db/index.js";
2
+ import { getAllProviders, getProviderById, createProvider, updateProvider, deleteProvider, getAllMappingGroups, PROVIDER_CONCURRENCY_DEFAULTS } from "../db/index.js";
3
3
  import { encrypt, decrypt } from "../utils/crypto.js";
4
4
  import { getSetting } from "../db/settings.js";
5
- import { HTTP_CREATED, HTTP_NOT_FOUND, HTTP_CONFLICT } from "./constants.js";
6
- const API_KEY_PREVIEW_MIN_LEN = 8;
5
+ import { HTTP_CREATED, HTTP_NOT_FOUND, HTTP_CONFLICT, HTTP_BAD_REQUEST } from "./constants.js";
6
+ const API_KEY_PREVIEW_MIN_LENGTH = 8;
7
7
  const API_KEY_PREVIEW_PREFIX_LEN = 4;
8
8
  const PROVIDER_NAME_RE = /^[a-zA-Z0-9_-]+$/;
9
9
  const CreateProviderSchema = Type.Object({
@@ -13,6 +13,9 @@ const CreateProviderSchema = Type.Object({
13
13
  api_key: Type.String({ minLength: 1 }),
14
14
  models: Type.Optional(Type.Array(Type.String())),
15
15
  is_active: Type.Optional(Type.Number()),
16
+ max_concurrency: Type.Optional(Type.Integer({ minimum: 0 })),
17
+ queue_timeout_ms: Type.Optional(Type.Integer({ minimum: 0 })),
18
+ max_queue_size: Type.Optional(Type.Integer({ minimum: 1 })),
16
19
  });
17
20
  const UpdateProviderSchema = Type.Object({
18
21
  name: Type.Optional(Type.String({ minLength: 1 })),
@@ -21,9 +24,12 @@ const UpdateProviderSchema = Type.Object({
21
24
  api_key: Type.Optional(Type.String({ minLength: 1 })),
22
25
  models: Type.Optional(Type.Array(Type.String())),
23
26
  is_active: Type.Optional(Type.Number()),
27
+ max_concurrency: Type.Optional(Type.Integer({ minimum: 0 })),
28
+ queue_timeout_ms: Type.Optional(Type.Integer({ minimum: 0 })),
29
+ max_queue_size: Type.Optional(Type.Integer({ minimum: 1 })),
24
30
  });
25
31
  export const adminProviderRoutes = (app, options, done) => {
26
- const { db } = options;
32
+ const { db, semaphoreManager, tracker } = options;
27
33
  app.get("/admin/api/providers", async (_request, reply) => {
28
34
  const encryptionKey = getSetting(db, "encryption_key");
29
35
  const providers = getAllProviders(db);
@@ -35,6 +41,10 @@ export const adminProviderRoutes = (app, options, done) => {
35
41
  api_key: s.api_key ? decrypt(s.api_key, encryptionKey) : "",
36
42
  models: JSON.parse(s.models || "[]"),
37
43
  is_active: s.is_active,
44
+ max_concurrency: s.max_concurrency,
45
+ queue_timeout_ms: s.queue_timeout_ms,
46
+ max_queue_size: s.max_queue_size,
47
+ concurrency_status: semaphoreManager?.getStatus(s.id) ?? { active: 0, queued: 0 },
38
48
  created_at: s.created_at,
39
49
  updated_at: s.updated_at,
40
50
  })));
@@ -42,7 +52,7 @@ export const adminProviderRoutes = (app, options, done) => {
42
52
  app.post("/admin/api/providers", { schema: { body: CreateProviderSchema } }, async (request, reply) => {
43
53
  const body = request.body;
44
54
  if (!PROVIDER_NAME_RE.test(body.name)) {
45
- return reply.status(400).send({ error: { message: "Provider 名称仅允许英文大小写字母、数字、横线和下划线" } });
55
+ return reply.status(HTTP_BAD_REQUEST).send({ error: { message: "Provider 名称仅允许英文大小写字母、数字、横线和下划线" } });
46
56
  }
47
57
  const encryptedKey = encrypt(body.api_key, getSetting(db, "encryption_key"));
48
58
  const id = createProvider(db, {
@@ -50,9 +60,23 @@ export const adminProviderRoutes = (app, options, done) => {
50
60
  api_type: body.api_type,
51
61
  base_url: body.base_url,
52
62
  api_key: encryptedKey,
53
- api_key_preview: body.api_key.length > 8 ? `${body.api_key.slice(0, 4)}...${body.api_key.slice(-4)}` : "****",
63
+ api_key_preview: body.api_key.length > API_KEY_PREVIEW_MIN_LENGTH ? `${body.api_key.slice(0, API_KEY_PREVIEW_PREFIX_LEN)}...${body.api_key.slice(-API_KEY_PREVIEW_PREFIX_LEN)}` : "****",
54
64
  models: JSON.stringify(body.models ?? []),
55
65
  is_active: body.is_active ?? 1,
66
+ max_concurrency: body.max_concurrency ?? PROVIDER_CONCURRENCY_DEFAULTS.max_concurrency,
67
+ queue_timeout_ms: body.queue_timeout_ms ?? PROVIDER_CONCURRENCY_DEFAULTS.queue_timeout_ms,
68
+ max_queue_size: body.max_queue_size ?? PROVIDER_CONCURRENCY_DEFAULTS.max_queue_size,
69
+ });
70
+ semaphoreManager?.updateConfig(id, {
71
+ maxConcurrency: body.max_concurrency ?? PROVIDER_CONCURRENCY_DEFAULTS.max_concurrency,
72
+ queueTimeoutMs: body.queue_timeout_ms ?? PROVIDER_CONCURRENCY_DEFAULTS.queue_timeout_ms,
73
+ maxQueueSize: body.max_queue_size ?? PROVIDER_CONCURRENCY_DEFAULTS.max_queue_size,
74
+ });
75
+ tracker?.updateProviderConfig(id, {
76
+ name: body.name,
77
+ maxConcurrency: body.max_concurrency ?? PROVIDER_CONCURRENCY_DEFAULTS.max_concurrency,
78
+ queueTimeoutMs: body.queue_timeout_ms ?? PROVIDER_CONCURRENCY_DEFAULTS.queue_timeout_ms,
79
+ maxQueueSize: body.max_queue_size ?? PROVIDER_CONCURRENCY_DEFAULTS.max_queue_size,
56
80
  });
57
81
  return reply.code(HTTP_CREATED).send({ id });
58
82
  });
@@ -64,7 +88,7 @@ export const adminProviderRoutes = (app, options, done) => {
64
88
  }
65
89
  const body = request.body;
66
90
  if (body.name !== undefined && !PROVIDER_NAME_RE.test(body.name)) {
67
- return reply.status(400).send({ error: { message: "Provider 名称仅允许英文大小写字母、数字、横线和下划线" } });
91
+ return reply.status(HTTP_BAD_REQUEST).send({ error: { message: "Provider 名称仅允许英文大小写字母、数字、横线和下划线" } });
68
92
  }
69
93
  const fields = {};
70
94
  if (body.name !== undefined)
@@ -77,11 +101,31 @@ export const adminProviderRoutes = (app, options, done) => {
77
101
  fields.is_active = body.is_active;
78
102
  if (body.models !== undefined)
79
103
  fields.models = JSON.stringify(body.models);
104
+ if (body.max_concurrency !== undefined)
105
+ fields.max_concurrency = body.max_concurrency;
106
+ if (body.queue_timeout_ms !== undefined)
107
+ fields.queue_timeout_ms = body.queue_timeout_ms;
108
+ if (body.max_queue_size !== undefined)
109
+ fields.max_queue_size = body.max_queue_size;
80
110
  if (body.api_key) {
81
111
  fields.api_key = encrypt(body.api_key, getSetting(db, "encryption_key"));
82
- fields.api_key_preview = body.api_key.length > 8 ? `${body.api_key.slice(0, 4)}...${body.api_key.slice(-4)}` : "****";
112
+ fields.api_key_preview = body.api_key.length > API_KEY_PREVIEW_MIN_LENGTH ? `${body.api_key.slice(0, API_KEY_PREVIEW_PREFIX_LEN)}...${body.api_key.slice(-API_KEY_PREVIEW_PREFIX_LEN)}` : "****";
83
113
  }
84
114
  updateProvider(db, id, fields);
115
+ const updated = getProviderById(db, id);
116
+ if (body.max_concurrency !== undefined || body.queue_timeout_ms !== undefined || body.max_queue_size !== undefined) {
117
+ semaphoreManager?.updateConfig(id, {
118
+ maxConcurrency: updated.max_concurrency,
119
+ queueTimeoutMs: updated.queue_timeout_ms,
120
+ maxQueueSize: updated.max_queue_size,
121
+ });
122
+ }
123
+ tracker?.updateProviderConfig(id, {
124
+ name: body.name ?? existing.name,
125
+ maxConcurrency: updated.max_concurrency,
126
+ queueTimeoutMs: updated.queue_timeout_ms,
127
+ maxQueueSize: updated.max_queue_size,
128
+ });
85
129
  return reply.send({ success: true });
86
130
  });
87
131
  app.delete("/admin/api/providers/:id", async (request, reply) => {
@@ -95,9 +139,13 @@ export const adminProviderRoutes = (app, options, done) => {
95
139
  return reply.code(HTTP_CONFLICT).send({ error: { message: `Provider is referenced by mapping group '${g.client_model}'` } });
96
140
  }
97
141
  }
98
- catch { /* rule format invalid, skip */ }
142
+ catch {
143
+ continue;
144
+ }
99
145
  }
100
146
  deleteProvider(db, id);
147
+ semaphoreManager?.remove(id);
148
+ tracker?.removeProviderConfig(id);
101
149
  return reply.send({ success: true });
102
150
  });
103
151
  done();
@@ -1,6 +1,9 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import { getAllRetryRules, createRetryRule, updateRetryRule, deleteRetryRule, } from "../db/index.js";
3
3
  import { HTTP_BAD_REQUEST, HTTP_CREATED } from "./constants.js";
4
+ const DEFAULT_RETRY_DELAY_MS = 5000;
5
+ const DEFAULT_MAX_RETRIES = 10;
6
+ const DEFAULT_MAX_DELAY_MS = 60000;
4
7
  const CreateRetryRuleSchema = Type.Object({
5
8
  name: Type.String({ minLength: 1 }),
6
9
  status_code: Type.Number({ minimum: 100, maximum: 599 }),
@@ -52,9 +55,9 @@ export const adminRetryRuleRoutes = (app, options, done) => {
52
55
  body_pattern: body.body_pattern,
53
56
  is_active: body.is_active ?? 1,
54
57
  retry_strategy: body.retry_strategy ?? "exponential",
55
- retry_delay_ms: body.retry_delay_ms ?? 5000,
56
- max_retries: body.max_retries ?? 10,
57
- max_delay_ms: body.max_delay_ms ?? 60000,
58
+ retry_delay_ms: body.retry_delay_ms ?? DEFAULT_RETRY_DELAY_MS,
59
+ max_retries: body.max_retries ?? DEFAULT_MAX_RETRIES,
60
+ max_delay_ms: body.max_delay_ms ?? DEFAULT_MAX_DELAY_MS,
58
61
  });
59
62
  refreshMatcher(matcher, db);
60
63
  return reply.code(HTTP_CREATED).send({ id });
@@ -1,9 +1,13 @@
1
1
  import { FastifyPluginCallback } from "fastify";
2
2
  import Database from "better-sqlite3";
3
3
  import { RetryRuleMatcher } from "../proxy/retry-rules.js";
4
+ import type { RequestTracker } from "../monitor/request-tracker.js";
5
+ import { ProviderSemaphoreManager } from "../proxy/semaphore.js";
4
6
  interface AdminRoutesOptions {
5
7
  db: Database.Database;
6
8
  matcher: RetryRuleMatcher | null;
9
+ tracker?: RequestTracker;
10
+ semaphoreManager?: ProviderSemaphoreManager;
7
11
  }
8
12
  export declare const adminRoutes: FastifyPluginCallback<AdminRoutesOptions>;
9
13
  export {};
@@ -9,12 +9,13 @@ import { adminMetricsRoutes } from "./metrics.js";
9
9
  import { adminProxyEnhancementRoutes } from "./proxy-enhancement.js";
10
10
  import { adminRouterKeyRoutes } from "./router-keys.js";
11
11
  import { adminSetupRoutes } from "./setup.js";
12
+ import { adminMonitorRoutes } from "./monitor.js";
12
13
  export const adminRoutes = (app, options, done) => {
13
14
  // Setup 路由不需要 auth
14
15
  app.register(adminSetupRoutes, { db: options.db });
15
16
  app.register(adminAuthPlugin, { db: options.db });
16
17
  app.register(adminLoginRoutes, { db: options.db });
17
- app.register(adminProviderRoutes, { db: options.db });
18
+ app.register(adminProviderRoutes, { db: options.db, semaphoreManager: options.semaphoreManager, tracker: options.tracker });
18
19
  app.register(adminMappingRoutes, { db: options.db });
19
20
  app.register(adminGroupRoutes, { db: options.db });
20
21
  app.register(adminRetryRuleRoutes, { db: options.db, matcher: options.matcher });
@@ -23,5 +24,6 @@ export const adminRoutes = (app, options, done) => {
23
24
  app.register(adminStatsRoutes, { db: options.db });
24
25
  app.register(adminMetricsRoutes, { db: options.db });
25
26
  app.register(adminProxyEnhancementRoutes, { db: options.db });
27
+ app.register(adminMonitorRoutes, { tracker: options.tracker });
26
28
  done();
27
29
  };
@@ -2,6 +2,9 @@ import { randomBytes } from "node:crypto";
2
2
  import jwt from "jsonwebtoken";
3
3
  import { getSetting, setSetting, isInitialized } from "../db/settings.js";
4
4
  import { hashPassword } from "../utils/password.js";
5
+ import { HTTP_BAD_REQUEST, HTTP_CONFLICT } from "./constants.js";
6
+ const CRYPTO_BYTES_LENGTH = 32;
7
+ const MIN_PASSWORD_LENGTH = 6;
5
8
  export const adminSetupRoutes = (app, options, done) => {
6
9
  const { db } = options;
7
10
  app.get("/admin/api/setup/status", async () => {
@@ -9,15 +12,15 @@ export const adminSetupRoutes = (app, options, done) => {
9
12
  });
10
13
  app.post("/admin/api/setup/initialize", async (request, reply) => {
11
14
  const { password } = request.body;
12
- if (!password || password.length < 6) { // eslint-disable-line no-magic-numbers
13
- return reply.code(400).send({ error: { message: "Password must be at least 6 characters" } });
15
+ if (!password || password.length < MIN_PASSWORD_LENGTH) {
16
+ return reply.code(HTTP_BAD_REQUEST).send({ error: { message: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` } });
14
17
  }
15
18
  // 事务中原子检查防竞态
16
19
  const alreadyInitialized = db.transaction(() => {
17
20
  if (isInitialized(db))
18
21
  return true;
19
- const encryptionKey = randomBytes(32).toString("hex");
20
- const jwtSecret = randomBytes(32).toString("hex");
22
+ const encryptionKey = randomBytes(CRYPTO_BYTES_LENGTH).toString("hex");
23
+ const jwtSecret = randomBytes(CRYPTO_BYTES_LENGTH).toString("hex");
21
24
  setSetting(db, "admin_password_hash", hashPassword(password));
22
25
  setSetting(db, "encryption_key", encryptionKey);
23
26
  setSetting(db, "jwt_secret", jwtSecret);
@@ -25,7 +28,7 @@ export const adminSetupRoutes = (app, options, done) => {
25
28
  return false;
26
29
  })();
27
30
  if (alreadyInitialized) {
28
- return reply.code(409).send({ error: { message: "Already initialized" } });
31
+ return reply.code(HTTP_CONFLICT).send({ error: { message: "Already initialized" } });
29
32
  }
30
33
  // 自动登录:签发 JWT
31
34
  const TOKEN_EXPIRY_SECONDS = 172800; // 48 hours,与 admin-auth 保持一致
package/dist/cli.js CHANGED
File without changes
@@ -1,6 +1,6 @@
1
1
  import Database from "better-sqlite3";
2
2
  export declare function initDatabase(dbPath: string): Database.Database;
3
- export { getActiveProviders, getAllProviders, getProviderById, createProvider, updateProvider, deleteProvider, } from "./providers.js";
3
+ export { getActiveProviders, getAllProviders, getProviderById, createProvider, updateProvider, deleteProvider, PROVIDER_CONCURRENCY_DEFAULTS, } from "./providers.js";
4
4
  export type { Provider } from "./providers.js";
5
5
  export { getModelMapping, getAllModelMappings, createModelMapping, updateModelMapping, deleteModelMapping, getMappingGroup, getMappingGroupById, getAllMappingGroups, createMappingGroup, updateMappingGroup, deleteMappingGroup, getActiveProviderModels, resolveByProviderModel, } from "./mappings.js";
6
6
  export type { ModelMapping, MappingGroup, ProviderModelEntry } from "./mappings.js";
package/dist/db/index.js CHANGED
@@ -36,7 +36,7 @@ export function initDatabase(dbPath) {
36
36
  return db;
37
37
  }
38
38
  // --- Re-export from per-table modules ---
39
- export { getActiveProviders, getAllProviders, getProviderById, createProvider, updateProvider, deleteProvider, } from "./providers.js";
39
+ export { getActiveProviders, getAllProviders, getProviderById, createProvider, updateProvider, deleteProvider, PROVIDER_CONCURRENCY_DEFAULTS, } from "./providers.js";
40
40
  export { getModelMapping, getAllModelMappings, createModelMapping, updateModelMapping, deleteModelMapping, getMappingGroup, getMappingGroupById, getAllMappingGroups, createMappingGroup, updateMappingGroup, deleteMappingGroup, getActiveProviderModels, resolveByProviderModel, } from "./mappings.js";
41
41
  export { getActiveRetryRules, getAllRetryRules, createRetryRule, updateRetryRule, deleteRetryRule, seedDefaultRules, } from "./retry-rules.js";
42
42
  export { insertRequestLog, getRequestLogs, getRequestLogById, deleteLogsBefore, insertMetrics, } from "./logs.js";
@@ -64,7 +64,9 @@ export function getActiveProviderModels(db) {
64
64
  results.push({ provider_name: p.name, backend_model: m });
65
65
  }
66
66
  }
67
- catch { /* 忽略解析失败 */ }
67
+ catch {
68
+ continue;
69
+ }
68
70
  }
69
71
  return results;
70
72
  }
@@ -120,7 +122,9 @@ export function resolveByProviderModel(db, providerName, backendModel) {
120
122
  return { client_model: g.client_model, provider_id: providerRow.id, backend_model: backendModel };
121
123
  }
122
124
  }
123
- catch { /* continue */ }
125
+ catch {
126
+ continue;
127
+ }
124
128
  }
125
129
  // provider 有这个模型但没有 mapping group,直接返回 provider 维度信息
126
130
  return { client_model: backendModel, provider_id: providerRow.id, backend_model: backendModel };
@@ -0,0 +1,3 @@
1
+ ALTER TABLE providers ADD COLUMN max_concurrency INTEGER NOT NULL DEFAULT 0;
2
+ ALTER TABLE providers ADD COLUMN queue_timeout_ms INTEGER NOT NULL DEFAULT 0;
3
+ ALTER TABLE providers ADD COLUMN max_queue_size INTEGER NOT NULL DEFAULT 100;
@@ -8,9 +8,17 @@ export interface Provider {
8
8
  api_key_preview?: string;
9
9
  models: string;
10
10
  is_active: number;
11
+ max_concurrency: number;
12
+ queue_timeout_ms: number;
13
+ max_queue_size: number;
11
14
  created_at: string;
12
15
  updated_at: string;
13
16
  }
17
+ export declare const PROVIDER_CONCURRENCY_DEFAULTS: {
18
+ readonly max_concurrency: 0;
19
+ readonly queue_timeout_ms: 0;
20
+ readonly max_queue_size: 100;
21
+ };
14
22
  export declare function getActiveProviders(db: Database.Database, apiType: "openai" | "anthropic"): Provider[];
15
23
  export declare function getAllProviders(db: Database.Database): Provider[];
16
24
  export declare function getProviderById(db: Database.Database, id: string): Provider | undefined;
@@ -22,6 +30,9 @@ export declare function createProvider(db: Database.Database, provider: {
22
30
  api_key_preview?: string;
23
31
  models?: string;
24
32
  is_active?: number;
33
+ max_concurrency?: number;
34
+ queue_timeout_ms?: number;
35
+ max_queue_size?: number;
25
36
  }): string;
26
- export declare function updateProvider(db: Database.Database, id: string, fields: Partial<Pick<Provider, "name" | "api_type" | "base_url" | "api_key" | "api_key_preview" | "is_active">>): void;
37
+ export declare function updateProvider(db: Database.Database, id: string, fields: Partial<Pick<Provider, "name" | "api_type" | "base_url" | "api_key" | "api_key_preview" | "models" | "is_active" | "max_concurrency" | "queue_timeout_ms" | "max_queue_size">>): void;
27
38
  export declare function deleteProvider(db: Database.Database, id: string): void;
@@ -1,7 +1,12 @@
1
1
  import { randomUUID } from "crypto";
2
2
  import { buildUpdateQuery, deleteById } from "./helpers.js";
3
+ export const PROVIDER_CONCURRENCY_DEFAULTS = {
4
+ max_concurrency: 0,
5
+ queue_timeout_ms: 0,
6
+ max_queue_size: 100,
7
+ };
3
8
  const PROVIDER_FIELDS = new Set([
4
- "name", "api_type", "base_url", "api_key", "api_key_preview", "models", "is_active",
9
+ "name", "api_type", "base_url", "api_key", "api_key_preview", "models", "is_active", "max_concurrency", "queue_timeout_ms", "max_queue_size",
5
10
  ]);
6
11
  export function getActiveProviders(db, apiType) {
7
12
  return db
@@ -17,8 +22,8 @@ export function getProviderById(db, id) {
17
22
  export function createProvider(db, provider) {
18
23
  const id = randomUUID();
19
24
  const now = new Date().toISOString();
20
- db.prepare(`INSERT INTO providers (id, name, api_type, base_url, api_key, api_key_preview, models, is_active, created_at, updated_at)
21
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, provider.name, provider.api_type, provider.base_url, provider.api_key, provider.api_key_preview ?? null, provider.models ?? "[]", provider.is_active ?? 1, now, now);
25
+ db.prepare(`INSERT INTO providers (id, name, api_type, base_url, api_key, api_key_preview, models, is_active, max_concurrency, queue_timeout_ms, max_queue_size, created_at, updated_at)
26
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, provider.name, provider.api_type, provider.base_url, provider.api_key, provider.api_key_preview ?? null, provider.models ?? "[]", provider.is_active ?? 1, provider.max_concurrency ?? PROVIDER_CONCURRENCY_DEFAULTS.max_concurrency, provider.queue_timeout_ms ?? PROVIDER_CONCURRENCY_DEFAULTS.queue_timeout_ms, provider.max_queue_size ?? PROVIDER_CONCURRENCY_DEFAULTS.max_queue_size, now, now);
22
27
  return id;
23
28
  }
24
29
  export function updateProvider(db, id, fields) {
@@ -1,6 +1,9 @@
1
1
  import { randomUUID } from "crypto";
2
2
  import { buildUpdateQuery, deleteById } from "./helpers.js";
3
3
  const RETRY_FIELDS = new Set(["name", "status_code", "body_pattern", "is_active", "retry_strategy", "retry_delay_ms", "max_retries", "max_delay_ms"]);
4
+ const DEFAULT_RETRY_DELAY_MS = 5000;
5
+ const DEFAULT_MAX_RETRIES = 10;
6
+ const DEFAULT_MAX_DELAY_MS = 60000;
4
7
  export function getActiveRetryRules(db) {
5
8
  return db
6
9
  .prepare("SELECT * FROM retry_rules WHERE is_active = 1 ORDER BY created_at DESC")
@@ -15,7 +18,7 @@ export function createRetryRule(db, rule) {
15
18
  const id = randomUUID();
16
19
  const now = new Date().toISOString();
17
20
  db.prepare(`INSERT INTO retry_rules (id, name, status_code, body_pattern, is_active, created_at, retry_strategy, retry_delay_ms, max_retries, max_delay_ms)
18
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, rule.name, rule.status_code, rule.body_pattern, rule.is_active ?? 1, now, rule.retry_strategy ?? "exponential", rule.retry_delay_ms ?? 5000, rule.max_retries ?? 10, rule.max_delay_ms ?? 60000);
21
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, rule.name, rule.status_code, rule.body_pattern, rule.is_active ?? 1, now, rule.retry_strategy ?? "exponential", rule.retry_delay_ms ?? DEFAULT_RETRY_DELAY_MS, rule.max_retries ?? DEFAULT_MAX_RETRIES, rule.max_delay_ms ?? DEFAULT_MAX_DELAY_MS);
19
22
  return id;
20
23
  }
21
24
  export function updateRetryRule(db, id, fields) {
@@ -30,7 +30,9 @@ export function getAvailableModels(db) {
30
30
  try {
31
31
  JSON.parse(r.models || "[]").forEach((m) => set.add(m));
32
32
  }
33
- catch { /* skip invalid JSON */ }
33
+ catch {
34
+ continue;
35
+ }
34
36
  }
35
37
  return [...set].sort();
36
38
  }
package/dist/index.js CHANGED
@@ -6,6 +6,10 @@ import { randomUUID } from "crypto";
6
6
  import Fastify from "fastify";
7
7
  import { insertRequestLog } from "./db/logs.js";
8
8
  const HTTP_NOT_FOUND = 404;
9
+ const HTTP_INTERNAL_ERROR = 500;
10
+ const HTTP_BAD_REQUEST = 400;
11
+ const PROVIDER_DEFAULT_QUEUE_TIMEOUT_MS = 5000;
12
+ const PROVIDER_DEFAULT_MAX_QUEUE_SIZE = 100;
9
13
  // 代理路由路径 → api_type,用于在全局 hook/errorHandler 中识别代理请求
10
14
  const PROXY_API_TYPES = {
11
15
  "/v1/chat/completions": "openai",
@@ -19,12 +23,14 @@ function getProxyApiType(url) {
19
23
  const __filename = fileURLToPath(import.meta.url);
20
24
  const __dirname = path.dirname(__filename);
21
25
  import { getConfig } from "./config.js";
22
- import { initDatabase, seedDefaultRules } from "./db/index.js";
26
+ import { initDatabase, seedDefaultRules, getAllProviders } from "./db/index.js";
23
27
  import { authMiddleware } from "./middleware/auth.js";
24
28
  import { openaiProxy } from "./proxy/openai.js";
25
29
  import { anthropicProxy } from "./proxy/anthropic.js";
26
30
  import { adminRoutes } from "./admin/routes.js";
27
31
  import { RetryRuleMatcher } from "./proxy/retry-rules.js";
32
+ import { ProviderSemaphoreManager } from "./proxy/semaphore.js";
33
+ import { RequestTracker } from "./monitor/request-tracker.js";
28
34
  import { modelState } from "./proxy/model-state.js";
29
35
  import fastifyStatic from "@fastify/static";
30
36
  export async function buildApp(options) {
@@ -72,7 +78,7 @@ export async function buildApp(options) {
72
78
  // 统一 schema validation 错误响应格式,代理路由的错误也记录到 request_logs
73
79
  app.setErrorHandler((error, request, reply) => {
74
80
  const fastifyError = error;
75
- const status = fastifyError.statusCode ?? 500;
81
+ const status = fastifyError.statusCode ?? HTTP_INTERNAL_ERROR;
76
82
  const proxyApiType = getProxyApiType(request.url);
77
83
  if (proxyApiType) {
78
84
  request.log.error({ statusCode: status, err: error }, `Proxy request error: ${fastifyError.message}`);
@@ -91,8 +97,8 @@ export async function buildApp(options) {
91
97
  router_key_id: request.routerKey?.id ?? null,
92
98
  });
93
99
  }
94
- if (status === 400 && fastifyError.validation) {
95
- return reply.code(400).send({ error: { message: fastifyError.message } });
100
+ if (status === HTTP_BAD_REQUEST && fastifyError.validation) {
101
+ return reply.code(HTTP_BAD_REQUEST).send({ error: { message: fastifyError.message } });
96
102
  }
97
103
  return reply.code(status).send({ error: { message: fastifyError.message } });
98
104
  });
@@ -102,6 +108,26 @@ export async function buildApp(options) {
102
108
  modelState.init(db);
103
109
  const matcher = new RetryRuleMatcher();
104
110
  matcher.load(db);
111
+ const semaphoreManager = new ProviderSemaphoreManager();
112
+ const tracker = new RequestTracker({ semaphoreManager });
113
+ tracker.startPushInterval();
114
+ // 从 DB 读取已有 provider 的并发配置,初始化信号量管理器和 tracker
115
+ const allProviders = getAllProviders(db);
116
+ for (const p of allProviders) {
117
+ if (p.max_concurrency > 0) {
118
+ semaphoreManager.updateConfig(p.id, {
119
+ maxConcurrency: p.max_concurrency,
120
+ queueTimeoutMs: p.queue_timeout_ms,
121
+ maxQueueSize: p.max_queue_size,
122
+ });
123
+ }
124
+ tracker.updateProviderConfig(p.id, {
125
+ name: p.name,
126
+ maxConcurrency: p.max_concurrency ?? 0,
127
+ queueTimeoutMs: p.queue_timeout_ms ?? PROVIDER_DEFAULT_QUEUE_TIMEOUT_MS,
128
+ maxQueueSize: p.max_queue_size ?? PROVIDER_DEFAULT_MAX_QUEUE_SIZE,
129
+ });
130
+ }
105
131
  app.register(authMiddleware, { db });
106
132
  app.register(openaiProxy, {
107
133
  db,
@@ -109,6 +135,8 @@ export async function buildApp(options) {
109
135
  retryMaxAttempts: config.RETRY_MAX_ATTEMPTS,
110
136
  retryBaseDelayMs: config.RETRY_BASE_DELAY_MS,
111
137
  matcher,
138
+ semaphoreManager,
139
+ tracker,
112
140
  });
113
141
  app.register(anthropicProxy, {
114
142
  db,
@@ -116,8 +144,10 @@ export async function buildApp(options) {
116
144
  retryMaxAttempts: config.RETRY_MAX_ATTEMPTS,
117
145
  retryBaseDelayMs: config.RETRY_BASE_DELAY_MS,
118
146
  matcher,
147
+ semaphoreManager,
148
+ tracker,
119
149
  });
120
- app.register(adminRoutes, { db, matcher });
150
+ app.register(adminRoutes, { db, matcher, tracker, semaphoreManager });
121
151
  // 前端静态文件服务(生产环境)
122
152
  const frontendDist = path.resolve(process.env.FRONTEND_DIST || path.join(__dirname, "../frontend-dist"));
123
153
  if (existsSync(frontendDist)) {
@@ -145,6 +175,7 @@ export async function buildApp(options) {
145
175
  app,
146
176
  db,
147
177
  close: async () => {
178
+ tracker.stopPushInterval();
148
179
  await app.close();
149
180
  db.close();
150
181
  },
@@ -1,5 +1,14 @@
1
1
  import { Transform, TransformCallback } from "stream";
2
2
  import { MetricsExtractor } from "./metrics-extractor.js";
3
+ import type { MetricsResult } from "./metrics-extractor.js";
4
+ export interface MetricsTransformOptions {
5
+ /** 每次处理 SSE 事件后触发的回调,附带当前指标快照 */
6
+ onMetrics?: (metrics: MetricsResult) => void;
7
+ /** 每收到一个 SSE data 行时触发,传入原始文本行 */
8
+ onChunk?: (rawLine: string) => void;
9
+ /** 回调节流间隔(毫秒),默认 5000 */
10
+ throttleMs?: number;
11
+ }
3
12
  /**
4
13
  * 旁路采集 SSE 指标的 Transform stream
5
14
  *
@@ -9,8 +18,15 @@ import { MetricsExtractor } from "./metrics-extractor.js";
9
18
  export declare class SSEMetricsTransform extends Transform {
10
19
  private parser;
11
20
  private extractor;
12
- constructor(apiType: "openai" | "anthropic", requestStartTime: number);
21
+ private onMetrics?;
22
+ private onChunk?;
23
+ private throttleMs;
24
+ private lastCallbackTime;
25
+ private flushed;
26
+ constructor(apiType: "openai" | "anthropic", requestStartTime: number, options?: MetricsTransformOptions);
13
27
  _transform(chunk: Buffer, _encoding: BufferEncoding, callback: TransformCallback): void;
14
28
  _flush(callback: TransformCallback): void;
15
29
  getExtractor(): MetricsExtractor;
30
+ /** 节流逻辑:首次或距上次回调超过 throttleMs 时触发 */
31
+ private emitMetricsIfReady;
16
32
  }
@@ -1,6 +1,7 @@
1
1
  import { Transform } from "stream";
2
2
  import { SSEParser } from "./sse-parser.js";
3
3
  import { MetricsExtractor } from "./metrics-extractor.js";
4
+ const DEFAULT_THROTTLE_MS = 5000;
4
5
  /**
5
6
  * 旁路采集 SSE 指标的 Transform stream
6
7
  *
@@ -10,16 +11,30 @@ import { MetricsExtractor } from "./metrics-extractor.js";
10
11
  export class SSEMetricsTransform extends Transform {
11
12
  parser;
12
13
  extractor;
13
- constructor(apiType, requestStartTime) {
14
+ onMetrics;
15
+ onChunk;
16
+ throttleMs;
17
+ lastCallbackTime = 0;
18
+ flushed = false;
19
+ constructor(apiType, requestStartTime, options) {
14
20
  super();
15
21
  this.parser = new SSEParser();
16
22
  this.extractor = new MetricsExtractor(apiType, requestStartTime);
23
+ this.onMetrics = options?.onMetrics;
24
+ this.onChunk = options?.onChunk;
25
+ this.throttleMs = options?.throttleMs ?? DEFAULT_THROTTLE_MS;
17
26
  }
18
27
  _transform(chunk, _encoding, callback) {
19
- const events = this.parser.feed(chunk.toString("utf-8"));
28
+ const text = chunk.toString("utf-8");
29
+ const events = this.parser.feed(text);
20
30
  for (const event of events) {
21
31
  this.extractor.processEvent(event);
32
+ // 将解析后的事件还原为 SSE data 行格式传给 onChunk
33
+ if (event.data != null && this.onChunk) {
34
+ this.onChunk(`data: ${event.data}`);
35
+ }
22
36
  }
37
+ this.emitMetricsIfReady();
23
38
  callback(null, chunk);
24
39
  }
25
40
  _flush(callback) {
@@ -27,9 +42,25 @@ export class SSEMetricsTransform extends Transform {
27
42
  for (const event of events) {
28
43
  this.extractor.processEvent(event);
29
44
  }
45
+ // flush 无条件推送最终状态,确保消费者能拿到完整指标
46
+ if (this.onMetrics && !this.flushed) {
47
+ this.flushed = true;
48
+ this.lastCallbackTime = Date.now();
49
+ this.onMetrics(this.extractor.getMetrics());
50
+ }
30
51
  callback();
31
52
  }
32
53
  getExtractor() {
33
54
  return this.extractor;
34
55
  }
56
+ /** 节流逻辑:首次或距上次回调超过 throttleMs 时触发 */
57
+ emitMetricsIfReady() {
58
+ if (!this.onMetrics)
59
+ return;
60
+ const now = Date.now();
61
+ if (now - this.lastCallbackTime >= this.throttleMs) {
62
+ this.lastCallbackTime = now;
63
+ this.onMetrics(this.extractor.getMetrics());
64
+ }
65
+ }
35
66
  }