llm-simple-router 1.1.2 → 1.1.4

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 (127) hide show
  1. package/dist/admin/providers.js +6 -4
  2. package/dist/admin/quick-setup.js +3 -1
  3. package/dist/app/register-routes.js +2 -0
  4. package/dist/config/model-context.d.ts +2 -0
  5. package/dist/config/model-context.js +4 -0
  6. package/dist/core/concurrency/semaphore.d.ts +15 -2
  7. package/dist/core/concurrency/semaphore.js +36 -4
  8. package/dist/core/constants.d.ts +1 -0
  9. package/dist/core/constants.js +3 -0
  10. package/dist/core/monitor/request-tracker.d.ts +6 -0
  11. package/dist/core/monitor/request-tracker.js +15 -0
  12. package/dist/core/types.d.ts +1 -1
  13. package/dist/db/providers.d.ts +16 -3
  14. package/dist/db/providers.js +27 -12
  15. package/dist/index.js +2 -0
  16. package/dist/proxy/handler/iteration-setup.js +7 -3
  17. package/dist/proxy/orchestration/orchestrator.d.ts +1 -1
  18. package/dist/proxy/orchestration/orchestrator.js +37 -12
  19. package/dist/proxy/orchestration/resilience.d.ts +3 -1
  20. package/dist/proxy/orchestration/resilience.js +17 -2
  21. package/dist/proxy/orchestration/scope.d.ts +1 -1
  22. package/dist/proxy/orchestration/scope.js +2 -2
  23. package/dist/proxy/proxy-core.d.ts +0 -11
  24. package/dist/proxy/proxy-core.js +2 -63
  25. package/dist/proxy/transport/http.d.ts +11 -13
  26. package/dist/proxy/transport/http.js +40 -30
  27. package/dist/proxy/transport/shared.d.ts +23 -0
  28. package/dist/proxy/transport/shared.js +58 -0
  29. package/dist/proxy/transport/stream.d.ts +56 -3
  30. package/dist/proxy/transport/stream.js +128 -49
  31. package/dist/proxy/transport/transport-fn.d.ts +2 -1
  32. package/dist/proxy/transport/transport-fn.js +3 -3
  33. package/frontend-dist/assets/AuthLayout-CmG_8Ovs.js +1 -0
  34. package/frontend-dist/assets/Card-BlGAfXME.js +1 -0
  35. package/frontend-dist/assets/CardContent-DTKSvHkJ.js +1 -0
  36. package/frontend-dist/assets/CardTitle-Cv1xB3mR.js +1 -0
  37. package/frontend-dist/assets/CascadingModelSelect-ul9uB6Dy.js +1 -0
  38. package/frontend-dist/assets/Checkbox-DxV7pVKU.js +1 -0
  39. package/frontend-dist/assets/CollapsibleContent-BgO8VoPR.js +1 -0
  40. package/frontend-dist/assets/CollapsibleTrigger-dG3CqCAV.js +1 -0
  41. package/frontend-dist/assets/ConcurrencyControl-BKR3IfV4.js +1 -0
  42. package/frontend-dist/assets/Dashboard-DCOF-zaF.js +3 -0
  43. package/frontend-dist/assets/{Input-DMDfXTXB.js → Input-BBtQpfsU.js} +1 -1
  44. package/frontend-dist/assets/Label-DARM_yCh.js +1 -0
  45. package/frontend-dist/assets/Login-qTUiO2Vc.js +1 -0
  46. package/frontend-dist/assets/Logs-BAywknrp.js +1 -0
  47. package/frontend-dist/assets/ModelMappings-DU06Tex1.js +1 -0
  48. package/frontend-dist/assets/Monitor-DR_u-5V1.js +1 -0
  49. package/frontend-dist/assets/Providers-DM5iF-Z5.js +1 -0
  50. package/frontend-dist/assets/ProxyEnhancement-CI-lDGff.js +1 -0
  51. package/frontend-dist/assets/QuickSetup-AWc9oZz4.js +1 -0
  52. package/frontend-dist/assets/RetryRules-DhA2ONMo.js +1 -0
  53. package/frontend-dist/assets/RouterKeys-CFILIP_P.js +1 -0
  54. package/frontend-dist/assets/{RovingFocusItem-5H5eE6G2.js → RovingFocusItem-mNZuQwzG.js} +1 -1
  55. package/frontend-dist/assets/Schedules-C8A9Dyry.js +1 -0
  56. package/frontend-dist/assets/Separator-CtbYW3SR.js +1 -0
  57. package/frontend-dist/assets/Settings-etFCYRt3.js +6 -0
  58. package/frontend-dist/assets/Setup-veKl98QO.js +1 -0
  59. package/frontend-dist/assets/Skeleton-aBg0O52j.js +1 -0
  60. package/frontend-dist/assets/Switch-K9syOZ4L.js +1 -0
  61. package/frontend-dist/assets/TableHeader-Dpn1Lnaz.js +1 -0
  62. package/frontend-dist/assets/TabsTrigger-C6L7-25Q.js +1 -0
  63. package/frontend-dist/assets/UnifiedRequestDialog-BQNd5d8M.js +3 -0
  64. package/frontend-dist/assets/{VisuallyHiddenInput-DrNFhnVL.js → VisuallyHiddenInput-CM0ZcPu6.js} +1 -1
  65. package/frontend-dist/assets/arrow-down-D7MkIKwy.js +1 -0
  66. package/frontend-dist/assets/badge-BVIIW0-Q.js +1 -0
  67. package/frontend-dist/assets/{button-BBiWml8B.js → button-CZXw3CE5.js} +2 -2
  68. package/frontend-dist/assets/chevron-right-XHFgIZAJ.js +1 -0
  69. package/frontend-dist/assets/dialog-C1UP6R9l.js +1 -0
  70. package/frontend-dist/assets/{image-zYdpUIEA.js → image-B1uUZwVK.js} +1 -1
  71. package/frontend-dist/assets/{index-DyQ39g4W.css → index-DGJSS9jI.css} +1 -1
  72. package/frontend-dist/assets/index-DU-d4dwG.js +58 -0
  73. package/frontend-dist/assets/model-patches-DdJLVJUH.js +1 -0
  74. package/frontend-dist/assets/{pencil-C3-MFg-d.js → pencil-HavpPvNF.js} +1 -1
  75. package/frontend-dist/assets/plus-BjrVWmRw.js +1 -0
  76. package/frontend-dist/assets/quickSetup-jgJgPUcH.js +1 -0
  77. package/frontend-dist/assets/quickSetup-qTjp3Z6J.js +1 -0
  78. package/frontend-dist/assets/search-q46OssNL.js +1 -0
  79. package/frontend-dist/assets/{sparkles-B5RWZZuf.js → sparkles-CCPKwVxK.js} +1 -1
  80. package/frontend-dist/assets/transform-domain-D0mVmoZd.js +1 -0
  81. package/frontend-dist/assets/{trash-2-Dn3T5-Z1.js → trash-2-CHuDHKxp.js} +1 -1
  82. package/frontend-dist/assets/{useClipboard-Bx3CrPal.js → useClipboard-C3_tZc-3.js} +1 -1
  83. package/frontend-dist/assets/useLogRetention-BiFJhaOm.js +1 -0
  84. package/frontend-dist/assets/{useProviderGroups-Og5FpCPe.js → useProviderGroups-CEb_RKrl.js} +1 -1
  85. package/frontend-dist/index.html +3 -3
  86. package/package.json +1 -1
  87. package/frontend-dist/assets/AuthLayout-jELzICkx.js +0 -1
  88. package/frontend-dist/assets/Card-uC_v0CEa.js +0 -1
  89. package/frontend-dist/assets/CardContent-CP3OiCj4.js +0 -1
  90. package/frontend-dist/assets/CardTitle-DGxuW5DZ.js +0 -1
  91. package/frontend-dist/assets/CascadingModelSelect-Dzk7rxIN.js +0 -1
  92. package/frontend-dist/assets/Checkbox-C1aVqGdC.js +0 -1
  93. package/frontend-dist/assets/CollapsibleContent-pBG4UkLo.js +0 -1
  94. package/frontend-dist/assets/CollapsibleTrigger-D5TkgXmz.js +0 -1
  95. package/frontend-dist/assets/ConcurrencyControl-5GweS-rY.js +0 -1
  96. package/frontend-dist/assets/Dashboard-wjd3d3qk.js +0 -3
  97. package/frontend-dist/assets/Label-BQXea0mo.js +0 -1
  98. package/frontend-dist/assets/Login-DNpCjxrY.js +0 -1
  99. package/frontend-dist/assets/Logs-BuL2Z0sF.js +0 -1
  100. package/frontend-dist/assets/ModelMappings-DeRhu-2N.js +0 -1
  101. package/frontend-dist/assets/Monitor-V30dnACo.js +0 -1
  102. package/frontend-dist/assets/Providers-BkkQhSTb.js +0 -1
  103. package/frontend-dist/assets/ProxyEnhancement-DjgebwfU.js +0 -1
  104. package/frontend-dist/assets/QuickSetup-BTVjEiU7.js +0 -1
  105. package/frontend-dist/assets/RetryRules-DFBHBG-B.js +0 -1
  106. package/frontend-dist/assets/RouterKeys-DZADvMfh.js +0 -1
  107. package/frontend-dist/assets/Schedules-DUubZ2uN.js +0 -1
  108. package/frontend-dist/assets/Separator-BqIs_Dy3.js +0 -1
  109. package/frontend-dist/assets/Settings-CO59WLOZ.js +0 -6
  110. package/frontend-dist/assets/Setup-Br7JZKNp.js +0 -1
  111. package/frontend-dist/assets/Skeleton-gVyjaP-y.js +0 -1
  112. package/frontend-dist/assets/Switch-D1ER0j6H.js +0 -1
  113. package/frontend-dist/assets/TableHeader-MqyrNSsx.js +0 -1
  114. package/frontend-dist/assets/TabsTrigger-DdjWJbUq.js +0 -1
  115. package/frontend-dist/assets/UnifiedRequestDialog-DRthlI6j.js +0 -3
  116. package/frontend-dist/assets/arrow-down-BFgGYafs.js +0 -1
  117. package/frontend-dist/assets/badge-Db4OYMEf.js +0 -1
  118. package/frontend-dist/assets/chevron-right-DYwStkJr.js +0 -1
  119. package/frontend-dist/assets/dialog-DRYeWncC.js +0 -1
  120. package/frontend-dist/assets/index-DTujoAWx.js +0 -58
  121. package/frontend-dist/assets/model-patches-DIy-rFuq.js +0 -1
  122. package/frontend-dist/assets/plus-xmIDnujf.js +0 -1
  123. package/frontend-dist/assets/quickSetup-CqxQRMCR.js +0 -1
  124. package/frontend-dist/assets/quickSetup-DplqYrvf.js +0 -1
  125. package/frontend-dist/assets/search-BxNrTsG8.js +0 -1
  126. package/frontend-dist/assets/transform-domain-KBixlLXR.js +0 -1
  127. package/frontend-dist/assets/useLogRetention-CdccNhYN.js +0 -1
