@apifuse/provider-sdk 2.2.0-beta.2 → 2.2.0-beta.21

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 (291) hide show
  1. package/AUTHORING.md +487 -0
  2. package/CHANGELOG.md +90 -0
  3. package/README.md +49 -3
  4. package/SUBMISSION.md +1 -1
  5. package/bin/apifuse-check.ts +44 -59
  6. package/bin/apifuse-create.ts +1 -1
  7. package/bin/apifuse-dev.ts +27 -52
  8. package/bin/apifuse-pack-check.ts +14 -0
  9. package/bin/apifuse-pack-smoke.ts +36 -81
  10. package/bin/apifuse-pack-types.ts +305 -0
  11. package/bin/apifuse-perf.ts +45 -127
  12. package/bin/apifuse-record.ts +659 -111
  13. package/bin/apifuse-submit-check.ts +546 -48
  14. package/bin/apifuse-sync-assets.ts +117 -0
  15. package/bin/apifuse.ts +1 -1
  16. package/bin/submit-check-delimited-text.ts +50 -0
  17. package/bin/submit-check-xml-semantics.ts +204 -0
  18. package/bin/submit-check-xml.ts +134 -0
  19. package/dist/auth-turn/index.d.ts +3 -3
  20. package/dist/auth.d.ts +2 -2
  21. package/dist/auth.js +9 -18
  22. package/dist/ceremonies/index.d.ts +9 -1
  23. package/dist/ceremonies/index.js +65 -18
  24. package/dist/cli/commands.d.ts +1 -1
  25. package/dist/cli/commands.js +8 -0
  26. package/dist/cli/create.d.ts +3 -0
  27. package/dist/cli/create.js +34 -35
  28. package/dist/cli/prompt-assets.d.ts +80 -0
  29. package/dist/cli/prompt-assets.js +743 -0
  30. package/dist/cli/templates/provider/AGENTS.md.tpl +17 -8
  31. package/dist/config/loader.d.ts +176 -8
  32. package/dist/config/loader.js +424 -95
  33. package/dist/contract-serialization.d.ts +2 -2
  34. package/dist/contract-serialization.js +3 -6
  35. package/dist/contract-types.d.ts +2 -2
  36. package/dist/contract.d.ts +3 -3
  37. package/dist/contract.js +4 -6
  38. package/dist/define.d.ts +9 -1
  39. package/dist/define.js +304 -119
  40. package/dist/dev.d.ts +1 -1
  41. package/dist/dev.js +1 -1
  42. package/dist/error-resolution.d.ts +2 -0
  43. package/dist/error-resolution.js +90 -0
  44. package/dist/errors.d.ts +22 -1
  45. package/dist/errors.js +88 -0
  46. package/dist/fixture-sanitization.d.ts +26 -0
  47. package/dist/fixture-sanitization.js +216 -0
  48. package/dist/i18n/catalog.d.ts +2 -2
  49. package/dist/i18n/catalog.js +4 -10
  50. package/dist/i18n/index.d.ts +2 -2
  51. package/dist/i18n/index.js +2 -2
  52. package/dist/i18n/keys.d.ts +2 -2
  53. package/dist/index.d.ts +46 -42
  54. package/dist/index.js +41 -37
  55. package/dist/lint.d.ts +1 -1
  56. package/dist/lint.js +8 -15
  57. package/dist/native-address.d.ts +43 -0
  58. package/dist/native-address.js +281 -0
  59. package/dist/native-egress-policy.d.ts +31 -0
  60. package/dist/native-egress-policy.js +288 -0
  61. package/dist/observability.d.ts +5 -2
  62. package/dist/observability.js +48 -1
  63. package/dist/provider.d.ts +12 -11
  64. package/dist/provider.js +10 -9
  65. package/dist/public-schema-field-lint.d.ts +1 -1
  66. package/dist/recipes/gov-api.js +1 -1
  67. package/dist/runtime/auth-flow.d.ts +2 -1
  68. package/dist/runtime/auth-flow.js +4 -3
  69. package/dist/runtime/browser.d.ts +1 -1
  70. package/dist/runtime/browser.js +15 -29
  71. package/dist/runtime/cache.d.ts +1 -1
  72. package/dist/runtime/cache.js +4 -8
  73. package/dist/runtime/choice.d.ts +1 -1
  74. package/dist/runtime/choice.js +31 -35
  75. package/dist/runtime/credential.d.ts +1 -1
  76. package/dist/runtime/credential.js +1 -1
  77. package/dist/runtime/env.d.ts +1 -1
  78. package/dist/runtime/executor.d.ts +1 -1
  79. package/dist/runtime/executor.js +31 -4
  80. package/dist/runtime/http.d.ts +2 -2
  81. package/dist/runtime/http.js +387 -47
  82. package/dist/runtime/insights.d.ts +1 -1
  83. package/dist/runtime/insights.js +6 -13
  84. package/dist/runtime/instrumentation.d.ts +2 -2
  85. package/dist/runtime/instrumentation.js +345 -22
  86. package/dist/runtime/keyring.js +1 -1
  87. package/dist/runtime/namespace.js +1 -1
  88. package/dist/runtime/native-network.d.ts +127 -0
  89. package/dist/runtime/native-network.js +1298 -0
  90. package/dist/runtime/otlp.d.ts +1 -1
  91. package/dist/runtime/perf.d.ts +1 -1
  92. package/dist/runtime/provider.d.ts +1 -1
  93. package/dist/runtime/provider.js +1 -2
  94. package/dist/runtime/proxy-errors.d.ts +1 -1
  95. package/dist/runtime/proxy-errors.js +9 -7
  96. package/dist/runtime/proxy-nodemaven.d.ts +56 -0
  97. package/dist/runtime/proxy-nodemaven.js +146 -0
  98. package/dist/runtime/proxy-retry-policy.d.ts +2 -2
  99. package/dist/runtime/proxy-retry-policy.js +2 -2
  100. package/dist/runtime/proxy-telemetry.d.ts +2 -1
  101. package/dist/runtime/proxy-telemetry.js +55 -52
  102. package/dist/runtime/redirects.d.ts +29 -0
  103. package/dist/runtime/redirects.js +36 -0
  104. package/dist/runtime/redis.d.ts +1 -1
  105. package/dist/runtime/redis.js +2 -4
  106. package/dist/runtime/request-options.d.ts +68 -1
  107. package/dist/runtime/request-options.js +548 -0
  108. package/dist/runtime/secrets.d.ts +27 -0
  109. package/dist/runtime/secrets.js +51 -0
  110. package/dist/runtime/state.d.ts +2 -2
  111. package/dist/runtime/state.js +238 -26
  112. package/dist/runtime/stealth.d.ts +5 -3
  113. package/dist/runtime/stealth.js +423 -143
  114. package/dist/runtime/stt.d.ts +1 -1
  115. package/dist/runtime/stt.js +11 -15
  116. package/dist/runtime/trace.d.ts +2 -2
  117. package/dist/runtime/trace.js +2 -4
  118. package/dist/runtime/waterfall.d.ts +1 -1
  119. package/dist/schema.d.ts +1 -1
  120. package/dist/schema.js +7 -15
  121. package/dist/serve.d.ts +1 -1
  122. package/dist/serve.js +1 -1
  123. package/dist/server/index.d.ts +7 -7
  124. package/dist/server/index.js +6 -6
  125. package/dist/server/self-test-input-tokens.d.ts +2 -1
  126. package/dist/server/self-test-input-tokens.js +18 -14
  127. package/dist/server/self-test-redaction.d.ts +1 -1
  128. package/dist/server/self-test-redaction.js +1 -1
  129. package/dist/server/self-test.d.ts +104 -3
  130. package/dist/server/self-test.js +673 -115
  131. package/dist/server/serve.d.ts +116 -4
  132. package/dist/server/serve.js +799 -128
  133. package/dist/server/types.d.ts +34 -9
  134. package/dist/server/types.js +8 -1
  135. package/dist/stateful/errors.d.ts +14 -0
  136. package/dist/stateful/errors.js +14 -0
  137. package/dist/stateful/http-provider-event-emitter.d.ts +40 -0
  138. package/dist/stateful/http-provider-event-emitter.js +237 -0
  139. package/dist/stateful/http-session-owner-registry.d.ts +44 -0
  140. package/dist/stateful/http-session-owner-registry.js +210 -0
  141. package/dist/stateful/index.d.ts +18 -0
  142. package/dist/stateful/index.js +18 -0
  143. package/dist/stateful/provider-event-delivery-failures.d.ts +32 -0
  144. package/dist/stateful/provider-event-delivery-failures.js +43 -0
  145. package/dist/stateful/provider-event-pipeline-metrics.d.ts +46 -0
  146. package/dist/stateful/provider-event-pipeline-metrics.js +48 -0
  147. package/dist/stateful/provider-event-pipeline.d.ts +50 -0
  148. package/dist/stateful/provider-event-pipeline.js +1 -0
  149. package/dist/stateful/provider-events.d.ts +101 -0
  150. package/dist/stateful/provider-events.js +289 -0
  151. package/dist/stateful/session-key.d.ts +15 -0
  152. package/dist/stateful/session-key.js +86 -0
  153. package/dist/stateful/stateful-provider-adapter-context.d.ts +5 -0
  154. package/dist/stateful/stateful-provider-adapter-context.js +42 -0
  155. package/dist/stateful/stateful-provider-adapter-metrics.d.ts +15 -0
  156. package/dist/stateful/stateful-provider-adapter-metrics.js +21 -0
  157. package/dist/stateful/stateful-provider-adapter.d.ts +98 -0
  158. package/dist/stateful/stateful-provider-adapter.js +287 -0
  159. package/dist/stateful/stateful-provider-observability.d.ts +62 -0
  160. package/dist/stateful/stateful-provider-observability.js +161 -0
  161. package/dist/stateful/stateful-provider-owner-forwarder.d.ts +41 -0
  162. package/dist/stateful/stateful-provider-owner-forwarder.js +215 -0
  163. package/dist/stateful/stateful-provider-runtime-context.d.ts +32 -0
  164. package/dist/stateful/stateful-provider-runtime-context.js +60 -0
  165. package/dist/stateful/stateful-provider-runtime-executor.d.ts +34 -0
  166. package/dist/stateful/stateful-provider-runtime-executor.js +52 -0
  167. package/dist/stateful/stateful-provider-session-routing.d.ts +71 -0
  168. package/dist/stateful/stateful-provider-session-routing.js +353 -0
  169. package/dist/stateful/stateful-provider-session-runtime.d.ts +98 -0
  170. package/dist/stateful/stateful-provider-session-runtime.js +245 -0
  171. package/dist/stateful-signing.d.ts +18 -0
  172. package/dist/stateful-signing.js +27 -0
  173. package/dist/stealth/profiles.d.ts +1 -1
  174. package/dist/stealth/profiles.js +5 -14
  175. package/dist/stream-evidence.d.ts +74 -0
  176. package/dist/stream-evidence.js +785 -0
  177. package/dist/stream.d.ts +1 -1
  178. package/dist/testing/index.d.ts +2 -2
  179. package/dist/testing/index.js +2 -2
  180. package/dist/testing/run.d.ts +32 -2
  181. package/dist/testing/run.js +478 -28
  182. package/dist/types.d.ts +342 -14
  183. package/dist/types.js +1 -0
  184. package/dist/user-input.d.ts +30 -0
  185. package/dist/user-input.js +66 -0
  186. package/package.json +16 -5
  187. package/src/auth-turn/index.ts +1 -1
  188. package/src/auth.ts +28 -86
  189. package/src/ceremonies/index.ts +103 -78
  190. package/src/cli/commands.ts +10 -0
  191. package/src/cli/create.ts +42 -35
  192. package/src/cli/prompt-assets.ts +865 -0
  193. package/src/cli/templates/provider/AGENTS.md.tpl +17 -8
  194. package/src/config/loader.ts +652 -204
  195. package/src/contract-serialization.ts +5 -11
  196. package/src/contract-types.ts +2 -2
  197. package/src/contract.ts +12 -28
  198. package/src/define.ts +499 -498
  199. package/src/dev.ts +4 -9
  200. package/src/error-resolution.ts +91 -0
  201. package/src/errors.ts +110 -5
  202. package/src/fixture-sanitization.ts +247 -0
  203. package/src/i18n/catalog.ts +10 -32
  204. package/src/i18n/index.ts +2 -2
  205. package/src/i18n/keys.ts +5 -11
  206. package/src/index.ts +112 -42
  207. package/src/lint.ts +88 -152
  208. package/src/native-address.ts +340 -0
  209. package/src/native-egress-policy.ts +358 -0
  210. package/src/observability.ts +51 -1
  211. package/src/provider.ts +66 -11
  212. package/src/public-schema-field-lint.ts +7 -33
  213. package/src/recipes/gov-api.ts +2 -5
  214. package/src/runtime/auth-flow.ts +7 -7
  215. package/src/runtime/browser.ts +64 -187
  216. package/src/runtime/cache.ts +20 -67
  217. package/src/runtime/choice.ts +79 -132
  218. package/src/runtime/credential.ts +2 -2
  219. package/src/runtime/env.ts +1 -1
  220. package/src/runtime/executor.ts +43 -20
  221. package/src/runtime/http.ts +494 -57
  222. package/src/runtime/insights.ts +15 -53
  223. package/src/runtime/instrumentation.ts +495 -66
  224. package/src/runtime/keyring.ts +7 -19
  225. package/src/runtime/namespace.ts +2 -7
  226. package/src/runtime/native-network.ts +1686 -0
  227. package/src/runtime/otlp.ts +12 -23
  228. package/src/runtime/perf.ts +1 -1
  229. package/src/runtime/provider.ts +4 -9
  230. package/src/runtime/proxy-errors.ts +29 -42
  231. package/src/runtime/proxy-nodemaven.ts +221 -0
  232. package/src/runtime/proxy-retry-policy.ts +3 -3
  233. package/src/runtime/proxy-telemetry.ts +79 -77
  234. package/src/runtime/redirects.ts +66 -0
  235. package/src/runtime/redis.ts +4 -12
  236. package/src/runtime/request-options.ts +679 -9
  237. package/src/runtime/secrets.ts +64 -0
  238. package/src/runtime/state.ts +353 -133
  239. package/src/runtime/stealth.ts +505 -154
  240. package/src/runtime/stt.ts +38 -94
  241. package/src/runtime/trace.ts +14 -44
  242. package/src/runtime/waterfall.ts +5 -18
  243. package/src/schema.ts +23 -84
  244. package/src/serve.ts +1 -1
  245. package/src/server/index.ts +29 -7
  246. package/src/server/self-test-input-tokens.ts +29 -14
  247. package/src/server/self-test-redaction.ts +2 -2
  248. package/src/server/self-test.ts +857 -132
  249. package/src/server/serve.ts +1151 -328
  250. package/src/server/types.ts +12 -13
  251. package/src/stateful/README.md +146 -0
  252. package/src/stateful/errors.ts +23 -0
  253. package/src/stateful/http-provider-event-emitter.ts +314 -0
  254. package/src/stateful/http-session-owner-registry.ts +306 -0
  255. package/src/stateful/index.ts +18 -0
  256. package/src/stateful/provider-event-delivery-failures.ts +80 -0
  257. package/src/stateful/provider-event-pipeline-metrics.ts +95 -0
  258. package/src/stateful/provider-event-pipeline.ts +61 -0
  259. package/src/stateful/provider-events.ts +462 -0
  260. package/src/stateful/session-key.ts +111 -0
  261. package/src/stateful/stateful-provider-adapter-context.ts +59 -0
  262. package/src/stateful/stateful-provider-adapter-metrics.ts +48 -0
  263. package/src/stateful/stateful-provider-adapter.ts +562 -0
  264. package/src/stateful/stateful-provider-observability.ts +261 -0
  265. package/src/stateful/stateful-provider-owner-forwarder.ts +287 -0
  266. package/src/stateful/stateful-provider-runtime-context.ts +92 -0
  267. package/src/stateful/stateful-provider-runtime-executor.ts +96 -0
  268. package/src/stateful/stateful-provider-session-routing.ts +555 -0
  269. package/src/stateful/stateful-provider-session-runtime.ts +403 -0
  270. package/src/stateful-signing.ts +46 -0
  271. package/src/stealth/profiles.ts +10 -26
  272. package/src/stream-evidence.ts +988 -0
  273. package/src/stream.ts +8 -19
  274. package/src/testing/index.ts +10 -2
  275. package/src/testing/run.ts +653 -74
  276. package/src/types.ts +408 -28
  277. package/src/user-input.ts +118 -0
  278. package/dist/cli/templates/provider/CLAUDE.md.tpl +0 -1
  279. package/src/cli/templates/provider/CLAUDE.md.tpl +0 -1
  280. /package/dist/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  281. /package/dist/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  282. /package/dist/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  283. /package/dist/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  284. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  285. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
  286. /package/src/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  287. /package/src/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  288. /package/src/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  289. /package/src/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  290. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  291. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
