@apifuse/provider-sdk 2.1.0-beta.2 → 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 +172 -8
  2. package/CHANGELOG.md +15 -1
  3. package/README.md +29 -15
  4. package/SUBMISSION.md +86 -0
  5. package/bin/apifuse-dev.ts +12 -5
  6. package/bin/apifuse-pack-check.ts +17 -2
  7. package/bin/apifuse-pack-smoke.ts +133 -6
  8. package/bin/apifuse-perf.ts +19 -15
  9. package/bin/apifuse-record.ts +41 -53
  10. package/bin/apifuse-submit-check.ts +1052 -0
  11. package/bin/apifuse.ts +1 -1
  12. package/package.json +19 -9
  13. package/src/choice-token.ts +164 -0
  14. package/src/cli/commands.ts +24 -3
  15. package/src/cli/create.ts +166 -51
  16. package/src/cli/templates/provider/README.md.tpl +66 -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 +1648 -43
  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 +152 -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 +827 -60
  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 +889 -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 | 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
  }
@@ -269,8 +523,20 @@ function toStatusCode(error: unknown): 400 | 404 | 500 | 502 | 504 {
269
523
  }
270
524
 
271
525
  if (error instanceof ProviderError) {
272
- if (error.code === "NOT_FOUND" || error.code === "NO_DATA") {
273
- return 404;
526
+ switch (error.code) {
527
+ case "NOT_FOUND":
528
+ case "not_found":
529
+ case "NO_DATA":
530
+ return 404;
531
+ case "RATE_LIMITED":
532
+ case "UPSTREAM_RATE_LIMIT":
533
+ case "LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR":
534
+ return 429;
535
+ case "UPSTREAM_ERROR":
536
+ return 502;
537
+ case "STT_UNAVAILABLE":
538
+ case "UNSUPPORTED_STT_BACKEND":
539
+ return 503;
274
540
  }
275
541
 
276
542
  return 400;
@@ -296,6 +562,7 @@ function logProviderError(
296
562
  requestId: string | undefined,
297
563
  error: unknown,
298
564
  status: number,
565
+ cost: ProviderRequestCost,
299
566
  ): void {
300
567
  const code =
301
568
  error instanceof ProviderError
@@ -305,6 +572,10 @@ function logProviderError(
305
572
  : "internal_error";
306
573
  const errorClass = error instanceof Error ? error.name : typeof error;
307
574
  const message = error instanceof Error ? error.message : String(error);
575
+ const details =
576
+ error instanceof ProviderError
577
+ ? providerObservabilityDetails(error)
578
+ : undefined;
308
579
  const emit =
309
580
  typeof logger === "function" ? logger : defaultProviderServerLogger;
310
581
  emit({
@@ -315,18 +586,50 @@ function logProviderError(
315
586
  route,
316
587
  ...(requestId ? { requestId } : {}),
317
588
  status,
589
+ ...cost,
318
590
  code,
319
591
  errorClass,
320
592
  message,
321
- ...(error instanceof TransportError && error.status
322
- ? { 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
+ }
323
602
  : {}),
324
603
  ...(error instanceof z.ZodError ? { issues: zodDetails(error) } : {}),
325
604
  });
326
605
  }
327
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
+
328
630
  function toJsonSuccessResponse(
329
631
  result: unknown,
632
+ ctx?: ProviderContext,
330
633
  ): Response | OperationSuccessResponse {
331
634
  if (result instanceof Response) {
332
635
  return result;
@@ -336,7 +639,335 @@ function toJsonSuccessResponse(
336
639
  return new Response(result);
337
640
  }
338
641
 
339
- 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;
340
971
  }
341
972
 
342
973
  function toAuthFlowResponse(
@@ -361,15 +992,57 @@ async function handleOperation(
361
992
  provider: ProviderDefinition,
362
993
  request: OperationRequest,
363
994
  operationId: string,
995
+ options: ProviderServerOptions = {},
996
+ proxyTelemetry?: ProxyTelemetryCollector,
364
997
  ): Promise<Response | OperationResponse> {
365
- const ctx = createProviderContext(provider, request);
366
- const result = await executeOperation(
998
+ const ctx = createProviderContext(
367
999
  provider,
1000
+ request,
368
1001
  operationId,
369
- ctx,
370
- request.input,
1002
+ options,
1003
+ proxyTelemetry,
371
1004
  );
372
- 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
+ });
373
1046
  }
374
1047
 
375
1048
  type AuthRoute = "start" | "continue" | "poll" | "abort";
@@ -378,6 +1051,7 @@ async function handleAuthFlow(
378
1051
  provider: ProviderDefinition,
379
1052
  request: AuthFlowRequest,
380
1053
  route: AuthRoute,
1054
+ options: ProviderServerOptions = {},
381
1055
  ): Promise<Response | AuthFlowResponse> {
382
1056
  const flow = provider.auth?.flow;
383
1057
  if (!flow) {
@@ -386,22 +1060,29 @@ async function handleAuthFlow(
386
1060
  });
387
1061
  }
388
1062
 
389
- const { context, getPatch } = createAuthFlowContext(provider, request);
390
-
391
- const result =
392
- route === "start"
393
- ? await flow.start(context)
394
- : route === "continue"
395
- ? await flow.continue(context, request.input ?? {})
396
- : route === "poll"
397
- ? flow.poll
398
- ? await flow.poll(context)
399
- : null
400
- : flow.abort
401
- ? await flow.abort(context)
402
- : 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;
403
1081
 
404
- return toAuthFlowResponse(result, getPatch());
1082
+ return toAuthFlowResponse(result, getPatch());
1083
+ } finally {
1084
+ context.stealth.close?.();
1085
+ }
405
1086
  }
406
1087
 
407
1088
  export function createServerApp(
@@ -434,6 +1115,8 @@ export function createServerApp(
434
1115
  app.post("/v1/:operation", async (c) => {
435
1116
  let rawBody: unknown;
436
1117
  const operation = c.req.param("operation");
1118
+ const proxyTelemetry = new ProxyTelemetryCollector();
1119
+ const requestCost = startRequestCost();
437
1120
  try {
438
1121
  rawBody = await c.req.raw
439
1122
  .clone()
@@ -442,8 +1125,37 @@ export function createServerApp(
442
1125
  const body = OperationRequestSchema.parse(rawBody);
443
1126
  const requestHeaders = Object.fromEntries(c.req.raw.headers.entries());
444
1127
  body.headers = { ...requestHeaders, ...body.headers };
445
- const response = await handleOperation(provider, body, operation);
446
- 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);
447
1159
  } catch (error) {
448
1160
  const status = toStatusCode(error);
449
1161
  const requestId = extractRequestId(rawBody);
@@ -455,20 +1167,33 @@ export function createServerApp(
455
1167
  requestId,
456
1168
  error,
457
1169
  status,
1170
+ finishRequestCost(requestCost),
458
1171
  );
1172
+ const telemetryHeader = proxyTelemetry.toHeaderValue();
1173
+ if (telemetryHeader) c.header(PROVIDER_TELEMETRY_HEADER, telemetryHeader);
459
1174
  return c.json(toErrorResponse(error, requestId), status);
460
1175
  }
461
1176
  });
462
1177
 
463
1178
  app.post("/auth/start", async (c) => {
464
1179
  let rawBody: unknown;
1180
+ const requestCost = startRequestCost();
465
1181
  try {
466
1182
  rawBody = await c.req.raw
467
1183
  .clone()
468
1184
  .json()
469
1185
  .catch(() => undefined);
470
1186
  const body = AuthFlowRequestSchema.parse(rawBody);
471
- 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
+ );
472
1197
  return response instanceof Response ? response : c.json(response);
473
1198
  } catch (error) {
474
1199
  const status = toStatusCode(error);
@@ -481,6 +1206,7 @@ export function createServerApp(
481
1206
  requestId,
482
1207
  error,
483
1208
  status,
1209
+ finishRequestCost(requestCost),
484
1210
  );
485
1211
  return c.json(toErrorResponse(error, requestId), status);
486
1212
  }
@@ -488,13 +1214,28 @@ export function createServerApp(
488
1214
 
489
1215
  app.post("/auth/continue", async (c) => {
490
1216
  let rawBody: unknown;
1217
+ const requestCost = startRequestCost();
491
1218
  try {
492
1219
  rawBody = await c.req.raw
493
1220
  .clone()
494
1221
  .json()
495
1222
  .catch(() => undefined);
496
1223
  const body = AuthFlowRequestSchema.parse(rawBody);
497
- 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
+ );
498
1239
  return response instanceof Response ? response : c.json(response);
499
1240
  } catch (error) {
500
1241
  const status = toStatusCode(error);
@@ -507,6 +1248,7 @@ export function createServerApp(
507
1248
  requestId,
508
1249
  error,
509
1250
  status,
1251
+ finishRequestCost(requestCost),
510
1252
  );
511
1253
  return c.json(toErrorResponse(error, requestId), status);
512
1254
  }
@@ -514,13 +1256,23 @@ export function createServerApp(
514
1256
 
515
1257
  app.post("/auth/poll", async (c) => {
516
1258
  let rawBody: unknown;
1259
+ const requestCost = startRequestCost();
517
1260
  try {
518
1261
  rawBody = await c.req.raw
519
1262
  .clone()
520
1263
  .json()
521
1264
  .catch(() => undefined);
522
1265
  const body = AuthFlowRequestSchema.parse(rawBody);
523
- 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
+ );
524
1276
  return response instanceof Response ? response : c.json(response);
525
1277
  } catch (error) {
526
1278
  const status = toStatusCode(error);
@@ -533,6 +1285,7 @@ export function createServerApp(
533
1285
  requestId,
534
1286
  error,
535
1287
  status,
1288
+ finishRequestCost(requestCost),
536
1289
  );
537
1290
  return c.json(toErrorResponse(error, requestId), status);
538
1291
  }
@@ -540,13 +1293,23 @@ export function createServerApp(
540
1293
 
541
1294
  app.post("/auth/disconnect", async (c) => {
542
1295
  let rawBody: unknown;
1296
+ const requestCost = startRequestCost();
543
1297
  try {
544
1298
  rawBody = await c.req.raw
545
1299
  .clone()
546
1300
  .json()
547
1301
  .catch(() => undefined);
548
1302
  const body = AuthFlowRequestSchema.parse(rawBody);
549
- 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
+ );
550
1313
  return response instanceof Response ? response : c.json(response);
551
1314
  } catch (error) {
552
1315
  const status = toStatusCode(error);
@@ -559,6 +1322,7 @@ export function createServerApp(
559
1322
  requestId,
560
1323
  error,
561
1324
  status,
1325
+ finishRequestCost(requestCost),
562
1326
  );
563
1327
  return c.json(toErrorResponse(error, requestId), status);
564
1328
  }
@@ -613,7 +1377,10 @@ export async function serve(
613
1377
  );
614
1378
  }
615
1379
 
616
- const app = createServerApp(provider, { logger: options.logger });
1380
+ const app = createServerApp(provider, {
1381
+ logger: options.logger,
1382
+ stt: options.stt,
1383
+ });
617
1384
 
618
1385
  bunRuntime.serve({
619
1386
  port: options.port ?? DEFAULT_PORT,