llm-simple-router 0.3.7 → 0.4.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 (104) hide show
  1. package/README.md +81 -49
  2. package/dist/admin/constants.d.ts +1 -8
  3. package/dist/admin/constants.js +2 -8
  4. package/dist/admin/logs.js +18 -3
  5. package/dist/admin/router-keys.js +1 -2
  6. package/dist/cli.js +0 -0
  7. package/dist/constants.d.ts +8 -0
  8. package/dist/constants.js +9 -0
  9. package/dist/db/index.d.ts +4 -4
  10. package/dist/db/index.js +2 -2
  11. package/dist/db/logs.d.ts +18 -33
  12. package/dist/db/logs.js +40 -17
  13. package/dist/db/metrics.d.ts +33 -0
  14. package/dist/db/metrics.js +7 -0
  15. package/dist/db/migrations/018_add_failover_field.sql +2 -0
  16. package/dist/db/retry-rules.d.ts +2 -2
  17. package/dist/db/retry-rules.js +26 -13
  18. package/dist/index.js +3 -5
  19. package/dist/monitor/request-tracker.d.ts +6 -0
  20. package/dist/monitor/request-tracker.js +23 -54
  21. package/dist/monitor/stream-extractor.d.ts +11 -0
  22. package/dist/monitor/stream-extractor.js +51 -0
  23. package/dist/proxy/anthropic.js +19 -32
  24. package/dist/proxy/log-helpers.d.ts +11 -4
  25. package/dist/proxy/log-helpers.js +5 -3
  26. package/dist/proxy/openai.js +18 -34
  27. package/dist/proxy/orchestrator.d.ts +52 -0
  28. package/dist/proxy/orchestrator.js +100 -0
  29. package/dist/proxy/proxy-core.d.ts +14 -26
  30. package/dist/proxy/proxy-core.js +40 -337
  31. package/dist/proxy/proxy-handler.d.ts +18 -0
  32. package/dist/proxy/proxy-handler.js +223 -0
  33. package/dist/proxy/proxy-logging.d.ts +28 -0
  34. package/dist/proxy/proxy-logging.js +122 -0
  35. package/dist/proxy/resilience.d.ts +63 -0
  36. package/dist/proxy/resilience.js +188 -0
  37. package/dist/proxy/scope.d.ts +18 -0
  38. package/dist/proxy/scope.js +37 -0
  39. package/dist/proxy/semaphore.d.ts +9 -2
  40. package/dist/proxy/semaphore.js +34 -7
  41. package/dist/proxy/stream-proxy.d.ts +7 -0
  42. package/dist/proxy/stream-proxy.js +263 -0
  43. package/dist/proxy/{upstream-call.d.ts → transport.d.ts} +25 -18
  44. package/dist/proxy/transport.js +128 -0
  45. package/dist/proxy/types.d.ts +58 -0
  46. package/dist/proxy/types.js +30 -0
  47. package/frontend-dist/assets/{CardContent-CucI6u41.js → CardContent-CTnwqTdL.js} +1 -1
  48. package/frontend-dist/assets/{CardHeader-d-DYsWxe.js → CardHeader-CfUeY7tk.js} +1 -1
  49. package/frontend-dist/assets/{CardTitle-CIDEQkWB.js → CardTitle-CWiDwWqd.js} +1 -1
  50. package/frontend-dist/assets/{Checkbox-CybCw3zS.js → Checkbox-BxNz70R_.js} +1 -1
  51. package/frontend-dist/assets/{CollapsibleTrigger-BFNhb19_.js → CollapsibleTrigger-Uz1aGdtH.js} +1 -1
  52. package/frontend-dist/assets/{Collection-DUBb4r6h.js → Collection-1EHC87X5.js} +1 -1
  53. package/frontend-dist/assets/{Dashboard-DLB6iqH1.js → Dashboard-C3FL30UN.js} +2 -2
  54. package/frontend-dist/assets/{DialogTitle-Dq-5o7nJ.js → DialogTitle-CAOFxr83.js} +1 -1
  55. package/frontend-dist/assets/{Input-HN3Il0-c.js → Input-DRIid2C6.js} +1 -1
  56. package/frontend-dist/assets/{Label-CXAeFn-r.js → Label-UyNN2jyE.js} +1 -1
  57. package/frontend-dist/assets/LogDetailDialog-8BT4vIlV.js +3 -0
  58. package/frontend-dist/assets/{Login-Br3qsdxf.js → Login-CnzH6TdS.js} +1 -1
  59. package/frontend-dist/assets/Logs-CbK8NB_X.js +1 -0
  60. package/frontend-dist/assets/{ModelMappings-DXC0sNH5.js → ModelMappings-DeRFgsYG.js} +1 -1
  61. package/frontend-dist/assets/Monitor-Dd80bdUn.js +1 -0
  62. package/frontend-dist/assets/{PopperContent-CnZejY31.js → PopperContent-B3fZao7v.js} +1 -1
  63. package/frontend-dist/assets/{Providers-8CHhW4uH.js → Providers-B_DbV-_y.js} +1 -1
  64. package/frontend-dist/assets/ProxyEnhancement-up1fnPzq.js +5 -0
  65. package/frontend-dist/assets/RetryRules-Dkuhjh0u.js +1 -0
  66. package/frontend-dist/assets/RouterKeys-CvMMAa4t.js +1 -0
  67. package/frontend-dist/assets/{RovingFocusItem-B7ZIkplZ.js → RovingFocusItem-X0bfqWWS.js} +1 -1
  68. package/frontend-dist/assets/{SelectValue-B32pgmTJ.js → SelectValue-zO8t-tx1.js} +1 -1
  69. package/frontend-dist/assets/{Setup-Df9IQo2x.js → Setup-ByT2ThOQ.js} +1 -1
  70. package/frontend-dist/assets/{Switch-CLeo7H6d.js → Switch-BEMjVugO.js} +1 -1
  71. package/frontend-dist/assets/{TableHeader-BpscAtT3.js → TableHeader-DpHWSnxK.js} +1 -1
  72. package/frontend-dist/assets/{TabsTrigger-DErAbTuM.js → TabsTrigger-Db6RqsZc.js} +1 -1
  73. package/frontend-dist/assets/{VisuallyHidden-CJBR3YB3.js → VisuallyHidden-hs8pj8OP.js} +1 -1
  74. package/frontend-dist/assets/{VisuallyHiddenInput-Cy0VuE1l.js → VisuallyHiddenInput-1m0nNADN.js} +1 -1
  75. package/frontend-dist/assets/{alert-dialog-BAR1JRmT.js → alert-dialog-PP91kaO8.js} +1 -1
  76. package/frontend-dist/assets/{button-D54q76GQ.js → button-Dcc0gF5i.js} +1 -1
  77. package/frontend-dist/assets/{client-Mb8fy_bC.js → client-DIIo9zPK.js} +2 -2
  78. package/frontend-dist/assets/{createLucideIcon-CCmQ9QKM.js → createLucideIcon-DGZkBjcJ.js} +1 -1
  79. package/frontend-dist/assets/{dialog-DSH5k5Kj.js → dialog-CxSyR-fN.js} +1 -1
  80. package/frontend-dist/assets/format-CPdJtjZ5.js +1 -0
  81. package/frontend-dist/assets/index-BL-LAtac.css +1 -0
  82. package/frontend-dist/assets/{index-BQBtSfem.js → index-CvT41fGL.js} +1 -1
  83. package/frontend-dist/assets/{lib-BgOqOzXI.js → lib-Bl0OuBjh.js} +1 -1
  84. package/frontend-dist/assets/{ohash.D__AXeF1-p4vp6Svt.js → ohash.D__AXeF1-B64hB831.js} +1 -1
  85. package/frontend-dist/assets/{useClipboard-DO-38TXr.js → useClipboard-CWc1cTDo.js} +1 -1
  86. package/frontend-dist/assets/{useForwardExpose-CzQFheaD.js → useForwardExpose-AkE0lq8y.js} +1 -1
  87. package/frontend-dist/assets/useNonce-DGyPxdjq.js +1 -0
  88. package/frontend-dist/assets/x-BuUpx9Fr.js +1 -0
  89. package/frontend-dist/index.html +7 -7
  90. package/package.json +1 -1
  91. package/dist/admin/services.d.ts +0 -7
  92. package/dist/admin/services.js +0 -63
  93. package/dist/proxy/retry.d.ts +0 -43
  94. package/dist/proxy/retry.js +0 -121
  95. package/dist/proxy/upstream-call.js +0 -208
  96. package/frontend-dist/assets/LogResponseViewer-CyBzv02a.js +0 -3
  97. package/frontend-dist/assets/Logs-Cu_IftdS.js +0 -1
  98. package/frontend-dist/assets/Monitor-CKlid1sC.js +0 -1
  99. package/frontend-dist/assets/ProxyEnhancement-CkYeXwgH.js +0 -5
  100. package/frontend-dist/assets/RetryRules-Csb7u9W4.js +0 -1
  101. package/frontend-dist/assets/RouterKeys-C6YIufmj.js +0 -1
  102. package/frontend-dist/assets/index-H-lnTkMr.css +0 -1
  103. package/frontend-dist/assets/useNonce-CU-NirfM.js +0 -1
  104. package/frontend-dist/assets/x-DEJ1xpi5.js +0 -1