@@ -2,22 +2,39 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { existsSync } from "node:fs";
3
3
  import path from "node:path";
4
4
 
5
- import Redis from "ioredis";
6
-
7
- import type { ProviderProxyPolicy, TraceConfig } from "../types";
8
-
5
+ import { Redis } from "ioredis";
6
+
7
+ import type { ProviderProxyPolicy, ProviderProxyProvider, TraceConfig } from "../types.js";
8
+ import {
9
+ NODEMAVEN_DEFAULT_PROTOCOL,
10
+ NODEMAVEN_FILTER_ENV,
11
+ NODEMAVEN_MAX_POOL_SIZE,
12
+ NODEMAVEN_PASSWORD_ENV,
13
+ NODEMAVEN_USERNAME_ENV,
14
+ type ProxyProtocol,
15
+ hasNodemavenCredentials,
16
+ nodemavenPoolSize,
17
+ synthesizeNodemavenProxy,
18
+ } from "../runtime/proxy-nodemaven.js";
19
+
20
+ export type { ProxyProtocol } from "../runtime/proxy-nodemaven.js";
21
+
22
+ /** Proxy vendors the SDK resolves natively (as opposed to the static env path). */
23
+ export type ProxyVendorName = "smartproxy" | "nodemaven";
24
+
25
+ // "smartproxy" here is api.smartproxy.org — a residential proxy with an IP
26
+ // extraction API (app_key → raw ip:port pool). It is NOT the company formerly
27
+ // named Smartproxy (smartproxy.com), which rebranded to Decodo in 2025 and is
28
+ // modelled separately as the `decodo` gateway vendor. Do not conflate them.
9
29
  export const SMARTPROXY_APP_KEY_ENV = "APIFUSE__PROXY__SMARTPROXY_APP_KEY";
10
30
  export const SMARTPROXY_MAX_LIFETIME_MINUTES = 2000;
11
31
  export const DEFAULT_SMARTPROXY_POOL_SIZE = 20;
12
32
  export const SMARTPROXY_MAX_POOL_SIZE = 20;
13
33
  export const DEFAULT_PROXY_PROVIDER_ENV = "APIFUSE__PROXY__PROVIDER";
14
34
  export const DEFAULT_PROXY_COUNTRY_ENV = "APIFUSE__PROXY__DEFAULT_COUNTRY";
15
- export const DEFAULT_PROXY_LIFETIME_ENV =
16
- "APIFUSE__PROXY__DEFAULT_LIFETIME_MINUTES";
17
- export const PROVIDER_CACHE_REDIS_URL_ENV =
18
- "APIFUSE__PROVIDER__CACHE_REDIS_URL";
19
- export const PROVIDER_STATE_REDIS_URL_ENV =
20
- "APIFUSE__PROVIDER__STATE_REDIS_URL";
35
+ export const DEFAULT_PROXY_LIFETIME_ENV = "APIFUSE__PROXY__DEFAULT_LIFETIME_MINUTES";
36
+ export const PROVIDER_CACHE_REDIS_URL_ENV = "APIFUSE__PROVIDER__CACHE_REDIS_URL";
37
+ export const PROVIDER_STATE_REDIS_URL_ENV = "APIFUSE__PROVIDER__STATE_REDIS_URL";
21
38
  export const REDIS_URL_ENV = "APIFUSE__REDIS__URL";
22
39
 