@@ -24,17 +24,6 @@ export type { ErrorKind } from "./format/types.js";
24
24
  * 由 formatBody 回调根据 kind 参数映射各自的 type/code 并组装 body。
25
25
  */
26
26
  export declare function createErrorFormatter(formatBody: (kind: ErrorKind, message: string) => Record<string, unknown>): ProxyErrorFormatter;
27
- /**
28
- * 拼接上游 URL,自动处理 base_url 已包含部分或完整 API 路径的情况。
29
- *
30
- * 兼容场景:
31
- * - base_url = `https://host/v1`, upstreamPath = `/v1/chat/completions` → `https://host/v1/chat/completions`
32
- * - base_url = `https://host/v1/chat/completions`, upstreamPath = `/v1/chat/completions` → `https://host/v1/chat/completions`
33
- * - base_url = `https://host/chat/completions`, upstreamPath = `/v1/chat/completions` → `https://host/chat/completions`
34
- * - base_url = `https://host/v1/`, upstreamPath = `/v1/chat/completions` → `https://host/v1/chat/completions`
35
- * - base_url = `https://host/api/paas/v4`, upstreamPath = `/api/paas/v4/chat/completions` → `https://host/api/paas/v4/chat/completions`
36
- */
37
- export declare function buildUpstreamUrl(baseUrl: string, upstreamPath: string): string;
38
27
  export declare const SKIP_UPSTREAM: Set<string>;
