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

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 (63) hide show
  1. package/AUTHORING.md +187 -8
  2. package/CHANGELOG.md +13 -1
  3. package/README.md +40 -18
  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 +76 -31
  9. package/bin/apifuse-record.ts +148 -94
  10. package/bin/apifuse-submit-check.ts +243 -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 +4 -7
  15. package/src/cli/create.ts +180 -51
  16. package/src/cli/templates/provider/.dockerignore.tpl +22 -0
  17. package/src/cli/templates/provider/.gitignore.tpl +22 -0
  18. package/src/cli/templates/provider/README.md.tpl +42 -7
  19. package/src/cli/templates/provider/dev.ts.tpl +1 -1
  20. package/src/cli/templates/provider/domain/README.md.tpl +3 -0
  21. package/src/cli/templates/provider/index.ts.tpl +5 -47
  22. package/src/cli/templates/provider/mappers/README.md.tpl +3 -0
  23. package/src/cli/templates/provider/meta.ts.tpl +7 -0
  24. package/src/cli/templates/provider/operations/index.ts.tpl +5 -0
  25. package/src/cli/templates/provider/operations/ping.ts.tpl +23 -0
  26. package/src/cli/templates/provider/schemas/ping.ts.tpl +16 -0
  27. package/src/cli/templates/provider/start.ts.tpl +1 -1
  28. package/src/cli/templates/provider/upstream/README.md.tpl +3 -0
  29. package/src/config/loader.ts +1206 -9
  30. package/src/define.ts +1620 -106
  31. package/src/errors.ts +12 -0
  32. package/src/i18n/catalog.ts +121 -0
  33. package/src/i18n/index.ts +2 -0
  34. package/src/i18n/keys.ts +64 -0
  35. package/src/index.ts +149 -8
  36. package/src/lint.ts +306 -51
  37. package/src/observability.ts +41 -0
  38. package/src/provider.ts +60 -3
  39. package/src/public-schema-field-lint.ts +237 -0
  40. package/src/runtime/auth-flow.ts +7 -0
  41. package/src/runtime/browser.ts +77 -21
  42. package/src/runtime/cache.ts +582 -0
  43. package/src/runtime/executor.ts +13 -1
  44. package/src/runtime/http.ts +939 -195
  45. package/src/runtime/insights.ts +11 -11
  46. package/src/runtime/instrumentation.ts +12 -4
  47. package/src/runtime/key-derivation.ts +1 -1
  48. package/src/runtime/keyring.ts +4 -3
  49. package/src/runtime/proxy-errors.ts +132 -0
  50. package/src/runtime/proxy-telemetry.ts +253 -0
  51. package/src/runtime/request-options.ts +66 -0
  52. package/src/runtime/state.ts +76 -0
  53. package/src/runtime/stealth.ts +1145 -0
  54. package/src/runtime/stt.ts +629 -0
  55. package/src/runtime/trace.ts +1 -1
  56. package/src/schema.ts +363 -1
  57. package/src/server/serve.ts +816 -58
  58. package/src/server/types.ts +35 -0
  59. package/src/stream.ts +210 -0
  60. package/src/testing/run.ts +17 -4
  61. package/src/types.ts +876 -53
  62. package/src/runtime/tls.ts +0 -434
  63. package/src/types/playwright-stealth.d.ts +0 -9
