@apifuse/provider-sdk 2.2.0-beta.7 → 2.2.0-beta.9

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.
package/dist/define.js CHANGED
@@ -8,7 +8,7 @@ const VALID_RUNTIMES = ["standard", "shared", "browser"];
8
8
  const VALID_AUTH_MODES = ["none", "platform-managed", "credentials", "oauth2"];
9
9
  const VALID_PROVIDER_ACCESS_VISIBILITIES = ["public", "early_access"];
10
10
  const VALID_PROVIDER_PROXY_MODES = ["disabled", "optional", "required"];
11
- const VALID_PROVIDER_PROXY_PROVIDERS = ["smartproxy", "decodo", "custom"];
11
+ const VALID_PROVIDER_PROXY_PROVIDERS = ["smartproxy", "nodemaven", "decodo", "custom"];
12
12
  const VALID_PROVIDER_PROXY_AFFINITIES = [
13
13
  "request",
14
14
  "operation",
@@ -17,6 +17,20 @@ const VALID_PROVIDER_PROXY_AFFINITIES = [
17
17
  ];
18
18
  const VALID_PROVIDER_STT_MODES = ["optional", "required"];
19
19
  const SMARTPROXY_APP_KEY_SECRET = "APIFUSE__PROXY__SMARTPROXY_APP_KEY";
20
+ const NODEMAVEN_USERNAME_SECRET = "APIFUSE__PROXY__NODEMAVEN_USERNAME";
21
+ const NODEMAVEN_PASSWORD_SECRET = "APIFUSE__PROXY__NODEMAVEN_PASSWORD";
22
+ // Per-vendor provider-declared credential secrets. A required-mode chain must
23
+ // declare every secret of every credentialed vendor it names, so a missing
24
+ // credential fails at build/validation time rather than during a live outage: a
25
+ // declared-but-uncredentialed fallback leg is a silently dead SPOF, which is
26
+ // exactly the failure class the multi-vendor chain exists to remove. Vendors
27
+ // absent from this map (e.g. `custom`/`decodo`, whose credentials come from the
28
+ // `APIFUSE__PROXY__URL` bring-your-own escape hatch, not provider secrets) impose
29
+ // no declaration requirement.
30
+ const VENDOR_REQUIRED_SECRETS = {
31
+ smartproxy: [SMARTPROXY_APP_KEY_SECRET],
32
+ nodemaven: [NODEMAVEN_USERNAME_SECRET, NODEMAVEN_PASSWORD_SECRET],
33
+ };
20
34
  const RESERVED_OPERATION_IDS = new Set(["auth", "health"]);
21
35
  const MCP_TOOL_NAME_REGEX = /^[A-Za-z][A-Za-z0-9_]{0,127}$/;
22
36
  const VALID_OPERATION_RISK_CLASSES = ["read", "write", "destructive", "external-send"];
@@ -137,11 +151,21 @@ function validateProviderProxy(config) {
137
151
  fix: `Use proxy: { mode: "required", provider: "smartproxy", geo: { country: "KR" }, session: { affinity: "connection", lifetimeMinutes: 30 } }`,
138
152
  });
139
153
  }
140
- rejectUnknownFields(proxy, new Set(["mode", "provider", "geo", "session"]), "proxy");
154
+ rejectUnknownFields(proxy, new Set(["mode", "provider", "providers", "geo", "session"]), "proxy");
141
155
  assertLiteralField(proxy.mode, "proxy.mode", VALID_PROVIDER_PROXY_MODES, config.id);
142
156
  if (proxy.provider !== undefined) {
143
157
  assertLiteralField(proxy.provider, "proxy.provider", VALID_PROVIDER_PROXY_PROVIDERS, config.id);
144
158
  }
