llm-simple-router 0.2.0 → 0.3.6
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.
- package/README.md +11 -0
- package/dist/admin/monitor.d.ts +7 -0
- package/dist/admin/monitor.js +25 -0
- package/dist/admin/providers.d.ts +4 -0
- package/dist/admin/providers.js +57 -9
- package/dist/admin/retry-rules.js +6 -3
- package/dist/admin/routes.d.ts +4 -0
- package/dist/admin/routes.js +3 -1
- package/dist/admin/setup.js +8 -5
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/mappings.js +6 -2
- package/dist/db/migrations/017_add_provider_concurrency.sql +3 -0
- package/dist/db/providers.d.ts +12 -1
- package/dist/db/providers.js +8 -3
- package/dist/db/retry-rules.js +4 -1
- package/dist/db/router-keys.js +3 -1
- package/dist/index.js +36 -5
- package/dist/metrics/sse-metrics-transform.d.ts +17 -1
- package/dist/metrics/sse-metrics-transform.js +33 -2
- package/dist/middleware/auth.js +5 -4
- package/dist/monitor/request-tracker.d.ts +49 -0
- package/dist/monitor/request-tracker.js +279 -0
- package/dist/monitor/runtime-collector.d.ts +11 -0
- package/dist/monitor/runtime-collector.js +41 -0
- package/dist/monitor/stats-aggregator.d.ts +22 -0
- package/dist/monitor/stats-aggregator.js +166 -0
- package/dist/monitor/types.d.ts +84 -0
- package/dist/monitor/types.js +1 -0
- package/dist/proxy/anthropic.d.ts +4 -0
- package/dist/proxy/anthropic.js +10 -2
- package/dist/proxy/enhancement-handler.js +3 -1
- package/dist/proxy/mapping-resolver.js +6 -2
- package/dist/proxy/openai.d.ts +4 -0
- package/dist/proxy/openai.js +10 -2
- package/dist/proxy/proxy-core.d.ts +6 -0
- package/dist/proxy/proxy-core.js +176 -85
- package/dist/proxy/retry.d.ts +1 -1
- package/dist/proxy/retry.js +3 -2
- package/dist/proxy/semaphore.d.ts +27 -0
- package/dist/proxy/semaphore.js +125 -0
- package/dist/utils/password.js +2 -1
- package/frontend-dist/assets/{CardContent-BE9fukPi.js → CardContent-DKzAH8lX.js} +1 -1
- package/frontend-dist/assets/{CardHeader-D5lVaeAA.js → CardHeader-BBKKDXEh.js} +1 -1
- package/frontend-dist/assets/{CardTitle-H-zwhi3Z.js → CardTitle-BQtpsfYd.js} +1 -1
- package/frontend-dist/assets/Checkbox-DhBbPKjw.js +1 -0
- package/frontend-dist/assets/CollapsibleTrigger-BC4bE5yr.js +1 -0
- package/frontend-dist/assets/Dashboard-3z77m9VQ.js +3 -0
- package/frontend-dist/assets/DialogTitle-Bh2A7j2j.js +1 -0
- package/frontend-dist/assets/Input-C5_w9X6Y.js +1 -0
- package/frontend-dist/assets/Label-9sUKofNb.js +1 -0
- package/frontend-dist/assets/Login-Dxo1j9ZV.js +1 -0
- package/frontend-dist/assets/Logs-KthJmRch.js +3 -0
- package/frontend-dist/assets/ModelMappings-Dci1SkBO.js +1 -0
- package/frontend-dist/assets/PopperContent-DGr-wo47.js +1 -0
- package/frontend-dist/assets/Providers-wZxNIXXh.js +1 -0
- package/frontend-dist/assets/ProxyEnhancement-CcvyXQNb.js +1 -0
- package/frontend-dist/assets/RetryRules-B9Cw-Ycd.js +1 -0
- package/frontend-dist/assets/RouterKeys-yZrj5YNm.js +1 -0
- package/frontend-dist/assets/{RovingFocusItem-DnIa_lwH.js → RovingFocusItem-CvMnUs02.js} +1 -1
- package/frontend-dist/assets/SelectValue-DhqRtJKk.js +1 -0
- package/frontend-dist/assets/Setup-DPk7lIZy.js +1 -0
- package/frontend-dist/assets/{TableHeader-D2GkiqRx.js → TableHeader-D9I1uQTp.js} +1 -1
- package/frontend-dist/assets/TabsTrigger-D4xjbMaQ.js +1 -0
- package/frontend-dist/assets/VisuallyHiddenInput-Dr8wp-H0.js +1 -0
- package/frontend-dist/assets/alert-dialog-ChPy9vB2.js +1 -0
- package/frontend-dist/assets/badge-HGT44FNA.js +3 -0
- package/frontend-dist/assets/{button-C4_mChkc.js → button-B3kgf-D2.js} +1 -1
- package/frontend-dist/assets/client-BBW9-06a.js +12 -0
- package/frontend-dist/assets/createLucideIcon-CCI4wMy0.js +1 -0
- package/frontend-dist/assets/dialog-BFhbK4vw.js +1 -0
- package/frontend-dist/assets/index-DSrFEJ7Y.css +1 -0
- package/frontend-dist/assets/index-SUCErp6B.js +1 -0
- package/frontend-dist/assets/lib-D-4ywYag.js +1 -0
- package/frontend-dist/assets/ohash.D__AXeF1-Bj3Sy1wQ.js +1 -0
- package/frontend-dist/assets/useClipboard-XyA4kDfF.js +1 -0
- package/frontend-dist/assets/useForwardExpose-CIZH3-CG.js +1 -0
- package/frontend-dist/assets/x-CTNEl6Fz.js +1 -0
- package/frontend-dist/index.html +7 -6
- package/package.json +1 -1
- package/frontend-dist/assets/Checkbox--1gw0dYW.js +0 -1
- package/frontend-dist/assets/CollapsibleTrigger-D_ptA35Y.js +0 -1
- package/frontend-dist/assets/Dashboard-D4AwkULO.js +0 -3
- package/frontend-dist/assets/Label-GiPfoz7u.js +0 -1
- package/frontend-dist/assets/Login-BUet1sbM.js +0 -1
- package/frontend-dist/assets/Logs-yztb_F9t.js +0 -3
- package/frontend-dist/assets/ModelMappings-MbZhdPNv.js +0 -1
- package/frontend-dist/assets/Providers-BjsqH6A2.js +0 -1
- package/frontend-dist/assets/RetryRules-C2vvJvLr.js +0 -1
- package/frontend-dist/assets/RouterKeys-DavrgpAQ.js +0 -1
- package/frontend-dist/assets/SelectValue-BB0Ckbjh.js +0 -1
- package/frontend-dist/assets/alert-dialog-CWjBke-O.js +0 -1
- package/frontend-dist/assets/badge-_ZHrMEpC.js +0 -3
- package/frontend-dist/assets/client-BWw0R36V.js +0 -12
- package/frontend-dist/assets/dialog-CUHMcTqp.js +0 -1
- package/frontend-dist/assets/index-DEl48bm9.css +0 -1
- package/frontend-dist/assets/index-UZK1BnPG.js +0 -1
- package/frontend-dist/assets/lib-Qs8xoTas.js +0 -1
- package/frontend-dist/assets/useForwardExpose-B-xauF1X.js +0 -1
- package/frontend-dist/assets/x-JBJB26JV.js +0 -1
package/README.md
CHANGED
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
| 多供应商 | 配置多个后端供应商,按模型映射路由 |
|
|
23
23
|
| 多密钥 (Router Keys) | 为不同使用方创建独立密钥,支持模型白名单 |
|
|
24
24
|
| 流式代理 | 完整支持 SSE 流式和非流式请求 |
|
|
25
|
+
| 供应商并发控制 | 按 Provider 维度限制并发数、队列长度和超时,防止单一供应商过载 |
|
|
26
|
+
| 实时监控 | SSE 推送活跃请求、延迟热力图、Token 吞吐、运行时资源指标 |
|
|
27
|
+
| 代理增强 (实验性) | 注入系统指令、会话记忆、模型锁定等增强功能 |
|
|
25
28
|
| 管理后台 | Vue 3 + shadcn-vue Web UI,管理供应商、映射、密钥 |
|
|
26
29
|
| 请求日志 | 结构化展示完整四阶段链路(客户端请求/上游请求/上游响应/客户端响应),适配 Claude Code 请求格式 |
|
|
27
30
|
| 性能指标 | TTFT、吞吐量、Token 用量、缓存命中率,支持按模型/密钥筛选 |
|
|
@@ -34,6 +37,14 @@
|
|
|
34
37
|
|-----------|-------------|
|
|
35
38
|
|  |  |
|
|
36
39
|
|
|
40
|
+
| 供应商并发控制 | 实时监控 |
|
|
41
|
+
|--------------|---------|
|
|
42
|
+
|  |  |
|
|
43
|
+
|
|
44
|
+
| 代理增强 (实验性) |
|
|
45
|
+
|-----------------|
|
|
46
|
+
|  |
|
|
47
|
+
|
|
37
48
|
| 模型映射 | 重试规则 |
|
|
38
49
|
|---------|--------|
|
|
39
50
|
|  |  |
|
|
@@ -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 {};
|
package/dist/admin/providers.js
CHANGED
|
@@ -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
|
|
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(
|
|
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 >
|
|
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(
|
|
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 >
|
|
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 {
|
|
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 ??
|
|
56
|
-
max_retries: body.max_retries ??
|
|
57
|
-
max_delay_ms: body.max_delay_ms ??
|
|
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 });
|
package/dist/admin/routes.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/admin/routes.js
CHANGED
|
@@ -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
|
};
|
package/dist/admin/setup.js
CHANGED
|
@@ -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 <
|
|
13
|
-
return reply.code(
|
|
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(
|
|
20
|
-
const jwtSecret = randomBytes(
|
|
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(
|
|
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/db/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/db/mappings.js
CHANGED
|
@@ -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 {
|
|
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 };
|
package/dist/db/providers.d.ts
CHANGED
|
@@ -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;
|
package/dist/db/providers.js
CHANGED
|
@@ -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) {
|
package/dist/db/retry-rules.js
CHANGED
|
@@ -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 ??
|
|
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) {
|
package/dist/db/router-keys.js
CHANGED
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 ??
|
|
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 ===
|
|
95
|
-
return reply.code(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|