@apifuse/provider-sdk 2.2.0-beta.67 → 2.2.0-beta.69

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 (77) hide show
  1. package/AUTHORING.md +34 -2
  2. package/CHANGELOG.md +8 -0
  3. package/README.md +18 -7
  4. package/bin/apifuse-record.ts +81 -21
  5. package/bin/apifuse-submit-check.ts +170 -28
  6. package/dist/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
  7. package/dist/cli/templates/provider/README.md.tpl +17 -9
  8. package/dist/config/loader.d.ts +16 -0
  9. package/dist/config/loader.js +25 -8
  10. package/dist/error-resolution.d.ts +13 -0
  11. package/dist/error-resolution.js +72 -35
  12. package/dist/fixture-sanitization.js +159 -15
  13. package/dist/lint.d.ts +2 -0
  14. package/dist/lint.js +549 -11
  15. package/dist/runtime/diagnostic-redactor.d.ts +6 -1
  16. package/dist/runtime/diagnostic-redactor.js +16 -14
  17. package/dist/runtime/executor.js +3 -0
  18. package/dist/runtime/http.d.ts +2 -0
  19. package/dist/runtime/http.js +17 -103
  20. package/dist/runtime/instrumentation.js +4 -1
  21. package/dist/runtime/provider.d.ts +7 -0
  22. package/dist/runtime/provider.js +14 -0
  23. package/dist/runtime/redirect-hop-headers.d.ts +19 -0
  24. package/dist/runtime/redirect-hop-headers.js +58 -0
  25. package/dist/runtime/redirects.d.ts +33 -1
  26. package/dist/runtime/redirects.js +113 -0
  27. package/dist/runtime/resolver-span-attributes.d.ts +34 -0
  28. package/dist/runtime/resolver-span-attributes.js +93 -0
  29. package/dist/runtime/resolver-usage.d.ts +8 -0
  30. package/dist/runtime/resolver-usage.js +5 -11
  31. package/dist/runtime/resolver-vendors/browser.js +5 -4
  32. package/dist/runtime/resolver-vendors/capsolver.js +11 -10
  33. package/dist/runtime/resolver-vendors/twocaptcha.js +9 -8
  34. package/dist/runtime/resolver.js +7 -6
  35. package/dist/runtime/stealth-owned-headers.d.ts +21 -8
  36. package/dist/runtime/stealth-owned-headers.js +28 -8
  37. package/dist/runtime/stealth-telemetry.d.ts +1 -1
  38. package/dist/runtime/stealth.js +158 -46
  39. package/dist/runtime/trace.js +2 -2
  40. package/dist/server/index.d.ts +1 -0
  41. package/dist/server/serve-implementation.d.ts +2 -1
  42. package/dist/server/serve-implementation.js +45 -30
  43. package/dist/server/state-backend-report.d.ts +70 -0
  44. package/dist/server/state-backend-report.js +113 -0
  45. package/dist/testing/run.js +14 -7
  46. package/dist/trace-sanitization.js +1 -1
  47. package/dist/types.d.ts +30 -6
  48. package/package.json +2 -2
  49. package/src/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
  50. package/src/cli/templates/provider/README.md.tpl +17 -9
  51. package/src/config/loader.ts +49 -12
  52. package/src/error-resolution.ts +89 -35
  53. package/src/fixture-sanitization.ts +175 -17
  54. package/src/lint.ts +595 -10
  55. package/src/runtime/diagnostic-redactor.ts +19 -13
  56. package/src/runtime/executor.ts +3 -0
  57. package/src/runtime/http.ts +24 -112
  58. package/src/runtime/instrumentation.ts +4 -1
  59. package/src/runtime/provider.ts +15 -0
  60. package/src/runtime/redirect-hop-headers.ts +71 -0
  61. package/src/runtime/redirects.ts +150 -1
  62. package/src/runtime/resolver-span-attributes.ts +139 -0
  63. package/src/runtime/resolver-usage.ts +8 -5
  64. package/src/runtime/resolver-vendors/browser.ts +5 -4
  65. package/src/runtime/resolver-vendors/capsolver.ts +11 -10
  66. package/src/runtime/resolver-vendors/twocaptcha.ts +9 -8
  67. package/src/runtime/resolver.ts +7 -6
  68. package/src/runtime/stealth-owned-headers.ts +34 -8
  69. package/src/runtime/stealth-telemetry.ts +6 -1
  70. package/src/runtime/stealth.ts +180 -58
  71. package/src/runtime/trace.ts +6 -2
  72. package/src/server/index.ts +4 -0
  73. package/src/server/serve-implementation.ts +58 -31
  74. package/src/server/state-backend-report.ts +185 -0
  75. package/src/testing/run.ts +15 -2
  76. package/src/trace-sanitization.ts +1 -1
  77. package/src/types.ts +30 -6
