@apifuse/provider-sdk 2.1.0-beta.3 → 2.1.0-beta.4

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 (60) hide show
  1. package/AUTHORING.md +163 -8
  2. package/CHANGELOG.md +8 -1
  3. package/README.md +17 -16
  4. package/SUBMISSION.md +4 -4
  5. package/bin/apifuse-dev.ts +12 -5
  6. package/bin/apifuse-pack-check.ts +9 -2
  7. package/bin/apifuse-pack-smoke.ts +127 -6
  8. package/bin/apifuse-perf.ts +19 -15
  9. package/bin/apifuse-record.ts +41 -53
  10. package/bin/apifuse-submit-check.ts +179 -7
  11. package/bin/apifuse.ts +1 -1
  12. package/package.json +17 -8
  13. package/src/choice-token.ts +164 -0
  14. package/src/cli/commands.ts +1 -3
  15. package/src/cli/create.ts +159 -50
  16. package/src/cli/templates/provider/README.md.tpl +24 -7
  17. package/src/cli/templates/provider/dev.ts.tpl +1 -1
  18. package/src/cli/templates/provider/domain/README.md.tpl +3 -0
  19. package/src/cli/templates/provider/index.ts.tpl +5 -47
  20. package/src/cli/templates/provider/mappers/README.md.tpl +3 -0
  21. package/src/cli/templates/provider/meta.ts.tpl +7 -0
  22. package/src/cli/templates/provider/operations/index.ts.tpl +5 -0
  23. package/src/cli/templates/provider/operations/ping.ts.tpl +23 -0
  24. package/src/cli/templates/provider/schemas/ping.ts.tpl +16 -0
  25. package/src/cli/templates/provider/start.ts.tpl +1 -1
  26. package/src/cli/templates/provider/upstream/README.md.tpl +3 -0
  27. package/src/config/loader.ts +1206 -9
  28. package/src/define.ts +1618 -104
  29. package/src/errors.ts +12 -0
  30. package/src/i18n/catalog.ts +121 -0
  31. package/src/i18n/index.ts +2 -0
  32. package/src/i18n/keys.ts +64 -0
  33. package/src/index.ts +149 -8
  34. package/src/lint.ts +297 -42
  35. package/src/observability.ts +41 -0
  36. package/src/provider.ts +60 -3
  37. package/src/public-schema-field-lint.ts +237 -0
  38. package/src/runtime/auth-flow.ts +7 -0
  39. package/src/runtime/browser.ts +77 -21
  40. package/src/runtime/cache.ts +582 -0
  41. package/src/runtime/executor.ts +13 -1
  42. package/src/runtime/http.ts +939 -195
  43. package/src/runtime/insights.ts +11 -11
  44. package/src/runtime/instrumentation.ts +12 -4
  45. package/src/runtime/key-derivation.ts +1 -1
  46. package/src/runtime/keyring.ts +4 -3
  47. package/src/runtime/proxy-errors.ts +132 -0
  48. package/src/runtime/proxy-telemetry.ts +253 -0
  49. package/src/runtime/request-options.ts +66 -0
  50. package/src/runtime/state.ts +76 -0
  51. package/src/runtime/stealth.ts +1145 -0
  52. package/src/runtime/stt.ts +629 -0
  53. package/src/schema.ts +363 -1
  54. package/src/server/serve.ts +816 -58
  55. package/src/server/types.ts +35 -0
  56. package/src/stream.ts +210 -0
  57. package/src/testing/run.ts +17 -4
  58. package/src/types.ts +869 -50
  59. package/src/runtime/tls.ts +0 -434
  60. package/src/types/playwright-stealth.d.ts +0 -9
@@ -1,24 +1,56 @@
1
1
  import { Hono } from "hono";
2
2
  import { z } from "zod";
3
-
4
3
  import { ProviderError, TransportError } from "../errors";
4
+ import {
5
+ categoryForStatus,
6
+ isRetryableCategory,
7
+ PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
8
+ type ProviderErrorCategory,
9
+ } from "../observability";
5
10
  import { createScratchpad } from "../runtime/auth-flow";
6
11
  import { createBrowserClient } from "../runtime/browser";
12
+ import { createProviderCache } from "../runtime/cache";
7
13
  import { createCredentialContext } from "../runtime/credential";
8
14
  import { createEnvContext } from "../runtime/env";
9
15
  import { executeOperation } from "../runtime/executor";
10
16
  import { createHttpClient } from "../runtime/http";
17
+ import { wrapWithInstrumentation } from "../runtime/instrumentation";
11
18
  import { getProviderBaseUrl } from "../runtime/provider";
12
- import { createTlsClient } from "../runtime/tls";
19
+ import {
20
+ PROXY_AUTH_IP_DENIED_CODE,
21
+ PROXY_EDGE_AUTH_REJECTED_CODE,
22
+ PROXY_POOL_EXHAUSTED_CODE,
23
+ } from "../runtime/proxy-errors";
24
+ import {
25
+ PROVIDER_TELEMETRY_HEADER,
26
+ ProxyTelemetryCollector,
27
+ } from "../runtime/proxy-telemetry";
28
+ import { createUnsupportedProviderRuntimeState } from "../runtime/state";
29
+ import { createStealthClient } from "../runtime/stealth";
30
+ import { createSttClientFromEnv } from "../runtime/stt";
13
31
  import { createTraceContext } from "../runtime/trace";
32
+ import { parseSchema } from "../schema";
33
+ import { getStealthProfile } from "../stealth/profiles";
34
+ import {
35
+ APIFUSE_STREAM_DONE_EVENT,
36
+ APIFUSE_STREAM_ERROR_EVENT,
37
+ encodeSseEvent,
38
+ error as streamError,
39
+ } from "../stream";
14
40
  import type {
15
41
  AuthContext,
16
42
  BrowserClient,
17
43
  FlowContext,
18
44
  FlowContextStore,
45
+ HttpRetrySummary,
46
+ OperationDefinition,
47
+ OperationHttpStreamTransport,
48
+ OperationSseTransport,
19
49
  ProviderContext,
20
50
  ProviderDefinition,
21
- TlsClient,
51
+ ProviderStreamEvent,
52
+ StealthClient,
53
+ SttContext,
22
54
  } from "../types";