@@ -1,21 +1,43 @@
1
- import { randomUUID } from "crypto";
2
- import { getProviderById, insertRequestLog, insertMetrics } from "../db/index.js";
3
- import { decrypt } from "../utils/crypto.js";
4
- import { getSetting } from "../db/settings.js";
5
- import { MetricsExtractor } from "../metrics/metrics-extractor.js";
6
- import { getMappingGroup } from "../db/index.js";
7
- import { resolveMapping } from "./mapping-resolver.js";
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";
13
- import { SemaphoreQueueFullError, SemaphoreTimeoutError } from "./semaphore.js";
14
- // ---------- Constants ----------
15
- const UPSTREAM_SUCCESS = 200;
16
- const FAILOVER_FAIL_THRESHOLD = 400;
17
- const STREAM_CONTENT_MAX_RAW = 8192;
18
- const STREAM_CONTENT_MAX_TEXT = 4096;
1
+ import { callGet as upstreamGet } from "./transport.js";
2
+ // Re-export for external consumers (openai.ts, anthropic.ts, etc.)
3
+ export { UPSTREAM_SUCCESS } from "./types.js";
4
+ /**
5
+ * 工厂函数,消除 openai/anthropic 错误格式化的重复代码。
6
+ * statusCode message 两个 provider 完全一致,仅 body 格式不同,
7
+ * formatBody 回调根据 kind 参数映射各自的 type/code 并组装 body。
8
+ */
9
+ export function createErrorFormatter(formatBody) {
10
+ return {
11
+ modelNotFound: (model) => ({
12
+ statusCode: 404,
13
+ body: formatBody("modelNotFound", `Model '${model}' is not configured`),
14
+ }),
15
+ modelNotAllowed: (model) => ({
16
+ statusCode: 403,
17
+ body: formatBody("modelNotAllowed", `Model '${model}' is not allowed for this API key`),
18
+ }),
19
+ providerUnavailable: () => ({
20
+ statusCode: 503,
21
+ body: formatBody("providerUnavailable", "Provider unavailable"),
22
+ }),
23
+ providerTypeMismatch: () => ({
24
+ statusCode: 500,
25
+ body: formatBody("providerTypeMismatch", "Provider type mismatch for this endpoint"),
26
+ }),
27
+ upstreamConnectionFailed: () => ({
28
+ statusCode: 502,
29
+ body: formatBody("upstreamConnectionFailed", "Failed to connect to upstream service"),
30
+ }),
31
+ concurrencyQueueFull: (providerId) => ({
32
+ statusCode: 503,
33
+ body: formatBody("concurrencyQueueFull", `Provider '${providerId}' concurrency queue is full`),
34
+ }),
35
+ concurrencyTimeout: (providerId, timeoutMs) => ({
36
+ statusCode: 504,
37
+ body: formatBody("concurrencyTimeout", `Provider '${providerId}' concurrency wait timeout (${timeoutMs}ms)`),
38
+ }),
39
+ };
40
+ }
19
41
  // ---------- Header utilities ----------