39
28
  export declare function selectHeaders(raw: RawHeaders, skip: Set<string>): Record<string, string>;
40
29
  export declare function buildUpstreamHeaders(clientHeaders: RawHeaders, apiKey: string, payloadBytes?: number, apiType?: "openai" | "openai-responses" | "anthropic"): Record<string, string>;
@@ -44,69 +44,8 @@ export function createErrorFormatter(formatBody) {
44
44
  }),
45
45
  };
46
46
  }
47
- // ---------- URL utilities ----------
48
- /**
49
- * 已知上游 API 路径后缀(不含 /v1 等版本前缀)。
50
- * 用于检测 base_url 中是否已包含完整路径。
51
- */
52
- const KNOWN_API_SUFFIXES = [
53
- "/chat/completions",
54
- "/messages",
55
- "/responses",
56
- ];
57
- /**
58
- * 拼接上游 URL,自动处理 base_url 已包含部分或完整 API 路径的情况。
59
- *
60
- * 兼容场景:
61
- * - base_url = `https://host/v1`, upstreamPath = `/v1/chat/completions` → `https://host/v1/chat/completions`
62
- * - base_url = `https://host/v1/chat/completions`, upstreamPath = `/v1/chat/completions` → `https://host/v1/chat/completions`
63
- * - base_url = `https://host/chat/completions`, upstreamPath = `/v1/chat/completions` → `https://host/chat/completions`
64
- * - base_url = `https://host/v1/`, upstreamPath = `/v1/chat/completions` → `https://host/v1/chat/completions`
65
- * - base_url = `https://host/api/paas/v4`, upstreamPath = `/api/paas/v4/chat/completions` → `https://host/api/paas/v4/chat/completions`
66
- */
67
- export function buildUpstreamUrl(baseUrl, upstreamPath) {
68
- const normalized = baseUrl.replace(/\/+$/, "");
69
- // 1) 完全匹配:base_url 已包含完整 upstreamPath
70
- if (normalized.endsWith(upstreamPath))
71
- return normalized;
72
- // 2) 检测 base_url 是否已包含已知 API 路径后缀
73
- // 例如 `https://host/v1/chat/completions` → 已包含,直接返回
74
- for (const suffix of KNOWN_API_SUFFIXES) {
75
- if (normalized.endsWith(suffix))
76
- return normalized;
77
- }
78
- // 3) 从 upstreamPath 中找到 base_url 的重叠部分,只追加非重叠尾部
79
- // 例如 base_url = `https://host/api/paas/v4`, upstreamPath = `/api/paas/v4/chat/completions`
80
- // → 重叠 `/api/paas/v4`,追加 `/chat/completions`
81
- // 例如 base_url = `https://host/v1`, upstreamPath = `/v1/chat/completions`
82
- // → 重叠 `/v1`,追加 `/chat/completions`
83
- const overlap = findPathOverlap(normalized, upstreamPath);
84
- if (overlap.length > 0) {
85
- const rest = upstreamPath.slice(overlap.length);
86
- return `${normalized}${rest}`;
87
- }
88
- // 4) 确保拼接处有且仅有一个 /
89
- if (!upstreamPath.startsWith("/"))
90
- return `${normalized}/${upstreamPath}`;
91
- return `${normalized}${upstreamPath}`;
92
- }
93
- /**
94
- * 找出 base_url 末尾与 upstreamPath 开头的最长重叠路径段。
95
- * 例如 base_url = `https://host/api/v4`, upstreamPath = `/api/v4/chat/completions` → 返回 `/api/v4`
96
- */
97
- function findPathOverlap(baseUrl, upstreamPath) {
98
- // 将 upstreamPath 按 / 拆分,逐段检查是否与 baseUrl 末尾匹配
99
- const segments = upstreamPath.split("/");
100
- // segments[0] 是空字符串(因为 upstreamPath 以 / 开头),至少需要 2 段才有意义
101
- const MIN_OVERLAP_SEGMENTS = 2;
102
- for (let len = segments.length - 1; len >= MIN_OVERLAP_SEGMENTS; len--) {
103
- const candidate = segments.slice(0, len).join("/");
104
- if (candidate.length > 0 && baseUrl.endsWith(candidate)) {
105
- return candidate;
106
- }
107
- }
108
- return "";
109
- }
47
+ // buildUpstreamUrl / findPathOverlap / KNOWN_API_SUFFIXES 已下沉至 ./transport/shared.ts
48
+ // (打破 proxy-core ↔ transport/http 循环依赖)
110
49
  // ---------- Header utilities ----------