@@ -38,7 +38,7 @@ import type {
38
38
  } from "../types.js";
39
39
  import { chrome149HeaderOrder } from "./chrome149-header-order.js";
40
40
  import { registerDiagnosticValue } from "./diagnostic-env.js";
41
- import { isStealthOwnedHeaderName } from "./stealth-owned-headers.js";
41
+ import { assertCallerHeadersSupported } from "./stealth-owned-headers.js";
42
42
  import {
43
43
  ENGINE_CEREMONY_EGRESS_LEASE,
44
44
  type CeremonyEgressBinding,
@@ -68,10 +68,15 @@ import {
68
68
  shouldRetryProxyTransportAttempt,
69
69
  validateUnsafeProxyTransportRetryMethods,
70
70
  } from "./proxy-retry-policy.js";
71
+ import { redirectHopHeaders } from "./redirect-hop-headers.js";
71
72
  import {
72
73
  evaluateRedirectHop,
74
+ invalidRedirectPolicy,
73
75
  isRedirectStatus,
74
76
  nextRedirectMethod,
77
+ nextSameOriginRedirectHop,
78
+ normalizeRedirectPolicy,
79
+ type RedirectPolicyTransport,
75
80
  resolveRedirectUrl,
76
81
  } from "./redirects.js";
77
82
  import {
@@ -139,12 +144,10 @@ function isBoundEgressTransportFailure(error: TransportError): boolean {
139
144
  );
140
145
  }
141
146
  const MAX_STEALTH_REDIRECT_HOPS = 10;
142
- const REDIRECT_BODY_HEADERS = new Set([
143
- "content-encoding",
144
- "content-language",
145
- "content-location",
146
- "content-type",
147
- ]);
147
+ const STEALTH_REDIRECT_POLICY_TRANSPORT: RedirectPolicyTransport = {
148
+ label: "ctx.stealth",
149
+ maxHops: MAX_STEALTH_REDIRECT_HOPS,
150
+ };
148
151
 
149
152
  function isRecord(value: unknown): value is Record<PropertyKey, unknown> {
150
153
  return typeof value === "object" && value !== null;
@@ -399,6 +402,8 @@ function normalizedHeaderEntries(
399
402
  // installs Accept-Language via DevTools next to User-Agent; real Chrome only
400
403
  // receives it from //net (URLRequestHttpJob::AddExtraHeaders), after
401
404
  // Accept-Encoding and before Cookie, as al-placement-capture.json B/C show.
405
+ // chrome-form-post-capture.json was taken without that option and needs no
406
+ // adjustment.
402
407
  const CHROME_HEADER_ORDERS = {
403
408
  navigation: [
404
409
  "sec-ch-ua",
@@ -435,7 +440,28 @@ const CHROME_HEADER_ORDERS = {
435
440
  "cookie",
436
441
  "priority",
437
442
  ],
438
- } as const;
443
+ "form-post": [
444
+ "content-length",
445
+ "cache-control",
446
+ "sec-ch-ua",
447
+ "sec-ch-ua-mobile",
448
+ "sec-ch-ua-platform",
449
+ "upgrade-insecure-requests",
450
+ "content-type",
451
+ "user-agent",
452
+ "origin",
453
+ "accept",
454
+ "sec-fetch-site",
455
+ "sec-fetch-mode",
456
+ "sec-fetch-user",
457
+ "sec-fetch-dest",
458
+ "referer",
459
+ "accept-encoding",
460
+ "accept-language",
461
+ "cookie",
462
+ "priority",
463
+ ],
464
+ } as const satisfies Record<Exclude<ChromeRequestClass, "xhr">, readonly string[]>;
439
465
 
440
466
  const CHROME_H1_HEADER_ORDERS = {
441
467
  navigation: [
@@ -492,7 +518,29 @@ const CHROME_H1_HEADER_ORDERS = {
492
518
  "accept-language",
493
519
  "cookie",
494
520
  ],
495
- } as const;
521
+ "form-post": [
522
+ "host",
523
+ "connection",
524
+ "content-length",
525
+ "cache-control",
526
+ "sec-ch-ua",
527
+ "sec-ch-ua-mobile",
528
+ "sec-ch-ua-platform",
529
+ "upgrade-insecure-requests",
530
+ "content-type",
531
+ "user-agent",
532
+ "origin",
533
+ "accept",
534
+ "sec-fetch-site",
535
+ "sec-fetch-mode",
536
+ "sec-fetch-user",
537
+ "sec-fetch-dest",
538
+ "referer",
539
+ "accept-encoding",
540
+ "accept-language",
541
+ "cookie",
542
+ ],
543
+ } as const satisfies Record<ChromeRequestClass, readonly string[]>;
496
544
 
497
545
  const CHROME_H1_HEADER_NAMES: Record<string, string> = {
498
546
  host: "Host",
@@ -516,7 +564,30 @@ const CHROME_H1_HEADER_NAMES: Record<string, string> = {
516
564
  range: "Range",
517
565
  };
518
566
 
519
- type ChromeRequestClass = keyof typeof CHROME_HEADER_ORDERS | "xhr";
567
+ type ChromeRequestClass = NonNullable<NonNullable<StealthFetchOptions["stealth"]>["requestClass"]>;
568
+
569
+ /**
570
+ * What each request class sends besides its header order, one row per public
571
+ * `stealth.requestClass`:
572
+ *
573
+ * - `fetchMetadata: "navigate"` is a document navigation: `Sec-Fetch-Mode:
574
+ * navigate`, `Sec-Fetch-Dest: document`, `Sec-Fetch-User: ?1` unless
575
+ * `userActivation` is false, `Upgrade-Insecure-Requests: 1`, and the
576
+ * profile's `Accept` and `Priority`. `"cors"` is a script fetch: `cors` /
577
+ * `empty`, `Accept: *\/*`, `Priority: u=1, i`.
578
+ * - `bodyful` sends `Content-Length`.
579
+ * - `validateCache` sends `Cache-Control: max-age=0`, which Chrome adds to a
580
+ * form submission (chrome-form-post-capture.json).
581
+ */
582
+ const CHROME_REQUEST_CLASSES = {
583
+ navigation: { fetchMetadata: "navigate", bodyful: false, validateCache: false },
584
+ "form-post": { fetchMetadata: "navigate", bodyful: true, validateCache: true },
585
+ xhr: { fetchMetadata: "cors", bodyful: false, validateCache: false },
586
+ post: { fetchMetadata: "cors", bodyful: true, validateCache: false },
587
+ } as const satisfies Record<
588
+ ChromeRequestClass,
589
+ { fetchMetadata: "navigate" | "cors"; bodyful: boolean; validateCache: boolean }
590
+ >;
520
591
 
521
592
  function chromeHeaderOrder(
522
593
  requestClass: ChromeRequestClass,
@@ -547,33 +618,11 @@ function chromeHeaderOrder(
547
618
  return order;
548
619
  }
549
620
 
550
- function normalizedCallerHeaderEntries(
551
- headers: Record<string, string | string[] | undefined>,
552
- ): HeaderTuple[] {
553
- const entries = normalizedHeaderEntries(headers);
554
- assertCallerHeadersSupported(entries);
555
- return entries;
556
- }
557
-
558
621
  function normalizedCallerHeaderEntriesFromRecord(headers: Record<string, string>): HeaderTuple[] {
559
622
  // The record has been through normalizeHeaders (lowercase, merged names); the
560
623
  // lowercase here only guards later insertions such as the cookie jar's header.
561
- const entries: HeaderTuple[] = Object.entries(headers).map(([name, value]) => [
562
- name.toLowerCase(),
563
- value,
564
- ]);
565
- assertCallerHeadersSupported(entries);
566
- return entries;
567
- }
568
-
569
- function assertCallerHeadersSupported(entries: readonly HeaderTuple[]): void {
570
- for (const [name] of entries) {
571
- if (isStealthOwnedHeaderName(name)) {
572
- throw new SDKError(`Stealth transport owns the "${name}" header; remove it from headers.`, {
573
- code: "STEALTH_HEADER_OVERRIDE_UNSUPPORTED",
574
- });
575
- }
576
- }
624
+ assertCallerHeadersSupported(headers);
625
+ return Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]);
577
626
  }
578
627
 
579
628
  function chromeRequestClass(
@@ -621,9 +670,10 @@ function buildChromeHeaderTuples(options: {
621
670
  ),
622
671
  );
623
672
  const requestClass = chromeRequestClass(options.method, options.requestClass);
673
+ const shape = CHROME_REQUEST_CLASSES[requestClass];
624
674
  const referer = caller.get("referer");
625
675
  const fetchSite = secFetchSite(options.requestUrl, referer);
626
- const isNavigation = requestClass === "navigation";
676
+ const isNavigation = shape.fetchMetadata === "navigate";
627
677
  const values = new Map<string, string>([
628
678
  ["sec-ch-ua", requiredEmulationHeader(emulation, "sec-ch-ua")],
629
679
  ["sec-ch-ua-mobile", requiredEmulationHeader(emulation, "sec-ch-ua-mobile")],
@@ -649,12 +699,16 @@ function buildChromeHeaderTuples(options: {
649
699
  values.set("upgrade-insecure-requests", "1");
650
700
  if (options.userActivation !== false) values.set("sec-fetch-user", "?1");
651
701
  }
702
+ if (shape.validateCache) values.set("cache-control", "max-age=0");
652
703
  for (const [name, value] of callerEntries) values.set(name, value);
653
704
  for (const name of ["content-type", "origin", "referer"] as const) {
654
705
  const value = caller.get(name);
655
706
  if (value !== undefined) values.set(name, value);
656
707
  }
657
- if (requestClass === "post") {
708
+ // A bodyful class keeps its class across a 301/302/303 that rewrites the method
709
+ // to GET (a form submission's redirect stays a validated navigation), but the
710
+ // rewritten request has no body and Chrome sends no Content-Length for it.
711
+ if (shape.bodyful && options.method !== "GET" && options.method !== "HEAD") {
658
712
  values.set(
659
713
  "content-length",
660
714
  caller.get("content-length") ?? String(Buffer.byteLength(options.body ?? "")),
@@ -1194,10 +1248,30 @@ function locationHeader(headers: Record<string, string>): string | undefined {
1194
1248
  return undefined;
1195
1249
  }
1196
1250
 
1197
- function withoutRedirectBodyHeaders(headers: Record<string, string>): Record<string, string> {
1198
- return Object.fromEntries(
1199
- Object.entries(headers).filter(([name]) => !REDIRECT_BODY_HEADERS.has(name.toLowerCase())),
1200
- );
1251
+ /**
1252
+ * Snapshot a caller's `redirectPolicy` before the first send. The policy acts
1253
+ * on redirects the transport follows, so it requires the default
1254
+ * `redirect: "follow"`.
1255
+ */
1256
+ function snapshotStealthRedirectPolicy(options: StealthFetchOptions) {
1257
+ let policy: ReturnType<typeof normalizeRedirectPolicy>;
1258
+ try {
1259
+ policy = normalizeRedirectPolicy(options.redirectPolicy, STEALTH_REDIRECT_POLICY_TRANSPORT);
1260
+ } catch (error) {
1261
+ if (error instanceof TransportError) throw error;
1262
+ throw invalidRedirectPolicy(
1263
+ STEALTH_REDIRECT_POLICY_TRANSPORT,
1264
+ "could not be read safely",
1265
+ error instanceof Error ? error : undefined,
1266
+ );
1267
+ }
1268
+ if (policy && options.redirect !== undefined && options.redirect !== "follow") {
1269
+ throw invalidRedirectPolicy(
1270
+ STEALTH_REDIRECT_POLICY_TRANSPORT,
1271
+ `it applies to followed redirects and cannot be combined with redirect: "${options.redirect}"`,
1272
+ );
1273
+ }
1274
+ return policy;
1201
1275
  }
1202
1276
 
1203
1277
  function assertStealthRedirectUrl(url: string): void {
@@ -1318,7 +1392,9 @@ function startStealthExchange(
1318
1392
  requestClass:
1319
1393
  requestClass === "navigation" && options.stealth?.userActivation === false
1320
1394
  ? "script_navigation"
1321
- : requestClass,
1395
+ : requestClass === "form-post"
1396
+ ? "form_post"
1397
+ : requestClass,
1322
1398
  ...(status === undefined ? {} : { status }),
1323
1399
  ...(errorCode ? { errorCode } : {}),
1324
1400
  ...(error === undefined
@@ -1359,6 +1435,10 @@ async function fetchStealthRedirectChain(
1359
1435
  let followedHops = 0;
1360
1436
  let response: StealthTransportResponse;
1361
1437
  const deadline = options.timeout ? performance.now() + options.timeout : undefined;
1438
+ // Snapshotted and validated by session.fetch before the first send.
1439
+ const redirectPolicy = options.redirectPolicy;
1440
+ const initialOrigin = new URL(requestUrl).origin;
1441
+ const visitedRequests = new Set([`${method} ${requestUrl}`]);
1362
1442
 
1363
1443
  let finishExchange: ReturnType<typeof startStealthExchange> | undefined;
1364
1444
  let status: number | undefined;
@@ -1415,28 +1495,48 @@ async function fetchStealthRedirectChain(
1415
1495
  });
1416
1496
  }
1417
1497
 
1418
- const nextUrl = resolveRedirectUrl(
1419
- response.headers.get("location") ?? undefined,
1420
- response.url ?? currentUrl,
1421
- );
1422
- if (!nextUrl) break;
1423
- if (followedHops >= MAX_STEALTH_REDIRECT_HOPS) {
1498
+ let nextUrl: string;
1499
+ let nextMethod: StealthMethod;
1500
+ if (redirectPolicy) {
1424
1501
  discardStealthRedirectBody(response);
1425
- throw new TransportError(
1426
- `Stealth request exceeded the ${MAX_STEALTH_REDIRECT_HOPS}-redirect limit`,
1427
- { code: "transport_network_error", status: 0 },
1502
+ ({ nextUrl, nextMethod } = nextSameOriginRedirectHop({
1503
+ policy: redirectPolicy,
1504
+ initialOrigin,
1505
+ currentUrl,
1506
+ responseUrl: response.url ?? currentUrl,
1507
+ status: response.status,
1508
+ method: currentMethod,
1509
+ location: response.headers.get("location") ?? undefined,
1510
+ followedHops,
1511
+ visitedRequests,
1512
+ }));
1513
+ } else {
1514
+ const location = resolveRedirectUrl(
1515
+ response.headers.get("location") ?? undefined,
1516
+ response.url ?? currentUrl,
1428
1517
  );
1518
+ if (!location) break;
1519
+ if (followedHops >= MAX_STEALTH_REDIRECT_HOPS) {
1520
+ discardStealthRedirectBody(response);
1521
+ throw new TransportError(
1522
+ `Stealth request exceeded the ${MAX_STEALTH_REDIRECT_HOPS}-redirect limit`,
1523
+ { code: "transport_network_error", status: 0 },
1524
+ );
1525
+ }
1526
+ discardStealthRedirectBody(response);
1527
+ nextUrl = location;
1528
+ nextMethod = nextRedirectMethod(response.status, currentMethod);
1429
1529
  }
1430
1530
  assertStealthRedirectUrl(nextUrl);
1431
- discardStealthRedirectBody(response);
1432
- const nextMethod = nextRedirectMethod(response.status, currentMethod);
1433
- if (nextMethod !== currentMethod) {
1434
- currentBody = undefined;
1435
- currentHeaders = withoutRedirectBodyHeaders(currentHeaders);
1436
- }
1531
+ currentHeaders = redirectHopHeaders(
1532
+ { from: currentUrl, to: nextUrl, methodChanged: nextMethod !== currentMethod },
1533
+ currentHeaders,
1534
+ );
1535
+ if (nextMethod !== currentMethod) currentBody = undefined;
1437
1536
  currentMethod = nextMethod;
1438
1537
  currentUrl = nextUrl;
1439
1538
  followedHops += 1;
1539
+ visitedRequests.add(`${currentMethod} ${currentUrl}`);
1440
1540
  emitStealthTelemetry(telemetry?.sink, (sink) => sink.recordRedirectHop());
1441
1541
  finishExchange(status);
1442
1542
  finishExchange = undefined;
@@ -1558,10 +1658,15 @@ function createSessionFetcher(
1558
1658
  let entry = clients.get(cacheKey);
1559
1659
  if (!entry) {
1560
1660
  entry = {
1661
+ // The header shape only partitions sessions; it is never handed to wreq as
1662
+ // session `defaultHeaders`. wreq merges those into every request that lacks
1663
+ // one of their names, so a header the SDK left off on purpose — a credential
1664
+ // dropped on a cross-origin redirect hop, the body headers after POST -> GET,
1665
+ // a cookie for another host — would come back with the first request's value.
1666
+ // Every request already sends its complete ordered header list.
1561
1667
  session: wreq.createSession({
1562
1668
  browser,
1563
1669
  os,
1564
- ...(defaultHeaders ? { defaultHeaders } : {}),
1565
1670
  ...(proxyUrl ? { proxy: proxyUrl } : {}),
1566
1671
  ...(ignoreTlsErrors ? { insecure: true } : {}),
1567
1672
  timeout: 30_000,
@@ -1761,7 +1866,11 @@ function createSessionFetcher(
1761
1866
  const { hasExplicitRetryPolicy, method, stealthRetryOptions } = (() => {
1762
1867
  try {
1763
1868
  const method = normalizeMethod(options.method ?? "GET");
1764
- normalizedCallerHeaderEntries(options.headers ?? {});
1869
+ assertCallerHeadersSupported(options.headers ?? {});
1870
+ // Replace the caller's object with the validated copy: the walker, a
1871
+ // retry and an explicit replay all read this snapshot.
1872
+ const redirectPolicy = snapshotStealthRedirectPolicy(options);
1873
+ if (redirectPolicy) options.redirectPolicy = redirectPolicy;
1765
1874
  const hasExplicitRetryPolicy = options.retry !== undefined;
1766
1875
  const stealthRetryOptions =
1767
1876
  normalizeProxyTransportRetryOptions(options.retry, {
@@ -2527,11 +2636,13 @@ function createSessionFetcher(
2527
2636
  stopWhen,
2528
2637
  params,
2529
2638
  sensitiveParams,
2639
+ headers: initialHeaders,
2530
2640
  ...fetchOptions
2531
2641
  } = options;
2532
2642
  const hops: StealthRedirectHop[] = [];
2533
2643
  let method = normalizeMethod(options.method ?? "GET");
2534
2644
  let body = options.body;
2645
+ let headers = initialHeaders;
2535
2646
  let response: StealthResponse | undefined;
2536
2647
  const visitedRequests = new Set<string>();
2537
2648
  const initialParams = params
@@ -2593,6 +2704,7 @@ function createSessionFetcher(
2593
2704
  try {
2594
2705
  response = await session.fetch(currentUrl, {
2595
2706
  ...fetchOptions,
2707
+ ...(headers ? { headers } : {}),
2596
2708
  body,
2597
2709
  method,
2598
2710
  ...(hopIndex === 0 && initialParams ? { params: initialParams } : {}),
@@ -2689,6 +2801,16 @@ function createSessionFetcher(
2689
2801
  cookieStore: cookieJar.serialize(),
2690
2802
  };
2691
2803
  }
2804
+ if (headers) {
2805
+ headers = redirectHopHeaders(
2806
+ {
2807
+ from: currentUrl,
2808
+ to: decision.nextUrl,
2809
+ methodChanged: decision.nextMethod !== method,
2810
+ },
2811
+ headers,
2812
+ );
2813
+ }
2692
2814
  if (decision.nextMethod !== method) {
2693
2815
  body = undefined;
2694
2816
  }
@@ -191,7 +191,11 @@ function normalizeAttributes(
191
191
  ? redactedKey === REDACTION_FAILED
192
192
  ? REDACTION_FAILED
193
193
  : REDACTED_QUERY_VALUE
194
- : normalizeAttributeValue(value, diagnosticAttributeRedactor(key, redact), retainBigints);
194
+ : normalizeAttributeValue(
195
+ value,
196
+ diagnosticAttributeRedactor(key, redact, value),
197
+ retainBigints,
198
+ );
195
199
  return [normalizedKey, normalizedValue] as const;
196
200
  })
197
201
  .filter(
@@ -226,7 +230,7 @@ function prepareResourceAttributesForExport(
226
230
  ? redactedKey === REDACTION_FAILED
227
231
  ? REDACTION_FAILED
228
232
  : REDACTED_QUERY_VALUE
229
- : sanitizeSpanNameForOutput(value, diagnosticAttributeRedactor(key, redact)),
233
+ : sanitizeSpanNameForOutput(value, diagnosticAttributeRedactor(key, redact, value)),
230
234
  ];
231
235
  }),
232
236
  );
@@ -115,6 +115,10 @@ export {
115
115
  type ServeOptions,
116
116
  serve,
117
117
  } from "./serve.js";
118
+ export type {
119
+ ProviderStateBackendLogEvent,
120
+ ProviderStoreBackendReport,
121
+ } from "./state-backend-report.js";
118
122
  export type { ProviderErrorObservability } from "../errors.js";
119
123
  export {
120
124
  computeSelfTestPlanDigest,
@@ -29,6 +29,7 @@ import { SDK_VERSION } from "./sdk-version.js";
29
29
  import { safeProviderErrorObservability } from "../error-observability.js";
30
30
  import { providerErrorStackFrames } from "./error-stack-frames.js";
31
31
  import {
32
+ registeredUpstreamErrorCategory,
32
33
  SDK_OWNED_PROVIDER_ERROR_CODES,
33
34
  SDK_RUNTIME_OWNED_ERROR_CODES,
34
35
  SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES,
@@ -106,7 +107,7 @@ import { wrapWithInstrumentation } from "../runtime/instrumentation.js";
106
107
  import type * as NativeNetworkRuntimeModule from "../runtime/native-network.js";
107
108
  import { createOcrClientFromEnv } from "../runtime/ocr.js";
108
109
  import { bindOcrTelemetry, OcrTelemetryCollector } from "../runtime/ocr-telemetry.js";
109
- import { getProviderBaseUrl } from "../runtime/provider.js";
110
+ import { getProviderBaseUrl, getProviderStealthBaseUrl } from "../runtime/provider.js";
110
111
  import {
111
112
  PROXY_AUTH_IP_DENIED_CODE,
112
113
  PROXY_EDGE_AUTH_REJECTED_CODE,
@@ -126,6 +127,7 @@ import {
126
127
  tenantOpaqueKeys,
127
128
  } from "../runtime/request-telemetry.js";
128
129
  import type * as ResolverRuntimeModule from "../runtime/resolver.js";
130
+ import { resolverSpanAttributes } from "../runtime/resolver-span-attributes.js";
129
131
  import {
130
132
  bindResolverTelemetry,
131
133
  createUnsupportedResolverClient,
@@ -216,6 +218,10 @@ import type {
216
218
  } from "../types.js";
217
219
  import { VALID_OPERATION_ERROR_STATUSES } from "../types.js";
218
220
  import type { SelfTestCancellationLogEvent } from "./self-test.js";
221
+ import {
222
+ type ProviderStateBackendLogEvent,
223
+ providerStateBackendLogEvent,
224
+ } from "./state-backend-report.js";
219
225
  import { resolveSelfTestMasterSecrets } from "./self-test-token.js";
220
226
  import {
221
227
  collectStaticDiagnosticSensitiveValues,
@@ -635,15 +641,6 @@ function bindResolverSignalWithoutRuntime(
635
641
  return boundResolver;
636
642
  }
637
643
 
638
- function getProviderStealthBaseUrl(provider: ProviderDefinition): string | undefined {
639
- const baseUrl = getProviderBaseUrl(provider);
640
- if (baseUrl) {
641
- return baseUrl;
642
- }
643
- const firstHost = provider.allowedHosts?.[0];
644
- return firstHost ? `https://${firstHost}` : undefined;
645
- }
646
-
647
644
  function getProviderStealthProfile(provider: ProviderDefinition) {
648
645
  return provider.stealth ? getStealthProfile(provider.stealth) : undefined;
649
646
  }
@@ -706,7 +703,7 @@ function createStealthChallengeDetection(
706
703
  const solve = () => resolver.solve(challenge, solveSignal, recorder ?? undefined);
707
704
  return recorder
708
705
  ? recorder.runSpan("resolver.solve", solve, {
709
- attributes: { challenge_kind: challenge.kind },
706
+ attributes: resolverSpanAttributes({ challenge_kind: challenge.kind }),
710
707
  })
711
708
  : solve();
712
709
  },
@@ -1476,6 +1473,7 @@ export type ProviderServerLogEvent =
1476
1473
  */
1477
1474
  warnings?: readonly string[];
1478
1475
  }
1476
+ | ProviderStateBackendLogEvent
1479
1477
  | SelfTestCancellationLogEvent;
1480
1478
 
1481
1479
  export type ProviderServerLogger = (event: ProviderServerLogEvent) => void;
@@ -1607,8 +1605,9 @@ function zodDetails(
1607
1605
  // (credential_unavailable) usually means a caller credential problem, an
1608
1606
  // internal stateful-routing deadline is APIFuse-owned despite its timeout
1609
1607
  // category, and the built-in upstream failure families keep their upstream
1610
- // attribution even when the author left the category at the provider_error
1611
- // default.
1608
+ // attribution even under an explicit non-upstream category. An unset category
1609
+ // needs no override: a registered upstream code already carries its category
1610
+ // (SDK_UPSTREAM_ERROR_CODE_REGISTRY), and the source follows from it.
1612
1611
  function publicErrorSource(error: unknown, category: ProviderErrorCategory): ProviderErrorSource {
1613
1612
  if (error instanceof StatefulRoutingDeadlineError) return "apifuse";
1614
1613
  if (isProviderError(error)) {
@@ -1931,6 +1930,24 @@ function providerObservabilityDetails(
1931
1930
  };
1932
1931
  }
1933
1932
 
1933
+ // The category a `ProviderError` (validation errors included) gets when its
1934
+ // author set none, ahead of the branch's own default: an operation-declared
1935
+ // rejection status (409/410/422) → upstream_rejected, then the registered
1936
+ // upstream code's category (UPSTREAM_SCHEMA_ERROR → upstream_schema_drift,
1937
+ // NOT_FOUND → upstream_http, ...). `undefined` leaves the branch default.
1938
+ function derivedErrorCategory(
1939
+ error: unknown,
1940
+ declaredStatus: unknown,
1941
+ ): ProviderErrorCategory | undefined {
1942
+ if (
1943
+ isEmittableErrorStatus(declaredStatus) &&
1944
+ categoryForStatus(declaredStatus) === "upstream_rejected"
1945
+ ) {
1946
+ return "upstream_rejected";
1947
+ }
1948
+ return registeredUpstreamErrorCategory(providerErrorCode(error));
1949
+ }
1950
+
1934
1951
  function classifiedErrorObservabilityDetails(
1935
1952
  error: unknown,
1936
1953
  declaredErrorCode?: OperationErrorCode,
@@ -1945,12 +1962,10 @@ function classifiedErrorObservabilityDetails(
1945
1962
  return {
1946
1963
  category: providerCategory
1947
1964
  ? providerCategory
1948
- : isEmittableErrorStatus(declaredStatus) &&
1949
- categoryForStatus(declaredStatus) === "upstream_rejected"
1950
- ? "upstream_rejected"
1951
- : isEmittableErrorStatus(declaredStatus) && declaredStatus >= 500
1965
+ : (derivedErrorCategory(error, declaredStatus) ??
1966
+ (isEmittableErrorStatus(declaredStatus) && declaredStatus >= 500
1952
1967
  ? "provider_error"
1953
- : "input_validation",
1968
+ : "input_validation")),
1954
1969
  taxonomyVersion: PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
1955
1970
  retryable: isProviderError(error)
1956
1971
  ? (providerErrorOption(error, "retryable") ?? effectiveDeclaration?.retryable ?? false)
@@ -1967,19 +1982,13 @@ function classifiedErrorObservabilityDetails(
1967
1982
  }
1968
1983
 
1969
1984
  if (isProviderError(error)) {
1970
- // Deterministic upstream refusals default to the rejection category:
1971
- // the UPSTREAM_REJECTED family and any operation-declared rejection
1972
- // status (409/410/422) classify as upstream_rejected unless the
1973
- // author set an explicit category.
1974
- const declaredStatus = effectiveDeclaration?.status;
1975
- const rejectionDefault =
1976
- providerErrorCode(error) === "UPSTREAM_REJECTED" ||
1977
- (isEmittableErrorStatus(declaredStatus) &&
1978
- categoryForStatus(declaredStatus) === "upstream_rejected")
1979
- ? ("upstream_rejected" as const)
1980
- : ("provider_error" as const);
1985
+ // Retryability does not follow the derived category: it stays
1986
+ // instance ?? declared ?? false below.
1981
1987
  return {
1982
- category: providerErrorOption(error, "category") ?? rejectionDefault,
1988
+ category:
1989
+ providerErrorOption(error, "category") ??
1990
+ derivedErrorCategory(error, effectiveDeclaration?.status) ??
1991
+ "provider_error",
1983
1992
  taxonomyVersion: PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
1984
1993
  retryable:
1985
1994
  providerErrorOption(error, "retryable") ?? effectiveDeclaration?.retryable ?? false,
@@ -3755,7 +3764,13 @@ function createServerAppWithCapabilityModules(
3755
3764
  }
3756
3765
 
3757
3766
  app.notFound((c) => {
3758
- const error = new ProviderError("Not found", { code: "not_found", retryable: false });
3767
+ // An unknown route is not an upstream answer: the explicit category keeps
3768
+ // the registered not_found code from attributing it to an upstream.
3769
+ const error = new ProviderError("Not found", {
3770
+ code: "not_found",
3771
+ category: "provider_error",
3772
+ retryable: false,
3773
+ });
3759
3774
  const observabilityDetails = errorObservabilityDetails(error);
3760
3775
  return responseWithErrorObservability(
3761
3776
  c.json(
@@ -4354,6 +4369,18 @@ export async function serve<TContext extends Partial<ProviderContext> = Provider
4354
4369
  !isUnavailableProviderEngine(engineSelection.engine),
4355
4370
  ),
4356
4371
  );
4372
+ // Same contract for the stores: one line per process boot naming the state
4373
+ // and cache backends this pod resolved, so "which Redis is this provider's
4374
+ // state actually on" is a log grep instead of an inference from the
4375
+ // Deployment spec (apifuse#2302).
4376
+ logger(
4377
+ providerStateBackendLogEvent({
4378
+ providerId: provider.id,
4379
+ sdkVersion: SDK_VERSION,
4380
+ injectedState: options.state !== undefined,
4381
+ allowMemoryFallback: options.allowMemoryStateFallback === true,
4382
+ }),
4383
+ );
4357
4384
 
4358
4385
  const servers: BunServerHandle[] = [];
4359
4386
  try {