20
42
  export const SKIP_UPSTREAM = new Set([
21
43
  "host",
@@ -36,8 +58,6 @@ export function selectHeaders(raw, skip) {
36
58
  }
37
59
  return out;
38
60
  }
39
- // 当前两个 provider 都使用 Bearer token
40
- // 如果未来需要支持其他鉴权方式,需要参数化 header 构造
41
61
  export function buildUpstreamHeaders(clientHeaders, apiKey, payloadBytes) {
42
62
  const headers = selectHeaders(clientHeaders, SKIP_UPSTREAM);
43
63
  headers["Authorization"] = `Bearer ${apiKey}`;
@@ -51,320 +71,3 @@ export function buildUpstreamHeaders(clientHeaders, apiKey, payloadBytes) {
51
71
  export function proxyGetRequest(backend, apiKey, clientHeaders, upstreamPath) {
52
72
  return upstreamGet(backend, apiKey, clientHeaders, upstreamPath, buildUpstreamHeaders);
53
73
  }
54
- // ---------- Helper functions for handleProxyPost ----------
55
- function handleIntercept(db, apiType, request, reply, interceptResponse, clientModel) {
56
- const logId = randomUUID();
57
- const isStream = request.body.stream === true;
58
- const respBody = JSON.stringify(interceptResponse.body);
59
- insertRequestLog(db, {
60
- id: logId, api_type: apiType, model: clientModel, provider_id: "router",
61
- status_code: interceptResponse.statusCode, latency_ms: 0,
62
- is_stream: isStream ? 1 : 0, error_message: null,
63
- created_at: new Date().toISOString(),
64
- request_body: JSON.stringify(request.body),
65
- response_body: respBody,
66
- client_request: JSON.stringify({ headers: request.headers, body: request.body }),
67
- upstream_request: interceptResponse.meta ? JSON.stringify(interceptResponse.meta) : null,
68
- client_response: JSON.stringify({ statusCode: interceptResponse.statusCode, body: respBody }),
69
- is_retry: 0, original_request_id: null,
70
- router_key_id: request.routerKey?.id ?? null, original_model: null,
71
- });
72
- return reply.status(interceptResponse.statusCode).send(interceptResponse.body);
73
- }
74
- function logRetryAttempts(db, params, attempts, result, startTime) {
75
- let lastSuccessLogId = params.logId;
76
- for (const attempt of attempts) {
77
- const isOriginal = attempt.attemptIndex === 0;
78
- const attemptLogId = isOriginal ? params.logId : randomUUID();
79
- if (attempt.error) {
80
- insertRequestLog(db, {
81
- id: attemptLogId, api_type: params.apiType, model: params.model, provider_id: params.providerId,
82
- status_code: HTTP_BAD_GATEWAY, latency_ms: attempt.latencyMs,
83
- is_stream: params.isStream ? 1 : 0, error_message: attempt.error,
84
- created_at: new Date().toISOString(), request_body: params.reqBodyStr,
85
- client_request: params.clientReq, upstream_request: params.upstreamReqBase,
86
- is_retry: isOriginal ? 0 : 1, original_request_id: isOriginal ? null : params.logId,
87
- router_key_id: params.routerKeyId, original_model: params.originalModel,
88
- });
89
- }
90
- else if (attempt.statusCode !== UPSTREAM_SUCCESS) {
91
- insertRequestLog(db, {
92
- id: attemptLogId, api_type: params.apiType, model: params.model, provider_id: params.providerId,
93
- status_code: attempt.statusCode, latency_ms: attempt.latencyMs,
94
- is_stream: params.isStream ? 1 : 0, error_message: null,
95
- created_at: new Date().toISOString(), request_body: params.reqBodyStr,
96
- response_body: attempt.responseBody, client_request: params.clientReq, upstream_request: params.upstreamReqBase,
97
- upstream_response: JSON.stringify({ statusCode: attempt.statusCode, body: attempt.responseBody }),
98
- client_response: JSON.stringify({ statusCode: attempt.statusCode, body: attempt.responseBody }),
99
- is_retry: isOriginal ? 0 : 1, original_request_id: isOriginal ? null : params.logId,
100
- router_key_id: params.routerKeyId, original_model: params.originalModel,
101
- });
102
- }
103
- else {
104
- const h = params.isStream
105
- ? (result.upstreamResponseHeaders ?? {})
106
- : (result.headers);
107
- insertSuccessLog(db, { apiType: params.apiType, model: params.model, provider: { id: params.providerId }, isStream: params.isStream, startTime,
108
- reqBody: params.reqBodyStr, clientReq: params.clientReq, upstreamReq: params.upstreamReqBase, id: attemptLogId,
109
- status: result.statusCode, respBody: attempt.responseBody, upHdrs: h, cliHdrs: h,
110
- isRetry: !isOriginal, originalRequestId: isOriginal ? null : params.logId,
111
- routerKeyId: params.routerKeyId, originalModel: params.originalModel });
112
- lastSuccessLogId = attemptLogId;
113
- }
114
- }
115
- return lastSuccessLogId;
116
- }
117
- function collectMetrics(db, apiType, result, isStream, lastSuccessLogId, providerId, backendModel, request) {
118
- if (isStream) {
119
- const streamResult = result;
120
- if (streamResult.metricsResult) {
121
- try {
122
- insertMetrics(db, { ...streamResult.metricsResult, request_log_id: lastSuccessLogId, provider_id: providerId, backend_model: backendModel, api_type: apiType });
123
- }
124
- catch (err) {
125
- request.log.error({ err }, "Failed to insert metrics");
126
- }
127
- }
128
- }
129
- else {
130
- try {
131
- const mr = MetricsExtractor.fromNonStreamResponse(apiType, result.body);
132
- if (mr)
133
- insertMetrics(db, { ...mr, request_log_id: lastSuccessLogId, provider_id: providerId, backend_model: backendModel, api_type: apiType });
134
- }
135
- catch (err) {
136
- request.log.error({ err }, "Failed to insert metrics");
137
- }
138
- }
139
- }
140
- // ---------- Shared proxy handler ----------
141
- const HTTP_BAD_GATEWAY = 502;
142
- const HTTP_BAD_REQUEST = 400;
143
- /**
144
- * 共享 POST handler,参数化 apiType/errorFormat/upstreamPath 等差异。
145
- * 当分组策略为 failover 时,在 while 循环中依次尝试不同 target,
146
- * 直到成功(或 headers 已发送)才返回。
147
- */
148
- export async function handleProxyPost(request, reply, apiType, upstreamPath, errors, deps, options) {
149
- const { db, streamTimeoutMs, retryMaxAttempts, retryBaseDelayMs, matcher } = deps;
150
- request.raw.socket.on("error", (err) => request.log.debug({ err }, "client socket error"));
151
- const clientModel = request.body.model || "unknown";
152
- // 代理增强:指令解析 + 模型替换 + 命令拦截
153
- const sessionId = request.headers["x-claude-code-session-id"];
154
- const { effectiveModel, originalModel, interceptResponse } = applyEnhancement(db, request, clientModel, sessionId);
155
- if (interceptResponse)
156
- return handleIntercept(db, apiType, request, reply, interceptResponse, clientModel);
157
- // 查询分组策略(只查一次)
158
- const group = getMappingGroup(db, effectiveModel);
159
- const isFailover = group?.strategy === "failover";
160
- const excludeTargets = [];
161
- while (true) {
162
- const startTime = Date.now();
163
- const logId = randomUUID();
164
- const routerKeyId = request.routerKey?.id ?? null;
165
- const body = request.body;
166
- const originalBody = JSON.parse(JSON.stringify(body));
167
- const isStream = body.stream === true;
168
- const cliHdrs = request.headers;
169
- const resolved = resolveMapping(db, effectiveModel, { now: new Date(), excludeTargets });
170
- if (!resolved) {
171
- if (isFailover && excludeTargets.length > 0) {
172
- return reply;
173
- }
174
- const e = errors.modelNotFound(effectiveModel);
175
- insertRejectedLog({ db, logId, apiType, model: effectiveModel, statusCode: e.statusCode,
176
- errorMessage: `No mapping found for model '${effectiveModel}'`, startTime, isStream,
177
- routerKeyId, originalBody, clientHeaders: cliHdrs, originalModel });
178
- return reply.status(e.statusCode).send(e.body);
179
- }
180
- // 白名单校验
181
- if (excludeTargets.length === 0) {
182
- const allowedModels = request.routerKey?.allowed_models;
183
- if (allowedModels) {
184
- try {
185
- const models = JSON.parse(allowedModels).filter((m) => m.trim() !== "");
186
- if (models.length > 0 && !models.includes(resolved.backend_model)) {
187
- const e = errors.modelNotAllowed(resolved.backend_model);
188
- insertRejectedLog({ db, logId, apiType, model: effectiveModel, statusCode: e.statusCode,
189
- errorMessage: `Model '${resolved.backend_model}' not allowed for this API key`,
190
- startTime, isStream, routerKeyId, originalBody, clientHeaders: cliHdrs,
191
- providerId: resolved.provider_id, originalModel });
192
- return reply.status(e.statusCode).send(e.body);
193
- }
194
- }
195
- catch {
196
- request.log.warn({ allowedModels: allowedModels?.slice(0, 80) }, "Invalid allowed_models JSON, allowing all models");
197
- } // eslint-disable-line no-magic-numbers
198
- }
199
- }
200
- const provider = getProviderById(db, resolved.provider_id);
201
- if (!provider || !provider.is_active) {
202
- const e = errors.providerUnavailable();
203
- insertRejectedLog({ db, logId, apiType, model: effectiveModel, statusCode: e.statusCode,
204
- errorMessage: `Provider '${resolved.provider_id}' unavailable or inactive`,
205
- startTime, isStream, routerKeyId, originalBody, clientHeaders: cliHdrs,
206
- providerId: resolved.provider_id, originalModel });
207
- return reply.status(e.statusCode).send(e.body);
208
- }
209
- if (provider.api_type !== apiType) {
210
- const e = errors.providerTypeMismatch();
211
- insertRejectedLog({ db, logId, apiType, model: effectiveModel, statusCode: e.statusCode,
212
- errorMessage: `Provider API type mismatch: expected '${apiType}', got '${provider.api_type}'`,
213
- startTime, isStream, routerKeyId, originalBody, clientHeaders: cliHdrs,
214
- providerId: resolved.provider_id, originalModel });
215
- return reply.status(e.statusCode).send(e.body);
216
- }
217
- deps.tracker?.start({
218
- id: logId, apiType, model: effectiveModel, providerId: provider.id,
219
- providerName: provider.name, isStream, startTime, status: "pending",
220
- retryCount: 0, attempts: [], clientIp: request.ip,
221
- });
222
- body.model = resolved.backend_model;
223
- const apiKey = decrypt(provider.api_key, getSetting(db, "encryption_key"));
224
- options?.beforeSendProxy?.(body, isStream);
225
- const reqBodyStr = JSON.stringify(body);
226
- const clientReq = JSON.stringify({ headers: cliHdrs, body: originalBody });
227
- const retryConfig = buildRetryConfig(retryMaxAttempts, retryBaseDelayMs, matcher);
228
- const upstreamReqBase = JSON.stringify({ url: `${provider.base_url}${upstreamPath}`, headers: buildUpstreamHeaders(cliHdrs, apiKey, Buffer.byteLength(reqBodyStr)), body: reqBodyStr });
229
- // === Semaphore acquire ===
230
- const semaphoreManager = deps.semaphoreManager;
231
- let semaphoreReleased = false;
232
- const releaseSemaphore = () => {
233
- if (!semaphoreReleased) {
234
- semaphoreReleased = true;
235
- semaphoreManager?.release(provider.id);
236
- }
237
- };
238
- if (semaphoreManager) {
239
- const ac = new AbortController();
240
- request.raw.on("close", () => ac.abort());
241
- try {
242
- await semaphoreManager.acquire(provider.id, ac.signal, () => {
243
- deps.tracker?.update(logId, { queued: true });
244
- });
245
- deps.tracker?.update(logId, { queued: false });
246
- }
247
- catch (err) {
248
- if (err instanceof DOMException && err.name === "AbortError") {
249
- deps.tracker?.complete(logId, { status: "failed" });
250
- return reply;
251
- }
252
- if (err instanceof SemaphoreQueueFullError) {
253
- request.log.warn({ providerId: provider.id }, "Concurrency queue full, rejecting request");
254
- const e = errors.concurrencyQueueFull(provider.id);
255
- deps.tracker?.complete(logId, { status: "failed", statusCode: e.statusCode });
256
- return reply.status(e.statusCode).send(e.body);
257
- }
258
- if (err instanceof SemaphoreTimeoutError) {
259
- request.log.warn({ providerId: provider.id, timeoutMs: err.timeoutMs }, "Concurrency wait timed out");
260
- const e = errors.concurrencyTimeout(provider.id, err.timeoutMs);
261
- deps.tracker?.complete(logId, { status: "failed", statusCode: e.statusCode });
262
- return reply.status(e.statusCode).send(e.body);
263
- }
264
- throw err;
265
- }
266
- }
267
- try {
268
- const { result: r, attempts } = isStream
269
- ? await retryableCall(() => {
270
- const metricsTransform = new SSEMetricsTransform(apiType, startTime, {
271
- onMetrics: (m) => {
272
- deps.tracker?.update(logId, {
273
- streamMetrics: {
274
- inputTokens: m.input_tokens,
275
- outputTokens: m.output_tokens,
276
- ttftMs: m.ttft_ms,
277
- stopReason: m.stop_reason,
278
- isComplete: m.is_complete === 1,
279
- },
280
- });
281
- },
282
- onChunk: (rawLine) => {
283
- deps.tracker?.appendStreamChunk(logId, rawLine, apiType, STREAM_CONTENT_MAX_RAW, STREAM_CONTENT_MAX_TEXT);
284
- },
285
- });
286
- return upstreamStream(provider, apiKey, body, cliHdrs, reply, streamTimeoutMs, upstreamPath, buildUpstreamHeaders, metricsTransform);
287
- }, retryConfig, reply)
288
- : await retryableCall(() => upstreamNonStream(provider, apiKey, body, cliHdrs, upstreamPath, buildUpstreamHeaders), retryConfig, reply);
289
- const trackerAttempts = attempts.map(a => ({
290
- statusCode: a.statusCode ?? null,
291
- error: a.error ?? null,
292
- latencyMs: a.latencyMs,
293
- providerId: provider.id,
294
- }));
295
- deps.tracker?.update(logId, {
296
- retryCount: Math.max(0, attempts.length - 1),
297
- attempts: trackerAttempts,
298
- providerId: provider.id,
299
- });
300
- const lastSuccessLogId = logRetryAttempts(db, {
301
- apiType, model: effectiveModel, providerId: provider.id, isStream,
302
- reqBodyStr, clientReq, upstreamReqBase, logId, routerKeyId, originalModel,
303
- }, attempts, r, startTime);
304
- // --- Failover 检查 ---
305
- if (isFailover && r.statusCode >= FAILOVER_FAIL_THRESHOLD && !reply.raw.headersSent) {
306
- deps.tracker?.complete(logId, { status: "failed", statusCode: r.statusCode });
307
- releaseSemaphore();
308
- excludeTargets.push(resolved);
309
- continue;
310
- }
311
- // 发送响应
312
- if (isStream) {
313
- if (r.statusCode !== UPSTREAM_SUCCESS) {
314
- for (const [k, v] of Object.entries(r.upstreamResponseHeaders ?? {}))
315
- reply.header(k, v);
316
- reply.status(r.statusCode).send(r.responseBody);
317
- }
318
- }
319
- else {
320
- const pr = r;
321
- // 非流式响应:模型替换时注入 router-response 标签
322
- if (originalModel && pr.statusCode === UPSTREAM_SUCCESS) {
323
- try {
324
- const bodyObj = JSON.parse(pr.body);
325
- if (bodyObj.content?.[0]?.text) {
326
- bodyObj.content[0].text += `\n\n${buildModelInfoTag(effectiveModel)}`;
327
- pr.body = JSON.stringify(bodyObj);
328
- }
329
- }
330
- catch {
331
- request.log.debug("Failed to inject model-info tag into non-JSON response");
332
- }
333
- }
334
- for (const [k, v] of Object.entries(pr.headers))
335
- reply.header(k, v);
336
- reply.status(pr.statusCode).send(pr.body);
337
- }
338
- if (r.statusCode === UPSTREAM_SUCCESS) {
339
- collectMetrics(db, apiType, r, isStream, lastSuccessLogId, provider.id, resolved.backend_model, request);
340
- }
341
- deps.tracker?.complete(logId, { status: r.statusCode < HTTP_BAD_REQUEST ? "completed" : "failed", statusCode: r.statusCode });
342
- releaseSemaphore();
343
- return reply;
344
- }
345
- catch (err) {
346
- const errMsg = err instanceof Error ? err.message : String(err);
347
- const sentH = buildUpstreamHeaders(cliHdrs, apiKey, Buffer.byteLength(reqBodyStr));
348
- const upstreamReq = JSON.stringify({ url: `${provider.base_url}${upstreamPath}`, headers: sentH, body: reqBodyStr });
349
- insertRequestLog(db, {
350
- id: logId, api_type: apiType, model: effectiveModel, provider_id: provider.id,
351
- status_code: HTTP_BAD_GATEWAY, latency_ms: Date.now() - startTime,
352
- is_stream: isStream ? 1 : 0, error_message: errMsg || "Upstream connection failed",
353
- created_at: new Date().toISOString(), request_body: reqBodyStr,
354
- client_request: clientReq, upstream_request: upstreamReq,
355
- router_key_id: routerKeyId, original_model: originalModel,
356
- });
357
- // --- Failover 检查(异常路径)---
358
- if (isFailover && !reply.raw.headersSent) {
359
- deps.tracker?.complete(logId, { status: "failed" });
360
- releaseSemaphore();
361
- excludeTargets.push(resolved);
362
- continue;
363
- }
364
- const e = errors.upstreamConnectionFailed();
365
- deps.tracker?.complete(logId, { status: "failed", statusCode: HTTP_BAD_GATEWAY });
366
- releaseSemaphore();
367
- return reply.status(e.statusCode).send(e.body);
368
- }
369
- }
370
- }
@@ -0,0 +1,18 @@
1
+ import type { FastifyReply, FastifyRequest } from "fastify";
2
+ import Database from "better-sqlite3";
3
+ import type { RequestTracker } from "../monitor/request-tracker.js";
4
+ import type { RetryRuleMatcher } from "./retry-rules.js";
5
+ import type { ProxyOrchestrator } from "./orchestrator.js";
6
+ import type { ProxyErrorFormatter } from "./proxy-core.js";
7
+ export interface RouteHandlerDeps {
8
+ db: Database.Database;
9
+ streamTimeoutMs: number;
10
+ retryMaxAttempts: number;
11
+ retryBaseDelayMs: number;
12
+ matcher?: RetryRuleMatcher;
13
+ tracker?: RequestTracker;
14
+ orchestrator: ProxyOrchestrator;
15
+ }
16
+ export declare function handleProxyRequest(request: FastifyRequest, reply: FastifyReply, apiType: "openai" | "anthropic", upstreamPath: string, errors: ProxyErrorFormatter, deps: RouteHandlerDeps, options?: {
17
+ beforeSendProxy?: (body: Record<string, unknown>, isStream: boolean) => void;
18
+ }): Promise<FastifyReply>;
@@ -0,0 +1,223 @@
1
+ import { randomUUID } from "crypto";
2
+ import { getMappingGroup, getProviderById, insertRequestLog } from "../db/index.js";
3
+ import { decrypt } from "../utils/crypto.js";
4
+ import { getSetting } from "../db/settings.js";
5
+ import { resolveMapping } from "./mapping-resolver.js";
6
+ import { applyEnhancement, buildModelInfoTag } from "./enhancement-handler.js";
7
+ import { SemaphoreQueueFullError, SemaphoreTimeoutError } from "./semaphore.js";
8
+ import { logResilienceResult, collectTransportMetrics, handleIntercept, sanitizeHeadersForLog, } from "./proxy-logging.js";
9
+ import { buildUpstreamHeaders } from "./proxy-core.js";
10
+ import { UPSTREAM_SUCCESS, ProviderSwitchNeeded } from "./types.js";
11
+ import { SSEMetricsTransform } from "../metrics/sse-metrics-transform.js";
12
+ import { callNonStream, callStream } from "./transport.js";
13
+ import { insertRejectedLog } from "./log-helpers.js";
14
+ const HTTP_ERROR_THRESHOLD = 400;
15
+ const STREAM_CONTENT_MAX_RAW = 8192;
16
+ const STREAM_CONTENT_MAX_TEXT = 4096;
17
+ export async function handleProxyRequest(request, reply, apiType, upstreamPath, errors, deps, options) {
18
+ request.raw.socket.on("error", (err) => request.log.debug({ err }, "client socket error"));
19
+ const clientModel = request.body.model || "unknown";
20
+ const sessionId = request.headers["x-claude-code-session-id"];
21
+ const { effectiveModel, originalModel, interceptResponse } = applyEnhancement(deps.db, request, clientModel, sessionId);
22
+ if (interceptResponse)
23
+ return handleIntercept(deps.db, apiType, request, reply, interceptResponse, clientModel);
24
+ const group = getMappingGroup(deps.db, effectiveModel);
25
+ const isFailover = group?.strategy === "failover";
26
+ const excludeTargets = [];
27
+ const originalBody = JSON.parse(JSON.stringify(request.body));
28
+ let rootLogId = null;
29
+ while (true) {
30
+ const startTime = Date.now();
31
+ const logId = randomUUID();
32
+ if (rootLogId === null)
33
+ rootLogId = logId;
34
+ const isFailoverIteration = rootLogId !== logId;
35
+ const routerKeyId = request.routerKey?.id ?? null;
36
+ const body = request.body;
37
+ const isStream = body.stream === true;
38
+ const cliHdrs = request.headers;
39
+ const resolved = resolveMapping(deps.db, effectiveModel, { now: new Date(), excludeTargets });
40
+ request.log.debug({ logId, model: effectiveModel, apiType, isStream, action: "resolve_mapping", resolved: !!resolved });
41
+ if (!resolved) {
42
+ if (isFailover && excludeTargets.length > 0) {
43
+ const e = errors.upstreamConnectionFailed();
44
+ insertRejectedLog({
45
+ db: deps.db, logId, apiType, model: effectiveModel, statusCode: e.statusCode,
46
+ errorMessage: `All failover targets exhausted (${excludeTargets.length} attempted)`,
47
+ startTime, isStream, routerKeyId, originalBody, clientHeaders: cliHdrs, originalModel,
48
+ isFailover: true, originalRequestId: rootLogId,
49
+ });
50
+ return reply.status(e.statusCode).send(e.body);
51
+ }
52
+ const e = errors.modelNotFound(effectiveModel);
53
+ insertRejectedLog({
54
+ db: deps.db, logId, apiType, model: effectiveModel, statusCode: e.statusCode,
55
+ errorMessage: `No mapping found for model '${effectiveModel}'`, startTime, isStream,
56
+ routerKeyId, originalBody, clientHeaders: cliHdrs, originalModel,
57
+ isFailover: isFailoverIteration, originalRequestId: isFailoverIteration ? rootLogId : null,
58
+ });
59
+ return reply.status(e.statusCode).send(e.body);
60
+ }
61
+ if (excludeTargets.length === 0) {
62
+ const allowedModels = request.routerKey?.allowed_models;
63
+ if (allowedModels) {
64
+ try {
65
+ const models = JSON.parse(allowedModels).filter((m) => m.trim() !== "");
66
+ if (models.length > 0 && !models.includes(resolved.backend_model)) {
67
+ const e = errors.modelNotAllowed(resolved.backend_model);
68
+ insertRejectedLog({
69
+ db: deps.db, logId, apiType, model: effectiveModel, statusCode: e.statusCode,
70
+ errorMessage: `Model '${resolved.backend_model}' not allowed`, startTime, isStream, routerKeyId,
71
+ originalBody, clientHeaders: cliHdrs, providerId: resolved.provider_id, originalModel,
72
+ isFailover: isFailoverIteration, originalRequestId: isFailoverIteration ? rootLogId : null,
73
+ });
74
+ return reply.status(e.statusCode).send(e.body);
75
+ }
76
+ }
77
+ catch {
78
+ request.log.warn({ allowedModels: allowedModels?.slice(0, 80) }, "Invalid allowed_models JSON, allowing all models");
79
+ } // eslint-disable-line no-magic-numbers
80
+ }
81
+ }
82
+ const provider = getProviderById(deps.db, resolved.provider_id);
83
+ if (!provider || !provider.is_active) {
84
+ const e = errors.providerUnavailable();
85
+ insertRejectedLog({
86
+ db: deps.db, logId, apiType, model: effectiveModel, statusCode: e.statusCode,
87
+ errorMessage: `Provider '${resolved.provider_id}' unavailable`, startTime, isStream, routerKeyId,
88
+ originalBody, clientHeaders: cliHdrs, providerId: resolved.provider_id, originalModel,
89
+ isFailover: isFailoverIteration, originalRequestId: isFailoverIteration ? rootLogId : null,
90
+ });
91
+ return reply.status(e.statusCode).send(e.body);
92
+ }
93
+ if (provider.api_type !== apiType) {
94
+ const e = errors.providerTypeMismatch();
95
+ insertRejectedLog({
96
+ db: deps.db, logId, apiType, model: effectiveModel, statusCode: e.statusCode,
97
+ errorMessage: `API type mismatch: expected '${apiType}'`, startTime, isStream, routerKeyId,
98
+ originalBody, clientHeaders: cliHdrs, providerId: resolved.provider_id, originalModel,
99
+ isFailover: isFailoverIteration, originalRequestId: isFailoverIteration ? rootLogId : null,
100
+ });
101
+ return reply.status(e.statusCode).send(e.body);
102
+ }
103
+ body.model = resolved.backend_model;
104
+ const apiKey = decrypt(provider.api_key, getSetting(deps.db, "encryption_key"));
105
+ options?.beforeSendProxy?.(body, isStream);
106
+ const reqBodyStr = JSON.stringify(body);
107
+ const clientReq = JSON.stringify({ headers: cliHdrs, body: originalBody });
108
+ const upstreamReqBase = JSON.stringify({
109
+ url: `${provider.base_url}${upstreamPath}`,
110
+ headers: sanitizeHeadersForLog(buildUpstreamHeaders(cliHdrs, apiKey, Buffer.byteLength(reqBodyStr))),
111
+ body: reqBodyStr,
112
+ });
113
+ // transportFn 闭包捕获当前迭代的 provider/apiKey/headers/body/reply 上下文
114
+ // target 由 resilience 层传入但当前架构下 provider 已在闭包中确定
115
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
116
+ const transportFn = async (_target) => {
117
+ if (isStream) {
118
+ const metricsTransform = new SSEMetricsTransform(apiType, startTime, {
119
+ onMetrics: (m) => {
120
+ deps.tracker?.update(logId, {
121
+ streamMetrics: {
122
+ inputTokens: m.input_tokens, outputTokens: m.output_tokens,
123
+ ttftMs: m.ttft_ms, stopReason: m.stop_reason, isComplete: m.is_complete === 1,
124
+ },
125
+ });
126
+ },
127
+ onChunk: (rawLine) => {
128
+ deps.tracker?.appendStreamChunk(logId, rawLine, apiType, STREAM_CONTENT_MAX_RAW, STREAM_CONTENT_MAX_TEXT);
129
+ },
130
+ });
131
+ const checkEarlyError = deps.matcher
132
+ ? (data) => deps.matcher.test(UPSTREAM_SUCCESS, data)
133
+ : undefined;
134
+ return callStream(provider, apiKey, body, cliHdrs, reply, deps.streamTimeoutMs, upstreamPath, buildUpstreamHeaders, metricsTransform, checkEarlyError);
135
+ }
136
+ const result = await callNonStream(provider, apiKey, body, cliHdrs, upstreamPath, buildUpstreamHeaders);
137
+ // 非流式响应注入模型信息标签(模型映射场景)
138
+ if (originalModel && result.kind === "success" && result.statusCode === UPSTREAM_SUCCESS) {
139
+ try {
140
+ const bodyObj = JSON.parse(result.body);
141
+ if (bodyObj.content?.[0]?.text) {
142
+ bodyObj.content[0].text += `\n\n${buildModelInfoTag(effectiveModel)}`;
143
+ return { ...result, body: JSON.stringify(bodyObj) };
144
+ }
145
+ }
146
+ catch {
147
+ request.log.debug("Failed to inject model-info tag into non-JSON response");
148
+ }
149
+ }
150
+ return result;
151
+ };
152
+ try {
153
+ const resilienceResult = await deps.orchestrator.handle(request, reply, apiType, { resolved, provider, clientModel: effectiveModel, isStream, trackerId: logId }, { retryMaxAttempts: deps.retryMaxAttempts, retryBaseDelayMs: deps.retryBaseDelayMs, isFailover, ruleMatcher: deps.matcher, transportFn });
154
+ const lastLogId = logResilienceResult(deps.db, {
155
+ apiType, model: effectiveModel, providerId: provider.id, isStream,
156
+ reqBodyStr, clientReq, upstreamReqBase, logId, routerKeyId, originalModel,
157
+ failover: { isFailoverIteration, rootLogId: rootLogId },
158
+ }, resilienceResult.attempts, resilienceResult.result, startTime);
159
+ collectTransportMetrics(deps.db, apiType, resilienceResult.result, isStream, lastLogId, provider.id, resolved.backend_model, request);
160
+ // Failover: 单 provider 内重试已耗尽但仍失败,尝试下一个 target
161
+ if (isFailover && !reply.raw.headersSent) {
162
+ const tr = resilienceResult.result;
163
+ const failed = tr.kind === "throw"
164
+ || ("statusCode" in tr && tr.statusCode >= HTTP_ERROR_THRESHOLD);
165
+ if (failed) {
166
+ excludeTargets.push(resolved);
167
+ continue;
168
+ }
169
+ }
170
+ // orchestrator.sendResponse 对 throw/stream_success/stream_abort 不发送,
171
+ // 对非 failover 的 error/throw 需要由外层发送错误响应
172
+ if (!reply.raw.headersSent) {
173
+ const tr = resilienceResult.result;
174
+ if (tr.kind === "throw" || (tr.kind === "error" && tr.statusCode >= HTTP_ERROR_THRESHOLD)) {
175
+ const err = errors.upstreamConnectionFailed();
176
+ return reply.status(err.statusCode).send(err.body);
177
+ }
178
+ }
179
+ return reply;
180
+ }
181
+ catch (e) {
182
+ if (e instanceof ProviderSwitchNeeded) {
183
+ request.log.debug({ logId, action: "provider_switch", targetProviderId: e.targetProviderId });
184
+ excludeTargets.push(resolved);
185
+ continue;
186
+ }
187
+ if (e instanceof SemaphoreQueueFullError) {
188
+ const err = errors.concurrencyQueueFull(provider.id);
189
+ insertRejectedLog({
190
+ db: deps.db, logId, apiType, model: effectiveModel, statusCode: err.statusCode,
191
+ errorMessage: `Concurrency queue full for provider '${provider.id}'`,
192
+ startTime, isStream, routerKeyId, originalBody, clientHeaders: cliHdrs,
193
+ providerId: provider.id, originalModel,
194
+ isFailover: isFailoverIteration, originalRequestId: isFailoverIteration ? rootLogId : null,
195
+ });
196
+ return reply.status(err.statusCode).send(err.body);
197
+ }
198
+ if (e instanceof SemaphoreTimeoutError) {
199
+ const err = errors.concurrencyTimeout(provider.id, e.timeoutMs);
200
+ insertRejectedLog({
201
+ db: deps.db, logId, apiType, model: effectiveModel, statusCode: err.statusCode,
202
+ errorMessage: `Concurrency wait timeout for provider '${provider.id}' (${e.timeoutMs}ms)`,
203
+ startTime, isStream, routerKeyId, originalBody, clientHeaders: cliHdrs,
204
+ providerId: provider.id, originalModel,
205
+ isFailover: isFailoverIteration, originalRequestId: isFailoverIteration ? rootLogId : null,
206
+ });
207
+ return reply.status(err.statusCode).send(err.body);
208
+ }
209
+ const errMsg = e instanceof Error ? e.message : String(e);
210
+ request.log.debug({ logId, error: errMsg, action: "upstream_error" });
211
+ insertRequestLog(deps.db, {
212
+ id: logId, api_type: apiType, model: effectiveModel, provider_id: provider.id,
213
+ status_code: 502, latency_ms: Date.now() - startTime, is_stream: isStream ? 1 : 0,
214
+ error_message: errMsg || "Upstream connection failed", created_at: new Date().toISOString(),
215
+ request_body: reqBodyStr, client_request: clientReq, upstream_request: upstreamReqBase,
216
+ is_failover: isFailoverIteration ? 1 : 0, original_request_id: isFailoverIteration ? rootLogId : null,
217
+ router_key_id: routerKeyId, original_model: originalModel,
218
+ });
219
+ const err = errors.upstreamConnectionFailed();
220
+ return reply.status(err.statusCode).send(err.body);
221
+ }
222
+ }
223
+ }
@@ -0,0 +1,28 @@
1
+ import Database from "better-sqlite3";
2
+ import { type FailoverContext } from "./log-helpers.js";
3
+ import type { FastifyRequest } from "fastify";
4
+ import type { ResilienceAttempt } from "./resilience.js";
5
+ import type { TransportResult } from "./types.js";
6
+ export { UPSTREAM_SUCCESS } from "./types.js";
7
+ export type { RawHeaders } from "./types.js";
8
+ /** 日志存储前脱敏 Authorization header,避免 API Key 被持久化 */
9
+ export declare function sanitizeHeadersForLog(headers: Record<string, string>): Record<string, string>;
10
+ export declare function handleIntercept(db: Database.Database, apiType: "openai" | "anthropic", request: FastifyRequest, reply: import("fastify").FastifyReply, interceptResponse: {
11
+ statusCode: number;
12
+ body: unknown;
13
+ meta?: unknown;
14
+ }, clientModel: string): import("fastify").FastifyReply;
15
+ export declare function logResilienceResult(db: Database.Database, params: {
16
+ apiType: "openai" | "anthropic";
17
+ model: string;
18
+ providerId: string;
19
+ isStream: boolean;
20
+ reqBodyStr: string;
21
+ clientReq: string;
22
+ upstreamReqBase: string;
23
+ logId: string;
24
+ routerKeyId: string | null;
25
+ originalModel: string | null;
26
+ failover?: FailoverContext;
27
+ }, attempts: ResilienceAttempt[], result: TransportResult, startTime: number): string;
28
+ export declare function collectTransportMetrics(db: Database.Database, apiType: "openai" | "anthropic", result: TransportResult, isStream: boolean, lastSuccessLogId: string, providerId: string, backendModel: string, request: FastifyRequest): void;