111
50
  export const SKIP_UPSTREAM = new Set([
112
51
  "host",
@@ -1,21 +1,19 @@
1
1
  import type { Agent } from "http";
2
2
  import type { RawHeaders, TransportResult } from "../types.js";
3
+ import { type BuildHeadersFn, type TransportCallOpts } from "./shared.js";
3
4
  export { callStream } from "./stream.js";
4
- export interface UpstreamRequestOptions {
5
- hostname: string;
6
- port: number;
7
- path: string;
8
- method: string;
9
- headers: Record<string, string>;
5
+ export { _transportInternals } from "./shared.js";
6
+ /** callNonStream 选项:timeoutMs=0/Infinity 表示禁用超时。 */
7
+ export interface NonStreamCallOpts extends TransportCallOpts {
8
+ timeoutMs?: number;
9
+ }
10
+ /** callGet 选项:仅超时(admin 探测,无客户端 signal 关联)。 */
11
+ export interface GetCallOpts {
12
+ timeoutMs?: number;
10
13
  }
11
- export declare const _transportInternals: {
12
- createUpstreamRequest(url: URL, options: UpstreamRequestOptions, agent?: Agent): import("http").ClientRequest;
13
- };
14
- export declare function buildRequestOptions(url: URL, headers: Record<string, string>, method?: string): UpstreamRequestOptions;
15
- export type BuildHeadersFn = (cliHdrs: RawHeaders, key: string, bytes?: number) => Record<string, string>;
16
14
  export declare function callNonStream(backend: {
17
15
  base_url: string;
18
- }, apiKey: string, body: Record<string, unknown>, clientHeaders: RawHeaders, upstreamPath: string, buildHeaders: BuildHeadersFn, agent?: Agent): Promise<TransportResult>;
16
+ }, apiKey: string, body: Record<string, unknown>, clientHeaders: RawHeaders, upstreamPath: string, buildHeaders: BuildHeadersFn, agent?: Agent, opts?: NonStreamCallOpts): Promise<TransportResult>;
19
17
  export interface GetTransportResult {
20
18
  statusCode: number;
21
19
  body: string;
@@ -23,4 +21,4 @@ export interface GetTransportResult {
23
21
  }
24
22
  export declare function callGet(backend: {
25
23
  base_url: string;
26
- }, apiKey: string, clientHeaders: RawHeaders, upstreamPath: string, buildHeaders: (cliHdrs: RawHeaders, key: string) => Record<string, string>, agent?: Agent): Promise<GetTransportResult>;
24
+ }, apiKey: string, clientHeaders: RawHeaders, upstreamPath: string, buildHeaders: (cliHdrs: RawHeaders, key: string) => Record<string, string>, agent?: Agent, opts?: GetCallOpts): Promise<GetTransportResult>;
@@ -1,40 +1,47 @@
1
- import { request as httpRequestFn } from "http";
2
- import { request as httpsRequestFn } from "https";
3
1
  import { UPSTREAM_SUCCESS, filterHeaders } from "../types.js";
