@apifuse/provider-sdk 2.2.0-beta.66 → 2.2.0-beta.68

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 (38) hide show
  1. package/AUTHORING.md +28 -1
  2. package/CHANGELOG.md +8 -0
  3. package/README.md +8 -4
  4. package/bin/apifuse-record.ts +207 -31
  5. package/bin/apifuse-submit-check.ts +273 -74
  6. package/dist/cli/local-connection.d.ts +111 -0
  7. package/dist/cli/local-connection.js +151 -0
  8. package/dist/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
  9. package/dist/cli/templates/provider/README.md.tpl +17 -9
  10. package/dist/config/loader.d.ts +16 -0
  11. package/dist/config/loader.js +25 -8
  12. package/dist/error-resolution.d.ts +13 -0
  13. package/dist/error-resolution.js +72 -35
  14. package/dist/lint.d.ts +2 -0
  15. package/dist/lint.js +493 -2
  16. package/dist/runtime/executor.js +3 -0
  17. package/dist/runtime/http.d.ts +2 -0
  18. package/dist/runtime/http.js +2 -1
  19. package/dist/runtime/provider.d.ts +7 -0
  20. package/dist/runtime/provider.js +14 -0
  21. package/dist/server/index.d.ts +1 -0
  22. package/dist/server/serve-implementation.d.ts +2 -1
  23. package/dist/server/serve-implementation.js +43 -29
  24. package/dist/server/state-backend-report.d.ts +70 -0
  25. package/dist/server/state-backend-report.js +113 -0
  26. package/package.json +1 -1
  27. package/src/cli/local-connection.ts +221 -0
  28. package/src/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
  29. package/src/cli/templates/provider/README.md.tpl +17 -9
  30. package/src/config/loader.ts +49 -12
  31. package/src/error-resolution.ts +89 -35
  32. package/src/lint.ts +541 -1
  33. package/src/runtime/executor.ts +3 -0
  34. package/src/runtime/http.ts +2 -1
  35. package/src/runtime/provider.ts +15 -0
  36. package/src/server/index.ts +4 -0
  37. package/src/server/serve-implementation.ts +56 -30
  38. package/src/server/state-backend-report.ts +185 -0
@@ -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,
@@ -216,6 +217,10 @@ import type {
216
217
  } from "../types.js";
217
218
  import { VALID_OPERATION_ERROR_STATUSES } from "../types.js";
218
219
  import type { SelfTestCancellationLogEvent } from "./self-test.js";
220
+ import {
221
+ type ProviderStateBackendLogEvent,
222
+ providerStateBackendLogEvent,
223
+ } from "./state-backend-report.js";
219
224
  import { resolveSelfTestMasterSecrets } from "./self-test-token.js";
220
225
  import {
221
226
  collectStaticDiagnosticSensitiveValues,
@@ -635,15 +640,6 @@ function bindResolverSignalWithoutRuntime(
635
640
  return boundResolver;
636
641
  }
637
642
 
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
643
  function getProviderStealthProfile(provider: ProviderDefinition) {
648
644
  return provider.stealth ? getStealthProfile(provider.stealth) : undefined;
649
645
  }
@@ -1476,6 +1472,7 @@ export type ProviderServerLogEvent =
1476
1472
  */
1477
1473
  warnings?: readonly string[];
1478
1474
  }
1475
+ | ProviderStateBackendLogEvent
1479
1476
  | SelfTestCancellationLogEvent;
1480
1477
 
1481
1478
  export type ProviderServerLogger = (event: ProviderServerLogEvent) => void;
