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.
Files changed (67) hide show
  1. package/README.md +12 -14
  2. package/dist/admin/groups.js +25 -0
  3. package/dist/admin/providers.d.ts +0 -1
  4. package/dist/admin/providers.js +16 -13
  5. package/dist/admin/proxy-enhancement.d.ts +7 -0
  6. package/dist/admin/proxy-enhancement.js +39 -0
  7. package/dist/admin/router-keys.d.ts +0 -1
  8. package/dist/admin/router-keys.js +17 -8
  9. package/dist/admin/routes.d.ts +0 -3
  10. package/dist/admin/routes.js +9 -4
  11. package/dist/admin/setup.d.ts +7 -0
  12. package/dist/admin/setup.js +44 -0
  13. package/dist/cli.d.ts +2 -0
  14. package/dist/cli.js +4 -0
  15. package/dist/config.d.ts +1 -4
  16. package/dist/config.js +13 -13
  17. package/dist/db/index.d.ts +5 -2
  18. package/dist/db/index.js +3 -1
  19. package/dist/db/logs.d.ts +5 -2
  20. package/dist/db/logs.js +4 -4
  21. package/dist/db/mappings.d.ts +16 -0
  22. package/dist/db/mappings.js +72 -0
  23. package/dist/db/migrations/014_create_settings.sql +4 -0
  24. package/dist/db/migrations/015_add_original_model.sql +1 -0
  25. package/dist/db/migrations/016_create_session_model_tables.sql +24 -0
  26. package/dist/db/session-states.d.ts +40 -0
  27. package/dist/db/session-states.js +37 -0
  28. package/dist/db/settings.d.ts +4 -0
  29. package/dist/db/settings.js +10 -0
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.js +53 -13
  32. package/dist/middleware/admin-auth.d.ts +2 -2
  33. package/dist/middleware/admin-auth.js +21 -8
  34. package/dist/middleware/auth.js +46 -1
  35. package/dist/proxy/anthropic.d.ts +0 -1
  36. package/dist/proxy/anthropic.js +2 -2
  37. package/dist/proxy/directive-parser.d.ts +7 -0
  38. package/dist/proxy/directive-parser.js +70 -0
  39. package/dist/proxy/enhancement-handler.d.ts +23 -0
  40. package/dist/proxy/enhancement-handler.js +167 -0
  41. package/dist/proxy/log-helpers.d.ts +41 -0
  42. package/dist/proxy/log-helpers.js +35 -0
  43. package/dist/proxy/mapping-resolver.js +39 -2
  44. package/dist/proxy/model-state.d.ts +28 -0
  45. package/dist/proxy/model-state.js +111 -0
  46. package/dist/proxy/openai.d.ts +0 -1
  47. package/dist/proxy/openai.js +4 -3
  48. package/dist/proxy/proxy-core.d.ts +9 -47
  49. package/dist/proxy/proxy-core.js +215 -344
  50. package/dist/proxy/response-cleaner.d.ts +5 -0
  51. package/dist/proxy/response-cleaner.js +60 -0
  52. package/dist/proxy/strategy/failover.d.ts +1 -1
  53. package/dist/proxy/strategy/failover.js +10 -2
  54. package/dist/proxy/strategy/random.d.ts +1 -1
  55. package/dist/proxy/strategy/random.js +8 -2
  56. package/dist/proxy/strategy/round-robin.d.ts +2 -1
  57. package/dist/proxy/strategy/round-robin.js +13 -2
  58. package/dist/proxy/strategy/targets-rule.d.ts +7 -0
  59. package/dist/proxy/strategy/targets-rule.js +14 -0
  60. package/dist/proxy/strategy/types.d.ts +5 -1
  61. package/dist/proxy/strategy/types.js +3 -0
  62. package/dist/proxy/upstream-call.d.ts +43 -0
  63. package/dist/proxy/upstream-call.js +208 -0
  64. package/dist/utils/password.d.ts +2 -0
  65. package/dist/utils/password.js +14 -0
  66. package/package.json +6 -5
  67. package/.env.example +0 -13