@@ -0,0 +1,582 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import Redis from "ioredis";
4
+
5
+ import type {
6
+ ProviderCache,
7
+ ProviderCacheGetOrSetOptions,
8
+ ProviderCacheKeyOptions,
9
+ ProviderCacheLookupMeta,
10
+ ProviderCacheResponseMeta,
11
+ ProviderCacheResult,
12
+ } from "../types";
13
+
14
+ type CacheSource = ProviderCacheLookupMeta["source"];
15
+
16
+ type CacheEnvelope = {
17
+ value: unknown;
18
+ writtenAt: number;
19
+ freshUntil: number;
20
+ staleUntil: number;
21
+ };
22
+
23
+ type MemoryEntry = CacheEnvelope & {
24
+ expiresAt: number;
25
+ lastAccessedAt: number;
26
+ };
27
+
28
+ type SharedCacheBackend = {
29
+ redis?: Redis;
30
+ memory: Map<string, MemoryEntry>;
31
+ inflight: Map<string, Promise<ProviderCacheResult<unknown>>>;
32
+ };
33
+
34
+ export type ProviderCacheOptions = {
35
+ providerId: string;
36
+ redisUrl?: string;
37
+ memoryMaxEntries?: number;
38
+ now?: () => number;
39
+ };
40
+
41
+ const DEFAULT_PREFIX = "apifuse:provider-cache:v1";
42
+ const DEFAULT_MEMORY_MAX_ENTRIES = 1_000;
43
+ const DEFAULT_REDIS_TIMEOUT_MS = 150;
44
+ const SECRET_FIELD_NAMES = new Set([
45
+ "authorization",
46
+ "cookie",
47
+ "password",
48
+ "secret",
49
+ "servicekey",
50
+ "service_key",
51
+ "token",
52
+ "apikey",
53
+ "api_key",
54
+ "access_token",
55
+ "refresh_token",
56
+ ]);
57
+
58
+ const sharedBackends = new Map<string, SharedCacheBackend>();
59
+
60
+ function redisUrlFromEnv(): string | undefined {
61
+ return (
62
+ process.env.APIFUSE__PROVIDER__CACHE_REDIS_URL?.trim() ||
63
+ process.env.APIFUSE__REDIS__URL?.trim() ||
64
+ undefined
65
+ );
66
+ }
67
+
68
+ function backendKey(redisUrl: string | undefined): string {
69
+ return redisUrl ?? "memory";
70
+ }
71
+
72
+ function getSharedBackend(redisUrl: string | undefined): SharedCacheBackend {
73
+ const key = backendKey(redisUrl);
74
+ const existing = sharedBackends.get(key);
75
+ if (existing) return existing;
76
+
77
+ const backend: SharedCacheBackend = {
78
+ memory: new Map(),
79
+ inflight: new Map(),
80
+ };
81
+
82
+ if (redisUrl) {
83
+ const redis = new Redis(redisUrl, {
84
+ connectTimeout: DEFAULT_REDIS_TIMEOUT_MS,
85
+ enableOfflineQueue: false,
86
+ lazyConnect: true,
87
+ maxRetriesPerRequest: 0,
88
+ retryStrategy: () => null,
89
+ });
90
+ redis.on("error", () => {
91
+ // Fail-open: cache connectivity must never fail provider execution.
92
+ });
93
+ backend.redis = redis;
94
+ }
95
+
96
+ sharedBackends.set(key, backend);
97
+ return backend;
98
+ }
99
+
100
+ function isRecord(value: unknown): value is Record<string, unknown> {
101
+ return value !== null && typeof value === "object" && !Array.isArray(value);
102
+ }
103
+
104
+ function shouldRedactField(name: string, extra: Set<string>): boolean {
105
+ const normalized = name.toLowerCase();
106
+ return (
107
+ SECRET_FIELD_NAMES.has(normalized) ||
108
+ extra.has(normalized) ||
109
+ normalized.includes("authorization") ||
110
+ normalized.includes("cookie") ||
111
+ normalized.includes("password") ||
112
+ normalized.includes("secret")
113
+ );
114
+ }
115
+
116
+ function normalizeKeyPart(value: unknown, extra: Set<string>): unknown {
117
+ if (Array.isArray(value)) {
118
+ return value.map((entry) => normalizeKeyPart(entry, extra));
119
+ }
120
+ if (isRecord(value)) {
121
+ const normalized: Record<string, unknown> = {};
122
+ for (const key of Object.keys(value).sort()) {
123
+ if (shouldRedactField(key, extra)) continue;
124
+ normalized[key] = normalizeKeyPart(value[key], extra);
125
+ }
126
+ return normalized;
127
+ }
128
+ return value;
129
+ }
130
+
131
+ function stableHash(value: unknown): string {
132
+ return createHash("sha256")
133
+ .update(JSON.stringify(value))
134
+ .digest("hex")
135
+ .slice(0, 32);
136
+ }
137
+
138
+ function jitteredTtlMs(ttlMs: number, jitterPct: number | undefined): number {
139
+ if (!jitterPct || jitterPct <= 0) return ttlMs;
140
+ const bounded = Math.min(jitterPct, 0.5);
141
+ const delta = ttlMs * bounded;
142
+ const multiplier = 1 - bounded + (Math.random() * delta * 2) / ttlMs;
143
+ return Math.max(1, Math.round(ttlMs * multiplier));
144
+ }
145
+
146
+ function safeParseEnvelope(raw: string | null): CacheEnvelope | null {
147
+ if (!raw) return null;
148
+ try {
149
+ const parsed: unknown = JSON.parse(raw);
150
+ if (
151
+ !isRecord(parsed) ||
152
+ typeof parsed.writtenAt !== "number" ||
153
+ typeof parsed.freshUntil !== "number" ||
154
+ typeof parsed.staleUntil !== "number" ||
155
+ !("value" in parsed)
156
+ ) {
157
+ return null;
158
+ }
159
+ return {
160
+ value: parsed.value,
161
+ writtenAt: parsed.writtenAt,
162
+ freshUntil: parsed.freshUntil,
163
+ staleUntil: parsed.staleUntil,
164
+ };
165
+ } catch {
166
+ return null;
167
+ }
168
+ }
169
+
170
+ function resultWithValue<T>(
171
+ value: unknown,
172
+ meta: ProviderCacheLookupMeta,
173
+ ): ProviderCacheResult<T> {
174
+ return {
175
+ value: <T>value,
176
+ meta,
177
+ };
178
+ }
179
+
180
+ function resultFromEnvelope<T>(
181
+ key: string,
182
+ envelope: CacheEnvelope,
183
+ now: number,
184
+ source: CacheSource,
185
+ ): ProviderCacheResult<T> | null {
186
+ if (now > envelope.staleUntil) return null;
187
+ return resultWithValue<T>(envelope.value, {
188
+ key,
189
+ hit: true,
190
+ stale: now > envelope.freshUntil,
191
+ ageMs: Math.max(0, now - envelope.writtenAt),
192
+ source,
193
+ });
194
+ }
195
+
196
+ function sourceSummary(
197
+ events: ProviderCacheLookupMeta[],
198
+ ): ProviderCacheResponseMeta["source"] {
199
+ const sources = new Set(events.map((event) => event.source));
200
+ if (sources.size === 0) return undefined;
201
+ if (sources.size === 1) return events[0]?.source;
202
+ return "mixed";
203
+ }
204
+
205
+ async function withRedisTimeout<T>(
206
+ operation: () => Promise<T>,
207
+ ): Promise<T | undefined> {
208
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
209
+ try {
210
+ const timeout = new Promise<undefined>((resolve) => {
211
+ timeoutId = setTimeout(
212
+ () => resolve(undefined),
213
+ DEFAULT_REDIS_TIMEOUT_MS,
214
+ );
215
+ });
216
+ return await Promise.race([operation().catch(() => undefined), timeout]);
217
+ } finally {
218
+ if (timeoutId) clearTimeout(timeoutId);
219
+ }
220
+ }
221
+
222
+ function redisStatus(redis: Redis): string {
223
+ return redis.status;
224
+ }
225
+
226
+ async function waitForRedisReady(redis: Redis): Promise<boolean> {
227
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
228
+ let settled = false;
229
+
230
+ return await new Promise<boolean>((resolve) => {
231
+ const cleanup = () => {
232
+ if (timeoutId) clearTimeout(timeoutId);
233
+ redis.off("ready", onReady);
234
+ redis.off("close", onUnavailable);
235
+ redis.off("end", onUnavailable);
236
+ redis.off("error", onUnavailable);
237
+ };
238
+ const finish = (ready: boolean) => {
239
+ if (settled) return;
240
+ settled = true;
241
+ cleanup();
242
+ resolve(ready);
243
+ };
244
+ const onReady = () => finish(true);
245
+ const onUnavailable = () => finish(false);
246
+
247
+ timeoutId = setTimeout(
248
+ () => finish(redisStatus(redis) === "ready"),
249
+ DEFAULT_REDIS_TIMEOUT_MS,
250
+ );
251
+ redis.once("ready", onReady);
252
+ redis.once("close", onUnavailable);
253
+ redis.once("end", onUnavailable);
254
+ redis.once("error", onUnavailable);
255
+ });
256
+ }
257
+
258
+ async function ensureRedisReady(redis: Redis): Promise<boolean> {
259
+ if (redisStatus(redis) === "ready") return true;
260
+
261
+ if (redisStatus(redis) === "wait" || redisStatus(redis) === "end") {
262
+ const connected = await withRedisTimeout(async () => {
263
+ await redis.connect();
264
+ return true;
265
+ });
266
+ return connected === true && redisStatus(redis) === "ready";
267
+ }
268
+
269
+ return await waitForRedisReady(redis);
270
+ }
271
+
272
+ export function createProviderCache(
273
+ options: ProviderCacheOptions,
274
+ ): ProviderCache {
275
+ const redisUrl = options.redisUrl ?? redisUrlFromEnv();
276
+ const backend = getSharedBackend(redisUrl);
277
+ const memoryMaxEntries = Math.max(
278
+ 1,
279
+ options.memoryMaxEntries ?? DEFAULT_MEMORY_MAX_ENTRIES,
280
+ );
281
+ const now = options.now ?? Date.now;
282
+ const events: ProviderCacheLookupMeta[] = [];
283
+
284
+ function record(meta: ProviderCacheLookupMeta): void {
285
+ events.push(meta);
286
+ }
287
+
288
+ function sweepMemory(currentTime: number): void {
289
+ for (const [entryKey, entry] of backend.memory) {
290
+ if (entry.expiresAt <= currentTime) {
291
+ backend.memory.delete(entryKey);
292
+ }
293
+ }
294
+ }
295
+
296
+ function enforceMemoryLimit(): void {
297
+ while (backend.memory.size > memoryMaxEntries) {
298
+ const oldestKey = backend.memory.keys().next().value;
299
+ if (typeof oldestKey !== "string") return;
300
+ backend.memory.delete(oldestKey);
301
+ }
302
+ }
303
+
304
+ function rememberEnvelope(
305
+ key: string,
306
+ envelope: CacheEnvelope,
307
+ currentTime: number,
308
+ ): void {
309
+ sweepMemory(currentTime);
310
+ backend.memory.delete(key);
311
+ backend.memory.set(key, {
312
+ ...envelope,
313
+ expiresAt: envelope.staleUntil,
314
+ lastAccessedAt: currentTime,
315
+ });
316
+ enforceMemoryLimit();
317
+ }
318
+
319
+ function touchMemory(
320
+ key: string,
321
+ entry: MemoryEntry,
322
+ currentTime: number,
323
+ ): void {
324
+ backend.memory.delete(key);
325
+ backend.memory.set(key, { ...entry, lastAccessedAt: currentTime });
326
+ }
327
+
328
+ async function readRedis<T>(
329
+ key: string,
330
+ currentTime: number,
331
+ ): Promise<{
332
+ envelope: CacheEnvelope;
333
+ result: ProviderCacheResult<T>;
334
+ } | null> {
335
+ const redis = backend.redis;
336
+ if (!redis || !(await ensureRedisReady(redis))) return null;
337
+
338
+ const raw = await withRedisTimeout(async () => {
339
+ return await redis.get(key);
340
+ });
341
+ if (typeof raw !== "string" && raw !== null) return null;
342
+
343
+ const envelope = safeParseEnvelope(raw);
344
+ if (!envelope) return null;
345
+
346
+ const result = resultFromEnvelope<T>(key, envelope, currentTime, "redis");
347
+ if (!result) return null;
348
+
349
+ rememberEnvelope(key, envelope, currentTime);
350
+ return { envelope, result };
351
+ }
352
+
353
+ async function read<T>(key: string): Promise<ProviderCacheResult<T> | null> {
354
+ const currentTime = now();
355
+ const memoryEntry = backend.memory.get(key);
356
+ let staleMemoryResult: ProviderCacheResult<T> | null = null;
357
+ let staleMemoryWrittenAt: number | undefined;
358
+ if (memoryEntry) {
359
+ if (memoryEntry.expiresAt <= currentTime) {
360
+ backend.memory.delete(key);
361
+ } else {
362
+ const memoryResult = resultFromEnvelope<T>(
363
+ key,
364
+ memoryEntry,
365
+ currentTime,
366
+ "memory",
367
+ );
368
+ if (memoryResult && !memoryResult.meta.stale) {
369
+ touchMemory(key, memoryEntry, currentTime);
370
+ return memoryResult;
371
+ }
372
+ staleMemoryResult = memoryResult;
373
+ staleMemoryWrittenAt = memoryEntry.writtenAt;
374
+ touchMemory(key, memoryEntry, currentTime);
375
+ }
376
+ }
377
+
378
+ const redisResult = await readRedis<T>(key, currentTime);
379
+ if (redisResult) {
380
+ if (!staleMemoryResult) return redisResult.result;
381
+ if (
382
+ !redisResult.result.meta.stale ||
383
+ redisResult.envelope.writtenAt >= (staleMemoryWrittenAt ?? 0)
384
+ ) {
385
+ return redisResult.result;
386
+ }
387
+ }
388
+
389
+ return staleMemoryResult;
390
+ }
391
+
392
+ async function write<T>(
393
+ key: string,
394
+ value: T,
395
+ cacheOptions: ProviderCacheGetOrSetOptions,
396
+ ): Promise<void> {
397
+ const currentTime = now();
398
+ const freshTtlMs = jitteredTtlMs(
399
+ cacheOptions.ttlMs,
400
+ cacheOptions.jitterPct,
401
+ );
402
+ const staleIfErrorMs = cacheOptions.staleIfErrorMs ?? 0;
403
+ const staleTtlMs = freshTtlMs + staleIfErrorMs;
404
+ const envelope: CacheEnvelope = {
405
+ value,
406
+ writtenAt: currentTime,
407
+ freshUntil: currentTime + freshTtlMs,
408
+ staleUntil: currentTime + staleTtlMs,
409
+ };
410
+ rememberEnvelope(key, envelope, currentTime);
411
+
412
+ const redis = backend.redis;
413
+ if (!redis || !(await ensureRedisReady(redis))) return;
414
+ await withRedisTimeout(() =>
415
+ redis.set(key, JSON.stringify(envelope), "PX", staleTtlMs),
416
+ );
417
+ }
418
+
419
+ async function loadAndStore<T>(
420
+ key: string,
421
+ loader: () => Promise<T>,
422
+ cacheOptions: ProviderCacheGetOrSetOptions,
423
+ staleCandidate: ProviderCacheResult<T> | null,
424
+ ): Promise<ProviderCacheResult<T>> {
425
+ try {
426
+ const value = await loader();
427
+ await write(key, value, cacheOptions);
428
+ return {
429
+ value,
430
+ meta: {
431
+ key,
432
+ hit: false,
433
+ stale: false,
434
+ source: "loader",
435
+ },
436
+ };
437
+ } catch (error) {
438
+ if (staleCandidate?.meta.stale) {
439
+ return staleCandidate;
440
+ }
441
+ throw error;
442
+ }
443
+ }
444
+
445
+ return {
446
+ key(namespace, parts, keyOptions?: ProviderCacheKeyOptions) {
447
+ const extra = new Set(
448
+ (keyOptions?.redactFields ?? []).map((field) => field.toLowerCase()),
449
+ );
450
+ const normalized = normalizeKeyPart(parts, extra);
451
+ return `${DEFAULT_PREFIX}:${options.providerId}:${namespace}:${stableHash(normalized)}`;
452
+ },
453
+
454
+ async get<T = unknown>(
455
+ key: string,
456
+ ): Promise<ProviderCacheResult<T> | null> {
457
+ const result = await read<T>(key);
458
+ if (result) record(result.meta);
459
+ return result;
460
+ },
461
+
462
+ set: write,
463
+
464
+ async delete(key: string): Promise<void> {
465
+ backend.memory.delete(key);
466
+ const redis = backend.redis;
467
+ if (!redis || !(await ensureRedisReady(redis))) return;
468
+ await withRedisTimeout(() => redis.del(key));
469
+ },
470
+
471
+ async getOrSet<T = unknown>(
472
+ key: string,
473
+ loader: () => Promise<T>,
474
+ cacheOptions: ProviderCacheGetOrSetOptions,
475
+ ): Promise<ProviderCacheResult<T>> {
476
+ const existing = await read<T>(key);
477
+ if (existing && !existing.meta.stale) {
478
+ record(existing.meta);
479
+ return existing;
480
+ }
481
+
482
+ const existingInflight = backend.inflight.get(key);
483
+ if (existingInflight) {
484
+ const inflightResult = await existingInflight;
485
+ const result = resultWithValue<T>(
486
+ inflightResult.value,
487
+ inflightResult.meta,
488
+ );
489
+ record(result.meta);
490
+ return result;
491
+ }
492
+
493
+ const promise: Promise<ProviderCacheResult<unknown>> = loadAndStore(
494
+ key,
495
+ loader,
496
+ cacheOptions,
497
+ existing,
498
+ ).finally(() => {
499
+ backend.inflight.delete(key);
500
+ });
501
+ backend.inflight.set(key, promise);
502
+ const loaded = await promise;
503
+ const result = resultWithValue<T>(loaded.value, loaded.meta);
504
+ record(result.meta);
505
+ return result;
506
+ },
507
+
508
+ responseMeta(): ProviderCacheResponseMeta | undefined {
509
+ if (events.length === 0) return undefined;
510
+ return {
511
+ hit: events.some((event) => event.hit),
512
+ stale: events.some((event) => event.stale),
513
+ keys: Array.from(new Set(events.map((event) => event.key))),
514
+ source: sourceSummary(events),
515
+ };
516
+ },
517
+ };
518
+ }
519
+
520
+ export function createBypassProviderCache(
521
+ options: Pick<ProviderCacheOptions, "providerId">,
522
+ ): ProviderCache {
523
+ const events: ProviderCacheLookupMeta[] = [];
524
+
525
+ return {
526
+ key(namespace, parts, keyOptions?: ProviderCacheKeyOptions) {
527
+ const extra = new Set(
528
+ (keyOptions?.redactFields ?? []).map((field) => field.toLowerCase()),
529
+ );
530
+ const normalized = normalizeKeyPart(parts, extra);
531
+ return `${DEFAULT_PREFIX}:${options.providerId}:${namespace}:${stableHash(normalized)}`;
532
+ },
533
+
534
+ async get<T = unknown>(
535
+ _key: string,
536
+ ): Promise<ProviderCacheResult<T> | null> {
537
+ return null;
538
+ },
539
+
540
+ async set(): Promise<void> {
541
+ // Intentionally disabled for SDK tools that must hit upstream directly.
542
+ },
543
+
544
+ async delete(): Promise<void> {
545
+ // Intentionally disabled for SDK tools that must hit upstream directly.
546
+ },
547
+
548
+ async getOrSet<T = unknown>(
549
+ key: string,
550
+ loader: () => Promise<T>,
551
+ ): Promise<ProviderCacheResult<T>> {
552
+ const value = await loader();
553
+ const meta: ProviderCacheLookupMeta = {
554
+ key,
555
+ hit: false,
556
+ stale: false,
557
+ source: "loader",
558
+ };
559
+ events.push(meta);
560
+ return { value, meta };
561
+ },
562
+
563
+ responseMeta(): ProviderCacheResponseMeta | undefined {
564
+ if (events.length === 0) return undefined;
565
+ return {
566
+ hit: false,
567
+ stale: false,
568
+ keys: Array.from(new Set(events.map((event) => event.key))),
569
+ source: sourceSummary(events),
570
+ };
571
+ },
572
+ };
573
+ }
574
+
575
+ export function resetProviderCacheForTests(): void {
576
+ for (const backend of sharedBackends.values()) {
577
+ backend.memory.clear();
578
+ backend.inflight.clear();
579
+ backend.redis?.disconnect();
580
+ }
581
+ sharedBackends.clear();
582
+ }
@@ -2,6 +2,14 @@ import { ProviderError } from "../errors";
2
2
  import { parseSchema } from "../schema";
3
3
  import type { ProviderContext, ProviderDefinition } from "../types";
4
4
 
5
+ export function isStreamingOperation(
6
+ provider: ProviderDefinition,
7
+ operationId: string,
8
+ ): boolean {
9
+ const kind = provider.operations[operationId]?.transport?.kind ?? "json";
10
+ return kind !== "json";
11
+ }
12
+
5
13
  /**
6
14
  * Execute a provider operation by calling its handler.
7
15
  *
@@ -40,11 +48,15 @@ export async function executeOperation(
40
48
 
41
49
  const execute = () =>
42
50
  ctx.trace.span(`handler:${operationId}`, () =>
43
- operation.handler(ctx, validatedInput),
51
+ Promise.resolve(operation.handler(ctx, validatedInput)),
44
52
  );
45
53
 
46
54
  const result = await execute();
47
55
 
56
+ if (isStreamingOperation(provider, operationId)) {
57
+ return result;
58
+ }
59
+
48
60
  return parseSchema(
49
61
  operation.output,
50
62
  result,