23
55
  import {
24
56
  type AuthFlowRequest,
@@ -34,6 +66,7 @@ import {
34
66
 
35
67
  const DEFAULT_HOST = "0.0.0.0";
36
68
  const DEFAULT_PORT = 3000;
69
+ const retryResponseMeta = new WeakMap<ProviderContext, HttpRetrySummary>();
37
70
 
38
71
  function createAuthStub(): AuthContext {
39
72
  return {
@@ -56,28 +89,85 @@ function createBrowserStub(): BrowserClient {
56
89
  };
57
90
  }
58
91
 
59
- function createTlsStub(): TlsClient {
92
+ function createStealthStub(): StealthClient {
60
93
  return {
61
94
  async fetch() {
62
- throw new ProviderError("TLS runtime is not available", {
63
- code: "TLS_RUNTIME_UNSUPPORTED",
95
+ throw new ProviderError("Stealth runtime is not available", {
96
+ code: "STEALTH_RUNTIME_UNSUPPORTED",
64
97
  });
65
98
  },
66
99
  createSession() {
67
- throw new ProviderError("TLS runtime is not available", {
68
- code: "TLS_RUNTIME_UNSUPPORTED",
100
+ throw new ProviderError("Stealth runtime is not available", {
101
+ code: "STEALTH_RUNTIME_UNSUPPORTED",
69
102
  });
70
103
  },
104
+ close() {
105
+ // no-op
106
+ },
71
107
  };
72
108
  }
73
109
 
110
+ function getProviderStealthBaseUrl(
111
+ provider: ProviderDefinition,
112
+ ): string | undefined {
113
+ const baseUrl = getProviderBaseUrl(provider);
114
+ if (baseUrl) {
115
+ return baseUrl;
116
+ }
117
+ const firstHost = provider.allowedHosts?.[0];
118
+ return firstHost ? `https://${firstHost}` : undefined;
119
+ }
120
+
121
+ function getProviderStealthProfile(provider: ProviderDefinition) {
122
+ return provider.stealth?.profile
123
+ ? getStealthProfile(provider.stealth.profile)
124
+ : undefined;
125
+ }
126
+
127
+ export function resolveProviderProxyAffinityKey(
128
+ provider: ProviderDefinition,
129
+ request: OperationRequest,
130
+ operationId: string,
131
+ ): string {
132
+ const connectionKey =
133
+ request.connection?.id ?? request.connection?.externalRef;
134
+ const affinity =
135
+ typeof provider.proxy === "object"
136
+ ? provider.proxy.session?.affinity
137
+ : undefined;
138
+ if (affinity === "operation") {
139
+ return `${provider.id}/${operationId}`;
140
+ }
141
+ return connectionKey ?? provider.id;
142
+ }
143
+
74
144
  function createProviderContext(
75
145
  provider: ProviderDefinition,
76
146
  request: OperationRequest,
147
+ operationId: string,
148
+ options: ProviderServerOptions = {},
149
+ proxyTelemetry?: ProxyTelemetryCollector,
77
150
  ): ProviderContext {
78
151
  const baseUrl = getProviderBaseUrl(provider);
152
+ const stealthBaseUrl = getProviderStealthBaseUrl(provider);
153
+ const stealthProfile = getProviderStealthProfile(provider);
154
+ const proxyClientOptions = {
155
+ upstream: { proxy: provider.proxy },
156
+ affinityKey: resolveProviderProxyAffinityKey(
157
+ provider,
158
+ request,
159
+ operationId,
160
+ ),
161
+ telemetry: proxyTelemetry,
162
+ };
163
+ let wrappedContext: ProviderContext | undefined;
164
+ const stealthClientOptions = {
165
+ upstream: proxyClientOptions.upstream,
166
+ affinityKey: proxyClientOptions.affinityKey,
167
+ telemetry: proxyTelemetry,
168
+ };
79
169
 
80
- return {
170
+ const context = wrapWithInstrumentation({
81
171
  env: createEnvContext(provider.secrets?.map((secret) => secret.name)),
82
172
  credential: createCredentialContext({
83
173
  allowedKeys: provider.credential?.keys,
@@ -89,13 +179,28 @@ function createProviderContext(
89
179
  connectionId: request.connection?.id,
90
180
  headers: request.headers ?? {},
91
181
  },
92
- http: createHttpClient(baseUrl),
93
- tls: baseUrl ? createTlsClient(baseUrl) : createTlsStub(),
182
+ http: createHttpClient(baseUrl, {
183
+ ...proxyClientOptions,
184
+ onRetrySummary: (summary) => {
185
+ if (summary.attempts <= 1 || !wrappedContext) return;
186
+ retryResponseMeta.set(wrappedContext, summary);
187
+ },
188
+ }),
189
+ cache: createProviderCache({ providerId: provider.id }),
190
+ state: createUnsupportedProviderRuntimeState(),
191
+ stealth: stealthBaseUrl
192
+ ? stealthProfile
193
+ ? createStealthClient(
194
+ stealthBaseUrl,
195
+ stealthProfile.name,
196
+ stealthClientOptions,
197
+ )
198
+ : createStealthClient(stealthBaseUrl, stealthClientOptions)
199
+ : createStealthStub(),
94
200
  browser:
95
201
  provider.runtime === "browser"
96
202
  ? createBrowserClient({
97
- cdpUrl:
98
- process.env.CDP_POOL_URL ?? process.env.APIFUSE_CDP_POOL_URL,
203
+ cdpUrl: process.env.APIFUSE__CDP_POOL__URL,
99
204
  headless: true,
100
205
  stealth: true,
101
206
  engine: provider.browser?.engine,
@@ -103,7 +208,10 @@ function createProviderContext(
103
208
  : createBrowserStub(),
104
209
  trace: createTraceContext(),
105
210
  auth: createAuthStub(),
106
- };
211
+ stt: options.stt ?? createSttClientFromEnv(provider.stt),
212
+ });
213
+ wrappedContext = context;
214
+ return context;
107
215
  }
108
216
 
109
217
  function createFlowContextStore(
@@ -145,16 +253,32 @@ function createFlowContextStore(
145
253
  function createAuthFlowContext(
146
254
  provider: ProviderDefinition,
147
255
  request: AuthFlowRequest,
256
+ options: ProviderServerOptions = {},
148
257
  ): {
149
258
  context: FlowContext;
150
259
  getPatch: () => Record<string, unknown | null> | undefined;
151
260
  } {
152
261
  const baseUrl = getProviderBaseUrl(provider);
262
+ const stealthBaseUrl = getProviderStealthBaseUrl(provider);
263
+ const stealthProfile = getProviderStealthProfile(provider);
153
264
  const contextData = request.context ?? {};
154
265
  const flowContextStore = createFlowContextStore(
155
266
  provider.context?.keys ?? Object.keys(contextData),
156
267
  contextData,
157
268
  );
269
+ const proxyClientOptions = {
270
+ upstream: { proxy: provider.proxy },
271
+ affinityKey:
272
+ request.connectionId ??
273
+ request.externalRef ??
274
+ request.tenantId ??
275
+ request.providerId ??
276
+ provider.id,
277
+ };
278
+ const stealthClientOptions = {
279
+ upstream: proxyClientOptions.upstream,
280
+ affinityKey: proxyClientOptions.affinityKey,
281
+ };
158
282
 
159
283
  return {
160
284
  context: {
@@ -162,39 +286,97 @@ function createAuthFlowContext(
162
286
  externalRef: request.externalRef,
163
287
  tenantId: request.tenantId ?? "",
164
288
  providerId: request.providerId ?? provider.id,
165
- http: createHttpClient(baseUrl),
289
+ http: createHttpClient(baseUrl, proxyClientOptions),
290
+ stealth: stealthBaseUrl
291
+ ? stealthProfile
292
+ ? createStealthClient(
293
+ stealthBaseUrl,
294
+ stealthProfile.name,
295
+ stealthClientOptions,
296
+ )
297
+ : createStealthClient(stealthBaseUrl, stealthClientOptions)
298
+ : createStealthStub(),
166
299
  env: createEnvContext(provider.secrets?.map((secret) => secret.name)),
167
300
  context: flowContextStore.context,
301
+ stt: options.stt ?? createSttClientFromEnv(provider.stt),
168
302
  },
169
303
  getPatch: flowContextStore.getPatch,
170
304
  };
171
305
  }
172
306
 
173
- export type ProviderServerLogEvent = {
174
- level: "warn" | "error";
175
- event: "provider_request_failed";
307
+ type ProviderRequestCost = {
308
+ durationMs: number;
309
+ cpuUserMicros: number;
310
+ cpuSystemMicros: number;
311
+ cpuTotalMicros: number;
312
+ };
313
+
314
+ type ProviderServerLogEventBase = ProviderRequestCost & {
176
315
  providerId: string;
177
316
  kind: "operation" | "auth";
178
317
  route: string;
179
318
  requestId?: string;
180
319
  status: number;
181
- code: string;
182
- errorClass: string;
183
- message: string;
184
- upstreamStatus?: number;
185
- issues?: Array<{ path: string; code: string; message: string }>;
186
320
  };
187
321
 
322
+ export type ProviderServerLogEvent =
323
+ | (ProviderServerLogEventBase & {
324
+ level: "info";
325
+ event: "provider_request_completed";
326
+ })
327
+ | (ProviderServerLogEventBase & {
328
+ level: "warn" | "error";
329
+ event: "provider_request_failed";
330
+ code: string;
331
+ errorClass: string;
332
+ message: string;
333
+ upstreamStatus?: number;
334
+ errorCategory?: ProviderErrorCategory;
335
+ taxonomyVersion?: string;
336
+ retryable?: boolean;
337
+ issues?: Array<{ path: string; code: string; message: string }>;
338
+ });
339
+
188
340
  export type ProviderServerLogger = (event: ProviderServerLogEvent) => void;
189
341
 
190
342
  export type ProviderServerOptions = {
191
343
  logger?: ProviderServerLogger;
344
+ /** Optional STT override for tests or custom hosts; local/prod normally resolves from env. */
345
+ stt?: SttContext;
192
346
  };
193
347
 
194
348
  const defaultProviderServerLogger: ProviderServerLogger = (event) => {
195
- console.error(JSON.stringify(event));
349
+ const line = JSON.stringify(event);
350
+ if (event.level === "info") {
351
+ console.log(line);
352
+ return;
353
+ }
354
+ console.error(line);
196
355
  };
197
356
 
357
+ function startRequestCost(): {
358
+ startedAtMs: number;
359
+ cpuStart: NodeJS.CpuUsage;
360
+ } {
361
+ return {
362
+ startedAtMs: performance.now(),
363
+ cpuStart: process.cpuUsage(),
364
+ };
365
+ }
366
+
367
+ function finishRequestCost(input: {
368
+ startedAtMs: number;
369
+ cpuStart: NodeJS.CpuUsage;
370
+ }): ProviderRequestCost {
371
+ const cpuDelta = process.cpuUsage(input.cpuStart);
372
+ return {
373
+ durationMs: Math.max(0, Math.round(performance.now() - input.startedAtMs)),
374
+ cpuUserMicros: Math.max(0, cpuDelta.user),
375
+ cpuSystemMicros: Math.max(0, cpuDelta.system),
376
+ cpuTotalMicros: Math.max(0, cpuDelta.user + cpuDelta.system),
377
+ };
378
+ }
379
+
198
380
  function zodDetails(error: z.ZodError): Array<{
199
381
  path: string;
200
382
  code: string;
@@ -212,14 +394,14 @@ function toErrorResponse(
212
394
  requestId?: string,
213
395
  ): OperationErrorResponse {
214
396
  if (error instanceof ProviderError) {
397
+ const details = publicProviderErrorDetails(error);
215
398
  return {
216
399
  error: {
217
400
  code: error.code ?? "provider_error",
218
401
  message: publicProviderErrorMessage(error),
219
402
  ...(requestId ? { requestId } : {}),
220
- ...(error instanceof TransportError && error.status
221
- ? { details: { upstreamStatus: error.status } }
222
- : {}),
403
+ ...(error.fix ? { fix: error.fix } : {}),
404
+ ...(details ? { details } : {}),
223
405
  },
224
406
  };
225
407
  }
@@ -244,8 +426,80 @@ function toErrorResponse(
244
426
  };
245
427
  }
246
428
 
429
+ function publicProviderErrorDetails(error: ProviderError): unknown {
430
+ const providerDetails = error.details;
431
+ const observabilityDetails = providerObservabilityDetails(error);
432
+
433
+ if (providerDetails === undefined) {
434
+ return observabilityDetails;
435
+ }
436
+ if (observabilityDetails === undefined) {
437
+ return providerDetails;
438
+ }
439
+ if (isPlainRecord(providerDetails) && isPlainRecord(observabilityDetails)) {
440
+ return { ...providerDetails, ...observabilityDetails };
441
+ }
442
+ return {
443
+ provider: providerDetails,
444
+ observability: observabilityDetails,
445
+ };
446
+ }
447
+
448
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
449
+ return value !== null && typeof value === "object" && !Array.isArray(value);
450
+ }
451
+
452
+ function providerObservabilityDetails(error: ProviderError):
453
+ | {
454
+ category: ProviderErrorCategory;
455
+ taxonomyVersion: string;
456
+ retryable: boolean;
457
+ upstreamStatus?: number;
458
+ }
459
+ | undefined {
460
+ if (!(error instanceof TransportError)) {
461
+ return undefined;
462
+ }
463
+ const isProxyPoolCode =
464
+ error.code === PROXY_POOL_EXHAUSTED_CODE ||
465
+ error.code === PROXY_EDGE_AUTH_REJECTED_CODE ||
466
+ error.code === "PROXY_ALLOCATION_FAILED";
467
+ const category =
468
+ error.options?.category ??
469
+ (isProxyPoolCode
470
+ ? "proxy_pool"
471
+ : error.code === PROXY_AUTH_IP_DENIED_CODE
472
+ ? "anti_bot_blocked"
473
+ : error.code === "transport_timeout"
474
+ ? "timeout"
475
+ : error.code === "transport_network_error"
476
+ ? "network"
477
+ : error.upstreamStatus
478
+ ? categoryForStatus(error.upstreamStatus)
479
+ : "upstream_http");
480
+ return {
481
+ category,
482
+ taxonomyVersion: PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
483
+ retryable:
484
+ error.options?.retryable ??
485
+ (category === "upstream_http" && error.upstreamStatus
486
+ ? error.upstreamStatus >= 500
487
+ : isRetryableCategory(category)),
488
+ ...(error.upstreamStatus ? { upstreamStatus: error.upstreamStatus } : {}),
489
+ };
490
+ }
491
+
247
492
  function publicProviderErrorMessage(error: ProviderError): string {
248
493
  if (error instanceof TransportError) {
494
+ if (error.code === PROXY_AUTH_IP_DENIED_CODE) {
495
+ return error.message;
496
+ }
497
+ if (error.code === PROXY_EDGE_AUTH_REJECTED_CODE) {
498
+ return error.message;
499
+ }
500
+ if (error.code === PROXY_POOL_EXHAUSTED_CODE) {
501
+ return error.message;
502
+ }
249
503
  if (error.code === "transport_timeout") return "Request timed out";
250
504
  if (error.code === "transport_network_error") return "Network error";
251
505
  if (error.code === "upstream_http_error" && error.status) {
@@ -259,7 +513,7 @@ function publicProviderErrorMessage(error: ProviderError): string {
259
513
  return error.message;
260
514
  }
261
515
 
262
- function toStatusCode(error: unknown): 400 | 404 | 429 | 500 | 502 | 504 {
516
+ function toStatusCode(error: unknown): 400 | 404 | 429 | 500 | 502 | 503 | 504 {
263
517
  if (error instanceof z.ZodError) {
264
518
  return 400;
265
519
  }
@@ -280,6 +534,9 @@ function toStatusCode(error: unknown): 400 | 404 | 429 | 500 | 502 | 504 {
280
534
  return 429;
281
535
  case "UPSTREAM_ERROR":
282
536
  return 502;
537
+ case "STT_UNAVAILABLE":
538
+ case "UNSUPPORTED_STT_BACKEND":
539
+ return 503;
283
540
  }
284
541
 
285
542
  return 400;
@@ -305,6 +562,7 @@ function logProviderError(
305
562
  requestId: string | undefined,
306
563
  error: unknown,
307
564
  status: number,
565
+ cost: ProviderRequestCost,
308
566
  ): void {
309
567
  const code =
310
568
  error instanceof ProviderError
@@ -314,6 +572,10 @@ function logProviderError(
314
572
  : "internal_error";
315
573
  const errorClass = error instanceof Error ? error.name : typeof error;
316
574
  const message = error instanceof Error ? error.message : String(error);
575
+ const details =
576
+ error instanceof ProviderError
577
+ ? providerObservabilityDetails(error)
578
+ : undefined;
317
579
  const emit =
318
580
  typeof logger === "function" ? logger : defaultProviderServerLogger;
319
581
  emit({
@@ -324,18 +586,50 @@ function logProviderError(
324
586
  route,
325
587
  ...(requestId ? { requestId } : {}),
326
588
  status,
589
+ ...cost,
327
590
  code,
328
591
  errorClass,
329
592
  message,
330
- ...(error instanceof TransportError && error.status
331
- ? { upstreamStatus: error.status }
593
+ ...(error instanceof TransportError && error.upstreamStatus
594
+ ? { upstreamStatus: error.upstreamStatus }
595
+ : {}),
596
+ ...(details
597
+ ? {
598
+ errorCategory: details.category,
599
+ taxonomyVersion: details.taxonomyVersion,
600
+ retryable: details.retryable,
601
+ }
332
602
  : {}),
333
603
  ...(error instanceof z.ZodError ? { issues: zodDetails(error) } : {}),
334
604
  });
335
605
  }
336
606
 
607
+ function logProviderSuccess(
608
+ logger: ProviderServerLogger | unknown,
609
+ provider: ProviderDefinition,
610
+ kind: "operation" | "auth",
611
+ route: string,
612
+ requestId: string | undefined,
613
+ status: number,
614
+ cost: ProviderRequestCost,
615
+ ): void {
616
+ const emit =
617
+ typeof logger === "function" ? logger : defaultProviderServerLogger;
618
+ emit({
619
+ level: "info",
620
+ event: "provider_request_completed",
621
+ providerId: provider.id,
622
+ kind,
623
+ route,
624
+ ...(requestId ? { requestId } : {}),
625
+ status,
626
+ ...cost,
627
+ });
628
+ }
629
+
337
630
  function toJsonSuccessResponse(
338
631
  result: unknown,
632
+ ctx?: ProviderContext,
339
633
  ): Response | OperationSuccessResponse {
340
634
  if (result instanceof Response) {
341
635
  return result;
@@ -345,7 +639,335 @@ function toJsonSuccessResponse(
345
639
  return new Response(result);
346
640
  }
347
641
 
348
- return { data: result };
642
+ const cacheMeta = ctx?.cache.responseMeta();
643
+ const retryMeta = ctx ? retryResponseMeta.get(ctx) : undefined;
644
+ const meta =
645
+ cacheMeta || retryMeta
646
+ ? {
647
+ ...(cacheMeta
648
+ ? {
649
+ cached: cacheMeta.hit,
650
+ stale: cacheMeta.stale,
651
+ cache: cacheMeta,
652
+ }
653
+ : {}),
654
+ ...(retryMeta ? { retry: retryMeta } : {}),
655
+ }
656
+ : undefined;
657
+ return {
658
+ data: result,
659
+ ...(meta ? { meta } : {}),
660
+ };
661
+ }
662
+
663
+ function isAsyncIterable<T = unknown>(
664
+ value: unknown,
665
+ ): value is AsyncIterable<T> {
666
+ if (!value || typeof value !== "object") return false;
667
+ const iterator = Reflect.get(value, Symbol.asyncIterator);
668
+ return typeof iterator === "function";
669
+ }
670
+
671
+ function responseWithCleanup(
672
+ response: Response,
673
+ cleanup: () => void,
674
+ ): Response {
675
+ if (!response.body) {
676
+ cleanup();
677
+ return response;
678
+ }
679
+ const reader = response.body.getReader();
680
+ let cleaned = false;
681
+ const runCleanup = () => {
682
+ if (cleaned) return;
683
+ cleaned = true;
684
+ cleanup();
685
+ };
686
+ const body = new ReadableStream<Uint8Array>({
687
+ async pull(controller) {
688
+ try {
689
+ const { done, value } = await reader.read();
690
+ if (done) {
691
+ controller.close();
692
+ runCleanup();
693
+ return;
694
+ }
695
+ if (value) controller.enqueue(value);
696
+ } catch (error) {
697
+ runCleanup();
698
+ controller.error(error);
699
+ }
700
+ },
701
+ async cancel(reason) {
702
+ try {
703
+ await reader.cancel(reason);
704
+ } finally {
705
+ runCleanup();
706
+ }
707
+ },
708
+ });
709
+ return new Response(body, {
710
+ headers: response.headers,
711
+ status: response.status,
712
+ statusText: response.statusText,
713
+ });
714
+ }
715
+
716
+ async function validateSseEvent(
717
+ operation: OperationDefinition,
718
+ event: ProviderStreamEvent,
719
+ ): Promise<ProviderStreamEvent> {
720
+ const transport = getSseTransport(operation);
721
+ const schema = transport?.events?.[event.event];
722
+ if (!schema) {
723
+ if (
724
+ event.event === APIFUSE_STREAM_ERROR_EVENT ||
725
+ event.event === APIFUSE_STREAM_DONE_EVENT
726
+ ) {
727
+ return event;
728
+ }
729
+ throw new ProviderError(
730
+ `SSE event "${event.event}" is not declared in operation transport.events.`,
731
+ {
732
+ code: "SSE_EVENT_UNDECLARED",
733
+ category: "output_validation",
734
+ retryable: false,
735
+ fix: `Add "${event.event}" to transport.events or stop emitting that event.`,
736
+ },
737
+ );
738
+ }
739
+ const data = await parseSchema(
740
+ schema,
741
+ event.data,
742
+ `transport.events.${event.event}`,
743
+ );
744
+ return { ...event, data };
745
+ }
746
+
747
+ function byteLength(value: Uint8Array | string): number {
748
+ if (typeof value === "string") {
749
+ return new TextEncoder().encode(value).byteLength;
750
+ }
751
+ return value.byteLength;
752
+ }
753
+
754
+ function assertStreamPayloadWithinLimit(
755
+ actualBytes: number,
756
+ maxBytes: number | undefined,
757
+ kind: "event" | "chunk",
758
+ ): void {
759
+ if (maxBytes === undefined || actualBytes <= maxBytes) return;
760
+ throw new ProviderError(
761
+ `Stream ${kind} exceeded declared byte limit (${actualBytes} > ${maxBytes}).`,
762
+ {
763
+ code:
764
+ kind === "event" ? "STREAM_EVENT_TOO_LARGE" : "STREAM_CHUNK_TOO_LARGE",
765
+ retryable: false,
766
+ category: "input_validation",
767
+ fix:
768
+ kind === "event"
769
+ ? "Emit smaller SSE events or increase transport.maxEventBytes."
770
+ : "Emit smaller stream chunks or increase transport.maxChunkBytes.",
771
+ },
772
+ );
773
+ }
774
+
775
+ function toSseResponse(
776
+ operation: OperationDefinition,
777
+ result: AsyncIterable<ProviderStreamEvent>,
778
+ cleanup: () => void,
779
+ requestId?: string,
780
+ ): Response {
781
+ const encoder = new TextEncoder();
782
+ const iterator = result[Symbol.asyncIterator]();
783
+ const transport = getSseTransport(operation);
784
+ let done = false;
785
+ let cleaned = false;
786
+ const runCleanup = () => {
787
+ if (cleaned) return;
788
+ cleaned = true;
789
+ cleanup();
790
+ };
791
+ const body = new ReadableStream<Uint8Array>({
792
+ async pull(controller) {
793
+ try {
794
+ if (done) {
795
+ controller.close();
796
+ runCleanup();
797
+ return;
798
+ }
799
+ const next = await iterator.next();
800
+ if (next.done) {
801
+ done = true;
802
+ controller.close();
803
+ runCleanup();
804
+ return;
805
+ }
806
+ const validated = await validateSseEvent(operation, next.value);
807
+ const encodedEvent = encodeSseEvent(validated);
808
+ const encodedBytes = encoder.encode(encodedEvent);
809
+ assertStreamPayloadWithinLimit(
810
+ encodedBytes.byteLength,
811
+ transport?.maxEventBytes,
812
+ "event",
813
+ );
814
+ controller.enqueue(encodedBytes);
815
+ } catch (error) {
816
+ const message =
817
+ error instanceof Error ? error.message : "Stream failed";
818
+ controller.enqueue(
819
+ encoder.encode(
820
+ encodeSseEvent(
821
+ streamError("stream_error", message, {
822
+ ...(requestId ? { requestId } : {}),
823
+ }),
824
+ ),
825
+ ),
826
+ );
827
+ controller.close();
828
+ done = true;
829
+ runCleanup();
830
+ }
831
+ },
832
+ async cancel(reason) {
833
+ try {
834
+ await iterator.return?.(reason);
835
+ } finally {
836
+ runCleanup();
837
+ }
838
+ },
839
+ });
840
+ return new Response(body, {
841
+ headers: {
842
+ "Cache-Control": "no-cache, no-transform",
843
+ Connection: "keep-alive",
844
+ "Content-Type": "text/event-stream; charset=utf-8",
845
+ },
846
+ });
847
+ }
848
+
849
+ function enforceStreamChunkLimit(
850
+ body: ReadableStream<Uint8Array>,
851
+ maxChunkBytes: number | undefined,
852
+ ): ReadableStream<Uint8Array> {
853
+ if (maxChunkBytes === undefined) return body;
854
+ const reader = body.getReader();
855
+ return new ReadableStream<Uint8Array>({
856
+ async pull(controller) {
857
+ try {
858
+ const { done, value } = await reader.read();
859
+ if (done) {
860
+ controller.close();
861
+ return;
862
+ }
863
+ if (value) {
864
+ assertStreamPayloadWithinLimit(
865
+ byteLength(value),
866
+ maxChunkBytes,
867
+ "chunk",
868
+ );
869
+ controller.enqueue(value);
870
+ }
871
+ } catch (error) {
872
+ controller.error(error);
873
+ }
874
+ },
875
+ cancel(reason) {
876
+ return reader.cancel(reason);
877
+ },
878
+ });
879
+ }
880
+
881
+ function toStreamingResponse(
882
+ operation: OperationDefinition,
883
+ result: unknown,
884
+ cleanup: () => void,
885
+ requestId?: string,
886
+ ): Response {
887
+ const transport = operation.transport?.kind ?? "json";
888
+ if (
889
+ transport === "sse" &&
890
+ (result instanceof Response || result instanceof ReadableStream)
891
+ ) {
892
+ cleanup();
893
+ throw new ProviderError(
894
+ "SSE operations must return an AsyncIterable of typed stream.event(...) values.",
895
+ {
896
+ code: "SSE_RESULT_UNSUPPORTED",
897
+ category: "output_validation",
898
+ retryable: false,
899
+ fix: "Return an async generator that yields stream.event(name, data) so APIFuse can validate event schemas and enforce event byte limits.",
900
+ },
901
+ );
902
+ }
903
+ if (result instanceof Response) {
904
+ const httpTransport = getHttpStreamTransport(operation);
905
+ if (
906
+ httpTransport &&
907
+ result.body &&
908
+ httpTransport?.maxChunkBytes !== undefined
909
+ ) {
910
+ return responseWithCleanup(
911
+ new Response(
912
+ enforceStreamChunkLimit(result.body, httpTransport.maxChunkBytes),
913
+ {
914
+ headers: result.headers,
915
+ status: result.status,
916
+ statusText: result.statusText,
917
+ },
918
+ ),
919
+ cleanup,
920
+ );
921
+ }
922
+ return responseWithCleanup(result, cleanup);
923
+ }
924
+ if (result instanceof ReadableStream) {
925
+ const httpTransport = getHttpStreamTransport(operation);
926
+ const stream =
927
+ httpTransport !== undefined
928
+ ? enforceStreamChunkLimit(result, httpTransport.maxChunkBytes)
929
+ : result;
930
+ return responseWithCleanup(
931
+ new Response(stream, {
932
+ headers:
933
+ transport === "sse"
934
+ ? { "Content-Type": "text/event-stream; charset=utf-8" }
935
+ : {
936
+ "Content-Type":
937
+ operation.transport?.kind === "http-stream"
938
+ ? (operation.transport.contentType ??
939
+ "application/octet-stream")
940
+ : "application/octet-stream",
941
+ },
942
+ }),
943
+ cleanup,
944
+ );
945
+ }
946
+ if (transport === "sse" && isAsyncIterable<ProviderStreamEvent>(result)) {
947
+ return toSseResponse(operation, result, cleanup, requestId);
948
+ }
949
+ cleanup();
950
+ throw new ProviderError(
951
+ `Streaming operation returned unsupported result for transport "${transport}"`,
952
+ {
953
+ code: "STREAM_RESULT_UNSUPPORTED",
954
+ fix: "Return an AsyncIterable of stream.event(...) values, a ReadableStream, or a Response from streaming operations.",
955
+ },
956
+ );
957
+ }
958
+
959
+ function getSseTransport(
960
+ operation: OperationDefinition,
961
+ ): OperationSseTransport | undefined {
962
+ return operation.transport?.kind === "sse" ? operation.transport : undefined;
963
+ }
964
+
965
+ function getHttpStreamTransport(
966
+ operation: OperationDefinition,
967
+ ): OperationHttpStreamTransport | undefined {
968
+ return operation.transport?.kind === "http-stream"
969
+ ? operation.transport
970
+ : undefined;
349
971
  }
350
972
 
351
973
  function toAuthFlowResponse(
@@ -370,15 +992,57 @@ async function handleOperation(
370
992
  provider: ProviderDefinition,
371
993
  request: OperationRequest,
372
994
  operationId: string,
995
+ options: ProviderServerOptions = {},
996
+ proxyTelemetry?: ProxyTelemetryCollector,
373
997
  ): Promise<Response | OperationResponse> {
374
- const ctx = createProviderContext(provider, request);
375
- const result = await executeOperation(
998
+ const ctx = createProviderContext(
376
999
  provider,
1000
+ request,
377
1001
  operationId,
378
- ctx,
379
- request.input,
1002
+ options,
1003
+ proxyTelemetry,
380
1004
  );
381
- return toJsonSuccessResponse(result);
1005
+ const operation = provider.operations[operationId];
1006
+ const streaming =
1007
+ operation?.transport?.kind && operation.transport.kind !== "json";
1008
+ let cleanupCalled = false;
1009
+ const cleanup = () => {
1010
+ if (cleanupCalled) return;
1011
+ cleanupCalled = true;
1012
+ ctx.stealth.close?.();
1013
+ };
1014
+ try {
1015
+ const result = await executeOperation(
1016
+ provider,
1017
+ operationId,
1018
+ ctx,
1019
+ request.input,
1020
+ );
1021
+ if (streaming && operation) {
1022
+ return toStreamingResponse(operation, result, cleanup, request.requestId);
1023
+ }
1024
+ return toJsonSuccessResponse(result, ctx);
1025
+ } catch (error) {
1026
+ cleanup();
1027
+ throw error;
1028
+ } finally {
1029
+ if (!streaming) cleanup();
1030
+ }
1031
+ }
1032
+
1033
+ function responseWithProviderTelemetry(
1034
+ response: Response,
1035
+ proxyTelemetry?: ProxyTelemetryCollector,
1036
+ ): Response {
1037
+ const headerValue = proxyTelemetry?.toHeaderValue();
1038
+ const headers = new Headers(response.headers);
1039
+ headers.delete(PROVIDER_TELEMETRY_HEADER);
1040
+ if (headerValue) headers.set(PROVIDER_TELEMETRY_HEADER, headerValue);
1041
+ return new Response(response.body, {
1042
+ headers,
1043
+ status: response.status,
1044
+ statusText: response.statusText,
1045
+ });
382
1046
  }
383
1047
 
384
1048
  type AuthRoute = "start" | "continue" | "poll" | "abort";
@@ -387,6 +1051,7 @@ async function handleAuthFlow(
387
1051
  provider: ProviderDefinition,
388
1052
  request: AuthFlowRequest,
389
1053
  route: AuthRoute,
1054
+ options: ProviderServerOptions = {},
390
1055
  ): Promise<Response | AuthFlowResponse> {
391
1056
  const flow = provider.auth?.flow;
392
1057
  if (!flow) {
@@ -395,22 +1060,29 @@ async function handleAuthFlow(
395
1060
  });
396
1061
  }
397
1062
 
398
- const { context, getPatch } = createAuthFlowContext(provider, request);
399
-
400
- const result =
401
- route === "start"
402
- ? await flow.start(context)
403
- : route === "continue"
404
- ? await flow.continue(context, request.input ?? {})
405
- : route === "poll"
406
- ? flow.poll
407
- ? await flow.poll(context)
408
- : null
409
- : flow.abort
410
- ? await flow.abort(context)
411
- : null;
1063
+ const { context, getPatch } = createAuthFlowContext(
1064
+ provider,
1065
+ request,
1066
+ options,
1067
+ );
1068
+ try {
1069
+ const result =
1070
+ route === "start"
1071
+ ? await flow.start(context)
1072
+ : route === "continue"
1073
+ ? await flow.continue(context, request.input ?? {})
1074
+ : route === "poll"
1075
+ ? flow.poll
1076
+ ? await flow.poll(context)
1077
+ : null
1078
+ : flow.abort
1079
+ ? await flow.abort(context)
1080
+ : null;
412
1081
 
413
- return toAuthFlowResponse(result, getPatch());
1082
+ return toAuthFlowResponse(result, getPatch());
1083
+ } finally {
1084
+ context.stealth.close?.();
1085
+ }
414
1086
  }
415
1087
 
416
1088
  export function createServerApp(
@@ -443,6 +1115,8 @@ export function createServerApp(
443
1115
  app.post("/v1/:operation", async (c) => {
444
1116
  let rawBody: unknown;
445
1117
  const operation = c.req.param("operation");
1118
+ const proxyTelemetry = new ProxyTelemetryCollector();
1119
+ const requestCost = startRequestCost();
446
1120
  try {
447
1121
  rawBody = await c.req.raw
448
1122
  .clone()
@@ -451,8 +1125,37 @@ export function createServerApp(
451
1125
  const body = OperationRequestSchema.parse(rawBody);
452
1126
  const requestHeaders = Object.fromEntries(c.req.raw.headers.entries());
453
1127
  body.headers = { ...requestHeaders, ...body.headers };
454
- const response = await handleOperation(provider, body, operation);
455
- return response instanceof Response ? response : c.json(response);
1128
+ const response = await handleOperation(
1129
+ provider,
1130
+ body,
1131
+ operation,
1132
+ options,
1133
+ proxyTelemetry,
1134
+ );
1135
+ if (response instanceof Response) {
1136
+ logProviderSuccess(
1137
+ logger,
1138
+ provider,
1139
+ "operation",
1140
+ operation,
1141
+ body.requestId,
1142
+ response.status,
1143
+ finishRequestCost(requestCost),
1144
+ );
1145
+ return responseWithProviderTelemetry(response, proxyTelemetry);
1146
+ }
1147
+ const telemetryHeader = proxyTelemetry.toHeaderValue();
1148
+ if (telemetryHeader) c.header(PROVIDER_TELEMETRY_HEADER, telemetryHeader);
1149
+ logProviderSuccess(
1150
+ logger,
1151
+ provider,
1152
+ "operation",
1153
+ operation,
1154
+ body.requestId,
1155
+ 200,
1156
+ finishRequestCost(requestCost),
1157
+ );
1158
+ return c.json(response);
456
1159
  } catch (error) {
457
1160
  const status = toStatusCode(error);
458
1161
  const requestId = extractRequestId(rawBody);
@@ -464,20 +1167,33 @@ export function createServerApp(
464
1167
  requestId,
465
1168
  error,
466
1169
  status,
1170
+ finishRequestCost(requestCost),
467
1171
  );
1172
+ const telemetryHeader = proxyTelemetry.toHeaderValue();
1173
+ if (telemetryHeader) c.header(PROVIDER_TELEMETRY_HEADER, telemetryHeader);
468
1174
  return c.json(toErrorResponse(error, requestId), status);
469
1175
  }
470
1176
  });
471
1177
 
472
1178
  app.post("/auth/start", async (c) => {
473
1179
  let rawBody: unknown;
1180
+ const requestCost = startRequestCost();
474
1181
  try {
475
1182
  rawBody = await c.req.raw
476
1183
  .clone()
477
1184
  .json()
478
1185
  .catch(() => undefined);
479
1186
  const body = AuthFlowRequestSchema.parse(rawBody);
480
- const response = await handleAuthFlow(provider, body, "start");
1187
+ const response = await handleAuthFlow(provider, body, "start", options);
1188
+ logProviderSuccess(
1189
+ logger,
1190
+ provider,
1191
+ "auth",
1192
+ "start",
1193
+ body.requestId,
1194
+ response instanceof Response ? response.status : 200,
1195
+ finishRequestCost(requestCost),
1196
+ );
481
1197
  return response instanceof Response ? response : c.json(response);
482
1198
  } catch (error) {
483
1199
  const status = toStatusCode(error);
@@ -490,6 +1206,7 @@ export function createServerApp(
490
1206
  requestId,
491
1207
  error,
492
1208
  status,
1209
+ finishRequestCost(requestCost),
493
1210
  );
494
1211
  return c.json(toErrorResponse(error, requestId), status);
495
1212
  }
@@ -497,13 +1214,28 @@ export function createServerApp(
497
1214
 
498
1215
  app.post("/auth/continue", async (c) => {
499
1216
  let rawBody: unknown;
1217
+ const requestCost = startRequestCost();
500
1218
  try {
501
1219
  rawBody = await c.req.raw
502
1220
  .clone()
503
1221
  .json()
504
1222
  .catch(() => undefined);
505
1223
  const body = AuthFlowRequestSchema.parse(rawBody);
506
- const response = await handleAuthFlow(provider, body, "continue");
1224
+ const response = await handleAuthFlow(
1225
+ provider,
1226
+ body,
1227
+ "continue",
1228
+ options,
1229
+ );
1230
+ logProviderSuccess(
1231
+ logger,
1232
+ provider,
1233
+ "auth",
1234
+ "continue",
1235
+ body.requestId,
1236
+ response instanceof Response ? response.status : 200,
1237
+ finishRequestCost(requestCost),
1238
+ );
507
1239
  return response instanceof Response ? response : c.json(response);
508
1240
  } catch (error) {
509
1241
  const status = toStatusCode(error);
@@ -516,6 +1248,7 @@ export function createServerApp(
516
1248
  requestId,
517
1249
  error,
518
1250
  status,
1251
+ finishRequestCost(requestCost),
519
1252
  );
520
1253
  return c.json(toErrorResponse(error, requestId), status);
521
1254
  }
@@ -523,13 +1256,23 @@ export function createServerApp(
523
1256
 
524
1257
  app.post("/auth/poll", async (c) => {
525
1258
  let rawBody: unknown;
1259
+ const requestCost = startRequestCost();
526
1260
  try {
527
1261
  rawBody = await c.req.raw
528
1262
  .clone()
529
1263
  .json()
530
1264
  .catch(() => undefined);
531
1265
  const body = AuthFlowRequestSchema.parse(rawBody);
532
- const response = await handleAuthFlow(provider, body, "poll");
1266
+ const response = await handleAuthFlow(provider, body, "poll", options);
1267
+ logProviderSuccess(
1268
+ logger,
1269
+ provider,
1270
+ "auth",
1271
+ "poll",
1272
+ body.requestId,
1273
+ response instanceof Response ? response.status : 200,
1274
+ finishRequestCost(requestCost),
1275
+ );
533
1276
  return response instanceof Response ? response : c.json(response);
534
1277
  } catch (error) {
535
1278
  const status = toStatusCode(error);
@@ -542,6 +1285,7 @@ export function createServerApp(
542
1285
  requestId,
543
1286
  error,
544
1287
  status,
1288
+ finishRequestCost(requestCost),
545
1289
  );
546
1290
  return c.json(toErrorResponse(error, requestId), status);
547
1291
  }
@@ -549,13 +1293,23 @@ export function createServerApp(
549
1293
 
550
1294
  app.post("/auth/disconnect", async (c) => {
551
1295
  let rawBody: unknown;
1296
+ const requestCost = startRequestCost();
552
1297
  try {
553
1298
  rawBody = await c.req.raw
554
1299
  .clone()
555
1300
  .json()
556
1301
  .catch(() => undefined);
557
1302
  const body = AuthFlowRequestSchema.parse(rawBody);
558
- const response = await handleAuthFlow(provider, body, "abort");
1303
+ const response = await handleAuthFlow(provider, body, "abort", options);
1304
+ logProviderSuccess(
1305
+ logger,
1306
+ provider,
1307
+ "auth",
1308
+ "disconnect",
1309
+ body.requestId,
1310
+ response instanceof Response ? response.status : 200,
1311
+ finishRequestCost(requestCost),
1312
+ );
559
1313
  return response instanceof Response ? response : c.json(response);
560
1314
  } catch (error) {
561
1315
  const status = toStatusCode(error);
@@ -568,6 +1322,7 @@ export function createServerApp(
568
1322
  requestId,
569
1323
  error,
570
1324
  status,
1325
+ finishRequestCost(requestCost),
571
1326
  );
572
1327
  return c.json(toErrorResponse(error, requestId), status);
573
1328
  }
@@ -622,7 +1377,10 @@ export async function serve(
622
1377
  );
623
1378
  }
624
1379
 
625
- const app = createServerApp(provider, { logger: options.logger });
1380
+ const app = createServerApp(provider, {
1381
+ logger: options.logger,
1382
+ stt: options.stt,
1383
+ });
626
1384
 
627
1385
  bunRuntime.serve({
628
1386
  port: options.port ?? DEFAULT_PORT,