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.
- package/config/recommended-providers.json +76 -0
- package/config/recommended-retry-rules.json +10 -0
- package/dist/admin/api-response.d.ts +27 -0
- package/dist/admin/api-response.js +40 -0
- package/dist/admin/constants.d.ts +0 -2
- package/dist/admin/constants.js +0 -3
- package/dist/admin/groups.js +9 -5
- package/dist/admin/logs.js +3 -2
- package/dist/admin/mappings.js +7 -6
- package/dist/admin/metrics.js +23 -5
- package/dist/admin/monitor.js +2 -1
- package/dist/admin/providers.js +13 -4
- package/dist/admin/proxy-enhancement.js +11 -6
- package/dist/admin/recommended.js +1 -9
- package/dist/admin/retry-rules.js +8 -4
- package/dist/admin/router-keys.js +5 -1
- package/dist/admin/routes.js +2 -0
- package/dist/admin/settings-import-export.js +3 -2
- package/dist/admin/settings.js +7 -5
- package/dist/admin/setup.js +3 -2
- package/dist/admin/stats.js +20 -3
- package/dist/admin/upgrade.d.ts +13 -0
- package/dist/admin/upgrade.js +114 -0
- package/dist/admin/usage.js +12 -24
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/constants.d.ts +3 -0
- package/dist/constants.js +11 -0
- package/dist/db/index.d.ts +3 -3
- package/dist/db/index.js +2 -2
- package/dist/db/mappings.js +5 -8
- package/dist/db/metrics.js +3 -4
- package/dist/db/providers.d.ts +8 -0
- package/dist/db/providers.js +6 -0
- package/dist/db/retry-rules.d.ts +1 -0
- package/dist/db/retry-rules.js +3 -0
- package/dist/db/settings.d.ts +2 -0
- package/dist/db/settings.js +7 -0
- package/dist/db/stats.d.ts +1 -2
- package/dist/db/stats.js +7 -11
- package/dist/index.d.ts +2 -0
- package/dist/index.js +55 -34
- package/dist/metrics/metrics-extractor.js +1 -1
- package/dist/metrics/sse-parser.js +2 -0
- package/dist/middleware/admin-auth.js +6 -5
- package/dist/middleware/auth.js +1 -10
- package/dist/monitor/request-tracker.d.ts +1 -0
- package/dist/monitor/request-tracker.js +9 -45
- package/dist/monitor/runtime-collector.js +1 -1
- package/dist/monitor/stream-content-accumulator.d.ts +14 -0
- package/dist/monitor/stream-content-accumulator.js +58 -0
- package/dist/proxy/anthropic.d.ts +2 -1
- package/dist/proxy/anthropic.js +3 -3
- package/dist/proxy/enhancement/directive-parser.d.ts +18 -0
- package/dist/proxy/{directive-parser.js → enhancement/directive-parser.js} +44 -0
- package/dist/proxy/{enhancement-handler.js → enhancement/enhancement-handler.js} +152 -32
- package/dist/proxy/enhancement/index.d.ts +3 -0
- package/dist/proxy/enhancement/index.js +3 -0
- package/dist/proxy/{response-cleaner.js → enhancement/response-cleaner.js} +14 -0
- package/dist/proxy/log-helpers.d.ts +1 -1
- package/dist/proxy/mapping-resolver.js +4 -4
- package/dist/proxy/openai.d.ts +2 -1
- package/dist/proxy/openai.js +4 -4
- package/dist/proxy/orchestrator.d.ts +0 -1
- package/dist/proxy/orchestrator.js +1 -3
- package/dist/proxy/proxy-core.d.ts +0 -4
- package/dist/proxy/proxy-core.js +0 -2
- package/dist/proxy/proxy-handler.d.ts +1 -1
- package/dist/proxy/proxy-handler.js +52 -132
- package/dist/proxy/proxy-logging.d.ts +0 -2
- package/dist/proxy/proxy-logging.js +1 -3
- package/dist/proxy/resilience.d.ts +5 -2
- package/dist/proxy/resilience.js +16 -7
- package/dist/proxy/strategy/failover.js +2 -7
- package/dist/proxy/strategy/random.js +2 -2
- package/dist/proxy/strategy/round-robin.js +2 -2
- package/dist/proxy/strategy/scheduled.js +1 -8
- package/dist/proxy/strategy/targets-rule.d.ts +1 -0
- package/dist/proxy/strategy/targets-rule.js +5 -0
- package/dist/proxy/transport-fn.d.ts +25 -0
- package/dist/proxy/transport-fn.js +55 -0
- package/dist/proxy/transport.d.ts +0 -25
- package/dist/proxy/transport.js +0 -38
- package/dist/upgrade/checker.d.ts +25 -0
- package/dist/upgrade/checker.js +120 -0
- package/dist/upgrade/deployment.d.ts +2 -0
- package/dist/upgrade/deployment.js +20 -0
- package/dist/upgrade/version.d.ts +1 -0
- package/dist/upgrade/version.js +13 -0
- package/dist/utils/password.js +4 -2
- package/dist/utils/time-range.d.ts +9 -0
- package/dist/utils/time-range.js +40 -0
- package/frontend-dist/assets/CardContent-WrBnGhTg.js +1 -0
- package/frontend-dist/assets/CardTitle-BcDYk7cq.js +1 -0
- package/frontend-dist/assets/Checkbox-MZf0YsDG.js +1 -0
- package/frontend-dist/assets/CollapsibleTrigger-CrOH9HlW.js +1 -0
- package/frontend-dist/assets/Collection-DcTx_Y54.js +1 -0
- package/frontend-dist/assets/Dashboard-D0oDrSLr.js +3 -0
- package/frontend-dist/assets/DialogTitle-Cl5Cd7QH.js +1 -0
- package/frontend-dist/assets/{Input-l5ZurXX5.js → Input-O0ebU-Va.js} +1 -1
- package/frontend-dist/assets/Label-C_S0y7Um.js +1 -0
- package/frontend-dist/assets/Login-DGY7uF8P.js +1 -0
- package/frontend-dist/assets/Logs-ls8pv89b.js +1 -0
- package/frontend-dist/assets/ModelMappings-DGlf0S4s.js +1 -0
- package/frontend-dist/assets/Monitor-BSI87grz.js +1 -0
- package/frontend-dist/assets/PopperContent-C6Q7hDmf.js +1 -0
- package/frontend-dist/assets/Providers-ZkRpj8_m.js +1 -0
- package/frontend-dist/assets/ProxyEnhancement-DFPI1W6Z.js +5 -0
- package/frontend-dist/assets/RetryRules-DtM31qsl.js +1 -0
- package/frontend-dist/assets/RouterKeys-D63tRFKm.js +1 -0
- package/frontend-dist/assets/RovingFocusItem-BJoylAKU.js +1 -0
- package/frontend-dist/assets/SelectValue-CLp5z6_I.js +1 -0
- package/frontend-dist/assets/Settings-DSgRKbTQ.js +6 -0
- package/frontend-dist/assets/Setup-BDmj6CRk.js +1 -0
- package/frontend-dist/assets/Switch-Wz-t_zkv.js +1 -0
- package/frontend-dist/assets/TableHeader-DGtcqGkw.js +1 -0
- package/frontend-dist/assets/TabsTrigger-CPCi2HIa.js +1 -0
- package/frontend-dist/assets/Teleport-DdjYHlNK.js +3 -0
- package/frontend-dist/assets/TooltipTrigger-H_QoPY1n.js +1 -0
- package/frontend-dist/assets/UnifiedRequestDialog-BAAfMJJl.js +3 -0
- package/frontend-dist/assets/{VisuallyHidden-BwwTtzb9.js → VisuallyHidden-Cyk-jWwh.js} +1 -1
- package/frontend-dist/assets/VisuallyHiddenInput-CYjNe_H8.js +1 -0
- package/frontend-dist/assets/alert-dialog-Bi3dliLl.js +1 -0
- package/frontend-dist/assets/badge-Kkta3e9W.js +1 -0
- package/frontend-dist/assets/button-BQ3s7yNh.js +12 -0
- package/frontend-dist/assets/{createLucideIcon-Biq59l_W.js → createLucideIcon-D1tkPDOQ.js} +1 -1
- package/frontend-dist/assets/dialog-DoIATUYw.js +1 -0
- package/frontend-dist/assets/{file-text-DoRW0hQW.js → file-text-Dt6QP1bZ.js} +1 -1
- package/frontend-dist/assets/index-BY0E7CHR.js +1 -0
- package/frontend-dist/assets/index-Bnrh1mFY.css +1 -0
- package/frontend-dist/assets/lib-CxwxnlwW.js +1 -0
- package/frontend-dist/assets/{ohash.D__AXeF1-BGxYMs6k.js → ohash.D__AXeF1-b0PiKZB_.js} +1 -1
- package/frontend-dist/assets/{useClipboard-vaHkvJHw.js → useClipboard-Cnnz6AAN.js} +1 -1
- package/frontend-dist/assets/useLogRetention-DYP5LOAc.js +1 -0
- package/frontend-dist/assets/useNonce-DKbOCfgM.js +1 -0
- package/frontend-dist/assets/x-CAoitXRt.js +1 -0
- package/frontend-dist/index.html +18 -9
- package/package.json +2 -1
- package/dist/proxy/directive-parser.d.ts +0 -7
- package/frontend-dist/assets/CardContent-CIO85eT6.js +0 -1
- package/frontend-dist/assets/CardTitle-DiqIReMT.js +0 -1
- package/frontend-dist/assets/Checkbox-C2u5pIp4.js +0 -1
- package/frontend-dist/assets/CollapsibleTrigger-RKFL41om.js +0 -1
- package/frontend-dist/assets/Collection-iiNnuTQj.js +0 -1
- package/frontend-dist/assets/Dashboard-DOEqP6gF.js +0 -3
- package/frontend-dist/assets/DialogTitle-CEqndrf6.js +0 -1
- package/frontend-dist/assets/Label-PgGtS8v2.js +0 -1
- package/frontend-dist/assets/Login-DaN6ZcCx.js +0 -1
- package/frontend-dist/assets/Logs-CleRQ7Xk.js +0 -1
- package/frontend-dist/assets/ModelMappings-CacA_ua_.js +0 -1
- package/frontend-dist/assets/Monitor-LSMFOBN2.js +0 -1
- package/frontend-dist/assets/PopperContent-zLFHqQP0.js +0 -1
- package/frontend-dist/assets/Providers-NT5MUDU0.js +0 -1
- package/frontend-dist/assets/ProxyEnhancement-DhOy8nNy.js +0 -5
- package/frontend-dist/assets/RetryRules-7arWa3jB.js +0 -1
- package/frontend-dist/assets/RouterKeys-CdaZunRg.js +0 -1
- package/frontend-dist/assets/SelectValue-CSg-MKW_.js +0 -1
- package/frontend-dist/assets/Settings-1ntV9XE3.js +0 -6
- package/frontend-dist/assets/Setup-CXLTDhYJ.js +0 -1
- package/frontend-dist/assets/Switch-DivrIFE3.js +0 -1
- package/frontend-dist/assets/TableHeader-Bn0bodWx.js +0 -1
- package/frontend-dist/assets/TabsContent-MWvOH_LJ.js +0 -1
- package/frontend-dist/assets/TabsTrigger-WKkUfO2M.js +0 -1
- package/frontend-dist/assets/Teleport-B0PNXZbP.js +0 -3
- package/frontend-dist/assets/UnifiedRequestDialog-Ba2e7YuJ.js +0 -3
- package/frontend-dist/assets/VisuallyHiddenInput-EGZSP7s8.js +0 -1
- package/frontend-dist/assets/alert-dialog-CS1yFhdV.js +0 -1
- package/frontend-dist/assets/badge-C-QcC5n2.js +0 -1
- package/frontend-dist/assets/button-Dbz2Be22.js +0 -12
- package/frontend-dist/assets/dialog-Cr0YQlLW.js +0 -1
- package/frontend-dist/assets/index-0H2uCGbx.js +0 -1
- package/frontend-dist/assets/index-D-cdVNCb.css +0 -1
- package/frontend-dist/assets/lib-B0lieqgg.js +0 -1
- package/frontend-dist/assets/useForwardExpose-C2_ks3sW.js +0 -1
- package/frontend-dist/assets/useLogRetention-Cs_fiKql.js +0 -1
- package/frontend-dist/assets/useNonce-C9do0jOI.js +0 -1
- package/frontend-dist/assets/x-BlTnH_0_.js +0 -1
- /package/dist/proxy/{enhancement-handler.d.ts → enhancement/enhancement-handler.d.ts} +0 -0
- /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,
|
|
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
|
-
//
|
|
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
|
-
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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();
|
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 });
|
package/dist/middleware/auth.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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;
|
|
@@ -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>;
|
package/dist/proxy/anthropic.js
CHANGED
|
@@ -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,
|
|
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.
|
|
22
|
+
return reply.code(e.statusCode).send(e.body);
|
|
23
23
|
}
|
|
24
|
-
const deps = { db, streamTimeoutMs,
|
|
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
|
+
}
|