llm-simple-router 0.3.7 → 0.4.1

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 (149) hide show
  1. package/README.md +144 -75
  2. package/dist/admin/constants.d.ts +1 -8
  3. package/dist/admin/constants.js +2 -8
  4. package/dist/admin/logs.js +25 -4
  5. package/dist/admin/metrics.js +7 -3
  6. package/dist/admin/recommended.d.ts +7 -0
  7. package/dist/admin/recommended.js +25 -0
  8. package/dist/admin/router-keys.js +1 -2
  9. package/dist/admin/routes.js +4 -0
  10. package/dist/admin/usage.d.ts +7 -0
  11. package/dist/admin/usage.js +66 -0
  12. package/dist/cli.js +0 -0
  13. package/dist/config/recommended.d.ts +24 -0
  14. package/dist/config/recommended.js +30 -0
  15. package/dist/constants.d.ts +8 -0
  16. package/dist/constants.js +9 -0
  17. package/dist/db/index.d.ts +7 -5
  18. package/dist/db/index.js +4 -3
  19. package/dist/db/logs.d.ts +24 -33
  20. package/dist/db/logs.js +52 -17
  21. package/dist/db/metrics.d.ts +36 -3
  22. package/dist/db/metrics.js +57 -42
  23. package/dist/db/migrations/018_add_failover_field.sql +2 -0
  24. package/dist/db/migrations/019_create_usage_windows.sql +11 -0
  25. package/dist/db/retry-rules.d.ts +0 -5
  26. package/dist/db/retry-rules.js +0 -23
  27. package/dist/db/usage-windows.d.ts +19 -0
  28. package/dist/db/usage-windows.js +37 -0
  29. package/dist/index.d.ts +2 -0
  30. package/dist/index.js +11 -8
  31. package/dist/monitor/request-tracker.d.ts +6 -0
  32. package/dist/monitor/request-tracker.js +23 -54
  33. package/dist/monitor/stream-extractor.d.ts +11 -0
  34. package/dist/monitor/stream-extractor.js +51 -0
  35. package/dist/proxy/anthropic.js +19 -32
  36. package/dist/proxy/log-helpers.d.ts +11 -4
  37. package/dist/proxy/log-helpers.js +5 -3
  38. package/dist/proxy/openai.js +18 -34
  39. package/dist/proxy/orchestrator.d.ts +52 -0
  40. package/dist/proxy/orchestrator.js +100 -0
  41. package/dist/proxy/proxy-core.d.ts +14 -26
  42. package/dist/proxy/proxy-core.js +40 -337
  43. package/dist/proxy/proxy-handler.d.ts +18 -0
  44. package/dist/proxy/proxy-handler.js +223 -0
  45. package/dist/proxy/proxy-logging.d.ts +28 -0
  46. package/dist/proxy/proxy-logging.js +122 -0
  47. package/dist/proxy/resilience.d.ts +63 -0
  48. package/dist/proxy/resilience.js +188 -0
  49. package/dist/proxy/scope.d.ts +18 -0
  50. package/dist/proxy/scope.js +37 -0
  51. package/dist/proxy/semaphore.d.ts +9 -2
  52. package/dist/proxy/semaphore.js +34 -7
  53. package/dist/proxy/stream-proxy.d.ts +7 -0
  54. package/dist/proxy/stream-proxy.js +263 -0
  55. package/dist/proxy/{upstream-call.d.ts → transport.d.ts} +25 -18
  56. package/dist/proxy/transport.js +128 -0
  57. package/dist/proxy/types.d.ts +58 -0
  58. package/dist/proxy/types.js +30 -0
  59. package/dist/proxy/usage-window-tracker.d.ts +11 -0
  60. package/dist/proxy/usage-window-tracker.js +75 -0
  61. package/dist/utils/datetime.d.ts +4 -0
  62. package/dist/utils/datetime.js +10 -0
  63. package/frontend-dist/assets/CardContent-fmM_iiuR.js +1 -0
  64. package/frontend-dist/assets/CardHeader-BzzFzZ1B.js +1 -0
  65. package/frontend-dist/assets/CardTitle-09d7O-11.js +1 -0
  66. package/frontend-dist/assets/Checkbox-DH8iqXQd.js +1 -0
  67. package/frontend-dist/assets/CollapsibleTrigger-DCRRORrU.js +1 -0
  68. package/frontend-dist/assets/Collection-DY9-Yue9.js +3 -0
  69. package/frontend-dist/assets/Dashboard-BEzoZuSm.js +3 -0
  70. package/frontend-dist/assets/DialogTitle-BeMGJzYO.js +1 -0
  71. package/frontend-dist/assets/Input-BhvZ-Up7.js +1 -0
  72. package/frontend-dist/assets/Label-DjtouWZ7.js +1 -0
  73. package/frontend-dist/assets/LogDetailDialog-BjRsy_FR.js +3 -0
  74. package/frontend-dist/assets/Login-hOCPB-34.js +1 -0
  75. package/frontend-dist/assets/Logs-C5c3BJsg.js +1 -0
  76. package/frontend-dist/assets/ModelMappings-CDjxwyyz.js +1 -0
  77. package/frontend-dist/assets/Monitor-CPAvIREG.js +1 -0
  78. package/frontend-dist/assets/PopperContent-CHNw_qb6.js +1 -0
  79. package/frontend-dist/assets/Providers-C9ZAqHxO.js +1 -0
  80. package/frontend-dist/assets/ProxyEnhancement-Ct5WbiB7.js +5 -0
  81. package/frontend-dist/assets/RetryRules-CbgyrP6w.js +1 -0
  82. package/frontend-dist/assets/RouterKeys-zmqgFEKp.js +1 -0
  83. package/frontend-dist/assets/SelectValue-CP4Sh7LP.js +1 -0
  84. package/frontend-dist/assets/Setup-BXDEPt4o.js +1 -0
  85. package/frontend-dist/assets/Switch-DF6awXqs.js +1 -0
  86. package/frontend-dist/assets/TableHeader-BKE_yVML.js +1 -0
  87. package/frontend-dist/assets/TabsTrigger-D8R7lxaI.js +1 -0
  88. package/frontend-dist/assets/TooltipTrigger-BjQXeFem.js +1 -0
  89. package/frontend-dist/assets/VisuallyHidden-B_NnkONE.js +1 -0
  90. package/frontend-dist/assets/VisuallyHiddenInput-cjeTgyDe.js +1 -0
  91. package/frontend-dist/assets/alert-dialog-BoGRIC1Q.js +1 -0
  92. package/frontend-dist/assets/badge-DIO8W_W9.js +1 -0
  93. package/frontend-dist/assets/button-qxGNBunr.js +12 -0
  94. package/frontend-dist/assets/{createLucideIcon-CCmQ9QKM.js → createLucideIcon-jHUFhqKn.js} +1 -1
  95. package/frontend-dist/assets/dialog-D8pIXeSs.js +1 -0
  96. package/frontend-dist/assets/format-CPdJtjZ5.js +1 -0
  97. package/frontend-dist/assets/index-C_disqMY.js +1 -0
  98. package/frontend-dist/assets/index-DDp6SHfg.css +1 -0
  99. package/frontend-dist/assets/lib-DjpgwSRA.js +1 -0
  100. package/frontend-dist/assets/{ohash.D__AXeF1-p4vp6Svt.js → ohash.D__AXeF1-nmJ7gFbh.js} +1 -1
  101. package/frontend-dist/assets/{useClipboard-DO-38TXr.js → useClipboard-CmLp2YGk.js} +1 -1
  102. package/frontend-dist/assets/useForwardExpose-awoGXQkg.js +1 -0
  103. package/frontend-dist/assets/useNonce-_2e-GL-A.js +1 -0
  104. package/frontend-dist/assets/x-B0G-wIAB.js +1 -0
  105. package/frontend-dist/index.html +7 -7
  106. package/package.json +1 -1
  107. package/dist/admin/services.d.ts +0 -7
  108. package/dist/admin/services.js +0 -63
  109. package/dist/proxy/retry.d.ts +0 -43
  110. package/dist/proxy/retry.js +0 -121
  111. package/dist/proxy/upstream-call.js +0 -208
  112. package/frontend-dist/assets/CardContent-CucI6u41.js +0 -1
  113. package/frontend-dist/assets/CardHeader-d-DYsWxe.js +0 -1
  114. package/frontend-dist/assets/CardTitle-CIDEQkWB.js +0 -1
  115. package/frontend-dist/assets/Checkbox-CybCw3zS.js +0 -1
  116. package/frontend-dist/assets/CollapsibleTrigger-BFNhb19_.js +0 -1
  117. package/frontend-dist/assets/Collection-DUBb4r6h.js +0 -3
  118. package/frontend-dist/assets/Dashboard-DLB6iqH1.js +0 -3
  119. package/frontend-dist/assets/DialogTitle-Dq-5o7nJ.js +0 -1
  120. package/frontend-dist/assets/Input-HN3Il0-c.js +0 -1
  121. package/frontend-dist/assets/Label-CXAeFn-r.js +0 -1
  122. package/frontend-dist/assets/LogResponseViewer-CyBzv02a.js +0 -3
  123. package/frontend-dist/assets/Login-Br3qsdxf.js +0 -1
  124. package/frontend-dist/assets/Logs-Cu_IftdS.js +0 -1
  125. package/frontend-dist/assets/ModelMappings-DXC0sNH5.js +0 -1
  126. package/frontend-dist/assets/Monitor-CKlid1sC.js +0 -1
  127. package/frontend-dist/assets/PopperContent-CnZejY31.js +0 -1
  128. package/frontend-dist/assets/Providers-8CHhW4uH.js +0 -1
  129. package/frontend-dist/assets/ProxyEnhancement-CkYeXwgH.js +0 -5
  130. package/frontend-dist/assets/RetryRules-Csb7u9W4.js +0 -1
  131. package/frontend-dist/assets/RouterKeys-C6YIufmj.js +0 -1
  132. package/frontend-dist/assets/RovingFocusItem-B7ZIkplZ.js +0 -1
  133. package/frontend-dist/assets/SelectValue-B32pgmTJ.js +0 -1
  134. package/frontend-dist/assets/Setup-Df9IQo2x.js +0 -1
  135. package/frontend-dist/assets/Switch-CLeo7H6d.js +0 -1
  136. package/frontend-dist/assets/TableHeader-BpscAtT3.js +0 -1
  137. package/frontend-dist/assets/TabsTrigger-DErAbTuM.js +0 -1
  138. package/frontend-dist/assets/VisuallyHidden-CJBR3YB3.js +0 -1
  139. package/frontend-dist/assets/VisuallyHiddenInput-Cy0VuE1l.js +0 -1
  140. package/frontend-dist/assets/alert-dialog-BAR1JRmT.js +0 -1
  141. package/frontend-dist/assets/button-D54q76GQ.js +0 -1
  142. package/frontend-dist/assets/client-Mb8fy_bC.js +0 -12
  143. package/frontend-dist/assets/dialog-DSH5k5Kj.js +0 -1
  144. package/frontend-dist/assets/index-BQBtSfem.js +0 -1
  145. package/frontend-dist/assets/index-H-lnTkMr.css +0 -1
  146. package/frontend-dist/assets/lib-BgOqOzXI.js +0 -1
  147. package/frontend-dist/assets/useForwardExpose-CzQFheaD.js +0 -1
  148. package/frontend-dist/assets/useNonce-CU-NirfM.js +0 -1
  149. package/frontend-dist/assets/x-DEJ1xpi5.js +0 -1