23
40
  export type ProxyOptions = {
@@ -55,6 +72,26 @@ export type ProxyResolutionOptions = {
55
72
  affinityKey?: string;
56
73
  /** Zero-based proxy-pool attempt index used by SDK transports for failover. */
57
74
  proxyAttempt?: number;
75
+ /**
76
+ * Tunnelling protocols the calling transport can use. When a resolved
77
+ * protocol is not in this set the resolver fails with
78
+ * `PROXY_PROTOCOL_UNSUPPORTED` instead of silently downgrading. Unset means
79
+ * permissive (both protocols allowed).
80
+ */
81
+ transportProtocols?: readonly ProxyProtocol[];
82
+ /**
83
+ * Explicit protocol override. Internal — for the verification harness and
84
+ * tests, or an advanced caller. Normal callers omit it and each vendor uses
85
+ * its own benchmarked default protocol (see VENDOR_DEFAULT_PROTOCOL). Not an
86
+ * env var and not a provider-policy field.
87
+ */
88
+ protocol?: ProxyProtocol;
89
+ /**
90
+ * Gateway pool "refresh" generation. Bumped by transports on pool refresh to
91
+ * derive a fresh gateway session set (ignored by allocation-style vendors,
92
+ * whose refresh is driven by cache invalidation).
93
+ */
94
+ proxyRefreshEpoch?: number;
58
95
  telemetry?: ProxyTelemetrySink;
59
96
  };
60
97
 
@@ -77,7 +114,8 @@ export type SmartproxyAllocatorBodyClass =
77
114
  | "usable_proxy_endpoints";
78
115
 
79
116
  export type ProxyResolutionTelemetryEvent = {
80
- provider: "smartproxy";
117
+ provider: ProxyVendorName;
118
+ protocol?: ProxyProtocol;
81
119
  cacheStatus: ProxyCacheStatus;
82
120
  cacheHit: boolean;
83
121
  resolutionMs: number;
@@ -95,7 +133,7 @@ export type ProxyResolutionTelemetryEvent = {
95
133
  };
96
134
 
97
135
  export type ProxyAttemptTelemetryEvent = {
98
- provider: "smartproxy";
136
+ provider: ProxyVendorName;
99
137
  attempt: number;
100
138
  poolIndex?: number;
101
139
  proxyHash?: string;
@@ -105,31 +143,69 @@ export type ProxyAttemptTelemetryEvent = {
105
143
  durationMs?: number;
106
144
  };
107
145
 
146
+ export type ProxyVendorFailoverTelemetryEvent = {
147
+ /** Vendor that failed or was skipped. */
148
+ vendor: ProxyVendorName;
149
+ /** Vendor tried next, or undefined when the chain is exhausted. */
150
+ nextVendor?: ProxyVendorName;
151
+ phase: "resolution" | "transport";
152
+ reason: "no_credentials" | "allocation_failed" | "pool_exhausted" | "protocol_unsupported";
153
+ attempt?: number;
154
+ };
155
+
108
156
  export type ProxyTelemetrySink = {
109
157
  recordProxyResolution(event: ProxyResolutionTelemetryEvent): void;
110
158
  recordProxyAttempt?(event: ProxyAttemptTelemetryEvent): void;
159
+ recordProxyVendorFailover?(event: ProxyVendorFailoverTelemetryEvent): void;
111
160
  };
112
161
 
162
+ export type ProxyResolutionSource =
163
+ | "explicit"
164
+ | "env"
165
+ | "config"
166
+ | "smartproxy-allocator"
167
+ | "nodemaven-gateway";
168
+
113
169
  export type ResolvedProxyConfig = {
114
170
  shouldWarn: boolean;
115
171
  url?: string;
116
- source?: "explicit" | "env" | "config" | "smartproxy-allocator";
172
+ /** SDK-native vendor that supplied the URL, when applicable. */
173
+ vendor?: ProxyVendorName;
174
+ source?: ProxyResolutionSource;
175
+ protocol?: ProxyProtocol;
117
176
  diagnostics?: Record<string, string | number | boolean>;
118
177
  };
119
178
 
179
+ export type ProxyResolutionErrorCode =
180
+ | "PROXY_REQUIRED"
181
+ | "PROXY_ALLOCATION_FAILED"
182
+ | "PROXY_PROTOCOL_UNSUPPORTED";
183
+
120
184
  export class ProxyResolutionError extends Error {
121
- readonly code: "PROXY_REQUIRED" | "PROXY_ALLOCATION_FAILED";
185
+ readonly code: ProxyResolutionErrorCode;
122
186
  readonly telemetry?: ProxyResolutionTelemetryEvent;
187
+ readonly vendor?: ProxyVendorName;
188
+ readonly vendorChain?: ProxyVendorName[];
189
+ readonly protocol?: ProxyProtocol;
123
190
 
124
191
  constructor(
125
- code: "PROXY_REQUIRED" | "PROXY_ALLOCATION_FAILED",
192
+ code: ProxyResolutionErrorCode,
126
193
  message: string,
127
- options?: { cause?: unknown; telemetry?: ProxyResolutionTelemetryEvent },
194
+ options?: {
195
+ cause?: unknown;
196
+ telemetry?: ProxyResolutionTelemetryEvent;
197
+ vendor?: ProxyVendorName;
198
+ vendorChain?: ProxyVendorName[];
199
+ protocol?: ProxyProtocol;
200
+ },
128
201
  ) {
129
202
  super(message, options);
130
203
  this.name = "ProxyResolutionError";
131
204
  this.code = code;
132
205
  this.telemetry = options?.telemetry;
206
+ this.vendor = options?.vendor;
207
+ this.vendorChain = options?.vendorChain;
208
+ this.protocol = options?.protocol;
133
209
  }
134
210
  }
135
211
 
@@ -165,8 +241,7 @@ const SMARTPROXY_LOCK_POLL_MAX_MS = 9_000;
165
241
  const SMARTPROXY_DEADLINE_MARGIN_MS = 1_000;
166
242
  const SMARTPROXY_INVALIDATION_SKIP_REDIS_MS = 30_000;
167
243
  const SMARTPROXY_ALLOCATOR_MAX_ATTEMPTS = 3;
168
- const SMARTPROXY_ALLOCATOR_DEADLINE_MS =
169
- SMARTPROXY_LOCK_TTL_MS - SMARTPROXY_DEADLINE_MARGIN_MS;
244
+ const SMARTPROXY_ALLOCATOR_DEADLINE_MS = SMARTPROXY_LOCK_TTL_MS - SMARTPROXY_DEADLINE_MARGIN_MS;
170
245
  const SMARTPROXY_ALLOCATOR_RETRY_BASE_MS = 25;
171
246
  // Smartproxy API extraction returns fresh IP:port candidates; the `life`
172
247
  // parameter controls session duration intent, not a hard endpoint lease. Keep
@@ -197,15 +272,11 @@ export function providerStateRedisUrlFromEnv(): string | undefined {
197
272
  }
198
273
 
199
274
  /** @internal Test-only hook for exercising shared proxy-cache behavior. */
200
- export function __setProxyRedisForTests(
201
- redis: ProxyRedisClient | undefined,
202
- ): void {
275
+ export function __setProxyRedisForTests(redis: ProxyRedisClient | undefined): void {
203
276
  proxyRedisForTests = redis;
204
277
  }
205
278
 
206
- export function __setSmartproxyAllocatorDeadlineMsForTests(
207
- deadlineMs: number | undefined,
208
- ): void {
279
+ export function __setSmartproxyAllocatorDeadlineMsForTests(deadlineMs: number | undefined): void {
209
280
  smartproxyAllocatorDeadlineMsForTests = deadlineMs;
210
281
  }
211
282
 
@@ -230,9 +301,7 @@ function getProxyRedis(): ProxyRedisClient | undefined {
230
301
  return redis;
231
302
  }
232
303
 
233
- async function withRedisTimeout<T>(
234
- operation: () => Promise<T>,
235
- ): Promise<T | undefined> {
304
+ async function withRedisTimeout<T>(operation: () => Promise<T>): Promise<T | undefined> {
236
305
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
237
306
  try {
238
307
  const timeout = new Promise<undefined>((resolve) => {
@@ -318,17 +387,11 @@ function isRecord(value: unknown): value is Record<string, unknown> {
318
387
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
319
388
  }
320
389
 
321
- function toProxyDiagnostics(
322
- value: unknown,
323
- ): Record<string, string | number | boolean> | undefined {
390
+ function toProxyDiagnostics(value: unknown): Record<string, string | number | boolean> | undefined {
324
391
  if (!isRecord(value)) return undefined;
325
392
  const diagnostics: Record<string, string | number | boolean> = {};
326
393
  for (const [key, item] of Object.entries(value)) {
327
- if (
328
- typeof item === "string" ||
329
- typeof item === "number" ||
330
- typeof item === "boolean"
331
- ) {
394
+ if (typeof item === "string" || typeof item === "number" || typeof item === "boolean") {
332
395
  diagnostics[key] = item;
333
396
  }
334
397
  }
@@ -405,17 +468,19 @@ function applyStickyProxySession(proxyUrl: string): string {
405
468
  return proxyUrl;
406
469
  }
407
470
 
471
+ // This rewrites sticky-session usernames for a bring-your-own *gateway* URL
472
+ // (APIFUSE__PROXY__URL). The `smartproxy` host here means a smartproxy.com /
473
+ // Decodo-family gateway that authenticates by username — NOT the
474
+ // api.smartproxy.org allocation vendor, whose endpoints are raw ip:port with
475
+ // no credentials and therefore return early above.
408
476
  const host = parsed.hostname.toLowerCase();
409
477
  if (!host.includes("smartproxy") && !host.includes("decodo")) {
410
478
  return proxyUrl;
411
479
  }
412
480
 
413
481
  const username = decodeURIComponent(parsed.username);
414
- const sessionId =
415
- process.env.APIFUSE__PROXY__SESSION_ID?.trim() || "apifuse-shared";
416
- const sessionDuration = readPositiveIntegerEnv(
417
- "APIFUSE__PROXY__SESSION_DURATION",
418
- );
482
+ const sessionId = process.env.APIFUSE__PROXY__SESSION_ID?.trim() || "apifuse-shared";
483
+ const sessionDuration = readPositiveIntegerEnv("APIFUSE__PROXY__SESSION_DURATION");
419
484
  const stickyUsername = host.includes("smartproxy")
420
485
  ? buildSmartproxyUsername(username, sessionId, sessionDuration)
421
486
  : buildDecodoUsername(username, sessionId, sessionDuration ?? "60");
@@ -430,27 +495,16 @@ function buildSmartproxyUsername(
430
495
  sessionDuration?: string,
431
496
  ): string {
432
497
  const parts = username.split("_");
433
- const configuredLife = parts
434
- .find((part) => part.startsWith("life-"))
435
- ?.slice("life-".length);
498
+ const configuredLife = parts.find((part) => part.startsWith("life-"))?.slice("life-".length);
436
499
  const baseUsername = parts
437
500
  .filter((part) => !part.startsWith("session-") && !part.startsWith("life-"))
438
501
  .join("_");
439
502
  return `${baseUsername}_session-${sessionId}_life-${sessionDuration ?? configuredLife ?? "60"}`;
440
503
  }
441
504
 
442
- function buildDecodoUsername(
443
- username: string,
444
- sessionId: string,
445
- sessionDuration: string,
446
- ): string {
447
- const withoutSticky = username.replace(
448
- /-session-.+-sessionduration-\d+$/,
449
- "",
450
- );
451
- const baseUsername = withoutSticky.startsWith("user-")
452
- ? withoutSticky
453
- : `user-${withoutSticky}`;
505
+ function buildDecodoUsername(username: string, sessionId: string, sessionDuration: string): string {
506
+ const withoutSticky = username.replace(/-session-.+-sessionduration-\d+$/, "");
507
+ const baseUsername = withoutSticky.startsWith("user-") ? withoutSticky : `user-${withoutSticky}`;
454
508
  return `${baseUsername}-session-${sessionId}-sessionduration-${sessionDuration}`;
455
509
  }
456
510
 
@@ -461,9 +515,7 @@ function syncProxyEnv(config: ApiFuseConfig): void {
461
515
  }
462
516
  }
463
517
 
464
- export function resolveProxyConfig(
465
- options: ProxyResolutionOptions = {},
466
- ): ResolvedProxyConfig {
518
+ export function resolveProxyConfig(options: ProxyResolutionOptions = {}): ResolvedProxyConfig {
467
519
  const explicitProxyUrl = normalizeProxyUrl(options.proxy);
468
520
  if (explicitProxyUrl) {
469
521
  return { shouldWarn: false, url: explicitProxyUrl };
@@ -485,9 +537,7 @@ export function resolveProxyConfig(
485
537
  return { shouldWarn: false, url: envProxyUrl };
486
538
  }
487
539
 
488
- const configuredProxyUrl = normalizeProxyUrl(
489
- options.apifuseConfig?.proxy?.url,
490
- );
540
+ const configuredProxyUrl = normalizeProxyUrl(options.apifuseConfig?.proxy?.url);
491
541
  if (configuredProxyUrl) {
492
542
  return { shouldWarn: false, url: configuredProxyUrl };
493
543
  }
@@ -511,68 +561,273 @@ export async function resolveProxyConfigAsync(
511
561
  return { shouldWarn: false };
512
562
  }
513
563
 
514
- const provider = resolveProxyProvider(policy);
515
- if (provider !== "smartproxy") {
564
+ const chain = resolveVendorChain(policy);
565
+ if (chain.length === 0) {
566
+ // decodo/custom/env-static providers keep the legacy static-URL path.
516
567
  return resolveProxyConfig({
517
568
  ...options,
518
569
  upstream: { proxy: true },
519
570
  });
520
571
  }
521
572
 
522
- const appKey = process.env[SMARTPROXY_APP_KEY_ENV]?.trim();
523
- if (!appKey) {
524
- if (policy.mode === "required") {
573
+ // Protocol is chosen per vendor (each vendor's benchmarked-best), with an
574
+ // optional explicit override for the harness/tests. Both are tunnelling
575
+ // schemes. transportProtocols is what the calling transport can actually use.
576
+ const transportProtocols = options.transportProtocols ?? (["http", "socks5"] as const);
577
+
578
+ const sizes = chain.map((vendor) => vendorPoolSize(vendor, policy));
579
+ const total = sizes.reduce((sum, size) => sum + size, 0);
580
+ const normalizedAttempt = normalizeAttemptIndex(options.proxyAttempt);
581
+ const { vendorIndex: startVendorIndex, poolIndex: startPoolIndex } = mapFlatAttempt(
582
+ total > 0 ? normalizedAttempt % total : 0,
583
+ sizes,
584
+ );
585
+ const refreshEpoch = normalizeAttemptIndex(options.proxyRefreshEpoch);
586
+
587
+ let lastError: unknown;
588
+ let blockedProtocol: ProxyProtocol | undefined;
589
+ for (let vendorIndex = startVendorIndex; vendorIndex < chain.length; vendorIndex++) {
590
+ const vendor = chain[vendorIndex] as ProxyVendorName;
591
+ const nextVendor = chain[vendorIndex + 1];
592
+ const poolIndex = vendorIndex === startVendorIndex ? startPoolIndex : 0;
593
+ const protocol = options.protocol ?? VENDOR_DEFAULT_PROTOCOL[vendor];
594
+
595
+ if (!vendorHasCredentials(vendor)) {
596
+ options.telemetry?.recordProxyVendorFailover?.({
597
+ vendor,
598
+ nextVendor,
599
+ phase: "resolution",
600
+ reason: "no_credentials",
601
+ });
602
+ continue;
603
+ }
604
+
605
+ // The calling transport must be able to use this vendor's protocol; if not,
606
+ // fail over to the next vendor rather than silently downgrading.
607
+ if (!transportProtocols.includes(protocol)) {
608
+ blockedProtocol = protocol;
609
+ options.telemetry?.recordProxyVendorFailover?.({
610
+ vendor,
611
+ nextVendor,
612
+ phase: "resolution",
613
+ reason: "protocol_unsupported",
614
+ });
615
+ continue;
616
+ }
617
+
618
+ try {
619
+ return await resolveWithVendor(vendor, policy, options, {
620
+ protocol,
621
+ poolIndex,
622
+ refreshEpoch,
623
+ });
624
+ } catch (error) {
625
+ // Config/programming errors (invalid filter, etc.) are not vendor
626
+ // outages — propagate them rather than failing over.
627
+ if (!(error instanceof ProxyResolutionError)) {
628
+ throw error;
629
+ }
630
+ if (error.telemetry) {
631
+ options.telemetry?.recordProxyResolution(error.telemetry);
632
+ }
633
+ lastError = error;
634
+ options.telemetry?.recordProxyVendorFailover?.({
635
+ vendor,
636
+ nextVendor,
637
+ phase: "resolution",
638
+ reason: "allocation_failed",
639
+ });
640
+ }
641
+ }
642
+
643
+ if (policy.mode === "required") {
644
+ if (lastError) {
645
+ throw lastError instanceof ProxyResolutionError
646
+ ? lastError
647
+ : new ProxyResolutionError(
648
+ "PROXY_ALLOCATION_FAILED",
649
+ `All proxy vendors [${chain.join(", ")}] failed for required proxy egress.`,
650
+ { cause: lastError, vendorChain: chain },
651
+ );
652
+ }
653
+ if (blockedProtocol) {
525
654
  throw new ProxyResolutionError(
526
- "PROXY_REQUIRED",
527
- `Smartproxy egress is required but ${SMARTPROXY_APP_KEY_ENV} is not configured.`,
655
+ "PROXY_PROTOCOL_UNSUPPORTED",
656
+ `No proxy vendor in [${chain.join(", ")}] could serve a protocol supported by this transport (supports: ${transportProtocols.join(", ")}; vendor wanted "${blockedProtocol}"). Route this provider through the stealth transport.`,
657
+ { protocol: blockedProtocol, vendorChain: chain },
528
658
  );
529
659
  }
530
- return { shouldWarn: true };
660
+ throw new ProxyResolutionError(
661
+ "PROXY_REQUIRED",
662
+ `Proxy egress is required but no vendor credentials are configured. Missing: ${chain
663
+ .map((vendor) => `${missingCredentialEnv(vendor)} (${vendor})`)
664
+ .join(", ")}.`,
665
+ { vendorChain: chain },
666
+ );
531
667
  }
532
- const lifetimeMinutes = resolveSmartproxyLifetime(policy);
668
+ return { shouldWarn: true };
669
+ }
670
+
671
+ /**
672
+ * Resolve the proxy URL for a provider-owned consumer such as a CAPTCHA solver.
673
+ * Vendor allocation and failover remain owned by the SDK.
674
+ */
675
+ export async function resolveProxy(
676
+ options: ProxyResolutionOptions = {},
677
+ ): Promise<ResolvedProxyConfig> {
678
+ const resolved = await resolveProxyConfigAsync(options);
679
+ const vendor = vendorFromResolvedSource(resolved.source);
680
+ return vendor ? { ...resolved, vendor } : resolved;
681
+ }
533
682
 
683
+ /**
684
+ * Each vendor's default egress protocol, chosen from live KR benchmarks. HTTP
685
+ * CONNECT wins for nodemaven (socks5 adds ~500ms through the gateway) and ties
686
+ * for smartproxy, and is the only protocol ctx.http (Bun native fetch) supports.
687
+ * Override per call via ProxyResolutionOptions.protocol (harness/tests).
688
+ */
689
+ export const VENDOR_DEFAULT_PROTOCOL: Readonly<Record<ProxyVendorName, ProxyProtocol>> = {
690
+ smartproxy: "http",
691
+ nodemaven: NODEMAVEN_DEFAULT_PROTOCOL,
692
+ };
693
+
694
+ /**
695
+ * Guard the No-MITM invariant: a resolved proxy URL must use a tunnelling scheme
696
+ * (http CONNECT or socks5) so the client TLS handshake reaches the origin
697
+ * end-to-end. Anything else would intercept TLS and break fingerprinting.
698
+ */
699
+ export function assertTunnelingScheme(url: string): void {
700
+ let scheme: string;
534
701
  try {
535
- const allocated = await allocateSmartproxy(
536
- policy,
537
- appKey,
538
- lifetimeMinutes,
539
- options.affinityKey,
540
- );
541
- options.telemetry?.recordProxyResolution(allocated.telemetry);
542
- const poolIndex = selectProxyPoolIndex(
543
- allocated.pool.urls.length,
544
- options.proxyAttempt,
702
+ scheme = new URL(url).protocol.replace(/:$/, "").toLowerCase();
703
+ } catch {
704
+ throw new ProxyResolutionError("PROXY_ALLOCATION_FAILED", `Malformed proxy URL: ${url}`);
705
+ }
706
+ if (scheme !== "http" && scheme !== "socks5") {
707
+ throw new ProxyResolutionError(
708
+ "PROXY_ALLOCATION_FAILED",
709
+ `Resolved proxy scheme "${scheme}" is not a tunnelling scheme (expected http or socks5). Refusing to route TLS through a non-tunnelling proxy.`,
545
710
  );
711
+ }
712
+ }
713
+
714
+ export type ProxyVendorResolutionContext = {
715
+ readonly protocol: ProxyProtocol;
716
+ readonly poolIndex: number;
717
+ readonly refreshEpoch: number;
718
+ /** Explicit vendor credentials. Omit only on the legacy ambient-env path. */
719
+ readonly credentials?: Readonly<Record<string, string>>;
720
+ /** Disable non-policy env defaults for deterministic injected adapters. */
721
+ readonly ambientDefaults?: boolean;
722
+ /** Disable env-discovered Redis sharing for deterministic injected adapters. */
723
+ readonly sharedCache?: boolean;
724
+ };
725
+
726
+ export async function resolveWithVendor(
727
+ vendor: ProxyVendorName,
728
+ policy: ProviderProxyPolicy,
729
+ options: ProxyResolutionOptions,
730
+ context: ProxyVendorResolutionContext,
731
+ ): Promise<ResolvedProxyConfig> {
732
+ if (vendor === "nodemaven") {
733
+ const startedAt = Date.now();
734
+ const username = (
735
+ context.credentials === undefined
736
+ ? process.env[NODEMAVEN_USERNAME_ENV]
737
+ : context.credentials[NODEMAVEN_USERNAME_ENV]
738
+ )?.trim();
739
+ const password = (
740
+ context.credentials === undefined
741
+ ? process.env[NODEMAVEN_PASSWORD_ENV]
742
+ : context.credentials[NODEMAVEN_PASSWORD_ENV]
743
+ )?.trim();
744
+ const filter =
745
+ context.credentials === undefined
746
+ ? process.env[NODEMAVEN_FILTER_ENV]
747
+ : context.credentials[NODEMAVEN_FILTER_ENV];
748
+ if (!username || !password) {
749
+ throw new ProxyResolutionError(
750
+ "PROXY_ALLOCATION_FAILED",
751
+ `NodeMaven credentials missing: set ${NODEMAVEN_USERNAME_ENV} and ${NODEMAVEN_PASSWORD_ENV}.`,
752
+ { vendor: "nodemaven" },
753
+ );
754
+ }
755
+ const synthesized = synthesizeNodemavenProxy({
756
+ policy,
757
+ credentials: {
758
+ username,
759
+ password,
760
+ ...(filter ? { filter } : {}),
761
+ },
762
+ affinityKey: options.affinityKey,
763
+ protocol: context.protocol,
764
+ poolIndex: context.poolIndex,
765
+ refreshEpoch: context.refreshEpoch,
766
+ country: resolveSmartproxyCountry(policy, context.ambientDefaults !== false),
767
+ });
768
+ options.telemetry?.recordProxyResolution({
769
+ provider: "nodemaven",
770
+ protocol: synthesized.protocol,
771
+ cacheStatus: "disabled",
772
+ cacheHit: false,
773
+ resolutionMs: Math.max(0, Date.now() - startedAt),
774
+ attempts: 1,
775
+ });
776
+ assertTunnelingScheme(synthesized.url);
546
777
  return {
547
778
  shouldWarn: false,
548
- url: allocated.pool.urls[poolIndex],
549
- source: "smartproxy-allocator",
779
+ url: synthesized.url,
780
+ source: "nodemaven-gateway",
781
+ protocol: synthesized.protocol,
550
782
  diagnostics: {
551
- ...allocated.pool.diagnostics,
552
- poolSize: allocated.pool.urls.length,
553
- poolIndex,
783
+ ...synthesized.diagnostics,
784
+ poolIndex: context.poolIndex,
554
785
  },
555
786
  };
556
- } catch (error) {
557
- if (error instanceof ProxyResolutionError && error.telemetry) {
558
- options.telemetry?.recordProxyResolution(error.telemetry);
559
- }
560
- if (policy.mode === "required") {
561
- throw error instanceof ProxyResolutionError
562
- ? error
563
- : new ProxyResolutionError(
564
- "PROXY_ALLOCATION_FAILED",
565
- "Smartproxy allocator failed for required proxy egress.",
566
- { cause: error },
567
- );
568
- }
569
- return { shouldWarn: true };
570
787
  }
788
+
789
+ // smartproxy allocation-style vendor.
790
+ const appKey = (
791
+ context.credentials === undefined
792
+ ? process.env[SMARTPROXY_APP_KEY_ENV]
793
+ : context.credentials[SMARTPROXY_APP_KEY_ENV]
794
+ )?.trim();
795
+ if (!appKey) {
796
+ // Guarded by vendorHasCredentials; treated as a vendor-internal failure.
797
+ throw new ProxyResolutionError(
798
+ "PROXY_ALLOCATION_FAILED",
799
+ `${SMARTPROXY_APP_KEY_ENV} is not configured.`,
800
+ { vendor: "smartproxy" },
801
+ );
802
+ }
803
+ const lifetimeMinutes = resolveSmartproxyLifetime(policy, context.ambientDefaults !== false);
804
+ const allocated = await allocateSmartproxy(
805
+ policy,
806
+ appKey,
807
+ lifetimeMinutes,
808
+ options.affinityKey,
809
+ context.protocol,
810
+ context.ambientDefaults !== false,
811
+ context.sharedCache !== false,
812
+ );
813
+ options.telemetry?.recordProxyResolution({ ...allocated.telemetry, protocol: context.protocol });
814
+ const poolIndex = selectProxyPoolIndex(allocated.pool.urls.length, context.poolIndex);
815
+ const url = allocated.pool.urls[poolIndex];
816
+ if (url) assertTunnelingScheme(url);
817
+ return {
818
+ shouldWarn: false,
819
+ url,
820
+ source: "smartproxy-allocator",
821
+ protocol: context.protocol,
822
+ diagnostics: {
823
+ ...allocated.pool.diagnostics,
824
+ poolSize: allocated.pool.urls.length,
825
+ poolIndex,
826
+ },
827
+ };
571
828
  }
572
829
 
573
- function resolvePolicy(
574
- options: ProxyResolutionOptions,
575
- ): ProviderProxyPolicy | undefined {
830
+ function resolvePolicy(options: ProxyResolutionOptions): ProviderProxyPolicy | undefined {
576
831
  if (options.proxyPolicy) {
577
832
  return options.proxyPolicy;
578
833
  }
@@ -583,32 +838,217 @@ function resolvePolicy(
583
838
  return undefined;
584
839
  }
585
840
 
586
- function resolveProxyProvider(policy: ProviderProxyPolicy): string {
587
- return (
588
- policy.provider ??
589
- process.env[DEFAULT_PROXY_PROVIDER_ENV]?.trim().toLowerCase() ??
590
- "custom"
591
- );
841
+ function isRegistryVendor(name: string | undefined): name is ProxyVendorName {
842
+ return name === "smartproxy" || name === "nodemaven";
843
+ }
844
+
845
+ /**
846
+ * Ordered list of SDK-native proxy vendors declared by the policy. `providers`
847
+ * takes precedence over the legacy singular `provider`; the platform default
848
+ * env is the final fallback. Non-registry names (decodo/custom) are dropped so
849
+ * an all-static chain falls through to the legacy env-URL path unchanged.
850
+ */
851
+ export function resolveVendorChain(policy: ProviderProxyPolicy): ProxyVendorName[] {
852
+ const declared: (ProviderProxyProvider | undefined)[] = policy.providers?.length
853
+ ? policy.providers
854
+ : [policy.provider ?? envDefaultProvider()];
855
+ const chain: ProxyVendorName[] = [];
856
+ for (const name of declared) {
857
+ if (isRegistryVendor(name) && !chain.includes(name)) {
858
+ chain.push(name);
859
+ }
860
+ }
861
+ return chain;
862
+ }
863
+
864
+ function envDefaultProvider(): ProviderProxyProvider | undefined {
865
+ const raw = process.env[DEFAULT_PROXY_PROVIDER_ENV]?.trim().toLowerCase();
866
+ return (raw as ProviderProxyProvider | undefined) ?? undefined;
867
+ }
868
+
869
+ function vendorHasCredentials(vendor: ProxyVendorName): boolean {
870
+ if (vendor === "nodemaven") return hasNodemavenCredentials();
871
+ return Boolean(process.env[SMARTPROXY_APP_KEY_ENV]?.trim());
872
+ }
873
+
874
+ function missingCredentialEnv(vendor: ProxyVendorName): string {
875
+ return vendor === "nodemaven" ? "APIFUSE__PROXY__NODEMAVEN_USERNAME" : SMARTPROXY_APP_KEY_ENV;
876
+ }
877
+
878
+ function vendorPoolSize(vendor: ProxyVendorName, policy: ProviderProxyPolicy): number {
879
+ return vendor === "nodemaven" ? nodemavenPoolSize(policy) : resolveSmartproxyPoolSize(policy);
880
+ }
881
+
882
+ /**
883
+ * Total attempt span across a policy's vendor chain — the sum of each vendor's
884
+ * pool size. Transports use this so successive attempts rotate a vendor's pool
885
+ * and then fail over to the next vendor via the flat attempt index. With one
886
+ * vendor this equals that vendor's pool size (today's behaviour).
887
+ */
888
+ export function resolvePolicyProxyPoolSpan(policy: ProviderProxyPolicy): number {
889
+ const chain = resolveVendorChain(policy);
890
+ if (chain.length === 0) return resolveSmartproxyPoolSize(policy);
891
+ return chain.reduce((sum, vendor) => sum + vendorPoolSize(vendor, policy), 0);
892
+ }
893
+
894
+ function vendorMaxPoolSize(vendor: ProxyVendorName): number {
895
+ return vendor === "nodemaven" ? NODEMAVEN_MAX_POOL_SIZE : SMARTPROXY_MAX_POOL_SIZE;
896
+ }
897
+
898
+ /**
899
+ * Absolute upper bound on a chain's attempt span — the sum of each vendor's
900
+ * *maximum* pool size. Unlike `resolvePolicyProxyPoolSpan` (the configured
901
+ * span), this backstop is independent of `session.poolSize`, so it never
902
+ * truncates a legitimately large pool below the point where the flat attempt
903
+ * index would cross into the next vendor (e.g. a 50-slot NodeMaven pool).
904
+ */
905
+ export function maxPolicyProxyPoolSpan(policy: ProviderProxyPolicy): number {
906
+ const chain = resolveVendorChain(policy);
907
+ if (chain.length === 0) return SMARTPROXY_MAX_POOL_SIZE;
908
+ return chain.reduce((sum, vendor) => sum + vendorMaxPoolSize(vendor), 0);
909
+ }
910
+
911
+ const UNSAFE_TRANSPORT_RETRY_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE", "TRACE"]);
912
+
913
+ /**
914
+ * Transport-retry attempt cap for a policy-managed request. A transport failure
915
+ * rotates the flat attempt index onto the *next* endpoint (and, once the index
916
+ * passes the primary vendor's pool span, the *next vendor*), so the cap must be
917
+ * the chain's full pool span for failover to reach the fallback vendor — the
918
+ * per-endpoint retry budget (default 3) never gets there.
919
+ *
920
+ * The span only widens beyond the caller's retry budget when ALL hold:
921
+ * - the request is policy-allocator managed (not a caller-supplied proxy URL);
922
+ * - the caller did NOT pin an explicit retry policy — `HttpRetryOptions.attempts`
923
+ * is the documented total-attempt ceiling and must be honoured verbatim;
924
+ * - the method is safe/idempotent — an unsafe request must never be duplicated
925
+ * across the pool even if some framework default would allow it;
926
+ * - the policy resolves a non-empty *registry* vendor chain (smartproxy /
927
+ * nodemaven). Static vendors (custom / decodo) and credential-less policies
928
+ * resolve no allocator pool, so every attempt would hit the same endpoint
929
+ * with no possible crossover — they keep the retry budget.
930
+ *
931
+ * The widened cap is bounded by the chain's true maximum span (sum of each
932
+ * vendor's max pool size), so a large NodeMaven pool (≤50) stays reachable and
933
+ * a pathological chain can never spin unbounded.
934
+ */
935
+ /**
936
+ * True when a policy request is in *implicit chain-rotation* mode: successive
937
+ * transport attempts rotate the flat index across the concatenated vendor pool
938
+ * spans (and, past the primary vendor's span, into the fallback vendor). This is
939
+ * the ONLY mode in which the transport loop widens its attempt cap AND
940
+ * de-duplicates repeated endpoints — the two behaviours must share one predicate
941
+ * so they never diverge. It holds when ALL of the widening conditions hold:
942
+ * - the request is policy-allocator managed (not a caller-supplied proxy URL);
943
+ * - the caller did NOT pin an explicit retry policy — its `attempts` ceiling is
944
+ * the documented contract and must be honoured verbatim against whatever
945
+ * endpoint each attempt resolves (even a repeated one), so no de-duplication;
946
+ * - the method is safe/idempotent — an unsafe request is never duplicated;
947
+ * - the policy resolves a non-empty registry vendor chain (smartproxy /
948
+ * nodemaven). Static vendors (custom / decodo) resolve the same URL every
949
+ * attempt, so there is nothing to rotate or de-duplicate.
950
+ */
951
+ export function policyRotatesTransportVendorChain(input: {
952
+ policy: ProviderProxyPolicy | undefined;
953
+ usesPolicyAllocator: boolean;
954
+ explicitRetry: boolean;
955
+ method: string;
956
+ }): boolean {
957
+ if (!input.usesPolicyAllocator || !input.policy || input.explicitRetry) {
958
+ return false;
959
+ }
960
+ if (UNSAFE_TRANSPORT_RETRY_METHODS.has(input.method.toUpperCase())) {
961
+ return false;
962
+ }
963
+ return resolveVendorChain(input.policy).length > 0;
964
+ }
965
+
966
+ export function resolvePolicyTransportAttemptCap(input: {
967
+ policy: ProviderProxyPolicy | undefined;
968
+ usesPolicyAllocator: boolean;
969
+ retryAttempts: number;
970
+ explicitRetry: boolean;
971
+ method: string;
972
+ }): number {
973
+ const budget = Math.max(1, Math.floor(input.retryAttempts));
974
+ if (
975
+ !policyRotatesTransportVendorChain({
976
+ policy: input.policy,
977
+ usesPolicyAllocator: input.usesPolicyAllocator,
978
+ explicitRetry: input.explicitRetry,
979
+ method: input.method,
980
+ })
981
+ ) {
982
+ return budget;
983
+ }
984
+ const span = Math.min(maxPolicyProxyPoolSpan(input.policy as ProviderProxyPolicy), resolvePolicyProxyPoolSpan(input.policy as ProviderProxyPolicy));
985
+ return Math.max(budget, span);
986
+ }
987
+
988
+ /**
989
+ * A registry vendor chain (smartproxy/nodemaven) resolves a potentially
990
+ * *different* endpoint per flat attempt index, so a transport retry should
991
+ * advance across endpoints and de-duplicate once the chain stops yielding new
992
+ * ones. Static/custom/decodo policies (empty registry chain) resolve the *same*
993
+ * URL every attempt by design — retrying that same endpoint is intended, so the
994
+ * transport loop must not de-duplicate them.
995
+ */
996
+ export function policyResolvesRegistryVendorChain(
997
+ policy: ProviderProxyPolicy | undefined,
998
+ ): boolean {
999
+ return Boolean(policy) && resolveVendorChain(policy as ProviderProxyPolicy).length > 0;
1000
+ }
1001
+
1002
+ /** Map a resolved proxy source label to the vendor that served it. */
1003
+ export function vendorFromResolvedSource(
1004
+ source: ResolvedProxyConfig["source"],
1005
+ ): ProxyVendorName | undefined {
1006
+ if (source === "nodemaven-gateway") return "nodemaven";
1007
+ if (source === "smartproxy-allocator") return "smartproxy";
1008
+ return undefined;
1009
+ }
1010
+
1011
+ function normalizeAttemptIndex(attempt: number | undefined): number {
1012
+ return Number.isFinite(attempt) ? Math.max(0, Math.floor(attempt as number)) : 0;
1013
+ }
1014
+
1015
+ /**
1016
+ * Map a flat attempt index into (vendorIndex, poolIndex) by concatenating each
1017
+ * vendor's pool space in chain order. With a single vendor this reduces to
1018
+ * `attempt % poolSize`, preserving today's behaviour exactly.
1019
+ */
1020
+ export function mapFlatAttempt(
1021
+ flat: number,
1022
+ sizes: readonly number[],
1023
+ ): { vendorIndex: number; poolIndex: number } {
1024
+ let cursor = flat;
1025
+ for (let vendorIndex = 0; vendorIndex < sizes.length; vendorIndex++) {
1026
+ const size = Math.max(1, sizes[vendorIndex] ?? 1);
1027
+ if (cursor < size) {
1028
+ return { vendorIndex, poolIndex: cursor };
1029
+ }
1030
+ cursor -= size;
1031
+ }
1032
+ return { vendorIndex: 0, poolIndex: 0 };
592
1033
  }
593
1034
 
594
1035
  function resolveSmartproxyCountry(
595
1036
  policy: ProviderProxyPolicy,
1037
+ ambientDefaults = true,
596
1038
  ): string | undefined {
597
1039
  return (
598
1040
  policy.geo?.country ??
599
- process.env[DEFAULT_PROXY_COUNTRY_ENV]?.trim().toUpperCase() ??
600
- undefined
1041
+ (ambientDefaults
1042
+ ? process.env[DEFAULT_PROXY_COUNTRY_ENV]?.trim().toUpperCase() || undefined
1043
+ : undefined)
601
1044
  );
602
1045
  }
603
1046
 
604
- function resolveSmartproxyLifetime(policy: ProviderProxyPolicy): number {
1047
+ function resolveSmartproxyLifetime(policy: ProviderProxyPolicy, ambientDefaults = true): number {
605
1048
  const configuredLifetime =
606
1049
  policy.session?.lifetimeMinutes ??
607
- readPositiveNumberEnv(DEFAULT_PROXY_LIFETIME_ENV, 30);
608
- return Math.min(
609
- SMARTPROXY_MAX_LIFETIME_MINUTES,
610
- Math.max(1, Math.floor(configuredLifetime)),
611
- );
1050
+ (ambientDefaults ? readPositiveNumberEnv(DEFAULT_PROXY_LIFETIME_ENV, 30) : 30);
1051
+ return Math.min(SMARTPROXY_MAX_LIFETIME_MINUTES, Math.max(1, Math.floor(configuredLifetime)));
612
1052
  }
613
1053
 
614
1054
  function readPositiveNumberEnv(name: string, fallback: number): number {
@@ -624,10 +1064,7 @@ function readPositiveNumberEnv(name: string, fallback: number): number {
624
1064
  function resolveSmartproxyPoolSize(policy: ProviderProxyPolicy): number {
625
1065
  return Math.min(
626
1066
  SMARTPROXY_MAX_POOL_SIZE,
627
- Math.max(
628
- 1,
629
- Math.floor(policy.session?.poolSize ?? DEFAULT_SMARTPROXY_POOL_SIZE),
630
- ),
1067
+ Math.max(1, Math.floor(policy.session?.poolSize ?? DEFAULT_SMARTPROXY_POOL_SIZE)),
631
1068
  );
632
1069
  }
633
1070
 
@@ -635,26 +1072,29 @@ function selectProxyPoolIndex(poolSize: number, attempt = 0): number {
635
1072
  if (poolSize <= 1) {
636
1073
  return 0;
637
1074
  }
638
- const normalizedAttempt = Number.isFinite(attempt)
639
- ? Math.max(0, Math.floor(attempt))
640
- : 0;
1075
+ const normalizedAttempt = Number.isFinite(attempt) ? Math.max(0, Math.floor(attempt)) : 0;
641
1076
  return normalizedAttempt % poolSize;
642
1077
  }
643
1078
 
644
1079
  function buildSmartproxyCacheKey(
645
1080
  policy: ProviderProxyPolicy,
1081
+ appKey: string,
646
1082
  affinityKey: string | undefined,
647
1083
  lifetimeMinutes: number,
1084
+ protocol: ProxyProtocol,
1085
+ ambientDefaults = true,
648
1086
  ): string {
649
1087
  const poolSize = resolveSmartproxyPoolSize(policy);
650
1088
  return JSON.stringify({
651
1089
  provider: "smartproxy",
652
- country: resolveSmartproxyCountry(policy),
1090
+ credentialHash: createHash("sha256")
1091
+ .update("apifuse-smartproxy-credential:v1\0")
1092
+ .update(appKey)
1093
+ .digest("hex"),
1094
+ protocol,
1095
+ country: resolveSmartproxyCountry(policy, ambientDefaults),
653
1096
  affinity: policy.session?.affinity ?? "request",
654
- affinityKey:
655
- (policy.session?.affinity ?? "request") === "request"
656
- ? undefined
657
- : affinityKey,
1097
+ affinityKey: (policy.session?.affinity ?? "request") === "request" ? undefined : affinityKey,
658
1098
  lifetimeMinutes,
659
1099
  poolSize,
660
1100
  });
@@ -665,11 +1105,17 @@ async function allocateSmartproxy(
665
1105
  appKey: string,
666
1106
  lifetimeMinutes: number,
667
1107
  affinityKey: string | undefined,
1108
+ protocol: ProxyProtocol,
1109
+ ambientDefaults = true,
1110
+ sharedCache = true,
668
1111
  ): Promise<SmartproxyAllocationResult> {
669
1112
  const cacheKey = buildSmartproxyCacheKey(
670
1113
  policy,
1114
+ appKey,
671
1115
  affinityKey,
672
1116
  lifetimeMinutes,
1117
+ protocol,
1118
+ ambientDefaults,
673
1119
  );
674
1120
  const startedAt = Date.now();
675
1121
  const now = startedAt;
@@ -678,7 +1124,15 @@ async function allocateSmartproxy(
678
1124
  const cached = proxyCache.get(cacheKey);
679
1125
  if (!skipCached && cached && isFresh(cached, now)) {
680
1126
  if (shouldSoftRefresh(cached, now)) {
681
- void refreshSmartproxyPool(cacheKey, policy, appKey, lifetimeMinutes);
1127
+ void refreshSmartproxyPool(
1128
+ cacheKey,
1129
+ policy,
1130
+ appKey,
1131
+ lifetimeMinutes,
1132
+ protocol,
1133
+ ambientDefaults,
1134
+ sharedCache,
1135
+ );
682
1136
  return {
683
1137
  pool: cached,
684
1138
  telemetry: telemetryForPool(cached, "soft_stale_refresh", startedAt, {
@@ -692,7 +1146,7 @@ async function allocateSmartproxy(
692
1146
  };
693
1147
  }
694
1148
 
695
- if (!skipCached) {
1149
+ if (!skipCached && sharedCache) {
696
1150
  const redisResult = await readSmartproxyRedisPool(cacheKey, startedAt);
697
1151
  if (redisResult) return redisResult;
698
1152
  }
@@ -714,6 +1168,9 @@ async function allocateSmartproxy(
714
1168
  appKey,
715
1169
  lifetimeMinutes,
716
1170
  startedAt,
1171
+ protocol,
1172
+ ambientDefaults,
1173
+ sharedCache,
717
1174
  ).finally(() => {
718
1175
  proxyInflight.delete(cacheKey);
719
1176
  });
@@ -730,9 +1187,7 @@ async function readSmartproxyRedisPool(
730
1187
  const redis = getProxyRedis();
731
1188
  if (!redis || !(await ensureRedisReady(redis))) return null;
732
1189
  const redisStartedAt = Date.now();
733
- const raw = await withRedisTimeout(() =>
734
- redis.get(smartproxyRedisPoolKey(cacheKey)),
735
- );
1190
+ const raw = await withRedisTimeout(() => redis.get(smartproxyRedisPoolKey(cacheKey)));
736
1191
  const redisReadMs = Math.max(0, Date.now() - redisStartedAt);
737
1192
  if (typeof raw !== "string") return null;
738
1193
  const pool = safeParseSmartproxyPool(raw);
@@ -754,6 +1209,9 @@ async function refreshSmartproxyPool(
754
1209
  policy: ProviderProxyPolicy,
755
1210
  appKey: string,
756
1211
  lifetimeMinutes: number,
1212
+ protocol: ProxyProtocol,
1213
+ ambientDefaults: boolean,
1214
+ sharedCache: boolean,
757
1215
  ): Promise<void> {
758
1216
  try {
759
1217
  await allocateSmartproxyShared(
@@ -762,6 +1220,9 @@ async function refreshSmartproxyPool(
762
1220
  appKey,
763
1221
  lifetimeMinutes,
764
1222
  Date.now(),
1223
+ protocol,
1224
+ ambientDefaults,
1225
+ sharedCache,
765
1226
  {
766
1227
  background: true,
767
1228
  },
@@ -777,9 +1238,12 @@ async function allocateSmartproxyShared(
777
1238
  appKey: string,
778
1239
  lifetimeMinutes: number,
779
1240
  startedAt: number,
1241
+ protocol: ProxyProtocol,
1242
+ ambientDefaults: boolean,
1243
+ sharedCache: boolean,
780
1244
  options: { background?: boolean } = {},
781
1245
  ): Promise<SmartproxyAllocationResult> {
782
- const redis = getProxyRedis();
1246
+ const redis = sharedCache ? getProxyRedis() : undefined;
783
1247
  if (!redis || !(await ensureRedisReady(redis))) {
784
1248
  return await allocateAndStoreSmartproxyPool(
785
1249
  cacheKey,
@@ -787,7 +1251,7 @@ async function allocateSmartproxyShared(
787
1251
  appKey,
788
1252
  lifetimeMinutes,
789
1253
  startedAt,
790
- { cacheStatus: "allocator" },
1254
+ { cacheStatus: "allocator", protocol, ambientDefaults },
791
1255
  );
792
1256
  }
793
1257
 
@@ -809,11 +1273,11 @@ async function allocateSmartproxyShared(
809
1273
  lifetimeMinutes,
810
1274
  startedAt,
811
1275
  {
812
- cacheStatus: options.background
813
- ? "soft_stale_refresh"
814
- : "allocator",
1276
+ cacheStatus: options.background ? "soft_stale_refresh" : "allocator",
815
1277
  redis,
816
1278
  poolKey,
1279
+ protocol,
1280
+ ambientDefaults,
817
1281
  },
818
1282
  );
819
1283
  } finally {
@@ -838,15 +1302,10 @@ async function allocateSmartproxyShared(
838
1302
  if (typeof pttl === "number" && pttl <= 0) {
839
1303
  continue;
840
1304
  }
841
- if (
842
- Date.now() - startedAt >
843
- SMARTPROXY_LOCK_POLL_MAX_MS - SMARTPROXY_DEADLINE_MARGIN_MS
844
- ) {
1305
+ if (Date.now() - startedAt > SMARTPROXY_LOCK_POLL_MAX_MS - SMARTPROXY_DEADLINE_MARGIN_MS) {
845
1306
  break;
846
1307
  }
847
- await sleep(
848
- Math.min(500, Math.max(50, typeof pttl === "number" ? pttl : 100)),
849
- );
1308
+ await sleep(Math.min(500, Math.max(50, typeof pttl === "number" ? pttl : 100)));
850
1309
  }
851
1310
 
852
1311
  throw new ProxyResolutionError(
@@ -893,9 +1352,7 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
893
1352
  }
894
1353
 
895
1354
  function smartproxyAllocatorDeadlineMs(): number {
896
- return (
897
- smartproxyAllocatorDeadlineMsForTests ?? SMARTPROXY_ALLOCATOR_DEADLINE_MS
898
- );
1355
+ return smartproxyAllocatorDeadlineMsForTests ?? SMARTPROXY_ALLOCATOR_DEADLINE_MS;
899
1356
  }
900
1357
 
901
1358
  function createDeadlineAbortController(deadlineAt: number): {
@@ -917,9 +1374,7 @@ function createDeadlineAbortController(deadlineAt: number): {
917
1374
  };
918
1375
  }
919
1376
 
920
- function smartproxyAllocatorDeadlineFailure(
921
- attempt: number,
922
- ): SmartproxyAllocatorFailure {
1377
+ function smartproxyAllocatorDeadlineFailure(attempt: number): SmartproxyAllocatorFailure {
923
1378
  return {
924
1379
  ok: false,
925
1380
  attempt,
@@ -978,6 +1433,8 @@ async function allocateAndStoreSmartproxyPool(
978
1433
  cacheStatus: ProxyCacheStatus;
979
1434
  redis?: ProxyRedisClient;
980
1435
  poolKey?: string;
1436
+ protocol: ProxyProtocol;
1437
+ ambientDefaults: boolean;
981
1438
  },
982
1439
  ): Promise<SmartproxyAllocationResult> {
983
1440
  const poolSize = resolveSmartproxyPoolSize(policy);
@@ -986,17 +1443,14 @@ async function allocateAndStoreSmartproxyPool(
986
1443
  appKey,
987
1444
  lifetimeMinutes,
988
1445
  poolSize,
1446
+ options.protocol,
1447
+ options.ambientDefaults,
989
1448
  );
990
1449
  const allocatorStartedAt = Date.now();
991
- const allocatorDeadlineAt =
992
- allocatorStartedAt + smartproxyAllocatorDeadlineMs();
1450
+ const allocatorDeadlineAt = allocatorStartedAt + smartproxyAllocatorDeadlineMs();
993
1451
  let allocation: SmartproxyAllocatorSuccess | undefined;
994
1452
  let lastFailure: SmartproxyAllocatorFailure | undefined;
995
- for (
996
- let attempt = 1;
997
- attempt <= SMARTPROXY_ALLOCATOR_MAX_ATTEMPTS;
998
- attempt += 1
999
- ) {
1453
+ for (let attempt = 1; attempt <= SMARTPROXY_ALLOCATOR_MAX_ATTEMPTS; attempt += 1) {
1000
1454
  if (Date.now() >= allocatorDeadlineAt) {
1001
1455
  lastFailure = smartproxyAllocatorDeadlineFailure(attempt);
1002
1456
  break;
@@ -1005,6 +1459,7 @@ async function allocateAndStoreSmartproxyPool(
1005
1459
  allocatorUrl,
1006
1460
  attempt,
1007
1461
  allocatorDeadlineAt,
1462
+ options.protocol,
1008
1463
  );
1009
1464
  if (attemptResult.ok) {
1010
1465
  allocation = attemptResult;
@@ -1013,8 +1468,7 @@ async function allocateAndStoreSmartproxyPool(
1013
1468
  lastFailure = attemptResult;
1014
1469
  if (attempt < SMARTPROXY_ALLOCATOR_MAX_ATTEMPTS) {
1015
1470
  if (Date.now() >= allocatorDeadlineAt) break;
1016
- const { controller, dispose } =
1017
- createDeadlineAbortController(allocatorDeadlineAt);
1471
+ const { controller, dispose } = createDeadlineAbortController(allocatorDeadlineAt);
1018
1472
  try {
1019
1473
  await sleep(smartproxyAllocatorBackoffMs(attempt), controller.signal);
1020
1474
  } catch {
@@ -1027,9 +1481,7 @@ async function allocateAndStoreSmartproxyPool(
1027
1481
 
1028
1482
  const allocatorMs = Math.max(0, Date.now() - allocatorStartedAt);
1029
1483
  const allocatorAttempts =
1030
- allocation?.attempt ??
1031
- lastFailure?.attempt ??
1032
- SMARTPROXY_ALLOCATOR_MAX_ATTEMPTS;
1484
+ allocation?.attempt ?? lastFailure?.attempt ?? SMARTPROXY_ALLOCATOR_MAX_ATTEMPTS;
1033
1485
  const allocatorBodyClass =
1034
1486
  lastFailure?.bodyClass ?? allocation?.bodyClass ?? "usable_proxy_endpoints";
1035
1487
  const allocatorStatus = lastFailure ? lastFailure.status : allocation?.status;
@@ -1063,7 +1515,7 @@ async function allocateAndStoreSmartproxyPool(
1063
1515
  expiresAt: allocatedAt + ttlMs,
1064
1516
  diagnostics: {
1065
1517
  provider: "smartproxy",
1066
- country: resolveSmartproxyCountry(policy) ?? "default",
1518
+ country: resolveSmartproxyCountry(policy, options.ambientDefaults) ?? "default",
1067
1519
  lifetimeMinutes,
1068
1520
  affinity: policy.session?.affinity ?? "request",
1069
1521
  rawConnect: true,
@@ -1106,9 +1558,7 @@ async function allocateAndStoreSmartproxyPool(
1106
1558
  };
1107
1559
  }
1108
1560
 
1109
- type SmartproxyAllocatorAttemptResult =
1110
- | SmartproxyAllocatorSuccess
1111
- | SmartproxyAllocatorFailure;
1561
+ type SmartproxyAllocatorAttemptResult = SmartproxyAllocatorSuccess | SmartproxyAllocatorFailure;
1112
1562
 
1113
1563
  type SmartproxyAllocatorSuccess = {
1114
1564
  ok: true;
@@ -1130,6 +1580,7 @@ async function fetchSmartproxyAllocatorAttempt(
1130
1580
  allocatorUrl: string,
1131
1581
  attempt: number,
1132
1582
  deadlineAt: number,
1583
+ protocol: ProxyProtocol,
1133
1584
  ): Promise<SmartproxyAllocatorAttemptResult> {
1134
1585
  const { controller, dispose } = createDeadlineAbortController(deadlineAt);
1135
1586
  let response: Response;
@@ -1150,10 +1601,7 @@ async function fetchSmartproxyAllocatorAttempt(
1150
1601
 
1151
1602
  let body: string;
1152
1603
  try {
1153
- body = await readSmartproxyAllocatorBodyWithDeadline(
1154
- response,
1155
- controller.signal,
1156
- );
1604
+ body = await readSmartproxyAllocatorBodyWithDeadline(response, controller.signal);
1157
1605
  } catch (error) {
1158
1606
  dispose();
1159
1607
  return {
@@ -1176,7 +1624,7 @@ async function fetchSmartproxyAllocatorAttempt(
1176
1624
  };
1177
1625
  }
1178
1626
 
1179
- const urls = parseSmartproxyAllocatorProxies(body);
1627
+ const urls = parseSmartproxyAllocatorProxies(body, protocol);
1180
1628
  const bodyClass = classifySmartproxyAllocatorBody(body, urls);
1181
1629
  if (urls.length === 0) {
1182
1630
  return {
@@ -1196,8 +1644,7 @@ async function fetchSmartproxyAllocatorAttempt(
1196
1644
  }
1197
1645
 
1198
1646
  function smartproxyAllocatorBackoffMs(attempt: number): number {
1199
- const base =
1200
- SMARTPROXY_ALLOCATOR_RETRY_BASE_MS * 2 ** Math.max(0, attempt - 1);
1647
+ const base = SMARTPROXY_ALLOCATOR_RETRY_BASE_MS * 2 ** Math.max(0, attempt - 1);
1201
1648
  const jitter = Math.floor(Math.random() * SMARTPROXY_ALLOCATOR_RETRY_BASE_MS);
1202
1649
  return base + jitter;
1203
1650
  }
@@ -1217,29 +1664,42 @@ function smartproxyAllocatorFailureMessage(
1217
1664
  return "Smartproxy allocator response did not contain a usable proxy endpoint.";
1218
1665
  }
1219
1666
 
1667
+ // Smartproxy get-ip-v3 `protocol` param: 1 = HTTP. The SOCKS5 value ("2") is a
1668
+ // best-effort mapping pending live vendor confirmation; http is the default and
1669
+ // the only value exercised in production today.
1670
+ const SMARTPROXY_PROTOCOL_PARAM: Record<ProxyProtocol, string> = {
1671
+ http: "1",
1672
+ socks5: "2",
1673
+ };
1674
+
1220
1675
  function buildSmartproxyAllocatorUrl(
1221
1676
  policy: ProviderProxyPolicy,
1222
1677
  appKey: string,
1223
1678
  lifetimeMinutes: number,
1224
1679
  poolSize: number,
1680
+ protocol: ProxyProtocol,
1681
+ ambientDefaults = true,
1225
1682
  ): string {
1226
1683
  const params = new URLSearchParams({
1227
1684
  app_key: appKey,
1228
1685
  pt: "9",
1229
1686
  num: String(poolSize),
1230
1687
  life: String(lifetimeMinutes),
1231
- protocol: "1",
1688
+ protocol: SMARTPROXY_PROTOCOL_PARAM[protocol],
1232
1689
  format: "txt",
1233
1690
  lb: "\\n",
1234
1691
  });
1235
- const country = resolveSmartproxyCountry(policy);
1692
+ const country = resolveSmartproxyCountry(policy, ambientDefaults);
1236
1693
  if (country) {
1237
1694
  params.set("cc", country);
1238
1695
  }
1239
- return `https://www.smartproxy.org/web_v1/ip/get-ip-v3?${params.toString()}`;
1696
+ // www.smartproxy.org stopped serving the extraction API on 2026-07-21 (the
1697
+ // old path 404s into the marketing site); the API lives on the api host.
1698
+ return `https://api.smartproxy.org/web_v1/ip/get-ip-v3?${params.toString()}`;
1240
1699
  }
1241
1700
 
1242
- function parseSmartproxyAllocatorProxies(body: string): string[] {
1701
+ function parseSmartproxyAllocatorProxies(body: string, protocol: ProxyProtocol): string[] {
1702
+ const scheme = protocol === "socks5" ? "socks5" : "http";
1243
1703
  const trimmed = body.trim();
1244
1704
  if (!trimmed) {
1245
1705
  return [];
@@ -1247,14 +1707,8 @@ function parseSmartproxyAllocatorProxies(body: string): string[] {
1247
1707
 
1248
1708
  try {
1249
1709
  const parsed: unknown = JSON.parse(trimmed);
1250
- const data =
1251
- parsed && typeof parsed === "object" && "data" in parsed
1252
- ? parsed.data
1253
- : undefined;
1254
- const list =
1255
- data && typeof data === "object" && "list" in data
1256
- ? data.list
1257
- : undefined;
1710
+ const data = parsed && typeof parsed === "object" && "data" in parsed ? parsed.data : undefined;
1711
+ const list = data && typeof data === "object" && "list" in data ? data.list : undefined;
1258
1712
  if (Array.isArray(list)) {
1259
1713
  return list
1260
1714
  .map((item) => {
@@ -1263,11 +1717,10 @@ function parseSmartproxyAllocatorProxies(body: string): string[] {
1263
1717
  }
1264
1718
  const ip = "ip" in item && typeof item.ip === "string" ? item.ip : "";
1265
1719
  const port =
1266
- "port" in item &&
1267
- (typeof item.port === "string" || typeof item.port === "number")
1720
+ "port" in item && (typeof item.port === "string" || typeof item.port === "number")
1268
1721
  ? item.port
1269
1722
  : "";
1270
- return ip && port ? `http://${ip}:${port}` : null;
1723
+ return ip && port ? `${scheme}://${ip}:${port}` : null;
1271
1724
  })
1272
1725
  .filter((url): url is string => url !== null);
1273
1726
  }
@@ -1279,7 +1732,7 @@ function parseSmartproxyAllocatorProxies(body: string): string[] {
1279
1732
  .split(/\r?\n/)
1280
1733
  .map((item) => item.trim())
1281
1734
  .filter((item) => /^\d{1,3}(?:\.\d{1,3}){3}:\d{2,5}$/.test(item))
1282
- .map((line) => `http://${line}`);
1735
+ .map((line) => `${scheme}://${line}`);
1283
1736
  }
1284
1737
 
1285
1738
  function classifySmartproxyAllocatorBody(
@@ -1307,35 +1760,32 @@ export function clearProxyResolutionCache(): void {
1307
1760
  invalidatedProxyKeys.clear();
1308
1761
  }
1309
1762
 
1310
- function markSmartproxyCacheInvalidated(
1311
- options: ProxyResolutionOptions = {},
1312
- ): string | undefined {
1763
+ function markSmartproxyCacheInvalidated(options: ProxyResolutionOptions = {}): string | undefined {
1313
1764
  const policy = resolvePolicy(options);
1314
1765
  if (!policy || policy.mode === "disabled") {
1315
1766
  return undefined;
1316
1767
  }
1317
- if (resolveProxyProvider(policy) !== "smartproxy") {
1768
+ if (!resolveVendorChain(policy).includes("smartproxy")) {
1318
1769
  return undefined;
1319
1770
  }
1320
1771
 
1321
1772
  const lifetimeMinutes = resolveSmartproxyLifetime(policy);
1773
+ const appKey = process.env[SMARTPROXY_APP_KEY_ENV]?.trim();
1774
+ if (!appKey) return undefined;
1322
1775
  const cacheKey = buildSmartproxyCacheKey(
1323
1776
  policy,
1777
+ appKey,
1324
1778
  options.affinityKey,
1325
1779
  lifetimeMinutes,
1780
+ options.protocol ?? VENDOR_DEFAULT_PROTOCOL.smartproxy,
1326
1781
  );
1327
- invalidatedProxyKeys.set(
1328
- cacheKey,
1329
- Date.now() + SMARTPROXY_INVALIDATION_SKIP_REDIS_MS,
1330
- );
1782
+ invalidatedProxyKeys.set(cacheKey, Date.now() + SMARTPROXY_INVALIDATION_SKIP_REDIS_MS);
1331
1783
  proxyCache.delete(cacheKey);
1332
1784
  proxyInflight.delete(cacheKey);
1333
1785
  return cacheKey;
1334
1786
  }
1335
1787
 
1336
- export function invalidateProxyResolutionCache(
1337
- options: ProxyResolutionOptions = {},
1338
- ): boolean {
1788
+ export function invalidateProxyResolutionCache(options: ProxyResolutionOptions = {}): boolean {
1339
1789
  return markSmartproxyCacheInvalidated(options) !== undefined;
1340
1790
  }
1341
1791
 
@@ -1374,9 +1824,7 @@ async function importConfig(filePath: string): Promise<ApiFuseConfig | null> {
1374
1824
  }
1375
1825
  }
1376
1826
 
1377
- export async function loadApiFuseConfig(
1378
- dir: string = process.cwd(),
1379
- ): Promise<ApiFuseConfig> {
1827
+ export async function loadApiFuseConfig(dir: string = process.cwd()): Promise<ApiFuseConfig> {
1380
1828
  const tsPath = path.resolve(dir, "apifuse.config.ts");
1381
1829
  if (existsSync(tsPath)) {
1382
1830
  const config = await importConfig(tsPath);