llm-simple-router 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -14
- package/dist/admin/groups.js +25 -0
- package/dist/admin/providers.d.ts +0 -1
- package/dist/admin/providers.js +16 -13
- package/dist/admin/proxy-enhancement.d.ts +7 -0
- package/dist/admin/proxy-enhancement.js +39 -0
- package/dist/admin/router-keys.d.ts +0 -1
- package/dist/admin/router-keys.js +17 -8
- package/dist/admin/routes.d.ts +0 -3
- package/dist/admin/routes.js +9 -4
- package/dist/admin/setup.d.ts +7 -0
- package/dist/admin/setup.js +44 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +4 -0
- package/dist/config.d.ts +1 -4
- package/dist/config.js +13 -13
- package/dist/db/index.d.ts +5 -2
- package/dist/db/index.js +3 -1
- package/dist/db/logs.d.ts +5 -2
- package/dist/db/logs.js +4 -4
- package/dist/db/mappings.d.ts +16 -0
- package/dist/db/mappings.js +72 -0
- package/dist/db/migrations/014_create_settings.sql +4 -0
- package/dist/db/migrations/015_add_original_model.sql +1 -0
- package/dist/db/migrations/016_create_session_model_tables.sql +24 -0
- package/dist/db/session-states.d.ts +40 -0
- package/dist/db/session-states.js +37 -0
- package/dist/db/settings.d.ts +4 -0
- package/dist/db/settings.js +10 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +53 -13
- package/dist/middleware/admin-auth.d.ts +2 -2
- package/dist/middleware/admin-auth.js +21 -8
- package/dist/middleware/auth.js +46 -1
- package/dist/proxy/anthropic.d.ts +0 -1
- package/dist/proxy/anthropic.js +2 -2
- package/dist/proxy/directive-parser.d.ts +7 -0
- package/dist/proxy/directive-parser.js +70 -0
- package/dist/proxy/enhancement-handler.d.ts +23 -0
- package/dist/proxy/enhancement-handler.js +167 -0
- package/dist/proxy/log-helpers.d.ts +41 -0
- package/dist/proxy/log-helpers.js +35 -0
- package/dist/proxy/mapping-resolver.js +39 -2
- package/dist/proxy/model-state.d.ts +28 -0
- package/dist/proxy/model-state.js +111 -0
- package/dist/proxy/openai.d.ts +0 -1
- package/dist/proxy/openai.js +4 -3
- package/dist/proxy/proxy-core.d.ts +9 -47
- package/dist/proxy/proxy-core.js +215 -344
- package/dist/proxy/response-cleaner.d.ts +5 -0
- package/dist/proxy/response-cleaner.js +60 -0
- package/dist/proxy/strategy/failover.d.ts +1 -1
- package/dist/proxy/strategy/failover.js +10 -2
- package/dist/proxy/strategy/random.d.ts +1 -1
- package/dist/proxy/strategy/random.js +8 -2
- package/dist/proxy/strategy/round-robin.d.ts +2 -1
- package/dist/proxy/strategy/round-robin.js +13 -2
- package/dist/proxy/strategy/targets-rule.d.ts +7 -0
- package/dist/proxy/strategy/targets-rule.js +14 -0
- package/dist/proxy/strategy/types.d.ts +5 -1
- package/dist/proxy/strategy/types.js +3 -0
- package/dist/proxy/upstream-call.d.ts +43 -0
- package/dist/proxy/upstream-call.js +208 -0
- package/dist/utils/password.d.ts +2 -0
- package/dist/utils/password.js +14 -0
- package/package.json +6 -5
- package/.env.example +0 -13
package/dist/proxy/proxy-core.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
|
-
import { request as httpRequestFn } from "http";
|
|
3
|
-
import { request as httpsRequestFn } from "https";
|
|
4
|
-
import { PassThrough } from "stream";
|
|
5
2
|
import { getProviderById, insertRequestLog, insertMetrics } from "../db/index.js";
|
|
6
3
|
import { decrypt } from "../utils/crypto.js";
|
|
7
|
-
import {
|
|
4
|
+
import { getSetting } from "../db/settings.js";
|
|
8
5
|
import { MetricsExtractor } from "../metrics/metrics-extractor.js";
|
|
6
|
+
import { getMappingGroup } from "../db/index.js";
|
|
9
7
|
import { resolveMapping } from "./mapping-resolver.js";
|
|
10
8
|
import { retryableCall, buildRetryConfig } from "./retry.js";
|
|
9
|
+
import { SSEMetricsTransform } from "../metrics/sse-metrics-transform.js";
|
|
10
|
+
import { proxyNonStream as upstreamNonStream, proxyStream as upstreamStream, proxyGetRequest as upstreamGet, } from "./upstream-call.js";
|
|
11
|
+
import { insertSuccessLog, insertRejectedLog } from "./log-helpers.js";
|
|
12
|
+
import { applyEnhancement, buildModelInfoTag } from "./enhancement-handler.js";
|
|
11
13
|
// ---------- Constants ----------
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
const HTTP_DEFAULT_PORT = 80;
|
|
15
|
-
const UPSTREAM_BAD_GATEWAY = 502;
|
|
14
|
+
const UPSTREAM_SUCCESS = 200;
|
|
15
|
+
const FAILOVER_FAIL_THRESHOLD = 400;
|
|
16
16
|
// ---------- Header utilities ----------
|
|
17
17
|
export const SKIP_UPSTREAM = new Set([
|
|
18
18
|
"host",
|
|
@@ -24,12 +24,6 @@ export const SKIP_UPSTREAM = new Set([
|
|
|
24
24
|
"transfer-encoding",
|
|
25
25
|
"upgrade",
|
|
26
26
|
]);
|
|
27
|
-
export const SKIP_DOWNSTREAM = new Set([
|
|
28
|
-
"content-length",
|
|
29
|
-
"transfer-encoding",
|
|
30
|
-
"connection",
|
|
31
|
-
"keep-alive",
|
|
32
|
-
]);
|
|
33
27
|
export function selectHeaders(raw, skip) {
|
|
34
28
|
const out = {};
|
|
35
29
|
for (const [key, value] of Object.entries(raw)) {
|
|
@@ -39,9 +33,8 @@ export function selectHeaders(raw, skip) {
|
|
|
39
33
|
}
|
|
40
34
|
return out;
|
|
41
35
|
}
|
|
42
|
-
// 当前两个 provider 都使用 Bearer token
|
|
36
|
+
// 当前两个 provider 都使用 Bearer token
|
|
43
37
|
// 如果未来需要支持其他鉴权方式,需要参数化 header 构造
|
|
44
|
-
/** 构建发往上游的请求 headers:过滤客户端 headers + 注入后端 API key */
|
|
45
38
|
export function buildUpstreamHeaders(clientHeaders, apiKey, payloadBytes) {
|
|
46
39
|
const headers = selectHeaders(clientHeaders, SKIP_UPSTREAM);
|
|
47
40
|
headers["Authorization"] = `Bearer ${apiKey}`;
|
|
@@ -51,358 +44,236 @@ export function buildUpstreamHeaders(clientHeaders, apiKey, payloadBytes) {
|
|
|
51
44
|
}
|
|
52
45
|
return headers;
|
|
53
46
|
}
|
|
54
|
-
// ----------
|
|
55
|
-
/** 根据 URL scheme 选择 http 或 https 模块 */
|
|
56
|
-
export function createUpstreamRequest(url, options) {
|
|
57
|
-
return url.protocol === "https:" ? httpsRequestFn(options) : httpRequestFn(options);
|
|
58
|
-
}
|
|
59
|
-
/** 从 URL + headers 构造 Node.js http.request 所需的 options */
|
|
60
|
-
export function buildRequestOptions(url, headers, method = "POST") {
|
|
61
|
-
return {
|
|
62
|
-
hostname: url.hostname,
|
|
63
|
-
port: Number(url.port) || (url.protocol === "https:" ? HTTPS_DEFAULT_PORT : HTTP_DEFAULT_PORT),
|
|
64
|
-
path: url.pathname,
|
|
65
|
-
method,
|
|
66
|
-
headers,
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
// ---------- Logging ----------
|
|
70
|
-
/** 插入成功请求日志,供 openai/anthropic 插件共享 */
|
|
71
|
-
export function insertSuccessLog(db, apiType, logId, model, provider, isStream, startTime, reqBody, clientReq, upstreamReq, status, respBody, upHdrs, cliHdrs, isRetry = false, originalRequestId = null, routerKeyId = null) {
|
|
72
|
-
insertRequestLog(db, {
|
|
73
|
-
id: logId, api_type: apiType, model, provider_id: provider.id,
|
|
74
|
-
status_code: status, latency_ms: Date.now() - startTime,
|
|
75
|
-
is_stream: isStream ? 1 : 0, error_message: null,
|
|
76
|
-
created_at: new Date().toISOString(), request_body: reqBody,
|
|
77
|
-
response_body: respBody, client_request: clientReq, upstream_request: upstreamReq,
|
|
78
|
-
upstream_response: JSON.stringify({ statusCode: status, headers: upHdrs, body: respBody }),
|
|
79
|
-
client_response: JSON.stringify({ statusCode: status, headers: cliHdrs, body: respBody }),
|
|
80
|
-
is_retry: isRetry ? 1 : 0, original_request_id: originalRequestId,
|
|
81
|
-
router_key_id: routerKeyId,
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
// ---------- Non-stream proxy ----------
|
|
85
|
-
export function proxyNonStream(backend, apiKey, body, clientHeaders, upstreamPath) {
|
|
86
|
-
return new Promise((resolve, reject) => {
|
|
87
|
-
const url = new URL(`${backend.base_url}${upstreamPath}`);
|
|
88
|
-
const payload = JSON.stringify(body);
|
|
89
|
-
const upstreamHeaders = buildUpstreamHeaders(clientHeaders, apiKey, Buffer.byteLength(payload));
|
|
90
|
-
const options = buildRequestOptions(url, upstreamHeaders);
|
|
91
|
-
const req = createUpstreamRequest(url, options);
|
|
92
|
-
req.on("response", (res) => {
|
|
93
|
-
const chunks = [];
|
|
94
|
-
res.on("data", (chunk) => chunks.push(chunk));
|
|
95
|
-
res.on("end", () => {
|
|
96
|
-
resolve({
|
|
97
|
-
statusCode: res.statusCode || UPSTREAM_BAD_GATEWAY,
|
|
98
|
-
body: Buffer.concat(chunks).toString("utf-8"),
|
|
99
|
-
headers: selectHeaders(res.headers, SKIP_DOWNSTREAM),
|
|
100
|
-
sentHeaders: { ...upstreamHeaders },
|
|
101
|
-
sentBody: payload,
|
|
102
|
-
});
|
|
103
|
-
});
|
|
104
|
-
});
|
|
105
|
-
req.on("error", (err) => reject(err));
|
|
106
|
-
req.write(payload);
|
|
107
|
-
req.end();
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
// ---------- Stream proxy (SSE) ----------
|
|
111
|
-
export function proxyStream(backend, apiKey, body, clientHeaders, reply, timeoutMs, upstreamPath, metricsTransform) {
|
|
112
|
-
return new Promise((resolve, reject) => {
|
|
113
|
-
const url = new URL(`${backend.base_url}${upstreamPath}`);
|
|
114
|
-
const payload = JSON.stringify(body);
|
|
115
|
-
const upstreamHeaders = buildUpstreamHeaders(clientHeaders, apiKey, Buffer.byteLength(payload));
|
|
116
|
-
const options = buildRequestOptions(url, upstreamHeaders);
|
|
117
|
-
const upstreamReq = createUpstreamRequest(url, options);
|
|
118
|
-
upstreamReq.on("response", (upstreamRes) => {
|
|
119
|
-
const statusCode = upstreamRes.statusCode || UPSTREAM_BAD_GATEWAY;
|
|
120
|
-
if (statusCode !== UPSTREAM_SUCCESS) {
|
|
121
|
-
// 非200路径:仅返回错误信息,不操作 reply
|
|
122
|
-
const chunks = [];
|
|
123
|
-
upstreamRes.on("data", (chunk) => chunks.push(chunk));
|
|
124
|
-
upstreamRes.on("end", () => {
|
|
125
|
-
const errorBody = Buffer.concat(chunks).toString("utf-8");
|
|
126
|
-
resolve({
|
|
127
|
-
statusCode,
|
|
128
|
-
responseBody: errorBody,
|
|
129
|
-
upstreamResponseHeaders: selectHeaders(upstreamRes.headers, SKIP_DOWNSTREAM),
|
|
130
|
-
sentHeaders: upstreamHeaders,
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
const sseHeaders = selectHeaders(upstreamRes.headers, SKIP_DOWNSTREAM);
|
|
136
|
-
sseHeaders["Content-Type"] = "text/event-stream";
|
|
137
|
-
sseHeaders["Cache-Control"] = "no-cache";
|
|
138
|
-
sseHeaders["Connection"] = "keep-alive";
|
|
139
|
-
reply.raw.writeHead(statusCode, sseHeaders);
|
|
140
|
-
const passThrough = new PassThrough();
|
|
141
|
-
if (metricsTransform) {
|
|
142
|
-
// 管道: upstreamRes → metricsTransform → passThrough → reply.raw
|
|
143
|
-
metricsTransform.pipe(passThrough).pipe(reply.raw);
|
|
144
|
-
}
|
|
145
|
-
else {
|
|
146
|
-
passThrough.pipe(reply.raw);
|
|
147
|
-
}
|
|
148
|
-
// 管道入口:有 metricsTransform 时写入它,否则直接写 passThrough
|
|
149
|
-
const pipeEntry = metricsTransform ?? passThrough;
|
|
150
|
-
const captureChunks = [];
|
|
151
|
-
let idleTimer = null;
|
|
152
|
-
let resolved = false;
|
|
153
|
-
function cleanup() {
|
|
154
|
-
if (idleTimer)
|
|
155
|
-
clearTimeout(idleTimer);
|
|
156
|
-
idleTimer = null;
|
|
157
|
-
if (!passThrough.destroyed)
|
|
158
|
-
passThrough.destroy();
|
|
159
|
-
if (metricsTransform && !metricsTransform.destroyed)
|
|
160
|
-
metricsTransform.destroy();
|
|
161
|
-
if (!upstreamRes.destroyed)
|
|
162
|
-
upstreamRes.destroy();
|
|
163
|
-
}
|
|
164
|
-
/** 从 metricsTransform 中提取指标,供 resolve 时附带 */
|
|
165
|
-
function collectMetrics(isComplete) {
|
|
166
|
-
if (!metricsTransform)
|
|
167
|
-
return undefined;
|
|
168
|
-
const result = metricsTransform.getExtractor().getMetrics();
|
|
169
|
-
if (!isComplete) {
|
|
170
|
-
return { ...result, is_complete: 0 };
|
|
171
|
-
}
|
|
172
|
-
return result;
|
|
173
|
-
}
|
|
174
|
-
reply.raw.on("close", () => {
|
|
175
|
-
if (!resolved) {
|
|
176
|
-
cleanup();
|
|
177
|
-
resolve({ statusCode, responseBody: undefined, upstreamResponseHeaders: sseHeaders, sentHeaders: upstreamHeaders, metricsResult: collectMetrics(false) });
|
|
178
|
-
}
|
|
179
|
-
});
|
|
180
|
-
passThrough.on("error", () => {
|
|
181
|
-
cleanup();
|
|
182
|
-
if (!resolved) {
|
|
183
|
-
resolved = true;
|
|
184
|
-
resolve({ statusCode, responseBody: undefined, upstreamResponseHeaders: sseHeaders, sentHeaders: upstreamHeaders, metricsResult: collectMetrics(false) });
|
|
185
|
-
}
|
|
186
|
-
});
|
|
187
|
-
function resetIdleTimer() {
|
|
188
|
-
if (idleTimer)
|
|
189
|
-
clearTimeout(idleTimer);
|
|
190
|
-
idleTimer = setTimeout(() => {
|
|
191
|
-
cleanup();
|
|
192
|
-
if (!resolved) {
|
|
193
|
-
resolved = true;
|
|
194
|
-
resolve({ statusCode, responseBody: undefined, upstreamResponseHeaders: sseHeaders, sentHeaders: upstreamHeaders, metricsResult: collectMetrics(false) });
|
|
195
|
-
}
|
|
196
|
-
}, timeoutMs);
|
|
197
|
-
}
|
|
198
|
-
resetIdleTimer();
|
|
199
|
-
upstreamRes.on("data", (chunk) => {
|
|
200
|
-
if (resolved)
|
|
201
|
-
return;
|
|
202
|
-
resetIdleTimer();
|
|
203
|
-
pipeEntry.write(chunk);
|
|
204
|
-
captureChunks.push(chunk);
|
|
205
|
-
});
|
|
206
|
-
upstreamRes.on("end", () => {
|
|
207
|
-
if (resolved)
|
|
208
|
-
return;
|
|
209
|
-
resolved = true;
|
|
210
|
-
if (idleTimer)
|
|
211
|
-
clearTimeout(idleTimer);
|
|
212
|
-
pipeEntry.end();
|
|
213
|
-
reply.raw.end();
|
|
214
|
-
resolve({
|
|
215
|
-
statusCode,
|
|
216
|
-
responseBody: Buffer.concat(captureChunks).toString("utf-8"),
|
|
217
|
-
upstreamResponseHeaders: sseHeaders,
|
|
218
|
-
sentHeaders: upstreamHeaders,
|
|
219
|
-
metricsResult: collectMetrics(true),
|
|
220
|
-
});
|
|
221
|
-
});
|
|
222
|
-
upstreamRes.on("error", (err) => {
|
|
223
|
-
if (resolved)
|
|
224
|
-
return;
|
|
225
|
-
resolved = true;
|
|
226
|
-
cleanup();
|
|
227
|
-
reject(err);
|
|
228
|
-
});
|
|
229
|
-
});
|
|
230
|
-
upstreamReq.on("error", (err) => reject(err));
|
|
231
|
-
upstreamReq.write(payload);
|
|
232
|
-
upstreamReq.end();
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
// ---------- GET proxy ----------
|
|
47
|
+
// ---------- GET proxy (thin wrapper) ----------
|
|
236
48
|
export function proxyGetRequest(backend, apiKey, clientHeaders, upstreamPath) {
|
|
237
|
-
return
|
|
238
|
-
const url = new URL(`${backend.base_url}${upstreamPath}`);
|
|
239
|
-
const headers = buildUpstreamHeaders(clientHeaders, apiKey);
|
|
240
|
-
const options = buildRequestOptions(url, headers, "GET");
|
|
241
|
-
const req = createUpstreamRequest(url, options);
|
|
242
|
-
req.on("response", (res) => {
|
|
243
|
-
const chunks = [];
|
|
244
|
-
res.on("data", (chunk) => chunks.push(chunk));
|
|
245
|
-
res.on("end", () => {
|
|
246
|
-
resolve({
|
|
247
|
-
statusCode: res.statusCode || UPSTREAM_BAD_GATEWAY,
|
|
248
|
-
body: Buffer.concat(chunks).toString("utf-8"),
|
|
249
|
-
headers: selectHeaders(res.headers, SKIP_DOWNSTREAM),
|
|
250
|
-
});
|
|
251
|
-
});
|
|
252
|
-
});
|
|
253
|
-
req.on("error", (err) => reject(err));
|
|
254
|
-
req.end();
|
|
255
|
-
});
|
|
49
|
+
return upstreamGet(backend, apiKey, clientHeaders, upstreamPath, buildUpstreamHeaders);
|
|
256
50
|
}
|
|
51
|
+
// ---------- Shared proxy handler ----------
|
|
257
52
|
const HTTP_BAD_GATEWAY = 502;
|
|
258
53
|
/**
|
|
259
|
-
* 共享 POST handler,参数化 apiType/errorFormat/upstreamPath
|
|
260
|
-
*
|
|
54
|
+
* 共享 POST handler,参数化 apiType/errorFormat/upstreamPath 等差异。
|
|
55
|
+
* 当分组策略为 failover 时,在 while 循环中依次尝试不同 target,
|
|
56
|
+
* 直到成功(或 headers 已发送)才返回。
|
|
261
57
|
*/
|
|
262
58
|
export async function handleProxyPost(request, reply, apiType, upstreamPath, errors, deps, options) {
|
|
263
|
-
const { db,
|
|
59
|
+
const { db, streamTimeoutMs, retryMaxAttempts, retryBaseDelayMs, matcher } = deps;
|
|
264
60
|
request.raw.socket.on("error", (err) => request.log.debug({ err }, "client socket error"));
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
|
|
61
|
+
const clientModel = request.body.model || "unknown";
|
|
62
|
+
// 代理增强:指令解析 + 模型替换 + 命令拦截
|
|
63
|
+
const sessionId = request.headers["x-claude-code-session-id"];
|
|
64
|
+
const { effectiveModel, originalModel, interceptResponse } = applyEnhancement(db, request, clientModel, sessionId);
|
|
65
|
+
// 命令拦截(如 select-model):直接返回,不转发上游
|
|
66
|
+
if (interceptResponse) {
|
|
67
|
+
const logId = randomUUID();
|
|
68
|
+
const isStream = request.body.stream === true;
|
|
69
|
+
const interceptRespBody = JSON.stringify(interceptResponse.body);
|
|
70
|
+
insertRequestLog(db, {
|
|
71
|
+
id: logId, api_type: apiType, model: clientModel, provider_id: "router",
|
|
72
|
+
status_code: interceptResponse.statusCode, latency_ms: 0,
|
|
73
|
+
is_stream: isStream ? 1 : 0, error_message: null,
|
|
74
|
+
created_at: new Date().toISOString(),
|
|
75
|
+
request_body: JSON.stringify(request.body),
|
|
76
|
+
response_body: interceptRespBody,
|
|
77
|
+
client_request: JSON.stringify({ headers: request.headers, body: request.body }),
|
|
78
|
+
upstream_request: interceptResponse.meta ? JSON.stringify(interceptResponse.meta) : null,
|
|
79
|
+
client_response: JSON.stringify({ statusCode: interceptResponse.statusCode, body: interceptRespBody }),
|
|
80
|
+
is_retry: 0, original_request_id: null,
|
|
81
|
+
router_key_id: request.routerKey?.id ?? null, original_model: null,
|
|
82
|
+
});
|
|
83
|
+
return reply.status(interceptResponse.statusCode).send(interceptResponse.body);
|
|
275
84
|
}
|
|
276
|
-
//
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
85
|
+
// 查询分组策略(只查一次)
|
|
86
|
+
const group = getMappingGroup(db, effectiveModel);
|
|
87
|
+
const isFailover = group?.strategy === "failover";
|
|
88
|
+
const excludeTargets = [];
|
|
89
|
+
while (true) {
|
|
90
|
+
const startTime = Date.now();
|
|
91
|
+
const logId = randomUUID();
|
|
92
|
+
const routerKeyId = request.routerKey?.id ?? null;
|
|
93
|
+
const body = request.body;
|
|
94
|
+
const originalBody = JSON.parse(JSON.stringify(body));
|
|
95
|
+
const isStream = body.stream === true;
|
|
96
|
+
const cliHdrs = request.headers;
|
|
97
|
+
const resolved = resolveMapping(db, effectiveModel, { now: new Date(), excludeTargets });
|
|
98
|
+
if (!resolved) {
|
|
99
|
+
if (isFailover && excludeTargets.length > 0) {
|
|
100
|
+
return reply;
|
|
284
101
|
}
|
|
102
|
+
const e = errors.modelNotFound(effectiveModel);
|
|
103
|
+
insertRejectedLog({ db, logId, apiType, model: effectiveModel, statusCode: e.statusCode,
|
|
104
|
+
errorMessage: `No mapping found for model '${effectiveModel}'`, startTime, isStream,
|
|
105
|
+
routerKeyId, originalBody, clientHeaders: cliHdrs, originalModel });
|
|
106
|
+
return reply.status(e.statusCode).send(e.body);
|
|
285
107
|
}
|
|
286
|
-
|
|
287
|
-
|
|
108
|
+
// 白名单校验
|
|
109
|
+
if (excludeTargets.length === 0) {
|
|
110
|
+
const allowedModels = request.routerKey?.allowed_models;
|
|
111
|
+
if (allowedModels) {
|
|
112
|
+
try {
|
|
113
|
+
const models = JSON.parse(allowedModels).filter((m) => m.trim() !== "");
|
|
114
|
+
if (models.length > 0 && !models.includes(resolved.backend_model)) {
|
|
115
|
+
const e = errors.modelNotAllowed(resolved.backend_model);
|
|
116
|
+
insertRejectedLog({ db, logId, apiType, model: effectiveModel, statusCode: e.statusCode,
|
|
117
|
+
errorMessage: `Model '${resolved.backend_model}' not allowed for this API key`,
|
|
118
|
+
startTime, isStream, routerKeyId, originalBody, clientHeaders: cliHdrs,
|
|
119
|
+
providerId: resolved.provider_id, originalModel });
|
|
120
|
+
return reply.status(e.statusCode).send(e.body);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
request.log.warn({ allowedModels: allowedModels?.slice(0, 80) }, "Invalid allowed_models JSON, allowing all models");
|
|
125
|
+
} // eslint-disable-line no-magic-numbers
|
|
126
|
+
}
|
|
288
127
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
128
|
+
const provider = getProviderById(db, resolved.provider_id);
|
|
129
|
+
if (!provider || !provider.is_active) {
|
|
130
|
+
const e = errors.providerUnavailable();
|
|
131
|
+
insertRejectedLog({ db, logId, apiType, model: effectiveModel, statusCode: e.statusCode,
|
|
132
|
+
errorMessage: `Provider '${resolved.provider_id}' unavailable or inactive`,
|
|
133
|
+
startTime, isStream, routerKeyId, originalBody, clientHeaders: cliHdrs,
|
|
134
|
+
providerId: resolved.provider_id, originalModel });
|
|
135
|
+
return reply.status(e.statusCode).send(e.body);
|
|
136
|
+
}
|
|
137
|
+
if (provider.api_type !== apiType) {
|
|
138
|
+
const e = errors.providerTypeMismatch();
|
|
139
|
+
insertRejectedLog({ db, logId, apiType, model: effectiveModel, statusCode: e.statusCode,
|
|
140
|
+
errorMessage: `Provider API type mismatch: expected '${apiType}', got '${provider.api_type}'`,
|
|
141
|
+
startTime, isStream, routerKeyId, originalBody, clientHeaders: cliHdrs,
|
|
142
|
+
providerId: resolved.provider_id, originalModel });
|
|
143
|
+
return reply.status(e.statusCode).send(e.body);
|
|
144
|
+
}
|
|
145
|
+
body.model = resolved.backend_model;
|
|
146
|
+
const apiKey = decrypt(provider.api_key, getSetting(db, "encryption_key"));
|
|
147
|
+
options?.beforeSendProxy?.(body, isStream);
|
|
148
|
+
const reqBodyStr = JSON.stringify(body);
|
|
149
|
+
const clientReq = JSON.stringify({ headers: cliHdrs, body: originalBody });
|
|
150
|
+
const retryConfig = buildRetryConfig(retryMaxAttempts, retryBaseDelayMs, matcher);
|
|
151
|
+
const upstreamReqBase = JSON.stringify({ url: `${provider.base_url}${upstreamPath}`, headers: buildUpstreamHeaders(cliHdrs, apiKey, Buffer.byteLength(reqBodyStr)), body: reqBodyStr });
|
|
152
|
+
try {
|
|
153
|
+
const { result: r, attempts } = isStream
|
|
154
|
+
? await retryableCall(() => {
|
|
155
|
+
const metricsTransform = new SSEMetricsTransform(apiType, startTime);
|
|
156
|
+
return upstreamStream(provider, apiKey, body, cliHdrs, reply, streamTimeoutMs, upstreamPath, buildUpstreamHeaders, metricsTransform);
|
|
157
|
+
}, retryConfig, reply)
|
|
158
|
+
: await retryableCall(() => upstreamNonStream(provider, apiKey, body, cliHdrs, upstreamPath, buildUpstreamHeaders), retryConfig, reply);
|
|
159
|
+
// 记录所有尝试的日志
|
|
160
|
+
let lastSuccessLogId = logId;
|
|
161
|
+
for (const attempt of attempts) {
|
|
162
|
+
const isOriginal = attempt.attemptIndex === 0;
|
|
163
|
+
const attemptLogId = isOriginal ? logId : randomUUID();
|
|
164
|
+
if (attempt.error) {
|
|
165
|
+
insertRequestLog(db, {
|
|
166
|
+
id: attemptLogId, api_type: apiType, model: effectiveModel, provider_id: provider.id,
|
|
167
|
+
status_code: HTTP_BAD_GATEWAY, latency_ms: attempt.latencyMs,
|
|
168
|
+
is_stream: isStream ? 1 : 0, error_message: attempt.error,
|
|
169
|
+
created_at: new Date().toISOString(), request_body: reqBodyStr,
|
|
170
|
+
client_request: clientReq, upstream_request: upstreamReqBase,
|
|
171
|
+
is_retry: isOriginal ? 0 : 1, original_request_id: isOriginal ? null : logId,
|
|
172
|
+
router_key_id: routerKeyId, original_model: originalModel,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
else if (attempt.statusCode !== UPSTREAM_SUCCESS) {
|
|
176
|
+
insertRequestLog(db, {
|
|
177
|
+
id: attemptLogId, api_type: apiType, model: effectiveModel, provider_id: provider.id,
|
|
178
|
+
status_code: attempt.statusCode, latency_ms: attempt.latencyMs,
|
|
179
|
+
is_stream: isStream ? 1 : 0, error_message: null,
|
|
180
|
+
created_at: new Date().toISOString(), request_body: reqBodyStr,
|
|
181
|
+
response_body: attempt.responseBody, client_request: clientReq, upstream_request: upstreamReqBase,
|
|
182
|
+
upstream_response: JSON.stringify({ statusCode: attempt.statusCode, body: attempt.responseBody }),
|
|
183
|
+
client_response: JSON.stringify({ statusCode: attempt.statusCode, body: attempt.responseBody }),
|
|
184
|
+
is_retry: isOriginal ? 0 : 1, original_request_id: isOriginal ? null : logId,
|
|
185
|
+
router_key_id: routerKeyId, original_model: originalModel,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
const h = isStream
|
|
190
|
+
? (r.upstreamResponseHeaders ?? {})
|
|
191
|
+
: (r.headers);
|
|
192
|
+
insertSuccessLog(db, { apiType, model: effectiveModel, provider, isStream, startTime,
|
|
193
|
+
reqBody: reqBodyStr, clientReq, upstreamReq: upstreamReqBase, id: attemptLogId,
|
|
194
|
+
status: r.statusCode, respBody: attempt.responseBody, upHdrs: h, cliHdrs: h,
|
|
195
|
+
isRetry: !isOriginal, originalRequestId: isOriginal ? null : logId,
|
|
196
|
+
routerKeyId, originalModel });
|
|
197
|
+
lastSuccessLogId = attemptLogId;
|
|
198
|
+
}
|
|
331
199
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
is_stream: isStream ? 1 : 0, error_message: null,
|
|
337
|
-
created_at: new Date().toISOString(), request_body: reqBodyStr,
|
|
338
|
-
response_body: attempt.responseBody, client_request: clientReq, upstream_request: upstreamReqBase,
|
|
339
|
-
upstream_response: JSON.stringify({ statusCode: attempt.statusCode, body: attempt.responseBody }),
|
|
340
|
-
client_response: JSON.stringify({ statusCode: attempt.statusCode, body: attempt.responseBody }),
|
|
341
|
-
is_retry: isOriginal ? 0 : 1, original_request_id: isOriginal ? null : logId,
|
|
342
|
-
router_key_id: routerKeyId,
|
|
343
|
-
});
|
|
200
|
+
// --- Failover 检查 ---
|
|
201
|
+
if (isFailover && r.statusCode >= FAILOVER_FAIL_THRESHOLD && !reply.raw.headersSent) {
|
|
202
|
+
excludeTargets.push(resolved);
|
|
203
|
+
continue;
|
|
344
204
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
205
|
+
// 发送响应
|
|
206
|
+
if (isStream) {
|
|
207
|
+
if (r.statusCode !== UPSTREAM_SUCCESS) {
|
|
208
|
+
for (const [k, v] of Object.entries(r.upstreamResponseHeaders ?? {}))
|
|
209
|
+
reply.header(k, v);
|
|
210
|
+
reply.status(r.statusCode).send(r.responseBody);
|
|
211
|
+
}
|
|
351
212
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
213
|
+
else {
|
|
214
|
+
const pr = r;
|
|
215
|
+
// 非流式响应:模型替换时注入 router-response 标签
|
|
216
|
+
if (originalModel && pr.statusCode === UPSTREAM_SUCCESS) {
|
|
217
|
+
try {
|
|
218
|
+
const bodyObj = JSON.parse(pr.body);
|
|
219
|
+
if (bodyObj.content?.[0]?.text) {
|
|
220
|
+
bodyObj.content[0].text += `\n\n${buildModelInfoTag(effectiveModel)}`;
|
|
221
|
+
pr.body = JSON.stringify(bodyObj);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
request.log.debug("Failed to inject model-info tag into non-JSON response");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
for (const [k, v] of Object.entries(pr.headers))
|
|
357
229
|
reply.header(k, v);
|
|
358
|
-
reply.status(
|
|
230
|
+
return reply.status(pr.statusCode).send(pr.body);
|
|
359
231
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
232
|
+
// metrics 采集
|
|
233
|
+
if (r.statusCode === UPSTREAM_SUCCESS) {
|
|
234
|
+
if (isStream) {
|
|
235
|
+
const streamResult = r;
|
|
236
|
+
if (streamResult.metricsResult) {
|
|
237
|
+
try {
|
|
238
|
+
insertMetrics(db, { ...streamResult.metricsResult, request_log_id: lastSuccessLogId, provider_id: provider.id, backend_model: resolved.backend_model, api_type: apiType });
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
request.log.error({ err }, "Failed to insert metrics");
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
372
246
|
try {
|
|
373
|
-
|
|
247
|
+
const mr = MetricsExtractor.fromNonStreamResponse(apiType, r.body);
|
|
248
|
+
if (mr)
|
|
249
|
+
insertMetrics(db, { ...mr, request_log_id: lastSuccessLogId, provider_id: provider.id, backend_model: resolved.backend_model, api_type: apiType });
|
|
374
250
|
}
|
|
375
251
|
catch (err) {
|
|
376
252
|
request.log.error({ err }, "Failed to insert metrics");
|
|
377
253
|
}
|
|
378
254
|
}
|
|
379
255
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
256
|
+
return reply;
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
260
|
+
const sentH = buildUpstreamHeaders(cliHdrs, apiKey, Buffer.byteLength(reqBodyStr));
|
|
261
|
+
const upstreamReq = JSON.stringify({ url: `${provider.base_url}${upstreamPath}`, headers: sentH, body: reqBodyStr });
|
|
262
|
+
insertRequestLog(db, {
|
|
263
|
+
id: logId, api_type: apiType, model: effectiveModel, provider_id: provider.id,
|
|
264
|
+
status_code: HTTP_BAD_GATEWAY, latency_ms: Date.now() - startTime,
|
|
265
|
+
is_stream: isStream ? 1 : 0, error_message: errMsg || "Upstream connection failed",
|
|
266
|
+
created_at: new Date().toISOString(), request_body: reqBodyStr,
|
|
267
|
+
client_request: clientReq, upstream_request: upstreamReq,
|
|
268
|
+
router_key_id: routerKeyId, original_model: originalModel,
|
|
269
|
+
});
|
|
270
|
+
// --- Failover 检查(异常路径)---
|
|
271
|
+
if (isFailover && !reply.raw.headersSent) {
|
|
272
|
+
excludeTargets.push(resolved);
|
|
273
|
+
continue;
|
|
389
274
|
}
|
|
275
|
+
const e = errors.upstreamConnectionFailed();
|
|
276
|
+
return reply.status(e.statusCode).send(e.body);
|
|
390
277
|
}
|
|
391
|
-
return reply;
|
|
392
|
-
}
|
|
393
|
-
catch (err) {
|
|
394
|
-
const errMsg = err instanceof Error ? err.message : String(err);
|
|
395
|
-
const sentH = buildUpstreamHeaders(cliHdrs, apiKey, Buffer.byteLength(reqBodyStr));
|
|
396
|
-
const upstreamReq = JSON.stringify({ url: `${provider.base_url}${upstreamPath}`, headers: sentH, body: reqBodyStr });
|
|
397
|
-
insertRequestLog(db, {
|
|
398
|
-
id: logId, api_type: apiType, model: clientModel, provider_id: provider.id,
|
|
399
|
-
status_code: HTTP_BAD_GATEWAY, latency_ms: Date.now() - startTime,
|
|
400
|
-
is_stream: isStream ? 1 : 0, error_message: errMsg || "Upstream connection failed",
|
|
401
|
-
created_at: new Date().toISOString(), request_body: reqBodyStr,
|
|
402
|
-
client_request: clientReq, upstream_request: upstreamReq,
|
|
403
|
-
router_key_id: routerKeyId,
|
|
404
|
-
});
|
|
405
|
-
const e = errors.upstreamConnectionFailed();
|
|
406
|
-
return reply.status(e.statusCode).send(e.body);
|
|
407
278
|
}
|
|
408
279
|
}
|