159
+ if (proxy.providers !== undefined) {
160
+ if (!Array.isArray(proxy.providers) || proxy.providers.length === 0) {
161
+ throw new ValidationError(`Provider "${config.id}" has invalid proxy.providers: must be a non-empty array of proxy vendors.`, {
162
+ fix: `Use proxy.providers: ["smartproxy", "nodemaven"] to declare an ordered fallback chain.`,
163
+ });
164
+ }
165
+ for (const vendor of proxy.providers) {
166
+ assertLiteralField(vendor, "proxy.providers[]", VALID_PROVIDER_PROXY_PROVIDERS, config.id);
167
+ }
168
+ }
145
169
  if (proxy.geo !== undefined) {
146
170
  if (!proxy.geo || typeof proxy.geo !== "object" || Array.isArray(proxy.geo)) {
147
171
  throw new ValidationError(`Provider "${config.id}" has invalid proxy.geo: must be an object.`, {
@@ -178,14 +202,45 @@ function validateProviderProxy(config) {
178
202
  throw new ValidationError(`Provider "${config.id}" has invalid proxy.session.poolSize: must be a positive integer.`);
179
203
  }
180
204
  }
181
- if (proxy.mode === "required" && proxy.provider === "smartproxy") {
182
- const hasSmartproxySecret = config.secrets?.some((secret) => secret.name === SMARTPROXY_APP_KEY_SECRET && secret.required !== false);
183
- if (!hasSmartproxySecret) {
184
- throw new ValidationError(`Provider "${config.id}" requires Smartproxy egress but does not declare ${SMARTPROXY_APP_KEY_SECRET}.`, {
185
- fix: `Add secrets: [{ name: "${SMARTPROXY_APP_KEY_SECRET}", required: true }] to the provider.`,
186
- });
205
+ // Every credentialed vendor in a required-mode chain must declare its
206
+ // provider secret(s) so a missing credential fails at build/validation time,
207
+ // not during a live outage. This covers the fallback legs too (not just the
208
+ // first vendor): a declared-but-uncredentialed nodemaven fallback would leave
209
+ // the chain silently down to a single vendor, reintroducing the SPOF the chain
210
+ // removes.
211
+ const vendorChain = proxy.providers && proxy.providers.length > 0
212
+ ? proxy.providers
213
+ : proxy.provider
214
+ ? [proxy.provider]
215
+ : [];
216
+ if (proxy.mode === "required") {
217
+ for (const vendor of vendorChain) {
218
+ const requiredSecrets = VENDOR_REQUIRED_SECRETS[vendor];
219
+ if (!requiredSecrets)
220
+ continue;
221
+ for (const secretName of requiredSecrets) {
222
+ // Match the canonical runtime gate (assertRequiredSecretsPresent /
223
+ // listMissingRequiredSecrets), which enforces only `required === true`
224
+ // declarations. A declaration that omits `required` (defaulting to
225
+ // optional) is skipped at runtime, so accepting it here would pass
226
+ // validation while leaving the credential unenforced until proxy
227
+ // resolution during a live request — the fail-open gap this check exists
228
+ // to close.
229
+ const declared = config.secrets?.some((secret) => secret.name === secretName && secret.required === true);
230
+ if (!declared) {
231
+ throw new ValidationError(`Provider "${config.id}" requires ${vendor} egress but does not declare ${secretName}.`, {
232
+ fix: `Add secrets: [{ name: "${secretName}", required: true }] to the provider (every vendor in a required proxy chain must declare its credential secrets).`,
233
+ });
234
+ }
235
+ }
187
236
  }
188
237
  }
238
+ // `decodo`/`custom` are deprecated vendor values (string-union members, so the
239
+ // @deprecated symbol gate can't catch them — warn at validation time instead).
240
+ const deprecatedVendors = vendorChain.filter((vendor) => vendor === "decodo" || vendor === "custom");
241
+ if (deprecatedVendors.length > 0) {
242
+ console.warn(`[provider-sdk] Provider "${config.id}" uses deprecated proxy vendor(s): ${deprecatedVendors.join(", ")}. Use "smartproxy"/"nodemaven", or the APIFUSE__PROXY__URL bring-your-own escape hatch.`);
243
+ }
189
244
  }
190
245
  function validateProviderStt(config) {
191
246
  const stt = config.stt;
@@ -1,6 +1,6 @@
1
1
  import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual, } from "node:crypto";
2
2
  import { assertFreshProviderChoiceIssuedAt, ProviderChoiceTokenError, } from "../choice-token.js";
3
- import { ProviderError } from "../errors.js";
3
+ import { isProviderError, ProviderError } from "../errors.js";
4
4
  export const PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV = "APIFUSE__PROVIDER_RUNTIME__CHOICE_TOKEN_MASTER_SECRET";
5
5
  const PRIMARY_CHOICE_TOKEN_KID = "v1";
6
6
  const MANAGED_CHOICE_TOKEN_VERSION = 1;
@@ -209,7 +209,25 @@ async function parseServerStoredChoice(options) {
209
209
  storage,
210
210
  contextState: options.contextState,
211
211
  });
212
- const record = await namespace.get(optionsStateKey(options.handle.state_id));
212
+ // Reading a server-stored choice back deserializes a persisted value. A
213
+ // corrupt/undecodable value would otherwise surface as a raw JSON.parse
214
+ // SyntaxError (or another unexpected throwable) that escapes the choice error
215
+ // taxonomy, gets masked as internal_error 500, and is treated as retryable by
216
+ // the hub -> reservation restart loop (2026-07-22 catchtable RCA, candidate A).
217
+ // Convert any non-branded throwable into a branded invalid_payload so it maps
218
+ // to a clean, non-retryable 400. Branded ProviderChoiceTokenError and genuine
219
+ // ProviderError (e.g. Redis-unavailable / state-unavailable) pass through so
220
+ // their category/retryable semantics are preserved.
221
+ let record;
222
+ try {
223
+ record = await namespace.get(optionsStateKey(options.handle.state_id));
224
+ }
225
+ catch (error) {
226
+ if (error instanceof ProviderChoiceTokenError || isProviderError(error)) {
227
+ throw error;
228
+ }
229
+ throw new ProviderChoiceTokenError("invalid_payload", "Provider choice token state payload could not be decoded.");
230
+ }
213
231
  if (!record) {
214
232
  throw new ProviderChoiceTokenError("invalid_payload", "Provider choice token state payload is missing.");
215
233
  }
@@ -1,4 +1,4 @@
1
- import { resolveProxyConfigAsync } from "../config/loader.js";
1
+ import { policyRotatesTransportVendorChain, resolvePolicyTransportAttemptCap, resolveProxyConfigAsync, } from "../config/loader.js";
2
2
  import { ProviderError, TransportError } from "../errors.js";
3
3
  import { parseSseStream, readableBytes, readableLines, readableTextChunks } from "../stream.js";
4
4
  import { computeProxyAttemptIndex, computeProxyTransportRetryDelayMs, createDefaultProxyTransportRetryOptions, isProxyTransportRetryMethod, normalizeProxyTransportRetryOptions, proxyTransportRetryErrorCode, proxyTransportRetryErrorStatus, shouldRetryProxyTransportAttempt, validateUnsafeProxyTransportRetryMethods, } from "./proxy-retry-policy.js";
@@ -7,6 +7,9 @@ const DEFAULT_HTTP_BASE_URL = "http://localhost";
7
7
  function isHttpStatusOutcome(outcome) {
8
8
  return "kind" in outcome && outcome.kind === "http-status";
9
9
  }
10
+ function isDedupeSkipOutcome(outcome) {
11
+ return "kind" in outcome && outcome.kind === "dedupe-skip";
12
+ }
10
13
  async function sleep(ms) {
11
14
  if (ms <= 0)
12
15
  return;
@@ -175,6 +178,9 @@ async function resolveNativeProxy(options, clientOptions, warn, proxyAttemptOffs
175
178
  baseProxyAttempt: clientOptions.proxyAttempt,
176
179
  retryAttemptOffset: proxyAttemptOffset,
177
180
  }),
181
+ // Bun's native fetch proxy option tunnels HTTP CONNECT only; SOCKS5 is not
182
+ // supported here, so a socks5 policy fails loudly rather than downgrading.
183
+ transportProtocols: ["http"],
178
184
  telemetry: clientOptions.telemetry,
179
185
  });
180
186
  if (resolvedProxy.shouldWarn) {
@@ -196,7 +202,7 @@ function normalizeNativeFetchBody(body) {
196
202
  copied.set(normalized);
197
203
  return copied.buffer;
198
204
  }
199
- async function fetchNativeHttp(baseUrl, url, method, options, clientOptions, warn, statusRetryCodes, proxyAttemptOffset = 0) {
205
+ async function fetchNativeHttp(baseUrl, url, method, options, clientOptions, warn, statusRetryCodes, proxyAttemptOffset = 0, dedupe) {
200
206
  const requestUrl = appendQueryParams(resolveHttpUrl(baseUrl, url), options.params);
201
207
  const controller = options.timeout ? new AbortController() : undefined;
202
208
  const timeoutHandle = options.timeout
@@ -204,7 +210,20 @@ async function fetchNativeHttp(baseUrl, url, method, options, clientOptions, war
204
210
  : undefined;
205
211
  let proxy;
206
212
  try {
213
+ // Resolve inside the try (and after the timeout is armed) so allocator
214
+ // failures are branded as TransportErrors and count against the request
215
+ // deadline, exactly as an inline resolve would.
207
216
  proxy = await resolveNativeProxy(options, clientOptions, warn, proxyAttemptOffset);
217
+ // For a registry allocator chain, skip an endpoint a prior attempt already
218
+ // tried rather than re-issuing the same request. Returning the sentinel
219
+ // (instead of breaking) lets the loop keep advancing the flat offset until
220
+ // it crosses into the fallback vendor's pool span.
221
+ if (dedupe && proxy) {
222
+ if (dedupe.attempted.has(proxy)) {
223
+ return { kind: "dedupe-skip" };
224
+ }
225
+ dedupe.attempted.add(proxy);
226
+ }
208
227
  const requestInit = {
209
228
  headers: options.headers,
210
229
  method,
@@ -325,22 +344,99 @@ export function createHttpClient(baseUrl, clientOptions = {}) {
325
344
  const attemptOptions = statusRetryEnabled
326
345
  ? { ...headersOptions, throwOnHttpError: false }
327
346
  : headersOptions;
328
- const executeOnce = (proxyAttemptOffset = 0) => fetchNativeHttp(baseUrl, url, methodName, attemptOptions, clientOptions, warnOnce, statusRetryEnabled ? retryOptions?.statusCodes : undefined, proxyAttemptOffset);
347
+ // Span the whole vendor chain on transport failures. Like ctx.stealth, a
348
+ // policy-managed proxy resolves a *different* endpoint/vendor per attempt
349
+ // (the flat proxyAttemptOffset rotates across the concatenated vendor pool
350
+ // spans), so a transport failure should advance to the next endpoint —
351
+ // potentially crossing into the fallback vendor — rather than stopping at
352
+ // the per-endpoint retry budget and stranding the request on the primary
353
+ // vendor. resolvePolicyTransportAttemptCap widens the cap to the chain span
354
+ // only for implicit, safe-method allocator requests; explicit retry
355
+ // policies (their documented `attempts` ceiling), unsafe methods, and
356
+ // static/non-registry vendors keep the retry budget. Status-code retries
357
+ // stay bounded by the retry budget regardless; only transport rotation gets
358
+ // the full span.
359
+ const policyProxy = (() => {
360
+ const policy = clientOptions.proxyPolicy ?? clientOptions.upstream?.proxy;
361
+ return policy && typeof policy === "object" ? policy : undefined;
362
+ })();
363
+ const usesPolicyAllocator = Boolean(policyProxy) && !options.proxy && !clientOptions.proxy;
364
+ const transportAttemptCap = retryOptions
365
+ ? resolvePolicyTransportAttemptCap({
366
+ policy: policyProxy,
367
+ usesPolicyAllocator,
368
+ retryAttempts: retryOptions.attempts,
369
+ explicitRetry,
370
+ method: methodName,
371
+ })
372
+ : 1;
373
+ // Track resolved endpoints across a policy-allocator chain. Successive
374
+ // attempts rotate the flat offset across the concatenated vendor pool
375
+ // spans, but an under-filled allocation (fewer live endpoints than the
376
+ // configured pool size) makes the modulo mapping repeat endpoints before
377
+ // the offset reaches the next vendor. Rather than re-hammering an
378
+ // already-tried endpoint under backoff, fetchNativeHttp returns a skip
379
+ // sentinel for a duplicate; the loop then advances the flat offset without
380
+ // issuing the request, so it keeps walking toward — and into — the fallback
381
+ // vendor's pool span instead of stalling on the primary vendor.
382
+ // De-duplication is gated on the SAME predicate that widens the attempt cap
383
+ // (implicit, safe-method, registry-chain rotation). It must NOT engage for
384
+ // an explicit retry policy: there the caller's `attempts` count is the
385
+ // contract and each attempt must issue against whatever endpoint it resolves
386
+ // — even a repeat — instead of being silently skipped (which would collapse
387
+ // a `poolSize: 1` + `attempts: 3` request to a single fetch).
388
+ const dedupeAllocatorEndpoints = policyRotatesTransportVendorChain({
389
+ policy: policyProxy,
390
+ usesPolicyAllocator,
391
+ explicitRetry,
392
+ method: methodName,
393
+ });
394
+ const dedupeContext = dedupeAllocatorEndpoints
395
+ ? { attempted: new Set() }
396
+ : undefined;
397
+ const executeOnce = (proxyAttemptOffset = 0) => fetchNativeHttp(baseUrl, url, methodName, attemptOptions, clientOptions, warnOnce, statusRetryEnabled ? retryOptions?.statusCodes : undefined, proxyAttemptOffset, dedupeContext);
329
398
  if (!retryEnabled || !retryOptions) {
330
399
  const outcome = await executeOnce();
400
+ if (isDedupeSkipOutcome(outcome)) {
401
+ // Single-shot path never de-duplicates (dedupeContext is undefined),
402
+ // but keep the union total.
403
+ throw new TransportError("HTTP request produced no terminal result", {
404
+ code: "retry_exhausted",
405
+ });
406
+ }
331
407
  if (isHttpStatusOutcome(outcome)) {
332
408
  throw toUpstreamHttpError(outcome.status);
333
409
  }
334
410
  return outcome;
335
411
  }
412
+ let lastError;
336
413
  let lastErrorCode;
337
414
  let lastStatus;
338
- for (let attempt = 1; attempt <= retryOptions.attempts; attempt += 1) {
415
+ // `attempt` walks the flat proxy offset across the full chain span; `issued`
416
+ // counts requests that were actually sent (skipped duplicate offsets do not
417
+ // increment it). Retry summaries and the status-retry budget must reflect
418
+ // issued requests, not the raw offset, so they stay accurate when partial
419
+ // allocations skip offsets.
420
+ let issued = 0;
421
+ for (let attempt = 1; attempt <= transportAttemptCap; attempt += 1) {
422
+ // Whether this offset actually issued a request (vs. a skipped duplicate),
423
+ // so the catch counts a thrown *transport* failure once without
424
+ // double-counting a status outcome that already incremented before it
425
+ // re-threw as an upstream HTTP error.
426
+ let issuedThisAttempt = false;
339
427
  try {
340
428
  const outcome = await executeOnce(attempt - 1);
429
+ if (isDedupeSkipOutcome(outcome)) {
430
+ // Duplicate endpoint from a partial allocation: advance the flat
431
+ // offset without issuing the request (no backoff, not a failure) so
432
+ // the loop keeps rotating toward the fallback vendor.
433
+ continue;
434
+ }
435
+ issued += 1;
436
+ issuedThisAttempt = true;
341
437
  if (isHttpStatusOutcome(outcome)) {
342
438
  lastStatus = outcome.status;
343
- if (outcome.retryable && attempt < retryOptions.attempts) {
439
+ if (outcome.retryable && issued < retryOptions.attempts) {
344
440
  await sleep(computeProxyTransportRetryDelayMs(retryOptions, attempt, outcome.headers));
345
441
  continue;
346
442
  }
@@ -350,10 +446,10 @@ export function createHttpClient(baseUrl, clientOptions = {}) {
350
446
  if (response.status >= 400 && headersOptions.throwOnHttpError !== false) {
351
447
  throw toUpstreamHttpError(response.status);
352
448
  }
353
- if (attempt > 1) {
449
+ if (issued > 1) {
354
450
  const summary = {
355
- attempts: attempt,
356
- retries: attempt - 1,
451
+ attempts: issued,
452
+ retries: issued - 1,
357
453
  ...(retryOptions.preset ? { preset: retryOptions.preset } : {}),
358
454
  transport: "native",
359
455
  ...(lastErrorCode ? { lastErrorCode } : {}),
@@ -364,10 +460,13 @@ export function createHttpClient(baseUrl, clientOptions = {}) {
364
460
  return response;
365
461
  }
366
462
  catch (error) {
463
+ if (!issuedThisAttempt)
464
+ issued += 1;
465
+ lastError = error;
367
466
  lastErrorCode = proxyTransportRetryErrorCode(error);
368
467
  lastStatus = proxyTransportRetryErrorStatus(error);
369
468
  const proxyUsed = Boolean(error.proxyUsed);
370
- if (attempt < retryOptions.attempts &&
469
+ if (attempt < transportAttemptCap &&
371
470
  shouldRetryProxyTransportAttempt({
372
471
  error,
373
472
  explicitRetry,
@@ -381,6 +480,13 @@ export function createHttpClient(baseUrl, clientOptions = {}) {
381
480
  throw error;
382
481
  }
383
482
  }
483
+ // Reached when the attempt cap is consumed without a terminal outcome —
484
+ // e.g. the final offsets of a partial allocation all resolved to
485
+ // already-tried endpoints and were skipped. Surface the last real transport
486
+ // failure rather than a synthetic exhaustion error.
487
+ if (lastError !== undefined) {
488
+ throw lastError;
489
+ }
384
490
  throw new TransportError("HTTP retry exhausted without a terminal result", {
385
491
  code: "retry_exhausted",
386
492
  });
@@ -11,8 +11,12 @@ const PROXY_POOL_STALE_STATUS_CODES = new Set([509, 512]);
11
11
  const PROXY_EDGE_TLS_REJECTED_STATUS_CODES = new Set([495]);
12
12
  const PROXY_AUTH_IP_DENIED_PATTERN = /\b(?:source|egress|client)\s+ip\b.{0,120}\b(?:deny|denied|unauthori[sz]ed|not\s+authori[sz]ed|white\s*list|allow\s*list)\b|\b(?:white\s*list|allow\s*list)\b.{0,120}\b(?:source|egress|client)\s+ip\b/i;
13
13
  const PROXY_EDGE_AUTH_REJECTED_PATTERN = /\bauth\s+ip\s+err\b|\bproxy\b.{0,120}\bauth(?:entication)?\b.{0,120}\b(?:reject(?:ed)?|fail(?:ed)?|invalid|den(?:y|ied)|unauthori[sz]ed)\b|\bauth(?:entication)?\b.{0,120}\b(?:reject(?:ed)?|fail(?:ed)?|invalid|den(?:y|ied)|unauthori[sz]ed)\b.{0,120}\bproxy\b/i;
14
- const PROXY_POOL_STALE_MESSAGE_PATTERN = /\bproxy\b.{0,120}\b(?:pool|lease|expired|unavailable|exhausted|non[\s-]?200\s+code:\s*(?:509|512))\b|\bnon[\s-]?200\s+code:\s*(?:509|512)\b.{0,120}\bproxy\b|\bsmartproxy\b.{0,120}\b(?:509|512)\b/i;
15
- const PROXY_EDGE_TLS_REJECTED_MESSAGE_PATTERN = /\b(?:smartproxy|proxy)\b.{0,160}\b(?:495|ssl|tls|cert(?:ificate)?|handshake|edge|connect|non[\s-]?200)\b|\b(?:495|ssl|tls|cert(?:ificate)?|handshake|edge|connect|non[\s-]?200)\b.{0,160}\b(?:smartproxy|proxy)\b/i;
14
+ // Vendor host tokens that can appear in upstream error strings. Adding a proxy
15
+ // vendor updates every classifier below in one place. `proxy` is the generic
16
+ // fallback so vendor-agnostic messages still classify.
17
+ const PROXY_VENDOR_ALTERNATION = "smartproxy|nodemaven|proxy";
18
+ const PROXY_POOL_STALE_MESSAGE_PATTERN = new RegExp(`\\bproxy\\b.{0,120}\\b(?:pool|lease|expired|unavailable|exhausted|non[\\s-]?200\\s+code:\\s*(?:509|512))\\b|\\bnon[\\s-]?200\\s+code:\\s*(?:509|512)\\b.{0,120}\\bproxy\\b|\\b(?:${PROXY_VENDOR_ALTERNATION})\\b.{0,120}\\b(?:509|512)\\b`, "i");
19
+ const PROXY_EDGE_TLS_REJECTED_MESSAGE_PATTERN = new RegExp(`\\b(?:${PROXY_VENDOR_ALTERNATION})\\b.{0,160}\\b(?:495|ssl|tls|cert(?:ificate)?|handshake|edge|connect|non[\\s-]?200)\\b|\\b(?:495|ssl|tls|cert(?:ificate)?|handshake|edge|connect|non[\\s-]?200)\\b.{0,160}\\b(?:${PROXY_VENDOR_ALTERNATION})\\b`, "i");
16
20
  export function isProxyAuthIpDeniedMessage(message) {
17
21
  return PROXY_AUTH_IP_DENIED_PATTERN.test(message);
18
22
  }
@@ -0,0 +1,35 @@
1
+ import type { ProviderProxyPolicy } from "../types.js";
2
+ export declare const NODEMAVEN_USERNAME_ENV = "APIFUSE__PROXY__NODEMAVEN_USERNAME";
3
+ export declare const NODEMAVEN_PASSWORD_ENV = "APIFUSE__PROXY__NODEMAVEN_PASSWORD";
4
+ export declare const NODEMAVEN_FILTER_ENV = "APIFUSE__PROXY__NODEMAVEN_FILTER";
5
+ export declare const NODEMAVEN_GATEWAY_HOST = "gate.nodemaven.com";
6
+ /** Both schemes tunnel bytes end-to-end, preserving the client TLS handshake. */
7
+ export type ProxyProtocol = "http" | "socks5";
8
+ /**
9
+ * NodeMaven's fastest protocol: HTTP CONNECT. Benchmarks (KR, cold + warm)
10
+ * showed socks5 through the gateway adds ~500ms per request over http, so
11
+ * NodeMaven never defaults to socks5.
12
+ */
13
+ export declare const NODEMAVEN_DEFAULT_PROTOCOL: ProxyProtocol;
14
+ export declare const NODEMAVEN_MAX_POOL_SIZE = 50;
15
+ export declare function hasNodemavenCredentials(): boolean;
16
+ export declare function nodemavenPoolSize(policy: ProviderProxyPolicy): number;
17
+ export type NodemavenSynthesisInput = {
18
+ policy: ProviderProxyPolicy;
19
+ affinityKey: string | undefined;
20
+ protocol: ProxyProtocol;
21
+ poolIndex: number;
22
+ refreshEpoch: number;
23
+ /** ISO 3166-1 alpha-2, already resolved by the caller (falls back to env). */
24
+ country?: string;
25
+ };
26
+ export type NodemavenSynthesis = {
27
+ url: string;
28
+ protocol: ProxyProtocol;
29
+ diagnostics: Record<string, string | number | boolean>;
30
+ };
31
+ /**
32
+ * Synthesize a NodeMaven gateway proxy URL locally from static credentials.
33
+ * There is no allocation API — geo/session are encoded in the username.
34
+ */
35
+ export declare function synthesizeNodemavenProxy(input: NodemavenSynthesisInput): NodemavenSynthesis;
@@ -0,0 +1,128 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ export const NODEMAVEN_USERNAME_ENV = "APIFUSE__PROXY__NODEMAVEN_USERNAME";
3
+ export const NODEMAVEN_PASSWORD_ENV = "APIFUSE__PROXY__NODEMAVEN_PASSWORD";
4
+ export const NODEMAVEN_FILTER_ENV = "APIFUSE__PROXY__NODEMAVEN_FILTER";
5
+ export const NODEMAVEN_GATEWAY_HOST = "gate.nodemaven.com";
6
+ /**
7
+ * NodeMaven's fastest protocol: HTTP CONNECT. Benchmarks (KR, cold + warm)
8
+ * showed socks5 through the gateway adds ~500ms per request over http, so
9
+ * NodeMaven never defaults to socks5.
10
+ */
11
+ export const NODEMAVEN_DEFAULT_PROTOCOL = "http";
12
+ /** NodeMaven gateway port ranges per protocol (docs: HTTP 8080-9080, SOCKS5 1080-2080). */
13
+ const NODEMAVEN_PORTS = {
14
+ http: { min: 8080, max: 9080 },
15
+ socks5: { min: 1080, max: 2080 },
16
+ };
17
+ const NODEMAVEN_FILTERS = new Set(["medium", "high"]);
18
+ const DEFAULT_NODEMAVEN_FILTER = "medium";
19
+ const DEFAULT_NODEMAVEN_POOL_SIZE = 20;
20
+ export const NODEMAVEN_MAX_POOL_SIZE = 50;
21
+ /** NodeMaven sticky sessions persist up to 24h server-side, keyed by the sid. */
22
+ const NODEMAVEN_MAX_LIFETIME_MINUTES = 1440;
23
+ const SID_LENGTH = 10;
24
+ export function hasNodemavenCredentials() {
25
+ return Boolean(readNodemavenUsername() && readNodemavenPassword());
26
+ }
27
+ function readNodemavenUsername() {
28
+ return process.env[NODEMAVEN_USERNAME_ENV]?.trim() || undefined;
29
+ }
30
+ function readNodemavenPassword() {
31
+ return process.env[NODEMAVEN_PASSWORD_ENV]?.trim() || undefined;
32
+ }
33
+ function resolveNodemavenFilter() {
34
+ const raw = process.env[NODEMAVEN_FILTER_ENV]?.trim().toLowerCase();
35
+ if (!raw)
36
+ return DEFAULT_NODEMAVEN_FILTER;
37
+ if (!NODEMAVEN_FILTERS.has(raw)) {
38
+ throw new Error(`${NODEMAVEN_FILTER_ENV} must be "medium" or "high"`);
39
+ }
40
+ return raw;
41
+ }
42
+ export function nodemavenPoolSize(policy) {
43
+ return Math.min(NODEMAVEN_MAX_POOL_SIZE, Math.max(1, Math.floor(policy.session?.poolSize ?? DEFAULT_NODEMAVEN_POOL_SIZE)));
44
+ }
45
+ function nodemavenLifetimeMinutes(policy) {
46
+ const configured = policy.session?.lifetimeMinutes;
47
+ if (typeof configured !== "number" || !Number.isFinite(configured) || configured <= 0) {
48
+ return NODEMAVEN_MAX_LIFETIME_MINUTES;
49
+ }
50
+ return Math.min(NODEMAVEN_MAX_LIFETIME_MINUTES, Math.max(1, Math.floor(configured)));
51
+ }
52
+ /** NodeMaven username tokens accept `[a-z0-9]`; slugify geo values to that set. */
53
+ function slugifyGeo(value) {
54
+ if (!value)
55
+ return undefined;
56
+ const slug = value
57
+ .trim()
58
+ .toLowerCase()
59
+ .replace(/[^a-z0-9]+/g, "");
60
+ return slug || undefined;
61
+ }
62
+ function isStickyAffinity(policy) {
63
+ return (policy.session?.affinity ?? "request") !== "request";
64
+ }
65
+ /**
66
+ * A sticky sid is deterministic from the affinity key so every process serving
67
+ * the same connection derives the same egress IP without shared storage. A
68
+ * rotating sid is random per call (a fresh egress IP per request).
69
+ */
70
+ function deriveSid(policy, affinityKey, poolIndex, refreshEpoch) {
71
+ if (!isStickyAffinity(policy) || !affinityKey) {
72
+ return randomBytes(SID_LENGTH).toString("hex").slice(0, SID_LENGTH);
73
+ }
74
+ const digest = createHash("sha256")
75
+ .update(`${affinityKey}:${refreshEpoch}:${poolIndex}`)
76
+ .digest("hex");
77
+ // hex digits are a subset of the allowed [a-z0-9] sid charset.
78
+ return digest.slice(0, SID_LENGTH);
79
+ }
80
+ function selectPort(protocol, sid, poolIndex) {
81
+ const { min, max } = NODEMAVEN_PORTS[protocol];
82
+ const span = max - min + 1;
83
+ const hashInt = Number.parseInt(createHash("sha256").update(`${sid}:${poolIndex}`).digest("hex").slice(0, 8), 16);
84
+ return min + (hashInt % span);
85
+ }
86
+ /**
87
+ * Synthesize a NodeMaven gateway proxy URL locally from static credentials.
88
+ * There is no allocation API — geo/session are encoded in the username.
89
+ */
90
+ export function synthesizeNodemavenProxy(input) {
91
+ const username = readNodemavenUsername();
92
+ const password = readNodemavenPassword();
93
+ if (!username || !password) {
94
+ throw new Error(`NodeMaven credentials missing: set ${NODEMAVEN_USERNAME_ENV} and ${NODEMAVEN_PASSWORD_ENV}.`);
95
+ }
96
+ const filter = resolveNodemavenFilter();
97
+ const sid = deriveSid(input.policy, input.affinityKey, input.poolIndex, input.refreshEpoch);
98
+ const port = selectPort(input.protocol, sid, input.poolIndex);
99
+ const lifetimeMinutes = nodemavenLifetimeMinutes(input.policy);
100
+ const country = slugifyGeo(input.country ?? input.policy.geo?.country);
101
+ const region = slugifyGeo(input.policy.geo?.subdivision);
102
+ const city = slugifyGeo(input.policy.geo?.city);
103
+ const tokens = [username];
104
+ if (country)
105
+ tokens.push("country", country);
106
+ if (region)
107
+ tokens.push("region", region);
108
+ if (city)
109
+ tokens.push("city", city);
110
+ tokens.push("sid", sid);
111
+ tokens.push("filter", filter);
112
+ tokens.push("ipv4", "true");
113
+ const proxyUsername = tokens.join("-");
114
+ // Username tokens are [a-z0-9-] only, which survive URL encoding unchanged.
115
+ const url = `${input.protocol}://${proxyUsername}:${encodeURIComponent(password)}@${NODEMAVEN_GATEWAY_HOST}:${port}`;
116
+ return {
117
+ url,
118
+ protocol: input.protocol,
119
+ diagnostics: {
120
+ vendor: "nodemaven",
121
+ protocol: input.protocol,
122
+ sticky: isStickyAffinity(input.policy),
123
+ filter,
124
+ lifetimeMinutes,
125
+ ...(country ? { country } : {}),
126
+ },
127
+ };
128
+ }
@@ -1,8 +1,9 @@
1
- import type { ProxyAttemptTelemetryEvent, ProxyResolutionTelemetryEvent, ProxyTelemetrySink } from "../config/loader.js";
1
+ import type { ProxyAttemptTelemetryEvent, ProxyResolutionTelemetryEvent, ProxyTelemetrySink, ProxyVendorFailoverTelemetryEvent } from "../config/loader.js";
2
2
  export declare const PROVIDER_TELEMETRY_HEADER = "X-ApiFuse-Provider-Telemetry";
3
3
  export declare class ProxyTelemetryCollector implements ProxyTelemetrySink {
4
4
  #private;
5
5
  recordProxyResolution(event: ProxyResolutionTelemetryEvent): void;
6
+ recordProxyVendorFailover(event: ProxyVendorFailoverTelemetryEvent): void;
6
7
  recordProxyAttempt(event: ProxyAttemptTelemetryEvent): void;
7
8
  toHeaderValue(): string | undefined;
8
9
  }
@@ -1,6 +1,7 @@
1
1
  export const PROVIDER_TELEMETRY_HEADER = "X-ApiFuse-Provider-Telemetry";
2
2
  const MAX_HEADER_BYTES = 4_096;
3
3
  const MAX_PROXY_ATTEMPT_SAMPLES = 24;
4
+ const MAX_PROXY_FAILOVER_SAMPLES = 12;
4
5
  const CACHE_STATUS_SEVERITY = {
5
6
  disabled: 0,
6
7
  memory_hit: 1,
@@ -28,9 +29,11 @@ function encodeBase64Url(value) {
28
29
  export class ProxyTelemetryCollector {
29
30
  #events = [];
30
31
  #attempts = [];
32
+ #failovers = [];
31
33
  recordProxyResolution(event) {
32
34
  this.#events.push({
33
- provider: "smartproxy",
35
+ provider: event.provider,
36
+ ...(event.protocol ? { protocol: event.protocol } : {}),
34
37
  cacheStatus: event.cacheStatus,
35
38
  cacheHit: event.cacheHit,
36
39
  resolutionMs: Math.max(0, Math.floor(event.resolutionMs)),
@@ -53,11 +56,22 @@ export class ProxyTelemetryCollector {
53
56
  refreshes: event.refreshes === undefined ? undefined : Math.max(0, Math.floor(event.refreshes)),
54
57
  });
55
58
  }
59
+ recordProxyVendorFailover(event) {
60
+ if (this.#failovers.length >= MAX_PROXY_FAILOVER_SAMPLES)
61
+ return;
62
+ this.#failovers.push({
63
+ vendor: event.vendor,
64
+ ...(event.nextVendor ? { nextVendor: event.nextVendor } : {}),
65
+ phase: event.phase,
66
+ reason: event.reason,
67
+ ...(event.attempt === undefined ? {} : { attempt: Math.max(0, Math.floor(event.attempt)) }),
68
+ });
69
+ }
56
70
  recordProxyAttempt(event) {
57
71
  if (this.#attempts.length >= MAX_PROXY_ATTEMPT_SAMPLES)
58
72
  return;
59
73
  this.#attempts.push({
60
- provider: "smartproxy",
74
+ provider: event.provider,
61
75
  attempt: Math.max(1, Math.floor(event.attempt || 1)),
62
76
  ...(event.poolIndex === undefined
63
77
  ? {}
@@ -75,8 +89,16 @@ export class ProxyTelemetryCollector {
75
89
  const [first, ...rest] = this.#events;
76
90
  if (!first)
77
91
  return undefined;
92
+ // The serving vendor/protocol is the last recorded resolution (a failed
93
+ // vendor records first, the vendor that served records last).
94
+ const serving = this.#events[this.#events.length - 1] ?? first;
95
+ const vendors = [];
96
+ for (const event of this.#events) {
97
+ if (!vendors.includes(event.provider))
98
+ vendors.push(event.provider);
99
+ }
78
100
  const aggregate = rest.reduce((acc, event) => ({
79
- provider: "smartproxy",
101
+ provider: event.provider,
80
102
  cacheStatus: worseStatus(acc.cacheStatus, event.cacheStatus),
81
103
  cacheHit: acc.cacheHit && event.cacheHit,
82
104
  resolutionMs: acc.resolutionMs + event.resolutionMs,
@@ -95,7 +117,8 @@ export class ProxyTelemetryCollector {
95
117
  const payload = {
96
118
  v: 1,
97
119
  proxy: {
98
- provider: "smartproxy",
120
+ provider: serving.provider,
121
+ ...(serving.protocol ? { protocol: serving.protocol } : {}),
99
122
  cacheStatus: aggregate.cacheStatus,
100
123
  cacheHit: aggregate.cacheHit,
101
124
  resolutionMs: aggregate.resolutionMs,
@@ -132,6 +155,18 @@ export class ProxyTelemetryCollector {
132
155
  })),
133
156
  }
134
157
  : {}),
158
+ ...(vendors.length > 1 ? { vendors } : {}),
159
+ ...(this.#failovers.length > 0
160
+ ? {
161
+ failovers: this.#failovers.map((failover) => ({
162
+ v: failover.vendor,
163
+ ...(failover.nextVendor ? { nx: failover.nextVendor } : {}),
164
+ p: failover.phase,
165
+ r: failover.reason,
166
+ ...(failover.attempt === undefined ? {} : { a: failover.attempt }),
167
+ })),
168
+ }
169
+ : {}),
135
170
  },
136
171
  };
137
172
  const encoded = encodeBase64Url(JSON.stringify(payload));
@@ -69,7 +69,18 @@ function resolveExpiresAt(ttl) {
69
69
  function envelopeFromJson(key, raw) {
70
70
  if (!raw)
71
71
  return null;
72
- const parsed = JSON.parse(raw);
72
+ // A corrupt/undecodable persisted envelope must be treated as absent rather
73
+ // than throwing a raw JSON.parse SyntaxError: an uncaught SyntaxError escapes
74
+ // the provider error taxonomy, is masked as internal_error 500, and is then
75
+ // retried by the hub (2026-07-22 catchtable reserve RCA, candidate A). Returning
76
+ // null also keeps list() from aborting the whole scan on a single bad entry.
77
+ let parsed;
78
+ try {
79
+ parsed = JSON.parse(raw);
80
+ }
81
+ catch {
82
+ return null;
83
+ }
73
84
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
74
85
  return null;
75
86
  }