@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/src/define.ts CHANGED
@@ -25,6 +25,7 @@ import type {
25
25
  ProviderDeploymentOverrides,
26
26
  ProviderHealthMonitorConfig,
27
27
  ProviderProxyConfig,
28
+ ProviderProxyProvider,
28
29
  ProviderPublicProfile,
29
30
  ProviderReviewed,
30
31
  ProviderSecretDeclaration,
@@ -78,7 +79,7 @@ const VALID_RUNTIMES = ["standard", "shared", "browser"] as const;
78
79
  const VALID_AUTH_MODES = ["none", "platform-managed", "credentials", "oauth2"] as const;
79
80
  const VALID_PROVIDER_ACCESS_VISIBILITIES = ["public", "early_access"] as const;
80
81
  const VALID_PROVIDER_PROXY_MODES = ["disabled", "optional", "required"] as const;
81
- const VALID_PROVIDER_PROXY_PROVIDERS = ["smartproxy", "decodo", "custom"] as const;
82
+ const VALID_PROVIDER_PROXY_PROVIDERS = ["smartproxy", "nodemaven", "decodo", "custom"] as const;
82
83
  const VALID_PROVIDER_PROXY_AFFINITIES = [
83
84
  "request",
84
85
  "operation",
@@ -87,6 +88,20 @@ const VALID_PROVIDER_PROXY_AFFINITIES = [
87
88
  ] as const;
88
89
  const VALID_PROVIDER_STT_MODES = ["optional", "required"] as const;
89
90
  const SMARTPROXY_APP_KEY_SECRET = "APIFUSE__PROXY__SMARTPROXY_APP_KEY";
91
+ const NODEMAVEN_USERNAME_SECRET = "APIFUSE__PROXY__NODEMAVEN_USERNAME";
92
+ const NODEMAVEN_PASSWORD_SECRET = "APIFUSE__PROXY__NODEMAVEN_PASSWORD";
93
+ // Per-vendor provider-declared credential secrets. A required-mode chain must
94
+ // declare every secret of every credentialed vendor it names, so a missing
95
+ // credential fails at build/validation time rather than during a live outage: a
96
+ // declared-but-uncredentialed fallback leg is a silently dead SPOF, which is
97
+ // exactly the failure class the multi-vendor chain exists to remove. Vendors
98
+ // absent from this map (e.g. `custom`/`decodo`, whose credentials come from the
99
+ // `APIFUSE__PROXY__URL` bring-your-own escape hatch, not provider secrets) impose
100
+ // no declaration requirement.
101
+ const VENDOR_REQUIRED_SECRETS: Partial<Record<ProviderProxyProvider, readonly string[]>> = {
102
+ smartproxy: [SMARTPROXY_APP_KEY_SECRET],
103
+ nodemaven: [NODEMAVEN_USERNAME_SECRET, NODEMAVEN_PASSWORD_SECRET],
104
+ };
90
105
  const RESERVED_OPERATION_IDS = new Set(["auth", "health"]);
91
106
  const MCP_TOOL_NAME_REGEX = /^[A-Za-z][A-Za-z0-9_]{0,127}$/;
92
107
  const VALID_OPERATION_RISK_CLASSES = ["read", "write", "destructive", "external-send"] as const;
@@ -371,11 +386,24 @@ function validateProviderProxy(config: {
371
386
  },
372
387
  );
373
388
  }
374
- rejectUnknownFields(proxy, new Set(["mode", "provider", "geo", "session"]), "proxy");
389
+ rejectUnknownFields(proxy, new Set(["mode", "provider", "providers", "geo", "session"]), "proxy");
375
390
  assertLiteralField(proxy.mode, "proxy.mode", VALID_PROVIDER_PROXY_MODES, config.id);
376
391
  if (proxy.provider !== undefined) {
377
392
  assertLiteralField(proxy.provider, "proxy.provider", VALID_PROVIDER_PROXY_PROVIDERS, config.id);
378
393
  }
394
+ if (proxy.providers !== undefined) {
395
+ if (!Array.isArray(proxy.providers) || proxy.providers.length === 0) {
396
+ throw new ValidationError(
397
+ `Provider "${config.id}" has invalid proxy.providers: must be a non-empty array of proxy vendors.`,
398
+ {
399
+ fix: `Use proxy.providers: ["smartproxy", "nodemaven"] to declare an ordered fallback chain.`,
400
+ },
401
+ );
402
+ }
403
+ for (const vendor of proxy.providers) {
404
+ assertLiteralField(vendor, "proxy.providers[]", VALID_PROVIDER_PROXY_PROVIDERS, config.id);
405
+ }
406
+ }
379
407
  if (proxy.geo !== undefined) {
380
408
  if (!proxy.geo || typeof proxy.geo !== "object" || Array.isArray(proxy.geo)) {
381
409
  throw new ValidationError(
@@ -433,19 +461,54 @@ function validateProviderProxy(config: {
433
461
  );
434
462
  }
435
463
  }
436
- if (proxy.mode === "required" && proxy.provider === "smartproxy") {
437
- const hasSmartproxySecret = config.secrets?.some(
438
- (secret) => secret.name === SMARTPROXY_APP_KEY_SECRET && secret.required !== false,
439
- );
440
- if (!hasSmartproxySecret) {
441
- throw new ValidationError(
442
- `Provider "${config.id}" requires Smartproxy egress but does not declare ${SMARTPROXY_APP_KEY_SECRET}.`,
443
- {
444
- fix: `Add secrets: [{ name: "${SMARTPROXY_APP_KEY_SECRET}", required: true }] to the provider.`,
445
- },
446
- );
464
+ // Every credentialed vendor in a required-mode chain must declare its
465
+ // provider secret(s) so a missing credential fails at build/validation time,
466
+ // not during a live outage. This covers the fallback legs too (not just the
467
+ // first vendor): a declared-but-uncredentialed nodemaven fallback would leave
468
+ // the chain silently down to a single vendor, reintroducing the SPOF the chain
469
+ // removes.
470
+ const vendorChain =
471
+ proxy.providers && proxy.providers.length > 0
472
+ ? proxy.providers
473
+ : proxy.provider
474
+ ? [proxy.provider]
475
+ : [];
476
+ if (proxy.mode === "required") {
477
+ for (const vendor of vendorChain) {
478
+ const requiredSecrets = VENDOR_REQUIRED_SECRETS[vendor];
479
+ if (!requiredSecrets) continue;
480
+ for (const secretName of requiredSecrets) {
481
+ // Match the canonical runtime gate (assertRequiredSecretsPresent /
482
+ // listMissingRequiredSecrets), which enforces only `required === true`
483
+ // declarations. A declaration that omits `required` (defaulting to
484
+ // optional) is skipped at runtime, so accepting it here would pass
485
+ // validation while leaving the credential unenforced until proxy
486
+ // resolution during a live request — the fail-open gap this check exists
487
+ // to close.
488
+ const declared = config.secrets?.some(
489
+ (secret) => secret.name === secretName && secret.required === true,
490
+ );
491
+ if (!declared) {
492
+ throw new ValidationError(
493
+ `Provider "${config.id}" requires ${vendor} egress but does not declare ${secretName}.`,
494
+ {
495
+ fix: `Add secrets: [{ name: "${secretName}", required: true }] to the provider (every vendor in a required proxy chain must declare its credential secrets).`,
496
+ },
497
+ );
498
+ }
499
+ }
447
500
  }
448
501
  }
502
+ // `decodo`/`custom` are deprecated vendor values (string-union members, so the
503
+ // @deprecated symbol gate can't catch them — warn at validation time instead).
504
+ const deprecatedVendors = vendorChain.filter(
505
+ (vendor) => vendor === "decodo" || vendor === "custom",
506
+ );
507
+ if (deprecatedVendors.length > 0) {
508
+ console.warn(
509
+ `[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.`,
510
+ );
511
+ }
449
512
  }
450
513
 
451
514
  function validateProviderStt(config: { id: string; stt?: ProviderSttConfig }): void {
@@ -11,7 +11,7 @@ import {
11
11
  ProviderChoiceTokenError,
12
12
  type ProviderChoiceTokenPayload,
13
13
  } from "../choice-token.js";
14
- import { ProviderError } from "../errors.js";
14
+ import { isProviderError, ProviderError } from "../errors.js";
15
15
  import type {
16
16
  CredentialContext,
17
17
  EnvContext,
@@ -23,6 +23,7 @@ import type {
23
23
  ProviderRequestContext,
24
24
  ProviderRuntimeState,
25
25
  ProviderStateDurationString,
26
+ StateValue,
26
27
  } from "../types.js";
27
28
 
28
29
  export const PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV =
@@ -359,9 +360,29 @@ async function parseServerStoredChoice(options: {
359
360
  storage,
360
361
  contextState: options.contextState,
361
362
  });
362
- const record = await namespace.get<ProviderChoiceTokenPayload>(
363
- optionsStateKey(options.handle.state_id),
364
- );
363
+ // Reading a server-stored choice back deserializes a persisted value. A
364
+ // corrupt/undecodable value would otherwise surface as a raw JSON.parse
365
+ // SyntaxError (or another unexpected throwable) that escapes the choice error
366
+ // taxonomy, gets masked as internal_error 500, and is treated as retryable by
367
+ // the hub -> reservation restart loop (2026-07-22 catchtable RCA, candidate A).
368
+ // Convert any non-branded throwable into a branded invalid_payload so it maps
369
+ // to a clean, non-retryable 400. Branded ProviderChoiceTokenError and genuine
370
+ // ProviderError (e.g. Redis-unavailable / state-unavailable) pass through so
371
+ // their category/retryable semantics are preserved.
372
+ let record: StateValue<ProviderChoiceTokenPayload> | null;
373
+ try {
374
+ record = await namespace.get<ProviderChoiceTokenPayload>(
375
+ optionsStateKey(options.handle.state_id),
376
+ );
377
+ } catch (error) {
378
+ if (error instanceof ProviderChoiceTokenError || isProviderError(error)) {
379
+ throw error;
380
+ }
381
+ throw new ProviderChoiceTokenError(
382
+ "invalid_payload",
383
+ "Provider choice token state payload could not be decoded.",
384
+ );
385
+ }
365
386
  if (!record) {
366
387
  throw new ProviderChoiceTokenError(
367
388
  "invalid_payload",
@@ -1,5 +1,9 @@
1
1
  import type { ProxyResolutionOptions } from "../config/loader.js";
2
- import { resolveProxyConfigAsync } from "../config/loader.js";
2
+ import {
3
+ policyRotatesTransportVendorChain,
4
+ resolvePolicyTransportAttemptCap,
5
+ resolveProxyConfigAsync,
6
+ } from "../config/loader.js";
3
7
  import { ProviderError, TransportError } from "../errors.js";
4
8
  import { parseSseStream, readableBytes, readableLines, readableTextChunks } from "../stream.js";
5
9
  import type {
@@ -8,6 +12,7 @@ import type {
8
12
  HttpResponse,
9
13
  HttpRetrySummary,
10
14
  HttpStreamResponse,
15
+ ProviderProxyPolicy,
11
16
  RequestOptions,
12
17
  RequestWithMethodOptions,
13
18
  SseMessage,
@@ -41,16 +46,28 @@ type HttpStatusOutcome = {
41
46
  proxyUsed: boolean;
42
47
  };
43
48
 
44
- type NativeHttpAttemptOutcome = HttpResponse | HttpStatusOutcome;
49
+ /**
50
+ * Sentinel returned when a policy-allocator attempt resolved an endpoint that a
51
+ * prior attempt already tried (an under-filled pool repeats endpoints via the
52
+ * modulo mapping before the flat offset crosses into the next vendor). The retry
53
+ * loop advances to the next offset rather than re-issuing the request — but does
54
+ * NOT treat it as chain exhaustion, so the offset still walks into the fallback
55
+ * vendor's span.
56
+ */
57
+ type NativeHttpSkipOutcome = { kind: "dedupe-skip" };
58
+
59
+ type NativeHttpAttemptOutcome = HttpResponse | HttpStatusOutcome | NativeHttpSkipOutcome;
45
60
 
46
61
  type NativeHttpAttemptError = TransportError & { proxyUsed?: boolean };
47
62
 
48
- function isHttpStatusOutcome(
49
- outcome: HttpResponse | HttpStatusOutcome,
50
- ): outcome is HttpStatusOutcome {
63
+ function isHttpStatusOutcome(outcome: NativeHttpAttemptOutcome): outcome is HttpStatusOutcome {
51
64
  return "kind" in outcome && outcome.kind === "http-status";
52
65
  }
53
66
 
67
+ function isDedupeSkipOutcome(outcome: NativeHttpAttemptOutcome): outcome is NativeHttpSkipOutcome {
68
+ return "kind" in outcome && outcome.kind === "dedupe-skip";
69
+ }
70
+
54
71
  async function sleep(ms: number): Promise<void> {
55
72
  if (ms <= 0) return;
56
73
  await new Promise((resolve) => setTimeout(resolve, ms));
@@ -256,6 +273,9 @@ async function resolveNativeProxy(
256
273
  baseProxyAttempt: clientOptions.proxyAttempt,
257
274
  retryAttemptOffset: proxyAttemptOffset,
258
275
  }),
276
+ // Bun's native fetch proxy option tunnels HTTP CONNECT only; SOCKS5 is not
277
+ // supported here, so a socks5 policy fails loudly rather than downgrading.
278
+ transportProtocols: ["http"],
259
279
  telemetry: clientOptions.telemetry,
260
280
  });
261
281
  if (resolvedProxy.shouldWarn) {
@@ -294,6 +314,7 @@ async function fetchNativeHttp(
294
314
  warn: (message: string) => void,
295
315
  statusRetryCodes?: readonly number[],
296
316
  proxyAttemptOffset = 0,
317
+ dedupe?: { attempted: Set<string> },
297
318
  ): Promise<NativeHttpAttemptOutcome> {
298
319
  const requestUrl = appendQueryParams(resolveHttpUrl(baseUrl, url), options.params);
299
320
  const controller = options.timeout ? new AbortController() : undefined;
@@ -303,7 +324,20 @@ async function fetchNativeHttp(
303
324
 
304
325
  let proxy: string | undefined;
305
326
  try {
327
+ // Resolve inside the try (and after the timeout is armed) so allocator
328
+ // failures are branded as TransportErrors and count against the request
329
+ // deadline, exactly as an inline resolve would.
306
330
  proxy = await resolveNativeProxy(options, clientOptions, warn, proxyAttemptOffset);
331
+ // For a registry allocator chain, skip an endpoint a prior attempt already
332
+ // tried rather than re-issuing the same request. Returning the sentinel
333
+ // (instead of breaking) lets the loop keep advancing the flat offset until
334
+ // it crosses into the fallback vendor's pool span.
335
+ if (dedupe && proxy) {
336
+ if (dedupe.attempted.has(proxy)) {
337
+ return { kind: "dedupe-skip" };
338
+ }
339
+ dedupe.attempted.add(proxy);
340
+ }
307
341
  const requestInit: NativeFetchInit = {
308
342
  headers: options.headers,
309
343
  method,
@@ -449,6 +483,58 @@ export function createHttpClient(
449
483
  ? { ...headersOptions, throwOnHttpError: false }
450
484
  : headersOptions;
451
485
 
486
+ // Span the whole vendor chain on transport failures. Like ctx.stealth, a
487
+ // policy-managed proxy resolves a *different* endpoint/vendor per attempt
488
+ // (the flat proxyAttemptOffset rotates across the concatenated vendor pool
489
+ // spans), so a transport failure should advance to the next endpoint —
490
+ // potentially crossing into the fallback vendor — rather than stopping at
491
+ // the per-endpoint retry budget and stranding the request on the primary
492
+ // vendor. resolvePolicyTransportAttemptCap widens the cap to the chain span
493
+ // only for implicit, safe-method allocator requests; explicit retry
494
+ // policies (their documented `attempts` ceiling), unsafe methods, and
495
+ // static/non-registry vendors keep the retry budget. Status-code retries
496
+ // stay bounded by the retry budget regardless; only transport rotation gets
497
+ // the full span.
498
+ const policyProxy: ProviderProxyPolicy | undefined = (() => {
499
+ const policy = clientOptions.proxyPolicy ?? clientOptions.upstream?.proxy;
500
+ return policy && typeof policy === "object" ? policy : undefined;
501
+ })();
502
+ const usesPolicyAllocator = Boolean(policyProxy) && !options.proxy && !clientOptions.proxy;
503
+ const transportAttemptCap = retryOptions
504
+ ? resolvePolicyTransportAttemptCap({
505
+ policy: policyProxy,
506
+ usesPolicyAllocator,
507
+ retryAttempts: retryOptions.attempts,
508
+ explicitRetry,
509
+ method: methodName,
510
+ })
511
+ : 1;
512
+
513
+ // Track resolved endpoints across a policy-allocator chain. Successive
514
+ // attempts rotate the flat offset across the concatenated vendor pool
515
+ // spans, but an under-filled allocation (fewer live endpoints than the
516
+ // configured pool size) makes the modulo mapping repeat endpoints before
517
+ // the offset reaches the next vendor. Rather than re-hammering an
518
+ // already-tried endpoint under backoff, fetchNativeHttp returns a skip
519
+ // sentinel for a duplicate; the loop then advances the flat offset without
520
+ // issuing the request, so it keeps walking toward — and into — the fallback
521
+ // vendor's pool span instead of stalling on the primary vendor.
522
+ // De-duplication is gated on the SAME predicate that widens the attempt cap
523
+ // (implicit, safe-method, registry-chain rotation). It must NOT engage for
524
+ // an explicit retry policy: there the caller's `attempts` count is the
525
+ // contract and each attempt must issue against whatever endpoint it resolves
526
+ // — even a repeat — instead of being silently skipped (which would collapse
527
+ // a `poolSize: 1` + `attempts: 3` request to a single fetch).
528
+ const dedupeAllocatorEndpoints = policyRotatesTransportVendorChain({
529
+ policy: policyProxy,
530
+ usesPolicyAllocator,
531
+ explicitRetry,
532
+ method: methodName,
533
+ });
534
+ const dedupeContext = dedupeAllocatorEndpoints
535
+ ? { attempted: new Set<string>() }
536
+ : undefined;
537
+
452
538
  const executeOnce = (proxyAttemptOffset = 0): Promise<NativeHttpAttemptOutcome> =>
453
539
  fetchNativeHttp(
454
540
  baseUrl,
@@ -459,24 +545,52 @@ export function createHttpClient(
459
545
  warnOnce,
460
546
  statusRetryEnabled ? retryOptions?.statusCodes : undefined,
461
547
  proxyAttemptOffset,
548
+ dedupeContext,
462
549
  );
463
550
 
464
551
  if (!retryEnabled || !retryOptions) {
465
552
  const outcome = await executeOnce();
553
+ if (isDedupeSkipOutcome(outcome)) {
554
+ // Single-shot path never de-duplicates (dedupeContext is undefined),
555
+ // but keep the union total.
556
+ throw new TransportError("HTTP request produced no terminal result", {
557
+ code: "retry_exhausted",
558
+ });
559
+ }
466
560
  if (isHttpStatusOutcome(outcome)) {
467
561
  throw toUpstreamHttpError(outcome.status);
468
562
  }
469
563
  return outcome;
470
564
  }
471
565
 
566
+ let lastError: unknown;
472
567
  let lastErrorCode: string | undefined;
473
568
  let lastStatus: number | undefined;
474
- for (let attempt = 1; attempt <= retryOptions.attempts; attempt += 1) {
569
+ // `attempt` walks the flat proxy offset across the full chain span; `issued`
570
+ // counts requests that were actually sent (skipped duplicate offsets do not
571
+ // increment it). Retry summaries and the status-retry budget must reflect
572
+ // issued requests, not the raw offset, so they stay accurate when partial
573
+ // allocations skip offsets.
574
+ let issued = 0;
575
+ for (let attempt = 1; attempt <= transportAttemptCap; attempt += 1) {
576
+ // Whether this offset actually issued a request (vs. a skipped duplicate),
577
+ // so the catch counts a thrown *transport* failure once without
578
+ // double-counting a status outcome that already incremented before it
579
+ // re-threw as an upstream HTTP error.
580
+ let issuedThisAttempt = false;
475
581
  try {
476
582
  const outcome = await executeOnce(attempt - 1);
583
+ if (isDedupeSkipOutcome(outcome)) {
584
+ // Duplicate endpoint from a partial allocation: advance the flat
585
+ // offset without issuing the request (no backoff, not a failure) so
586
+ // the loop keeps rotating toward the fallback vendor.
587
+ continue;
588
+ }
589
+ issued += 1;
590
+ issuedThisAttempt = true;
477
591
  if (isHttpStatusOutcome(outcome)) {
478
592
  lastStatus = outcome.status;
479
- if (outcome.retryable && attempt < retryOptions.attempts) {
593
+ if (outcome.retryable && issued < retryOptions.attempts) {
480
594
  await sleep(computeProxyTransportRetryDelayMs(retryOptions, attempt, outcome.headers));
481
595
  continue;
482
596
  }
@@ -488,10 +602,10 @@ export function createHttpClient(
488
602
  throw toUpstreamHttpError(response.status);
489
603
  }
490
604
 
491
- if (attempt > 1) {
605
+ if (issued > 1) {
492
606
  const summary: HttpRetrySummary = {
493
- attempts: attempt,
494
- retries: attempt - 1,
607
+ attempts: issued,
608
+ retries: issued - 1,
495
609
  ...(retryOptions.preset ? { preset: retryOptions.preset } : {}),
496
610
  transport: "native",
497
611
  ...(lastErrorCode ? { lastErrorCode } : {}),
@@ -501,11 +615,13 @@ export function createHttpClient(
501
615
  }
502
616
  return response;
503
617
  } catch (error) {
618
+ if (!issuedThisAttempt) issued += 1;
619
+ lastError = error;
504
620
  lastErrorCode = proxyTransportRetryErrorCode(error);
505
621
  lastStatus = proxyTransportRetryErrorStatus(error);
506
622
  const proxyUsed = Boolean((error as NativeHttpAttemptError).proxyUsed);
507
623
  if (
508
- attempt < retryOptions.attempts &&
624
+ attempt < transportAttemptCap &&
509
625
  shouldRetryProxyTransportAttempt({
510
626
  error,
511
627
  explicitRetry,
@@ -521,6 +637,13 @@ export function createHttpClient(
521
637
  }
522
638
  }
523
639
 
640
+ // Reached when the attempt cap is consumed without a terminal outcome —
641
+ // e.g. the final offsets of a partial allocation all resolved to
642
+ // already-tried endpoints and were skipped. Surface the last real transport
643
+ // failure rather than a synthetic exhaustion error.
644
+ if (lastError !== undefined) {
645
+ throw lastError;
646
+ }
524
647
  throw new TransportError("HTTP retry exhausted without a terminal result", {
525
648
  code: "retry_exhausted",
526
649
  });
@@ -19,10 +19,18 @@ const PROXY_AUTH_IP_DENIED_PATTERN =
19
19
  /\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;
20
20
  const PROXY_EDGE_AUTH_REJECTED_PATTERN =
21
21
  /\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;
22
- const PROXY_POOL_STALE_MESSAGE_PATTERN =
23
- /\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;
24
- const PROXY_EDGE_TLS_REJECTED_MESSAGE_PATTERN =
25
- /\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;
22
+ // Vendor host tokens that can appear in upstream error strings. Adding a proxy
23
+ // vendor updates every classifier below in one place. `proxy` is the generic
24
+ // fallback so vendor-agnostic messages still classify.
25
+ const PROXY_VENDOR_ALTERNATION = "smartproxy|nodemaven|proxy";
26
+ const PROXY_POOL_STALE_MESSAGE_PATTERN = new RegExp(
27
+ `\\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`,
28
+ "i",
29
+ );
30
+ const PROXY_EDGE_TLS_REJECTED_MESSAGE_PATTERN = new RegExp(
31
+ `\\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`,
32
+ "i",
33
+ );
26
34
 
27
35
  export function isProxyAuthIpDeniedMessage(message: string): boolean {
28
36
  return PROXY_AUTH_IP_DENIED_PATTERN.test(message);
@@ -0,0 +1,178 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+
3
+ import type { ProviderProxyPolicy } from "../types.js";
4
+
5
+ export const NODEMAVEN_USERNAME_ENV = "APIFUSE__PROXY__NODEMAVEN_USERNAME";
6
+ export const NODEMAVEN_PASSWORD_ENV = "APIFUSE__PROXY__NODEMAVEN_PASSWORD";
7
+ export const NODEMAVEN_FILTER_ENV = "APIFUSE__PROXY__NODEMAVEN_FILTER";
8
+
9
+ export const NODEMAVEN_GATEWAY_HOST = "gate.nodemaven.com";
10
+
11
+ /** Both schemes tunnel bytes end-to-end, preserving the client TLS handshake. */
12
+ export type ProxyProtocol = "http" | "socks5";
13
+
14
+ /**
15
+ * NodeMaven's fastest protocol: HTTP CONNECT. Benchmarks (KR, cold + warm)
16
+ * showed socks5 through the gateway adds ~500ms per request over http, so
17
+ * NodeMaven never defaults to socks5.
18
+ */
19
+ export const NODEMAVEN_DEFAULT_PROTOCOL: ProxyProtocol = "http";
20
+
21
+ /** NodeMaven gateway port ranges per protocol (docs: HTTP 8080-9080, SOCKS5 1080-2080). */
22
+ const NODEMAVEN_PORTS: Record<ProxyProtocol, { min: number; max: number }> = {
23
+ http: { min: 8080, max: 9080 },
24
+ socks5: { min: 1080, max: 2080 },
25
+ };
26
+
27
+ const NODEMAVEN_FILTERS = new Set(["medium", "high"]);
28
+ const DEFAULT_NODEMAVEN_FILTER = "medium";
29
+ const DEFAULT_NODEMAVEN_POOL_SIZE = 20;
30
+ export const NODEMAVEN_MAX_POOL_SIZE = 50;
31
+ /** NodeMaven sticky sessions persist up to 24h server-side, keyed by the sid. */
32
+ const NODEMAVEN_MAX_LIFETIME_MINUTES = 1440;
33
+ const SID_LENGTH = 10;
34
+
35
+ export function hasNodemavenCredentials(): boolean {
36
+ return Boolean(readNodemavenUsername() && readNodemavenPassword());
37
+ }
38
+
39
+ function readNodemavenUsername(): string | undefined {
40
+ return process.env[NODEMAVEN_USERNAME_ENV]?.trim() || undefined;
41
+ }
42
+
43
+ function readNodemavenPassword(): string | undefined {
44
+ return process.env[NODEMAVEN_PASSWORD_ENV]?.trim() || undefined;
45
+ }
46
+
47
+ function resolveNodemavenFilter(): string {
48
+ const raw = process.env[NODEMAVEN_FILTER_ENV]?.trim().toLowerCase();
49
+ if (!raw) return DEFAULT_NODEMAVEN_FILTER;
50
+ if (!NODEMAVEN_FILTERS.has(raw)) {
51
+ throw new Error(`${NODEMAVEN_FILTER_ENV} must be "medium" or "high"`);
52
+ }
53
+ return raw;
54
+ }
55
+
56
+ export function nodemavenPoolSize(policy: ProviderProxyPolicy): number {
57
+ return Math.min(
58
+ NODEMAVEN_MAX_POOL_SIZE,
59
+ Math.max(1, Math.floor(policy.session?.poolSize ?? DEFAULT_NODEMAVEN_POOL_SIZE)),
60
+ );
61
+ }
62
+
63
+ function nodemavenLifetimeMinutes(policy: ProviderProxyPolicy): number {
64
+ const configured = policy.session?.lifetimeMinutes;
65
+ if (typeof configured !== "number" || !Number.isFinite(configured) || configured <= 0) {
66
+ return NODEMAVEN_MAX_LIFETIME_MINUTES;
67
+ }
68
+ return Math.min(NODEMAVEN_MAX_LIFETIME_MINUTES, Math.max(1, Math.floor(configured)));
69
+ }
70
+
71
+ /** NodeMaven username tokens accept `[a-z0-9]`; slugify geo values to that set. */
72
+ function slugifyGeo(value: string | undefined): string | undefined {
73
+ if (!value) return undefined;
74
+ const slug = value
75
+ .trim()
76
+ .toLowerCase()
77
+ .replace(/[^a-z0-9]+/g, "");
78
+ return slug || undefined;
79
+ }
80
+
81
+ function isStickyAffinity(policy: ProviderProxyPolicy): boolean {
82
+ return (policy.session?.affinity ?? "request") !== "request";
83
+ }
84
+
85
+ /**
86
+ * A sticky sid is deterministic from the affinity key so every process serving
87
+ * the same connection derives the same egress IP without shared storage. A
88
+ * rotating sid is random per call (a fresh egress IP per request).
89
+ */
90
+ function deriveSid(
91
+ policy: ProviderProxyPolicy,
92
+ affinityKey: string | undefined,
93
+ poolIndex: number,
94
+ refreshEpoch: number,
95
+ ): string {
96
+ if (!isStickyAffinity(policy) || !affinityKey) {
97
+ return randomBytes(SID_LENGTH).toString("hex").slice(0, SID_LENGTH);
98
+ }
99
+ const digest = createHash("sha256")
100
+ .update(`${affinityKey}:${refreshEpoch}:${poolIndex}`)
101
+ .digest("hex");
102
+ // hex digits are a subset of the allowed [a-z0-9] sid charset.
103
+ return digest.slice(0, SID_LENGTH);
104
+ }
105
+
106
+ function selectPort(protocol: ProxyProtocol, sid: string, poolIndex: number): number {
107
+ const { min, max } = NODEMAVEN_PORTS[protocol];
108
+ const span = max - min + 1;
109
+ const hashInt = Number.parseInt(
110
+ createHash("sha256").update(`${sid}:${poolIndex}`).digest("hex").slice(0, 8),
111
+ 16,
112
+ );
113
+ return min + (hashInt % span);
114
+ }
115
+
116
+ export type NodemavenSynthesisInput = {
117
+ policy: ProviderProxyPolicy;
118
+ affinityKey: string | undefined;
119
+ protocol: ProxyProtocol;
120
+ poolIndex: number;
121
+ refreshEpoch: number;
122
+ /** ISO 3166-1 alpha-2, already resolved by the caller (falls back to env). */
123
+ country?: string;
124
+ };
125
+
126
+ export type NodemavenSynthesis = {
127
+ url: string;
128
+ protocol: ProxyProtocol;
129
+ diagnostics: Record<string, string | number | boolean>;
130
+ };
131
+
132
+ /**
133
+ * Synthesize a NodeMaven gateway proxy URL locally from static credentials.
134
+ * There is no allocation API — geo/session are encoded in the username.
135
+ */
136
+ export function synthesizeNodemavenProxy(input: NodemavenSynthesisInput): NodemavenSynthesis {
137
+ const username = readNodemavenUsername();
138
+ const password = readNodemavenPassword();
139
+ if (!username || !password) {
140
+ throw new Error(
141
+ `NodeMaven credentials missing: set ${NODEMAVEN_USERNAME_ENV} and ${NODEMAVEN_PASSWORD_ENV}.`,
142
+ );
143
+ }
144
+
145
+ const filter = resolveNodemavenFilter();
146
+ const sid = deriveSid(input.policy, input.affinityKey, input.poolIndex, input.refreshEpoch);
147
+ const port = selectPort(input.protocol, sid, input.poolIndex);
148
+ const lifetimeMinutes = nodemavenLifetimeMinutes(input.policy);
149
+
150
+ const country = slugifyGeo(input.country ?? input.policy.geo?.country);
151
+ const region = slugifyGeo(input.policy.geo?.subdivision);
152
+ const city = slugifyGeo(input.policy.geo?.city);
153
+
154
+ const tokens = [username];
155
+ if (country) tokens.push("country", country);
156
+ if (region) tokens.push("region", region);
157
+ if (city) tokens.push("city", city);
158
+ tokens.push("sid", sid);
159
+ tokens.push("filter", filter);
160
+ tokens.push("ipv4", "true");
161
+ const proxyUsername = tokens.join("-");
162
+
163
+ // Username tokens are [a-z0-9-] only, which survive URL encoding unchanged.
164
+ const url = `${input.protocol}://${proxyUsername}:${encodeURIComponent(password)}@${NODEMAVEN_GATEWAY_HOST}:${port}`;
165
+
166
+ return {
167
+ url,
168
+ protocol: input.protocol,
169
+ diagnostics: {
170
+ vendor: "nodemaven",
171
+ protocol: input.protocol,
172
+ sticky: isStickyAffinity(input.policy),
173
+ filter,
174
+ lifetimeMinutes,
175
+ ...(country ? { country } : {}),
176
+ },
177
+ };
178
+ }