@@ -0,0 +1,263 @@
1
+ import { PassThrough } from "stream";
2
+ import { UPSTREAM_SUCCESS, filterHeaders } from "./types.js";
3
+ import { _transportInternals, buildRequestOptions, } from "./transport.js";
4
+ const UPSTREAM_BAD_GATEWAY = 502;
5
+ const BUFFER_SIZE_LIMIT = 4096;
6
+ class StreamProxy {
7
+ statusCode;
8
+ sentUpstreamHeaders;
9
+ reply;
10
+ metricsTransform;
11
+ checkEarlyError;
12
+ timeoutMs;
13
+ state = "BUFFERING";
14
+ resolved = false;
15
+ resolveFn = null;
16
+ pendingResult = null;
17
+ bufferChunks = [];
18
+ captureChunks = [];
19
+ idleTimer = null;
20
+ headersSent = false;
21
+ closeHandlerRegistered = false;
22
+ sseHeaders;
23
+ passThrough = new PassThrough();
24
+ pipeEntry;
25
+ constructor(statusCode, rawUpstreamHeaders, sentUpstreamHeaders, reply, metricsTransform, checkEarlyError, timeoutMs) {
26
+ this.statusCode = statusCode;
27
+ this.sentUpstreamHeaders = sentUpstreamHeaders;
28
+ this.reply = reply;
29
+ this.metricsTransform = metricsTransform;
30
+ this.checkEarlyError = checkEarlyError;
31
+ this.timeoutMs = timeoutMs;
32
+ this.sseHeaders = filterHeaders(rawUpstreamHeaders);
33
+ this.sseHeaders["Content-Type"] = "text/event-stream";
34
+ this.sseHeaders["Cache-Control"] = "no-cache";
35
+ this.sseHeaders["Connection"] = "keep-alive";
36
+ this.pipeEntry = metricsTransform ?? this.passThrough;
37
+ }
38
+ bindResolve(resolve) {
39
+ this.resolveFn = resolve;
40
+ if (this.pendingResult)
41
+ resolve(this.pendingResult);
42
+ }
43
+ transition(newState) {
44
+ const VALID = {
45
+ BUFFERING: ["STREAMING", "EARLY_ERROR"],
46
+ STREAMING: ["COMPLETED", "ABORTED"],
47
+ COMPLETED: [],
48
+ EARLY_ERROR: [],
49
+ ABORTED: [],
50
+ };
51
+ if (!VALID[this.state].includes(newState)) {
52
+ throw new Error(`Invalid state transition: ${this.state} → ${newState}`);
53
+ }
54
+ this.state = newState;
55
+ }
56
+ terminal(kind, extra = {}, deferred = false) {
57
+ if (this.resolved)
58
+ return;
59
+ this.resolved = true;
60
+ const base = {
61
+ statusCode: this.statusCode,
62
+ upstreamResponseHeaders: this.sseHeaders,
63
+ sentHeaders: this.sentUpstreamHeaders,
64
+ };
65
+ let result;
66
+ switch (kind) {
67
+ case "stream_success":
68
+ result = { kind: "stream_success", ...base, metrics: extra.metrics };
69
+ break;
70
+ case "stream_error":
71
+ result = { kind: "stream_error", ...base, body: extra.body, headers: this.sseHeaders };
72
+ break;
73
+ case "stream_abort":
74
+ result = { kind: "stream_abort", ...base, metrics: extra.metrics };
75
+ break;
76
+ }
77
+ // deferred 模式:先 resolve 让 handler 链路(日志写入等)在 microtask 中执行,
78
+ // cleanup 由调用方在 setImmediate(macrotask)中处理。
79
+ if (deferred) {
80
+ if (this.resolveFn) {
81
+ this.resolveFn(result);
82
+ }
83
+ else {
84
+ this.pendingResult = result;
85
+ }
86
+ }
87
+ else {
88
+ this.cleanup();
89
+ if (this.resolveFn) {
90
+ this.resolveFn(result);
91
+ }
92
+ else {
93
+ this.pendingResult = result;
94
+ }
95
+ }
96
+ }
97
+ cleanup() {
98
+ if (this.idleTimer)
99
+ clearTimeout(this.idleTimer);
100
+ this.idleTimer = null;
101
+ if (!this.passThrough.destroyed)
102
+ this.passThrough.destroy();
103
+ if (this.metricsTransform && !this.metricsTransform.destroyed)
104
+ this.metricsTransform.destroy();
105
+ }
106
+ collectMetrics(isComplete) {
107
+ if (!this.metricsTransform)
108
+ return undefined;
109
+ const result = this.metricsTransform.getExtractor().getMetrics();
110
+ return isComplete ? result : { ...result, is_complete: 0 };
111
+ }
112
+ resetIdleTimer() {
113
+ if (this.idleTimer)
114
+ clearTimeout(this.idleTimer);
115
+ this.idleTimer = setTimeout(() => {
116
+ if (this.resolved)
117
+ return;
118
+ this.terminal("stream_abort", { metrics: this.collectMetrics(false) });
119
+ }, this.timeoutMs);
120
+ }
121
+ startStreaming() {
122
+ if (this.headersSent)
123
+ return;
124
+ this.transition("STREAMING");
125
+ this.headersSent = true;
126
+ this.reply.raw.writeHead(this.statusCode, this.sseHeaders);
127
+ if (this.metricsTransform) {
128
+ this.metricsTransform.pipe(this.passThrough, { end: true });
129
+ }
130
+ // 手动转发而非 pipe,避免 Node.js 在 dest 上自动注册 close/finish handler
131
+ this.passThrough.on("data", (chunk) => {
132
+ this.reply.raw.write(chunk);
133
+ });
134
+ // 不在 passThrough end 事件中调用 reply.raw.end(),
135
+ // 因为 onEnd() 统一管理响应结束时机,确保日志在 reply end 之前写入
136
+ for (const c of this.bufferChunks)
137
+ this.pipeEntry.write(c);
138
+ this.bufferChunks.length = 0;
139
+ }
140
+ registerCloseHandler() {
141
+ if (this.closeHandlerRegistered)
142
+ return;
143
+ this.closeHandlerRegistered = true;
144
+ this.reply.raw.on("close", () => {
145
+ if (this.resolved)
146
+ return;
147
+ if (this.state === "BUFFERING" || this.state === "STREAMING") {
148
+ this.transition("ABORTED");
149
+ }
150
+ this.terminal("stream_abort", { metrics: this.collectMetrics(false) });
151
+ });
152
+ }
153
+ onData(chunk) {
154
+ if (this.resolved)
155
+ return;
156
+ this.resetIdleTimer();
157
+ this.captureChunks.push(chunk);
158
+ if (this.state === "BUFFERING") {
159
+ this.bufferChunks.push(chunk);
160
+ const buf = Buffer.concat(this.bufferChunks);
161
+ const text = buf.toString("utf-8");
162
+ if (text.includes("\n\n")) {
163
+ if (this.checkEarlyError?.(text)) {
164
+ this.transition("EARLY_ERROR");
165
+ this.terminal("stream_error", { body: text });
166
+ return;
167
+ }
168
+ this.startStreaming();
169
+ }
170
+ else if (buf.length >= BUFFER_SIZE_LIMIT) {
171
+ this.startStreaming();
172
+ }
173
+ return;
174
+ }
175
+ this.pipeEntry.write(chunk);
176
+ }
177
+ onEnd() {
178
+ if (this.resolved)
179
+ return;
180
+ if (this.idleTimer)
181
+ clearTimeout(this.idleTimer);
182
+ if (this.state === "BUFFERING" && this.checkEarlyError) {
183
+ const text = Buffer.concat(this.captureChunks).toString("utf-8");
184
+ if (this.checkEarlyError(text)) {
185
+ this.transition("EARLY_ERROR");
186
+ this.terminal("stream_error", { body: text });
187
+ return;
188
+ }
189
+ this.startStreaming();
190
+ }
191
+ if (this.state === "STREAMING") {
192
+ this.transition("COMPLETED");
193
+ }
194
+ // 通过 terminal 的 deferred 模式统一 resolve:
195
+ // 先 resolve Promise,让 handler 链路(日志写入等)在 microtask 中执行。
196
+ // reply.raw.end() 延迟到 setImmediate(macrotask),确保 microtask 先完成。
197
+ // light-my-request 监听 reply.raw 的 end 事件判定响应完成,
198
+ // 这保证了 inject() 返回时日志已经写入 DB。
199
+ const metrics = this.collectMetrics(true);
200
+ this.terminal("stream_success", { metrics }, true);
201
+ // 延迟结束管道和响应,属于 reply 层面操作,不属于 StreamProxy 状态管理
202
+ setImmediate(() => {
203
+ this.pipeEntry.end();
204
+ if (this.headersSent)
205
+ this.reply.raw.end();
206
+ this.cleanup();
207
+ });
208
+ }
209
+ onUpstreamError(err) {
210
+ if (this.resolved)
211
+ return;
212
+ this.resolved = true;
213
+ this.cleanup();
214
+ const result = { kind: "throw", error: err };
215
+ if (this.resolveFn) {
216
+ this.resolveFn(result);
217
+ }
218
+ else {
219
+ this.pendingResult = result;
220
+ }
221
+ }
222
+ }
223
+ // ---------- callStream ----------
224
+ export function callStream(backend, apiKey, body, clientHeaders, reply, timeoutMs, upstreamPath, buildHeaders, metricsTransform, checkEarlyError, compatResolve) {
225
+ return new Promise((resolve) => {
226
+ const effectiveResolve = compatResolve ?? resolve;
227
+ const url = new URL(`${backend.base_url}${upstreamPath}`);
228
+ const payload = JSON.stringify(body);
229
+ const upstreamHeaders = buildHeaders(clientHeaders, apiKey, Buffer.byteLength(payload));
230
+ const options = buildRequestOptions(url, upstreamHeaders);
231
+ const upstreamReq = _transportInternals.createUpstreamRequest(url, options);
232
+ upstreamReq.on("response", (upstreamRes) => {
233
+ const statusCode = upstreamRes.statusCode || UPSTREAM_BAD_GATEWAY;
234
+ if (statusCode !== UPSTREAM_SUCCESS) {
235
+ const chunks = [];
236
+ upstreamRes.on("data", (chunk) => chunks.push(chunk));
237
+ upstreamRes.on("end", () => {
238
+ effectiveResolve({
239
+ kind: "stream_error",
240
+ statusCode,
241
+ body: Buffer.concat(chunks).toString("utf-8"),
242
+ headers: filterHeaders(upstreamRes.headers),
243
+ sentHeaders: upstreamHeaders,
244
+ });
245
+ });
246
+ return;
247
+ }
248
+ const proxy = new StreamProxy(statusCode, upstreamRes.headers, upstreamHeaders, reply, metricsTransform, checkEarlyError, timeoutMs);
249
+ proxy.bindResolve(effectiveResolve);
250
+ proxy.registerCloseHandler();
251
+ // 无 early error checker 时直接开始流式传输
252
+ if (!checkEarlyError)
253
+ proxy.startStreaming();
254
+ proxy.resetIdleTimer();
255
+ upstreamRes.on("data", (chunk) => proxy.onData(chunk));
256
+ upstreamRes.on("end", () => proxy.onEnd());
257
+ upstreamRes.on("error", (err) => proxy.onUpstreamError(err));
258
+ });
259
+ upstreamReq.on("error", (error) => effectiveResolve({ kind: "throw", error }));
260
+ upstreamReq.write(payload);
261
+ upstreamReq.end();
262
+ });
263
+ }
@@ -1,7 +1,8 @@
1
1
  import type { FastifyReply } from "fastify";