4
- import { buildUpstreamUrl } from "../proxy-core.js";
5
- // Re-export callStream from stream-proxy.ts for external consumers
2
+ import { DEFAULT_GET_TIMEOUT_MS } from "../../core/constants.js";
3
+ import { buildUpstreamUrl, _transportInternals, buildRequestOptions, } from "./shared.js";
4
+ // Re-export callStream from stream.ts for external consumers
6
5
  export { callStream } from "./stream.js";
6
+ // 兼容测试 mock:transport.test.ts 经 http 模块命名空间修改 _transportInternals 属性
7
+ export { _transportInternals } from "./shared.js";
7
8
  // ---------- Constants ----------
8
9
  const UPSTREAM_BAD_GATEWAY = 502;
9
10
  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, agent) {
14
- const opts = agent ? { ...options, agent } : options;
15
- return url.protocol === "https:"
16
- ? httpsRequestFn(opts)
17
- : httpRequestFn(opts);
18
- },
19
- };
20
- export function buildRequestOptions(url, headers, method = "POST") {
21
- return {
22
- hostname: url.hostname,
23
- port: Number(url.port) ||
24
- (url.protocol === "https:" ? HTTPS_DEFAULT_PORT : HTTP_DEFAULT_PORT),
25
- path: url.pathname,
26
- method,
27
- headers,
28
- };
29
- }
30
11
  // ---------- callNonStream ----------