@@ -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 { SSEMetricsTransform } from "../metrics/sse-metrics-transform.js";
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
- export const UPSTREAM_SUCCESS = 200;
13
- const HTTPS_DEFAULT_PORT = 443;
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(commit eaa4f7d 将 Anthropic 从 x-api-key 改为 Bearer)
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
- // ---------- Request utilities ----------
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 new Promise((resolve, reject) => {
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
- * 消除 openai.ts / anthropic.ts 中约 120 行重复代码。
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, encryptionKey, streamTimeoutMs, retryMaxAttempts, retryBaseDelayMs, matcher } = deps;
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 startTime = Date.now();
266
- const logId = randomUUID();
267
- const routerKeyId = request.routerKey?.id ?? null;
268
- const body = request.body;
269
- const originalBody = JSON.parse(JSON.stringify(body));
270
- const clientModel = body.model || "unknown";
271
- const resolved = resolveMapping(db, clientModel, { now: new Date() });
272
- if (!resolved) {
273
- const e = errors.modelNotFound(clientModel);
274
- return reply.status(e.statusCode).send(e.body);
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 allowedModels = request.routerKey?.allowed_models;
278
- if (allowedModels) {
279
- try {
280
- const models = JSON.parse(allowedModels);
281
- if (models.length > 0 && !models.includes(resolved.backend_model)) {
282
- const e = errors.modelNotAllowed(resolved.backend_model);
283
- return reply.status(e.statusCode).send(e.body);
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
- catch {
287
- request.log.warn("Invalid allowed_models JSON, allowing all models");
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
- const provider = getProviderById(db, resolved.provider_id);
291
- if (!provider || !provider.is_active) {
292
- const e = errors.providerUnavailable();
293
- return reply.status(e.statusCode).send(e.body);
294
- }
295
- if (provider.api_type !== apiType) {
296
- const e = errors.providerTypeMismatch();
297
- return reply.status(e.statusCode).send(e.body);
298
- }
299
- body.model = resolved.backend_model;
300
- const apiKey = decrypt(provider.api_key, encryptionKey);
301
- const isStream = body.stream === true;
302
- // 允许调用方在发送代理请求前修改 body(如 openai stream_options 注入)
303
- options?.beforeSendProxy?.(body, isStream);
304
- const reqBodyStr = JSON.stringify(body);
305
- const cliHdrs = request.headers;
306
- const clientReq = JSON.stringify({ headers: cliHdrs, body: originalBody });
307
- const retryConfig = buildRetryConfig(retryMaxAttempts, retryBaseDelayMs, matcher);
308
- const upstreamReqBase = JSON.stringify({ url: `${provider.base_url}${upstreamPath}`, headers: buildUpstreamHeaders(cliHdrs, apiKey, Buffer.byteLength(reqBodyStr)), body: reqBodyStr });
309
- try {
310
- const { result: r, attempts } = isStream
311
- ? await retryableCall(() => {
312
- const metricsTransform = new SSEMetricsTransform(apiType, startTime);
313
- return proxyStream(provider, apiKey, body, cliHdrs, reply, streamTimeoutMs, upstreamPath, metricsTransform);
314
- }, retryConfig, reply)
315
- : await retryableCall(() => proxyNonStream(provider, apiKey, body, cliHdrs, upstreamPath), retryConfig, reply);
316
- // 记录所有尝试的日志
317
- let lastSuccessLogId = logId;
318
- for (const attempt of attempts) {
319
- const isOriginal = attempt.attemptIndex === 0;
320
- const attemptLogId = isOriginal ? logId : randomUUID();
321
- if (attempt.error) {
322
- insertRequestLog(db, {
323
- id: attemptLogId, api_type: apiType, model: clientModel, provider_id: provider.id,
324
- status_code: HTTP_BAD_GATEWAY, latency_ms: attempt.latencyMs,
325
- is_stream: isStream ? 1 : 0, error_message: attempt.error,
326
- created_at: new Date().toISOString(), request_body: reqBodyStr,
327
- client_request: clientReq, upstream_request: upstreamReqBase,
328
- is_retry: isOriginal ? 0 : 1, original_request_id: isOriginal ? null : logId,
329
- router_key_id: routerKeyId,
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
- else if (attempt.statusCode !== UPSTREAM_SUCCESS) {
333
- insertRequestLog(db, {
334
- id: attemptLogId, api_type: apiType, model: clientModel, provider_id: provider.id,
335
- status_code: attempt.statusCode, latency_ms: attempt.latencyMs,
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
- else {
346
- const h = isStream
347
- ? (r.upstreamResponseHeaders ?? {})
348
- : (r.headers);
349
- insertSuccessLog(db, apiType, attemptLogId, clientModel, provider, isStream, startTime, reqBodyStr, clientReq, upstreamReqBase, r.statusCode, attempt.responseBody, h, h, !isOriginal, isOriginal ? null : logId, routerKeyId);
350
- lastSuccessLogId = attemptLogId;
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
- if (isStream) {
355
- if (r.statusCode !== UPSTREAM_SUCCESS) {
356
- for (const [k, v] of Object.entries(r.upstreamResponseHeaders ?? {}))
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(r.statusCode).send(r.responseBody);
230
+ return reply.status(pr.statusCode).send(pr.body);
359
231
  }
360
- }
361
- else {
362
- const pr = r;
363
- for (const [k, v] of Object.entries(pr.headers))
364
- reply.header(k, v);
365
- return reply.status(pr.statusCode).send(pr.body);
366
- }
367
- // 仅对最终成功请求采集 metrics
368
- if (r.statusCode === UPSTREAM_SUCCESS) {
369
- if (isStream) {
370
- const streamResult = r;
371
- if (streamResult.metricsResult) {
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
- insertMetrics(db, { ...streamResult.metricsResult, request_log_id: lastSuccessLogId, provider_id: provider.id, backend_model: resolved.backend_model, api_type: apiType });
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
- else {
381
- try {
382
- const mr = MetricsExtractor.fromNonStreamResponse(apiType, r.body);
383
- if (mr)
384
- insertMetrics(db, { ...mr, request_log_id: lastSuccessLogId, provider_id: provider.id, backend_model: resolved.backend_model, api_type: apiType });
385
- }
386
- catch (err) {
387
- request.log.error({ err }, "Failed to insert metrics");
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
  }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * 清理历史消息中的路由相关内容(命令消息和 router-response 标签)。
3
+ * 只清理历史轮次,跳过最后一条 user 消息(当前轮由 directive-parser 处理)。
4
+ */
5
+ export declare function cleanRouterResponses(body: Record<string, unknown>): Record<string, unknown>;