2
- import type { RawHeaders } from "./proxy-core.js";
2
+ import type { RawHeaders, TransportResult } from "./types.js";
3
3
  import type { MetricsResult } from "../metrics/metrics-extractor.js";
4
- import { SSEMetricsTransform } from "../metrics/sse-metrics-transform.js";
4
+ import type { SSEMetricsTransform } from "../metrics/sse-metrics-transform.js";
5
+ export { callStream } from "./stream-proxy.js";
5
6
  export interface UpstreamRequestOptions {
6
7
  hostname: string;
7
8
  port: number;
@@ -9,6 +10,23 @@ export interface UpstreamRequestOptions {
9
10
  method: string;
10
11
  headers: Record<string, string>;
11
12
  }
13
+ export declare const _transportInternals: {
14
+ createUpstreamRequest(url: URL, options: UpstreamRequestOptions): import("http").ClientRequest;
15
+ };
16
+ export declare function createUpstreamRequest(url: URL, options: UpstreamRequestOptions): import("http").ClientRequest;
17
+ export declare function buildRequestOptions(url: URL, headers: Record<string, string>, method?: string): UpstreamRequestOptions;
18
+ export type BuildHeadersFn = (cliHdrs: RawHeaders, key: string, bytes?: number) => Record<string, string>;
19
+ export declare function callNonStream(backend: {
20
+ base_url: string;
21
+ }, apiKey: string, body: Record<string, unknown>, clientHeaders: RawHeaders, upstreamPath: string, buildHeaders: BuildHeadersFn): Promise<TransportResult>;
22
+ export interface GetTransportResult {
23
+ statusCode: number;
24
+ body: string;
25
+ headers: Record<string, string>;
26
+ }
27
+ export declare function callGet(backend: {
28
+ base_url: string;
29
+ }, apiKey: string, clientHeaders: RawHeaders, upstreamPath: string, buildHeaders: (cliHdrs: RawHeaders, key: string) => Record<string, string>): Promise<GetTransportResult>;
12
30
  export interface ProxyResult {
13
31
  statusCode: number;
14
32
  body: string;
@@ -22,22 +40,11 @@ export interface StreamProxyResult {
22
40
  upstreamResponseHeaders?: Record<string, string>;
23
41
  sentHeaders?: Record<string, string>;
24
42
  metricsResult?: MetricsResult;
43
+ abnormalClose?: boolean;
25
44
  }
26
- export interface GetProxyResult {
27
- statusCode: number;
28
- body: string;
29
- headers: Record<string, string>;
30
- }
31
- /** 根据 URL scheme 选择 http 或 https 模块 */
32
- export declare function createUpstreamRequest(url: URL, options: UpstreamRequestOptions): import("http").ClientRequest;
33
- /** 从 URL + headers 构造 Node.js http.request 所需的 options */
34
- export declare function buildRequestOptions(url: URL, headers: Record<string, string>, method?: string): UpstreamRequestOptions;
35
- export declare function proxyNonStream(backend: {
36
- base_url: string;
37
- }, apiKey: string, body: Record<string, unknown>, clientHeaders: RawHeaders, upstreamPath: string, buildHeaders: (cliHdrs: RawHeaders, key: string, bytes?: number) => Record<string, string>): Promise<ProxyResult>;
38
- export declare function proxyStream(backend: {
45
+ export declare function proxyNonStreamCompat(backend: {
39
46
  base_url: string;
40
- }, apiKey: string, body: Record<string, unknown>, clientHeaders: RawHeaders, reply: FastifyReply, timeoutMs: number, upstreamPath: string, buildHeaders: (cliHdrs: RawHeaders, key: string, bytes?: number) => Record<string, string>, metricsTransform?: SSEMetricsTransform): Promise<StreamProxyResult>;
41
- export declare function proxyGetRequest(backend: {
47
+ }, apiKey: string, body: Record<string, unknown>, clientHeaders: RawHeaders, upstreamPath: string, buildHeaders: BuildHeadersFn): Promise<ProxyResult>;
48
+ export declare function proxyStreamCompat(backend: {
42
49
  base_url: string;
43
- }, apiKey: string, clientHeaders: RawHeaders, upstreamPath: string, buildHeaders: (cliHdrs: RawHeaders, key: string) => Record<string, string>): Promise<GetProxyResult>;
50
+ }, apiKey: string, body: Record<string, unknown>, clientHeaders: RawHeaders, reply: FastifyReply, timeoutMs: number, upstreamPath: string, buildHeaders: BuildHeadersFn, metricsTransform?: SSEMetricsTransform, checkEarlyError?: (bufferedData: string) => boolean): Promise<StreamProxyResult>;
@@ -0,0 +1,128 @@
1
+ import { request as httpRequestFn } from "http";
2
+ import { request as httpsRequestFn } from "https";
3
+ import { UPSTREAM_SUCCESS, filterHeaders } from "./types.js";
4
+ import { callStream } from "./stream-proxy.js";
5
+ // Re-export callStream from stream-proxy.ts for external consumers
6
+ export { callStream } from "./stream-proxy.js";
7
+ // ---------- Constants ----------
8
+ const UPSTREAM_BAD_GATEWAY = 502;
9
+ const UPSTREAM_SUCCESS_RANGE = 100;
10
+ const HTTPS_DEFAULT_PORT = 443;
11
+ const HTTP_DEFAULT_PORT = 80;
12
+ export const _transportInternals = {
13
+ createUpstreamRequest(url, options) {
14
+ return url.protocol === "https:"
15
+ ? httpsRequestFn(options)
16
+ : httpRequestFn(options);
17
+ },
18
+ };
19
+ export function createUpstreamRequest(url, options) {
20
+ return _transportInternals.createUpstreamRequest(url, options);
21
+ }
22
+ export function buildRequestOptions(url, headers, method = "POST") {
23
+ return {
24
+ hostname: url.hostname,
25
+ port: Number(url.port) ||
26
+ (url.protocol === "https:" ? HTTPS_DEFAULT_PORT : HTTP_DEFAULT_PORT),
27
+ path: url.pathname,
28
+ method,
29
+ headers,
30
+ };
31
+ }
32
+ // ---------- callNonStream ----------
33
+ export function callNonStream(backend, apiKey, body, clientHeaders, upstreamPath, buildHeaders) {
34
+ return new Promise((resolve) => {
35
+ const url = new URL(`${backend.base_url}${upstreamPath}`);
36
+ const payload = JSON.stringify(body);
37
+ const upstreamHeaders = buildHeaders(clientHeaders, apiKey, Buffer.byteLength(payload));
38
+ const options = buildRequestOptions(url, upstreamHeaders);
39
+ const req = _transportInternals.createUpstreamRequest(url, options);
40
+ req.on("response", (res) => {
41
+ const chunks = [];
42
+ res.on("data", (chunk) => chunks.push(chunk));
43
+ res.on("end", () => {
44
+ const statusCode = res.statusCode || UPSTREAM_BAD_GATEWAY;
45
+ const responseBody = Buffer.concat(chunks).toString("utf-8");
46
+ const headers = filterHeaders(res.headers);
47
+ if (statusCode >= UPSTREAM_SUCCESS && statusCode < UPSTREAM_SUCCESS + UPSTREAM_SUCCESS_RANGE) {
48
+ resolve({
49
+ kind: "success",
50
+ statusCode,
51
+ body: responseBody,
52
+ headers,
53
+ sentHeaders: upstreamHeaders,
54
+ sentBody: payload,
55
+ });
56
+ }
57
+ else {
58
+ resolve({
59
+ kind: "error",
60
+ statusCode,
61
+ body: responseBody,
62
+ headers,
63
+ sentHeaders: upstreamHeaders,
64
+ sentBody: payload,
65
+ });
66
+ }
67
+ });
68
+ });
69
+ req.on("error", (error) => resolve({ kind: "throw", error }));
70
+ req.write(payload);
71
+ req.end();
72
+ });
73
+ }
74
+ export function callGet(backend, apiKey, clientHeaders, upstreamPath, buildHeaders) {
75
+ return new Promise((resolve, reject) => {
76
+ const url = new URL(`${backend.base_url}${upstreamPath}`);
77
+ const headers = buildHeaders(clientHeaders, apiKey);
78
+ const options = buildRequestOptions(url, headers, "GET");
79
+ const req = _transportInternals.createUpstreamRequest(url, options);
80
+ req.on("response", (res) => {
81
+ const chunks = [];
82
+ res.on("data", (chunk) => chunks.push(chunk));
83
+ res.on("end", () => {
84
+ resolve({
85
+ statusCode: res.statusCode || UPSTREAM_BAD_GATEWAY,
86
+ body: Buffer.concat(chunks).toString("utf-8"),
87
+ headers: filterHeaders(res.headers),
88
+ });
89
+ });
90
+ });
91
+ req.on("error", (err) => reject(err));
92
+ req.end();
93
+ });
94
+ }
95
+ export function proxyNonStreamCompat(backend, apiKey, body, clientHeaders, upstreamPath, buildHeaders) {
96
+ return callNonStream(backend, apiKey, body, clientHeaders, upstreamPath, buildHeaders)
97
+ .then((r) => {
98
+ if (r.kind === "throw")
99
+ throw r.error;
100
+ return {
101
+ statusCode: r.statusCode,
102
+ body: "body" in r ? r.body : "",
103
+ headers: "headers" in r ? r.headers : {},
104
+ sentHeaders: r.sentHeaders,
105
+ sentBody: "sentBody" in r ? r.sentBody : "",
106
+ };
107
+ });
108
+ }
109
+ export function proxyStreamCompat(backend, apiKey, body, clientHeaders, reply, timeoutMs, upstreamPath, buildHeaders, metricsTransform, checkEarlyError) {
110
+ return new Promise((resolve, reject) => {
111
+ function onResult(r) {
112
+ if (r.kind === "throw") {
113
+ reject(r.error);
114
+ return;
115
+ }
116
+ const metrics = (r.kind === "stream_success" || r.kind === "stream_abort") ? r.metrics : undefined;
117
+ resolve({
118
+ statusCode: r.statusCode,
119
+ responseBody: r.kind === "stream_success" ? undefined : ("body" in r ? r.body : undefined),
120
+ upstreamResponseHeaders: ("upstreamResponseHeaders" in r ? r.upstreamResponseHeaders : undefined) ?? ("headers" in r ? r.headers : {}) ?? {},
121
+ sentHeaders: r.sentHeaders,
122
+ metricsResult: metrics ?? undefined,
123
+ abnormalClose: r.kind === "stream_abort",
124
+ });
125
+ }
126
+ callStream(backend, apiKey, body, clientHeaders, reply, timeoutMs, upstreamPath, buildHeaders, metricsTransform, checkEarlyError, onResult);
127
+ });
128
+ }
@@ -0,0 +1,58 @@
1
+ import type { MetricsResult } from "../metrics/metrics-extractor.js";
2
+ export declare const UPSTREAM_SUCCESS = 200;
3
+ export type RawHeaders = Record<string, string | string[] | undefined>;
4
+ export declare function filterHeaders(raw: RawHeaders): Record<string, string>;
5
+ /**
6
+ * 上游调用的传输层结果。
7
+ * discriminated union,按 kind 区分 6 种情况:
8
+ * - success / stream_success:正常完成
9
+ * - stream_error / stream_abort:流式传输中途异常
10
+ * - error:非流式错误响应
11
+ * - throw:未捕获异常
12
+ */
13
+ export type TransportResult = {
14
+ kind: "success";
15
+ statusCode: number;
16
+ body: string;
17
+ headers: Record<string, string>;
18
+ sentHeaders: Record<string, string>;
19
+ sentBody: string;
20
+ } | {
21
+ kind: "stream_success";
22
+ statusCode: number;
23
+ metrics?: MetricsResult;
24
+ upstreamResponseHeaders?: Record<string, string>;
25
+ sentHeaders: Record<string, string>;
26
+ } | {
27
+ kind: "stream_error";
28
+ statusCode: number;
29
+ body: string;
30
+ headers: Record<string, string>;
31
+ sentHeaders: Record<string, string>;
32
+ } | {
33
+ kind: "stream_abort";
34
+ statusCode: number;
35
+ metrics?: MetricsResult;
36
+ upstreamResponseHeaders?: Record<string, string>;
37
+ sentHeaders: Record<string, string>;
38
+ } | {
39
+ kind: "error";
40
+ statusCode: number;
41
+ body: string;
42
+ headers: Record<string, string>;
43
+ sentHeaders: Record<string, string>;
44
+ sentBody: string;
45
+ } | {
46
+ kind: "throw";
47
+ error: Error;
48
+ };
49
+ /**
50
+ * 跨 provider failover 时由 ResilienceLayer 抛出,
51
+ * orchestrator 捕获后释放当前信号量并获取新 provider 的信号量。
52
+ */
53
+ export declare class ProviderSwitchNeeded extends Error {
54
+ readonly targetProviderId: string;
55
+ constructor(targetProviderId: string);
56
+ }
57
+ /** 流式传输阶段状态 */
58
+ export type StreamState = "BUFFERING" | "STREAMING" | "COMPLETED" | "EARLY_ERROR" | "ABORTED";
@@ -0,0 +1,30 @@
1
+ // ---------- Shared constants & types ----------
2
+ export const UPSTREAM_SUCCESS = 200;
3
+ /** 过滤掉不应转发给下游的 hop-by-hop headers */
4
+ const SKIP_DOWNSTREAM = new Set([
5
+ "content-length",
6
+ "transfer-encoding",
7
+ "connection",
8
+ "keep-alive",
9
+ ]);
10
+ export function filterHeaders(raw) {
11
+ const out = {};
12
+ for (const [key, value] of Object.entries(raw)) {
13
+ if (value == null || SKIP_DOWNSTREAM.has(key.toLowerCase()))
14
+ continue;
15
+ out[key] = Array.isArray(value) ? value.join(", ") : value;
16
+ }
17
+ return out;
18
+ }
19
+ /**
20
+ * 跨 provider failover 时由 ResilienceLayer 抛出,
21
+ * orchestrator 捕获后释放当前信号量并获取新 provider 的信号量。
22
+ */
23
+ export class ProviderSwitchNeeded extends Error {
24
+ targetProviderId;
25
+ constructor(targetProviderId) {
26
+ super(`Provider switch needed: ${targetProviderId}`);
27
+ this.targetProviderId = targetProviderId;
28
+ this.name = "ProviderSwitchNeeded";
29
+ }
30
+ }
@@ -0,0 +1,11 @@
1
+ import Database from "better-sqlite3";
2
+ export declare class UsageWindowTracker {
3
+ private db;
4
+ constructor(db: Database.Database);
5
+ /** 请求成功后调用,按需创建新窗口 */
6
+ recordRequest(routerKeyId?: string): void;
7
+ /** 启动时补齐因宕机/重启而缺失的窗口 */
8
+ reconcileOnStartup(): void;
9
+ /** 从 baseTime 开始,每 5h 一个窗口,直到覆盖 lastLogTime */
10
+ private backfillWindows;
11
+ }
@@ -0,0 +1,75 @@
1
+ import { randomUUID } from "crypto";
2
+ import { getLatestWindow, insertWindow } from "../db/usage-windows.js";
3
+ import { toSqliteDatetime, parseSqliteDatetime as parseDate } from "../utils/datetime.js";
4
+ // eslint-disable-next-line no-magic-numbers
5
+ const WINDOW_DURATION_MS = 5 * 3600_000;
6
+ export class UsageWindowTracker {
7
+ db;
8
+ constructor(db) {
9
+ this.db = db;
10
+ }
11
+ /** 请求成功后调用,按需创建新窗口 */
12
+ recordRequest(routerKeyId) {
13
+ const now = new Date();
14
+ const latest = getLatestWindow(this.db, routerKeyId);
15
+ if (!latest || now > parseDate(latest.end_time)) {
16
+ const startTime = truncateToMinute(now);
17
+ insertWindow(this.db, {
18
+ id: randomUUID(),
19
+ router_key_id: routerKeyId ?? null,
20
+ start_time: toSqliteDatetime(startTime),
21
+ end_time: toSqliteDatetime(new Date(startTime.getTime() + WINDOW_DURATION_MS)),
22
+ });
23
+ }
24
+ }
25
+ /** 启动时补齐因宕机/重启而缺失的窗口 */
26
+ reconcileOnStartup() {
27
+ const latest = getLatestWindow(this.db);
28
+ // 查找 request_logs 中最新一条请求的时间
29
+ const lastLog = this.db.prepare("SELECT created_at FROM request_logs ORDER BY created_at DESC LIMIT 1").get();
30
+ if (!lastLog)
31
+ return;
32
+ if (!latest) {
33
+ // 从未创建过窗口,但有请求记录,从最早请求创建初始窗口
34
+ const firstLog = this.db.prepare("SELECT created_at FROM request_logs ORDER BY created_at ASC LIMIT 1").get();
35
+ if (!firstLog)
36
+ return;
37
+ const start = parseDate(firstLog.created_at);
38
+ const truncated = truncateToMinute(start);
39
+ insertWindow(this.db, {
40
+ id: randomUUID(),
41
+ router_key_id: null,
42
+ start_time: toSqliteDatetime(truncated),
43
+ end_time: toSqliteDatetime(new Date(truncated.getTime() + WINDOW_DURATION_MS)),
44
+ });
45
+ // 继续补齐后续窗口
46
+ this.backfillWindows(truncated);
47
+ return;
48
+ }
49
+ // 有窗口,检查 end_time 之后是否有请求
50
+ this.backfillWindows(parseDate(latest.end_time));
51
+ }
52
+ /** 从 baseTime 开始,每 5h 一个窗口,直到覆盖 lastLogTime */
53
+ backfillWindows(baseTime) {
54
+ const lastLog = this.db.prepare("SELECT created_at FROM request_logs ORDER BY created_at DESC LIMIT 1").get();
55
+ if (!lastLog)
56
+ return;
57
+ const lastLogTime = parseDate(lastLog.created_at);
58
+ let windowStart = baseTime;
59
+ while (windowStart < lastLogTime) {
60
+ const windowEnd = new Date(windowStart.getTime() + WINDOW_DURATION_MS);
61
+ insertWindow(this.db, {
62
+ id: randomUUID(),
63
+ router_key_id: null,
64
+ start_time: toSqliteDatetime(windowStart),
65
+ end_time: toSqliteDatetime(windowEnd),
66
+ });
67
+ windowStart = windowEnd;
68
+ }
69
+ }
70
+ }
71
+ function truncateToMinute(date) {
72
+ const d = new Date(date);
73
+ d.setSeconds(0, 0);
74
+ return d;
75
+ }
@@ -0,0 +1,4 @@
1
+ /** Date → SQLite datetime 文本 (YYYY-MM-DD HH:MM:SS),UTC 时区,与 DEFAULT (datetime('now')) 对齐 */
2
+ export declare function toSqliteDatetime(date: Date): string;
3
+ /** 兼容 ISO 和 SQLite datetime 格式的日期解析,均视为 UTC */
4
+ export declare function parseSqliteDatetime(s: string): Date;
@@ -0,0 +1,10 @@
1
+ /** Date → SQLite datetime 文本 (YYYY-MM-DD HH:MM:SS),UTC 时区,与 DEFAULT (datetime('now')) 对齐 */
2
+ export function toSqliteDatetime(date) {
3
+ return date.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, "");
4
+ }
5
+ /** 兼容 ISO 和 SQLite datetime 格式的日期解析,均视为 UTC */
6
+ export function parseSqliteDatetime(s) {
7
+ if (s.includes("T"))
8
+ return new Date(s);
9
+ return new Date(s + "Z");
10
+ }
@@ -0,0 +1 @@
1
+ import{Ft as e,K as t,Lt as n,U as r,at as i,ct as a,r as o}from"./button-qxGNBunr.js";var s=[`data-size`],c=t({__name:`Card`,props:{class:{type:[Boolean,null,String,Object,Array]},size:{default:`default`}},setup(t){let c=t;return(l,u)=>(i(),r(`div`,{"data-slot":`card`,"data-size":t.size,class:n(e(o)(`ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-lg py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-lg *:[img:last-child]:rounded-b-lg group/card flex flex-col`,c.class))},[a(l.$slots,`default`)],10,s))}}),l=t({__name:`CardContent`,props:{class:{type:[Boolean,null,String,Object,Array]}},setup(t){let s=t;return(t,c)=>(i(),r(`div`,{"data-slot":`card-content`,class:n(e(o)(`px-4 group-data-[size=sm]/card:px-3`,s.class))},[a(t.$slots,`default`)],2))}});export{c as n,l as t};
@@ -0,0 +1 @@
1
+ import{Ft as e,K as t,Lt as n,U as r,at as i,ct as a,r as o}from"./button-qxGNBunr.js";var s=t({__name:`CardHeader`,props:{class:{type:[Boolean,null,String,Object,Array]}},setup(t){let s=t;return(t,c)=>(i(),r(`div`,{"data-slot":`card-header`,class:n(e(o)(`gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]`,s.class))},[a(t.$slots,`default`)],2))}});export{s as t};
@@ -0,0 +1 @@
1
+ import{Ft as e,K as t,Lt as n,U as r,at as i,ct as a,r as o}from"./button-qxGNBunr.js";var s=t({__name:`CardTitle`,props:{class:{type:[Boolean,null,String,Object,Array]}},setup(t){let s=t;return(t,c)=>(i(),r(`div`,{"data-slot":`card-title`,class:n(e(o)(`text-base leading-snug font-medium group-data-[size=sm]/card:text-sm cn-font-heading`,s.class))},[a(t.$slots,`default`)],2))}});export{s as t};