31
- export function callNonStream(backend, apiKey, body, clientHeaders, upstreamPath, buildHeaders, agent) {
12
+ export function callNonStream(backend, apiKey, body, clientHeaders, upstreamPath, buildHeaders, agent, opts) {
32
13
  return new Promise((resolve) => {
33
14
  const url = new URL(buildUpstreamUrl(backend.base_url, upstreamPath));
34
15
  const payload = JSON.stringify(body);
35
16
  const upstreamHeaders = buildHeaders(clientHeaders, apiKey, Buffer.byteLength(payload));
36
17
  const options = buildRequestOptions(url, upstreamHeaders);
37
18
  const req = _transportInternals.createUpstreamRequest(url, options, agent);
19
+ // 上游无活动超时:0/Infinity 跳过(与 StreamProxy idleTimer 守卫对称)。
20
+ // destroy 必须带 error 参数,否则不 emit 'error' 事件,Promise 永挂。
21
+ const timeoutMs = opts?.timeoutMs;
22
+ if (timeoutMs !== undefined && Number.isFinite(timeoutMs) && timeoutMs > 0) {
23
+ req.setTimeout(timeoutMs);
24
+ req.on("timeout", () => req.destroy(new Error("upstream inactivity timeout")));
25
+ }
26
+ // 客户端断连:abort 信号穿透到上游 socket,立即切断连接。
27
+ // resolveOnce 在 Promise settle 时移除 abort listener,避免重试累积残留
28
+ // (与 callStream 的 resolveOnce 模式对称)。
29
+ const clientSignal = opts?.signal;
30
+ const onClientAbort = clientSignal ? () => req.destroy(new Error("client aborted")) : undefined;
31
+ const resolveOnce = (r) => {
32
+ if (onClientAbort && clientSignal && !clientSignal.aborted) {
33
+ clientSignal.removeEventListener("abort", onClientAbort);
34
+ }
35
+ resolve(r);
36
+ };
37
+ if (onClientAbort && clientSignal) {
38
+ if (clientSignal.aborted) {
39
+ onClientAbort();
40
+ }
41
+ else {
42
+ clientSignal.addEventListener("abort", onClientAbort, { once: true });
43
+ }
44
+ }
38
45
  req.on("response", (res) => {
39
46
  const chunks = [];
40
47
  res.on("data", (chunk) => chunks.push(chunk));
@@ -43,7 +50,7 @@ export function callNonStream(backend, apiKey, body, clientHeaders, upstreamPath
43
50
  const responseBody = Buffer.concat(chunks).toString("utf-8");
44
51
  const headers = filterHeaders(res.headers);
45
52
  if (statusCode >= UPSTREAM_SUCCESS && statusCode < UPSTREAM_SUCCESS + UPSTREAM_SUCCESS_RANGE) {
46
- resolve({
53
+ resolveOnce({
47
54
  kind: "success",
48
55
  statusCode,
49
56
  body: responseBody,
@@ -53,7 +60,7 @@ export function callNonStream(backend, apiKey, body, clientHeaders, upstreamPath
53
60
  });
54
61
  }
55
62
  else {
56
- resolve({
63
+ resolveOnce({
57
64
  kind: "error",
58
65
  statusCode,
59
66
  body: responseBody,
@@ -65,19 +72,22 @@ export function callNonStream(backend, apiKey, body, clientHeaders, upstreamPath
65
72
  });
66
73
  // 上游响应过程中连接中断时,IncomingMessage 发射 'error' 事件。
67
74
  // 无 listener 会导致 uncaught exception 使进程退出。
68
- res.on("error", (error) => resolve({ kind: "throw", error }));
75
+ res.on("error", (error) => resolveOnce({ kind: "throw", error }));
69
76
  });
70
- req.on("error", (error) => resolve({ kind: "throw", error }));
77
+ req.on("error", (error) => resolveOnce({ kind: "throw", error }));
71
78
  req.write(payload);
72
79
  req.end();
73
80
  });
74
81
  }
75
- export function callGet(backend, apiKey, clientHeaders, upstreamPath, buildHeaders, agent) {
82
+ export function callGet(backend, apiKey, clientHeaders, upstreamPath, buildHeaders, agent, opts) {
76
83
  return new Promise((resolve, reject) => {
77
84
  const url = new URL(buildUpstreamUrl(backend.base_url, upstreamPath));
78
85
  const headers = buildHeaders(clientHeaders, apiKey);
79
86
  const options = buildRequestOptions(url, headers, "GET");
80
87
  const req = _transportInternals.createUpstreamRequest(url, options, agent);
88
+ // GET 探测默认 30s 超时;destroy(error) 触发 'error' 事件 → reject。
89
+ req.setTimeout(opts?.timeoutMs ?? DEFAULT_GET_TIMEOUT_MS);
90
+ req.on("timeout", () => req.destroy(new Error("GET timeout")));
81
91
  req.on("response", (res) => {
82
92
  const chunks = [];
83
93
  res.on("data", (chunk) => chunks.push(chunk));
@@ -0,0 +1,23 @@
1
+ import type { Agent } from "http";
2
+ import type { RawHeaders } from "../types.js";
3
+ /** 非流式/流式调用通用可选项:客户端断连信号。 */
4
+ export interface TransportCallOpts {
5
+ signal?: AbortSignal;
6
+ }
7
+ /**
8
+ * 拼接上游 URL,自动处理 base_url 已包含部分或完整 API 路径的情况。
9
+ * 兼容:base_url 可带或不带 /v1 前缀、可含完整 endpoint,本函数只追加非重叠尾部。
10
+ */
11
+ export declare function buildUpstreamUrl(baseUrl: string, upstreamPath: string): string;
12
+ export interface UpstreamRequestOptions {
13
+ hostname: string;
14
+ port: number;
15
+ path: string;
16
+ method: string;
17
+ headers: Record<string, string>;
18
+ }
19
+ export declare const _transportInternals: {
20
+ createUpstreamRequest(url: URL, options: UpstreamRequestOptions, agent?: Agent): import("http").ClientRequest;
21
+ };
22
+ export declare function buildRequestOptions(url: URL, headers: Record<string, string>, method?: string): UpstreamRequestOptions;
23
+ export type BuildHeadersFn = (cliHdrs: RawHeaders, key: string, bytes?: number) => Record<string, string>;
@@ -0,0 +1,58 @@
1
+ import { request as httpRequestFn } from "http";
2
+ import { request as httpsRequestFn } from "https";
3
+ // ===== URL building =====
4
+ const KNOWN_API_SUFFIXES = [
5
+ "/chat/completions",
6
+ "/messages",
7
+ "/responses",
8
+ ];
9
+ /**
10
+ * 拼接上游 URL,自动处理 base_url 已包含部分或完整 API 路径的情况。
11
+ * 兼容:base_url 可带或不带 /v1 前缀、可含完整 endpoint,本函数只追加非重叠尾部。
12
+ */
13
+ export function buildUpstreamUrl(baseUrl, upstreamPath) {
14
+ const normalized = baseUrl.replace(/\/+$/, "");
15
+ if (normalized.endsWith(upstreamPath))
16
+ return normalized;
17
+ for (const suffix of KNOWN_API_SUFFIXES) {
18
+ if (normalized.endsWith(suffix))
19
+ return normalized;
20
+ }
21
+ const overlap = findPathOverlap(normalized, upstreamPath);
22
+ if (overlap.length > 0) {
23
+ return `${normalized}${upstreamPath.slice(overlap.length)}`;
24
+ }
25
+ if (!upstreamPath.startsWith("/"))
26
+ return `${normalized}/${upstreamPath}`;
27
+ return `${normalized}${upstreamPath}`;
28
+ }
29
+ function findPathOverlap(baseUrl, upstreamPath) {
30
+ const segments = upstreamPath.split("/");
31
+ const MIN_OVERLAP_SEGMENTS = 2;
32
+ for (let len = segments.length - 1; len >= MIN_OVERLAP_SEGMENTS; len--) {
33
+ const candidate = segments.slice(0, len).join("/");
34
+ if (candidate.length > 0 && baseUrl.endsWith(candidate)) {
35
+ return candidate;
36
+ }
37
+ }
38
+ return "";
39
+ }
40
+ // ===== Request utilities =====
41
+ const HTTPS_DEFAULT_PORT = 443;
42
+ const HTTP_DEFAULT_PORT = 80;
43
+ export const _transportInternals = {
44
+ createUpstreamRequest(url, options, agent) {
45
+ const opts = agent ? { ...options, agent } : options;
46
+ return url.protocol === "https:" ? httpsRequestFn(opts) : httpRequestFn(opts);
47
+ },
48
+ };
49
+ export function buildRequestOptions(url, headers, method = "POST") {
50
+ return {
51
+ hostname: url.hostname,
52
+ port: Number(url.port) ||
53
+ (url.protocol === "https:" ? HTTPS_DEFAULT_PORT : HTTP_DEFAULT_PORT),
54
+ path: url.pathname,
55
+ method,
56
+ headers,
57
+ };
58
+ }
@@ -1,12 +1,65 @@
1
- import type { Agent } from "http";
1
+ import { Transform } from "stream";
2
+ import type { Agent, ClientRequest, IncomingMessage } from "http";
2
3
  import type { FastifyReply } from "fastify";
3
4
  import type { RawHeaders, TransportResult } from "../types.js";
4
5
  import type { SSEMetricsTransform } from "../../metrics/sse-metrics-transform.js";
5
6
  import type { StreamLoopGuard } from "../../core/loop-prevention/index.js";
6
- import { type BuildHeadersFn } from "./http.js";
7
+ import { type BuildHeadersFn, type TransportCallOpts } from "./shared.js";
8
+ /** callStream 选项:connectTimeoutMs 为响应头前的无活动超时(复用 stream timeout 语义)。 */
9
+ export interface StreamCallOpts extends TransportCallOpts {
10
+ connectTimeoutMs?: number;
11
+ }
12
+ /**
13
+ * SSE 流式代理状态机。导出仅为单元测试,非公开 API。
14
+ * 负责 buffering/streaming 状态转换、idle 超时、上游资源销毁。
15
+ */
16
+ export declare class StreamProxy {
17
+ private readonly statusCode;
18
+ private readonly sentUpstreamHeaders;
19
+ private readonly reply;
20
+ private readonly metricsTransform;
21
+ private readonly checkEarlyError;
22
+ private readonly timeoutMs;
23
+ private readonly loopGuard;
24
+ private readonly timeoutContext?;
25
+ private readonly onTimeoutAbort?;
26
+ private readonly upstreamRes?;
27
+ private readonly upstreamReq?;
28
+ private state;
29
+ private resolved;
30
+ private resolveFn;
31
+ private pendingResult;
32
+ private readonly bufferChunks;
33
+ private totalBuffered;
34
+ private lastChunkEndedWithNewline;
35
+ private idleTimer;
36
+ private headersSent;
37
+ private closeHandlerRegistered;
38
+ private readonly sseHeaders;
39
+ private readonly passThrough;
40
+ private readonly pipeEntry;
41
+ private readonly formatTransform;
42
+ private sseScanBuffer;
43
+ private static readonly SSE_SCAN_MAX;
44
+ constructor(statusCode: number, rawUpstreamHeaders: RawHeaders, sentUpstreamHeaders: Record<string, string>, reply: FastifyReply, metricsTransform: SSEMetricsTransform | undefined, checkEarlyError: ((data: string) => boolean) | undefined, timeoutMs: number, loopGuard: StreamLoopGuard | undefined, formatTransform?: Transform, timeoutContext?: {
45
+ modelId: string;
46
+ providerId: string;
47
+ } | undefined, onTimeoutAbort?: ((timeoutMs: number) => void) | undefined, upstreamRes?: IncomingMessage | undefined, upstreamReq?: ClientRequest | undefined);
48
+ bindResolve(resolve: (result: TransportResult) => void): void;
49
+ private transition;
50
+ private terminal;
51
+ private cleanup;
52
+ private collectMetrics;
53
+ resetIdleTimer(): void;
54
+ startStreaming(): void;
55
+ registerCloseHandler(): void;
56
+ onData(chunk: Buffer): void;
57
+ onEnd(): void;
58
+ onUpstreamError(err: Error): void;
59
+ }
7
60
  export declare function callStream(backend: {
8
61
  base_url: string;
9
62
  }, apiKey: string, body: Record<string, unknown>, clientHeaders: RawHeaders, reply: FastifyReply, timeoutMs: number, upstreamPath: string, buildHeaders: BuildHeadersFn, metricsTransform?: SSEMetricsTransform, checkEarlyError?: (bufferedData: string) => boolean, compatResolve?: (result: TransportResult) => void, loopGuard?: StreamLoopGuard, formatTransform?: import("stream").Transform, timeoutContext?: {
10
63
  modelId: string;
11
64
  providerId: string;
12
- }, onTimeoutAbort?: (timeoutMs: number) => void, agent?: Agent): Promise<TransportResult>;
65
+ }, onTimeoutAbort?: (timeoutMs: number) => void, agent?: Agent, opts?: StreamCallOpts): Promise<TransportResult>;