@@ -1607,8 +1604,9 @@ function zodDetails(
1607
1604
  // (credential_unavailable) usually means a caller credential problem, an
1608
1605
  // internal stateful-routing deadline is APIFuse-owned despite its timeout
1609
1606
  // 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.
1607
+ // attribution even under an explicit non-upstream category. An unset category
1608
+ // needs no override: a registered upstream code already carries its category
1609
+ // (SDK_UPSTREAM_ERROR_CODE_REGISTRY), and the source follows from it.
1612
1610
  function publicErrorSource(error: unknown, category: ProviderErrorCategory): ProviderErrorSource {
1613
1611
  if (error instanceof StatefulRoutingDeadlineError) return "apifuse";
1614
1612
  if (isProviderError(error)) {
@@ -1931,6 +1929,24 @@ function providerObservabilityDetails(
1931
1929
  };
1932
1930
  }
1933
1931
 
1932
+ // The category a `ProviderError` (validation errors included) gets when its
1933
+ // author set none, ahead of the branch's own default: an operation-declared
1934
+ // rejection status (409/410/422) → upstream_rejected, then the registered
1935
+ // upstream code's category (UPSTREAM_SCHEMA_ERROR → upstream_schema_drift,
1936
+ // NOT_FOUND → upstream_http, ...). `undefined` leaves the branch default.
1937
+ function derivedErrorCategory(
1938
+ error: unknown,
1939
+ declaredStatus: unknown,
1940
+ ): ProviderErrorCategory | undefined {
1941
+ if (
1942
+ isEmittableErrorStatus(declaredStatus) &&
1943
+ categoryForStatus(declaredStatus) === "upstream_rejected"
1944
+ ) {
1945
+ return "upstream_rejected";
1946
+ }
1947
+ return registeredUpstreamErrorCategory(providerErrorCode(error));
1948
+ }
1949
+
1934
1950
  function classifiedErrorObservabilityDetails(
1935
1951
  error: unknown,
1936
1952
  declaredErrorCode?: OperationErrorCode,
@@ -1945,12 +1961,10 @@ function classifiedErrorObservabilityDetails(
1945
1961
  return {
1946
1962
  category: providerCategory
1947
1963
  ? providerCategory
1948
- : isEmittableErrorStatus(declaredStatus) &&
1949
- categoryForStatus(declaredStatus) === "upstream_rejected"
1950
- ? "upstream_rejected"
1951
- : isEmittableErrorStatus(declaredStatus) && declaredStatus >= 500
1964
+ : (derivedErrorCategory(error, declaredStatus) ??
1965
+ (isEmittableErrorStatus(declaredStatus) && declaredStatus >= 500
1952
1966
  ? "provider_error"
1953
- : "input_validation",
1967
+ : "input_validation")),
1954
1968
  taxonomyVersion: PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
1955
1969
  retryable: isProviderError(error)
1956
1970
  ? (providerErrorOption(error, "retryable") ?? effectiveDeclaration?.retryable ?? false)
@@ -1967,19 +1981,13 @@ function classifiedErrorObservabilityDetails(
1967
1981
  }
1968
1982
 
1969
1983
  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);
1984
+ // Retryability does not follow the derived category: it stays
1985
+ // instance ?? declared ?? false below.
1981
1986
  return {
1982
- category: providerErrorOption(error, "category") ?? rejectionDefault,
1987
+ category:
1988
+ providerErrorOption(error, "category") ??
1989
+ derivedErrorCategory(error, effectiveDeclaration?.status) ??
1990
+ "provider_error",
1983
1991
  taxonomyVersion: PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
1984
1992
  retryable:
1985
1993
  providerErrorOption(error, "retryable") ?? effectiveDeclaration?.retryable ?? false,
@@ -3755,7 +3763,13 @@ function createServerAppWithCapabilityModules(
3755
3763
  }
3756
3764
 
3757
3765
  app.notFound((c) => {
3758
- const error = new ProviderError("Not found", { code: "not_found", retryable: false });
3766
+ // An unknown route is not an upstream answer: the explicit category keeps
3767
+ // the registered not_found code from attributing it to an upstream.
3768
+ const error = new ProviderError("Not found", {
3769
+ code: "not_found",
3770
+ category: "provider_error",
3771
+ retryable: false,
3772
+ });
3759
3773
  const observabilityDetails = errorObservabilityDetails(error);
3760
3774
  return responseWithErrorObservability(
3761
3775
  c.json(
@@ -4354,6 +4368,18 @@ export async function serve<TContext extends Partial<ProviderContext> = Provider
4354
4368
  !isUnavailableProviderEngine(engineSelection.engine),
4355
4369
  ),
4356
4370
  );
4371
+ // Same contract for the stores: one line per process boot naming the state
4372
+ // and cache backends this pod resolved, so "which Redis is this provider's
4373
+ // state actually on" is a log grep instead of an inference from the
4374
+ // Deployment spec (apifuse#2302).
4375
+ logger(
4376
+ providerStateBackendLogEvent({
4377
+ providerId: provider.id,
4378
+ sdkVersion: SDK_VERSION,
4379
+ injectedState: options.state !== undefined,
4380
+ allowMemoryFallback: options.allowMemoryStateFallback === true,
4381
+ }),
4382
+ );
4357
4383
 
4358
4384
  const servers: BunServerHandle[] = [];
4359
4385
  try {
@@ -0,0 +1,185 @@
1
+ import {
2
+ providerCacheRedisResolutionFromEnv,
3
+ type ProviderRedisUrlResolution,
4
+ providerStateRedisResolutionFromEnv,
5
+ } from "../config/loader.js";
6
+
7
+ /**
8
+ * What a provider store resolved to at boot.
9
+ *
10
+ * `endpoint` is deliberately host and port only: the URL can carry a password
11
+ * (`redis://:secret@host:6379`), and the diagnostic inventory already treats
12
+ * those credential components — and only those — as sensitive
13
+ * (`collectDiagnosticSensitiveValues`, `urlCredentialComponents(url, {
14
+ * includePathAndQuery: false })`). A URL that does not parse contributes no
15
+ * endpoint at all rather than a redacted-looking fragment of itself.
16
+ */
17
+ export type ProviderStoreBackendReport = {
18
+ /**
19
+ * `redis` when a URL resolved, `memory` for the in-process fallback,
20
+ * `unsupported` when state has no backend and every call fails closed,
21
+ * `injected` when the host handed the server a store object.
22
+ */
23
+ readonly backend: "redis" | "memory" | "unsupported" | "injected";
24
+ /** Env name that supplied the Redis URL. Present only for `redis`. */
25
+ readonly envName?: string;
26
+ /**
27
+ * `host:port` of the resolved Redis. Never includes credentials, path or
28
+ * query. Absent when the URL does not parse, or when its query carries an
29
+ * ioredis option that moves the endpoint (`path`, `port`, `host`, `family`)
30
+ * so the authority is not where the client connects — see
31
+ * `state_redis_url_ambiguous_endpoint`.
32
+ */
33
+ readonly endpoint?: string;
34
+ /** URL scheme, e.g. `redis` or `rediss`. Present only for `redis`. */
35
+ readonly scheme?: string;
36
+ /** True when the URL came from a shared fallback env rather than the store's own. */
37
+ readonly fallback?: boolean;
38
+ };
39
+
40
+ /**
41
+ * One line per process boot naming the stores the provider resolved. Without
42
+ * it, a provider silently riding the cache Redis for its state is
43
+ * indistinguishable from one on a durable instance, from outside the pod and
44
+ * from the pod's own logs (apifuse#2302, the reason apifuse#2144 could only be
45
+ * verified by inference from the Deployment spec).
46
+ */
47
+ export type ProviderStateBackendLogEvent = {
48
+ /** `warn` when a warning was raised, otherwise `info`. */
49
+ readonly level: "info" | "warn";
50
+ readonly event: "provider_state_backend";
51
+ readonly providerId: string;
52
+ readonly state: ProviderStoreBackendReport;
53
+ readonly cache: ProviderStoreBackendReport;
54
+ readonly sdkVersion: string;
55
+ /**
56
+ * Non-fatal findings, e.g. `state_redis_url_fallback` (the state store is
57
+ * sharing an instance provisioned for something else),
58
+ * `state_redis_url_unparsed`, or `state_redis_url_ambiguous_endpoint` (the
59
+ * URL's query carries an ioredis option that moves the endpoint, so the
60
+ * authority is not reported as the endpoint). Absent when the resolution
61
+ * was unambiguous.
62
+ */
63
+ readonly warnings?: readonly string[];
64
+ };
65
+
66
+ /**
67
+ * Whether ioredis' own URL parsing would move the endpoint away from the
68
+ * authority this function can see. `createProviderRedisClient` hands the raw
69
+ * URL to `new Redis(url, …)`, and `parseURL` ends with
70
+ * `defaults(result, queryOptions)` — lodash `defaults`, so a query key only
71
+ * applies where the authority supplied nothing:
72
+ *
73
+ * - `path` is never derived from the authority of a `redis://` URL (that
74
+ * pathname is the db index), so `?path=` always applies — and
75
+ * `StandaloneConnector` connects to `options.path` INSTEAD of host/port.
76
+ * The client is then on a Unix socket the authority says nothing about.
77
+ * - `port` applies only when the URL carries no explicit port.
78
+ * - `host` never applies here: we return early unless `parsed.hostname` is
79
+ * set, so `result.host` is always already populated.
80
+ * - `family` selects an address family; it does not move `host:port`.
81
+ *
82
+ * Verified against the pinned ioredis 5.11.1. In those cases the endpoint is
83
+ * WITHHELD and the ambiguity named, rather than reported as the authority — a
84
+ * diagnostic whose whole job is to answer "which Redis is this provider on"
85
+ * must not answer it wrongly. The query is never echoed: it can carry a
86
+ * password, which is why `endpoint` is host-and-port only to begin with.
87
+ */
88
+ function queryMovesEndpoint(parsed: URL): boolean {
89
+ if (parsed.searchParams.has("path")) return true;
90
+ return parsed.searchParams.has("port") && parsed.port === "";
91
+ }
92
+
93
+ function redisEndpoint(url: string): {
94
+ endpoint?: string;
95
+ scheme?: string;
96
+ ambiguous?: boolean;
97
+ } {
98
+ let parsed: URL;
99
+ try {
100
+ parsed = new URL(url);
101
+ } catch {
102
+ return {};
103
+ }
104
+ // No authority means this is not the `redis://host:port` shape at all (e.g.
105
+ // a bare `host:port`, which parses as a scheme). Report nothing rather than
106
+ // a fragment of a string whose parts we have not identified.
107
+ if (!parsed.hostname) return {};
108
+ const scheme = parsed.protocol.replace(/:$/, "");
109
+ if (queryMovesEndpoint(parsed)) return { scheme, ambiguous: true };
110
+ return {
111
+ scheme,
112
+ endpoint: parsed.port ? `${parsed.hostname}:${parsed.port}` : parsed.hostname,
113
+ };
114
+ }
115
+
116
+ function redisReport(resolution: ProviderRedisUrlResolution): {
117
+ report: ProviderStoreBackendReport;
118
+ parsed: boolean;
119
+ ambiguous: boolean;
120
+ } {
121
+ const { endpoint, scheme, ambiguous } = redisEndpoint(resolution.url);
122
+ return {
123
+ parsed: endpoint !== undefined || ambiguous === true,
124
+ ambiguous: ambiguous === true,
125
+ report: {
126
+ backend: "redis",
127
+ envName: resolution.envName,
128
+ ...(endpoint === undefined ? {} : { endpoint }),
129
+ ...(scheme === undefined ? {} : { scheme }),
130
+ ...(resolution.fallback ? { fallback: true } : {}),
131
+ },
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Build the boot event from the same env precedence the stores themselves use
137
+ * (`providerStateRedisResolutionFromEnv` / `providerCacheRedisResolutionFromEnv`),
138
+ * so the line can never describe a backend other than the one in use.
139
+ */
140
+ export function providerStateBackendLogEvent(input: {
141
+ readonly providerId: string;
142
+ readonly sdkVersion: string;
143
+ /** The host passed an explicit state store, so no env resolution happened. */
144
+ readonly injectedState: boolean;
145
+ /** Mirrors `createProviderRuntimeStateFromEnv({ allowMemoryFallback })`. */
146
+ readonly allowMemoryFallback: boolean;
147
+ }): ProviderStateBackendLogEvent {
148
+ const warnings: string[] = [];
149
+ let state: ProviderStoreBackendReport;
150
+ if (input.injectedState) {
151
+ state = { backend: "injected" };
152
+ } else {
153
+ const resolution = providerStateRedisResolutionFromEnv();
154
+ if (resolution) {
155
+ const { report, parsed, ambiguous } = redisReport(resolution);
156
+ state = report;
157
+ if (!parsed) warnings.push("state_redis_url_unparsed");
158
+ if (ambiguous) warnings.push("state_redis_url_ambiguous_endpoint");
159
+ if (resolution.fallback) warnings.push("state_redis_url_fallback");
160
+ } else {
161
+ state = { backend: input.allowMemoryFallback ? "memory" : "unsupported" };
162
+ }
163
+ }
164
+
165
+ const cacheResolution = providerCacheRedisResolutionFromEnv();
166
+ let cache: ProviderStoreBackendReport;
167
+ if (cacheResolution) {
168
+ const { report, parsed, ambiguous } = redisReport(cacheResolution);
169
+ cache = report;
170
+ if (!parsed) warnings.push("cache_redis_url_unparsed");
171
+ if (ambiguous) warnings.push("cache_redis_url_ambiguous_endpoint");
172
+ } else {
173
+ cache = { backend: "memory" };
174
+ }
175
+
176
+ return {
177
+ level: warnings.length > 0 ? "warn" : "info",
178
+ event: "provider_state_backend",
179
+ providerId: input.providerId,
180
+ state,
181
+ cache,
182
+ sdkVersion: input.sdkVersion,
183
+ ...(warnings.length === 0 ? {} : { warnings }),
184
+ };
185
+ }