proxy-agent.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. import { socksDispatcher } from "fetch-socks";
  2. import { Agent, type Dispatcher, ProxyAgent, setGlobalDispatcher } from "undici";
  3. import { getGlobalAgentPool as getPool } from "@/lib/proxy-agent/agent-pool";
  4. import type { Provider } from "@/types/provider";
  5. import { getEnvConfig } from "./config/env.schema";
  6. import { logger } from "./logger";
  7. /**
  8. * undici 全局超时配置
  9. *
  10. * 背景:undici (Node.js 内置 fetch) 有默认的 300 秒超时 (headersTimeout + bodyTimeout)
  11. * 问题:即使业务层通过 AbortController 设置更长的超时,undici 的 300 秒会先触发
  12. * 解决:显式配置 undici 全局超时(默认 600 秒,可通过环境变量调整),匹配 LLM 服务的最大响应时间
  13. *
  14. * @see https://github.com/nodejs/undici/issues/1373
  15. * @see https://github.com/nodejs/node/issues/46706
  16. */
  17. const {
  18. FETCH_CONNECT_TIMEOUT: connectTimeout,
  19. FETCH_HEADERS_TIMEOUT: headersTimeout,
  20. FETCH_BODY_TIMEOUT: bodyTimeout,
  21. } = getEnvConfig();
  22. /**
  23. * 设置 undici 全局 Agent,覆盖默认的 300 秒超时
  24. * 此配置对所有 fetch() 调用生效(无论是否使用代理)
  25. */
  26. setGlobalDispatcher(
  27. new Agent({
  28. connectTimeout,
  29. headersTimeout,
  30. bodyTimeout,
  31. })
  32. );
  33. logger.info("undici global dispatcher configured", {
  34. connectTimeout,
  35. headersTimeout,
  36. bodyTimeout,
  37. note: "覆盖 undici 默认 300s 超时,匹配 LLM 最大响应时间",
  38. });
  39. /**
  40. * 代理配置结果
  41. */
  42. export interface ProxyConfig {
  43. agent: ProxyAgent | Dispatcher;
  44. fallbackToDirect: boolean;
  45. proxyUrl: string;
  46. http2Enabled: boolean; // HTTP/2 是否启用(SOCKS 代理不支持 HTTP/2)
  47. }
  48. /**
  49. * 最小的供应商代理配置接口(用于类型安全)
  50. * 仅包含创建代理 Agent 所需的必要字段
  51. */
  52. export interface ProviderProxyConfig {
  53. id: number;
  54. name?: string;
  55. proxyUrl: string | null;
  56. proxyFallbackToDirect: boolean;
  57. }
  58. /**
  59. * 为供应商创建代理 Agent(如果配置了代理)
  60. *
  61. * 支持协议:
  62. * - http:// - HTTP 代理
  63. * - https:// - HTTPS 代理
  64. * - socks5:// - SOCKS5 代理
  65. * - socks4:// - SOCKS4 代理
  66. *
  67. * HTTP/2 支持:
  68. * - HTTP/HTTPS 代理支持 HTTP/2(通过 undici 的 allowH2 选项)
  69. * - SOCKS 代理不支持 HTTP/2(undici 限制)
  70. *
  71. * @param provider 供应商配置(Provider 或 ProviderProxyConfig)
  72. * @param targetUrl 目标请求 URL
  73. * @param enableHttp2 是否启用 HTTP/2(默认 false)
  74. * @returns 代理配置对象,如果未配置代理则返回 null
  75. */
  76. export function createProxyAgentForProvider(
  77. provider: Provider | ProviderProxyConfig,
  78. targetUrl: string,
  79. enableHttp2 = false
  80. ): ProxyConfig | null {
  81. // 未配置代理
  82. if (!provider.proxyUrl) {
  83. return null;
  84. }
  85. const proxyUrl = provider.proxyUrl.trim();
  86. if (!proxyUrl) {
  87. return null;
  88. }
  89. try {
  90. // 解析代理 URL(验证格式)
  91. const parsedProxy = new URL(proxyUrl);
  92. // 根据协议选择 Agent
  93. let agent: ProxyAgent | Dispatcher;
  94. let actualHttp2Enabled = false; // 实际是否启用 HTTP/2
  95. if (parsedProxy.protocol === "socks5:" || parsedProxy.protocol === "socks4:") {
  96. // SOCKS 代理通过 fetch-socks(undici 兼容)
  97. // 使用 socksDispatcher 创建 undici 兼容的 Dispatcher
  98. agent = socksDispatcher(
  99. {
  100. type: parsedProxy.protocol === "socks5:" ? 5 : 4,
  101. host: parsedProxy.hostname,
  102. port: parseInt(parsedProxy.port, 10) || 1080,
  103. userId: parsedProxy.username || undefined,
  104. password: parsedProxy.password || undefined,
  105. },
  106. {
  107. connect: {
  108. timeout: connectTimeout,
  109. },
  110. }
  111. );
  112. actualHttp2Enabled = false; // SOCKS 不支持 HTTP/2
  113. // 警告:SOCKS 代理不支持 HTTP/2
  114. if (enableHttp2) {
  115. logger.warn("SOCKS proxy does not support HTTP/2, falling back to HTTP/1.1", {
  116. providerId: provider.id,
  117. providerName: provider.name ?? "unknown",
  118. protocol: parsedProxy.protocol,
  119. });
  120. }
  121. logger.debug("SOCKS dispatcher created via fetch-socks", {
  122. providerId: provider.id,
  123. providerName: provider.name ?? "unknown",
  124. protocol: parsedProxy.protocol,
  125. proxyHost: parsedProxy.hostname,
  126. proxyPort: parsedProxy.port,
  127. targetUrl: new URL(targetUrl).origin,
  128. http2Enabled: false,
  129. });
  130. } else if (parsedProxy.protocol === "http:" || parsedProxy.protocol === "https:") {
  131. // HTTP/HTTPS 代理(使用 undici)
  132. // 支持 HTTP/2:通过 allowH2 选项启用 ALPN 协商
  133. // ⭐ 配置超时,覆盖 undici 默认值,匹配 LLM 最大响应时间(默认 600 秒,可通过环境变量调整)
  134. agent = new ProxyAgent({
  135. uri: proxyUrl,
  136. allowH2: enableHttp2,
  137. connectTimeout,
  138. headersTimeout, // 等待响应头的超时
  139. bodyTimeout, // 等待响应体的超时
  140. });
  141. actualHttp2Enabled = enableHttp2;
  142. logger.debug("HTTP/HTTPS ProxyAgent created", {
  143. providerId: provider.id,
  144. providerName: provider.name ?? "unknown",
  145. protocol: parsedProxy.protocol,
  146. proxyHost: parsedProxy.hostname,
  147. proxyPort: parsedProxy.port,
  148. targetUrl: new URL(targetUrl).origin,
  149. http2Enabled: enableHttp2,
  150. connectTimeout,
  151. headersTimeout,
  152. bodyTimeout,
  153. });
  154. } else {
  155. throw new Error(
  156. `Unsupported proxy protocol: ${parsedProxy.protocol}. Supported protocols: http://, https://, socks5://, socks4://`
  157. );
  158. }
  159. return {
  160. agent,
  161. fallbackToDirect: provider.proxyFallbackToDirect ?? false,
  162. proxyUrl: maskProxyUrl(proxyUrl),
  163. http2Enabled: actualHttp2Enabled,
  164. };
  165. } catch (error) {
  166. logger.error("Failed to create ProxyAgent", {
  167. providerId: provider.id,
  168. providerName: provider.name ?? "unknown",
  169. proxyUrl: maskProxyUrl(proxyUrl),
  170. error: error instanceof Error ? error.message : String(error),
  171. });
  172. // 代理配置错误,直接抛出异常(不降级)
  173. throw new Error(
  174. `Invalid proxy configuration: ${error instanceof Error ? error.message : String(error)}`
  175. );
  176. }
  177. }
  178. /**
  179. * 脱敏代理 URL(隐藏密码)
  180. * 示例:http://user:[email protected]:8080 -> http://user:***@proxy.com:8080
  181. *
  182. * @param proxyUrl 原始代理 URL
  183. * @returns 脱敏后的代理 URL
  184. */
  185. export function maskProxyUrl(proxyUrl: string): string {
  186. try {
  187. const url = new URL(proxyUrl);
  188. if (url.password) {
  189. url.password = "***";
  190. }
  191. return url.toString();
  192. } catch {
  193. // 如果 URL 解析失败,使用正则替换
  194. return proxyUrl.replace(/:([^:@]+)@/, ":***@");
  195. }
  196. }
  197. /**
  198. * 验证代理 URL 格式是否合法
  199. *
  200. * @param proxyUrl 代理 URL
  201. * @returns 是否合法
  202. */
  203. export function isValidProxyUrl(proxyUrl: string): boolean {
  204. if (!proxyUrl || !proxyUrl.trim()) {
  205. return false;
  206. }
  207. try {
  208. const url = new URL(proxyUrl.trim());
  209. // 检查协议
  210. const supportedProtocols = ["http:", "https:", "socks5:", "socks4:"];
  211. if (!supportedProtocols.includes(url.protocol)) {
  212. return false;
  213. }
  214. // 必须有 hostname
  215. if (!url.hostname) {
  216. return false;
  217. }
  218. return true;
  219. } catch {
  220. return false;
  221. }
  222. }
  223. // Re-export from agent-pool module
  224. export {
  225. type AgentPool,
  226. type AgentPoolConfig,
  227. type AgentPoolStats,
  228. generateAgentCacheKey,
  229. getGlobalAgentPool,
  230. resetGlobalAgentPool,
  231. } from "./proxy-agent/agent-pool";
  232. /**
  233. * Extended ProxyConfig with cache key for health management
  234. */
  235. export interface ProxyConfigWithCacheKey extends ProxyConfig {
  236. /** Cache key for marking agent as unhealthy on SSL errors */
  237. cacheKey: string;
  238. }
  239. /**
  240. * Get proxy agent for provider using the global Agent Pool
  241. *
  242. * This is the recommended way to get a proxy agent as it:
  243. * 1. Reuses agents across requests to the same endpoint
  244. * 2. Isolates connections between different endpoints
  245. * 3. Supports health management (mark unhealthy on SSL errors)
  246. *
  247. * @param provider Provider configuration
  248. * @param targetUrl Target request URL
  249. * @param enableHttp2 Whether to enable HTTP/2 (default: false)
  250. * @returns ProxyConfig with cacheKey, or null if no proxy configured
  251. */
  252. export async function getProxyAgentForProvider(
  253. provider: Provider | ProviderProxyConfig,
  254. targetUrl: string,
  255. enableHttp2 = false
  256. ): Promise<ProxyConfigWithCacheKey | null> {
  257. // No proxy configured
  258. if (!provider.proxyUrl) {
  259. return null;
  260. }
  261. const proxyUrl = provider.proxyUrl.trim();
  262. if (!proxyUrl) {
  263. return null;
  264. }
  265. const pool = getPool();
  266. const { agent, cacheKey } = await pool.getAgent({
  267. endpointUrl: targetUrl,
  268. proxyUrl,
  269. enableHttp2,
  270. });
  271. // Determine actual HTTP/2 status (SOCKS doesn't support HTTP/2)
  272. const parsedProxy = new URL(proxyUrl);
  273. const isSocks = parsedProxy.protocol === "socks5:" || parsedProxy.protocol === "socks4:";
  274. const actualHttp2Enabled = isSocks ? false : enableHttp2;
  275. return {
  276. agent,
  277. fallbackToDirect: provider.proxyFallbackToDirect ?? false,
  278. proxyUrl: maskProxyUrl(proxyUrl),
  279. http2Enabled: actualHttp2Enabled,
  280. cacheKey,
  281. };
  282. }