workers-ai-provider 3.1.14 → 3.2.0

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.
@@ -0,0 +1,696 @@
1
+ import type { LanguageModelV3 } from "@ai-sdk/provider";
2
+ import { createClientFallbackModel } from "./client-fallback";
3
+ import { findProviderBySlug, type GatewayProviderInfo, type WireFormat } from "./gateway-providers";
4
+ import { createResumableStream, type ResumeExpiredPolicy } from "./resumable-stream";
5
+
6
+ export {
7
+ createResumableStream,
8
+ type ResumableStreamOptions,
9
+ type ResumeExpiredPolicy,
10
+ } from "./resumable-stream";
11
+ export {
12
+ type FallbackAttempt,
13
+ type GatewayErrorCode,
14
+ type GatewayErrorContext,
15
+ WorkersAIFallbackError,
16
+ WorkersAIGatewayError,
17
+ } from "./errors";
18
+ export { type FallbackLeg, createClientFallbackModel } from "./client-fallback";
19
+ export {
20
+ type Billing,
21
+ GATEWAY_PROVIDERS,
22
+ type GatewayProviderInfo,
23
+ type WireFormat,
24
+ detectProviderByUrl,
25
+ findProviderBySlug,
26
+ wireableProviders,
27
+ } from "./gateway-providers";
28
+
29
+ /**
30
+ * Gateway delegate — route AI SDK catalog models through Cloudflare AI Gateway,
31
+ * with capability-driven transport selection.
32
+ *
33
+ * Two transports back the same model, chosen from the requested options:
34
+ *
35
+ * - **Run path** `env.AI.run(slug, body, { returnRawResponse })` — resumable
36
+ * streaming (`cf-aig-run-id`). The default.
37
+ * - **Gateway path** `env.AI.gateway(id).run([entry, …fallback])` — server-side
38
+ * fallback and caching. Does not surface `cf-aig-run-id`, so resume is off.
39
+ *
40
+ * The SAME `@ai-sdk/*` provider parses the response on either path, so there is no
41
+ * per-provider or per-path response parsing here. Provider plugins (which import
42
+ * `@ai-sdk/openai`, `@ai-sdk/anthropic`, …) are injected from sub-path modules
43
+ * (`workers-ai-provider/openai`, …) so those AI SDK packages stay OPTIONAL peer
44
+ * dependencies — you only install the ones you use.
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * import { createGatewayDelegate } from "workers-ai-provider/gateway-delegate";
49
+ * import { openai } from "workers-ai-provider/openai";
50
+ * import { streamText } from "ai";
51
+ *
52
+ * const wai = createGatewayDelegate({
53
+ * binding: env.AI,
54
+ * gateway: "my-gateway",
55
+ * providers: [openai],
56
+ * });
57
+ *
58
+ * const result = streamText({ model: wai("openai/gpt-5"), prompt: "Hello" });
59
+ * // result.response.headers["cf-aig-run-id"] is set — resume from there.
60
+ * ```
61
+ */
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Slug parsing
65
+ // ---------------------------------------------------------------------------
66
+
67
+ export interface ParsedSlug {
68
+ /** First path segment — the registry resolver key (selects provider + wire format). */
69
+ resolverKey: string;
70
+ /** Remaining segments — the provider-native model id. */
71
+ modelId: string;
72
+ }
73
+
74
+ /**
75
+ * Parse a `vendor/model` slug. The first segment is the resolver key (which
76
+ * registry entry handles it); the rest is the provider-native model id. Routing
77
+ * providers keep multi-segment model ids, e.g. `openrouter/anthropic/claude`.
78
+ */
79
+ export function parseSlug(slug: string): ParsedSlug {
80
+ const slash = slug.indexOf("/");
81
+ if (slash === -1) {
82
+ throw new GatewayDelegateError(
83
+ "config",
84
+ `Model slug "${slug}" has no resolver key. Use "<provider>/<model>" (e.g. "openai/gpt-5").`,
85
+ );
86
+ }
87
+ const resolverKey = slug.slice(0, slash);
88
+ const modelId = slug.slice(slash + 1);
89
+ if (!resolverKey || !modelId) {
90
+ throw new GatewayDelegateError(
91
+ "config",
92
+ `Model slug "${slug}" is malformed. Use "<provider>/<model>" (e.g. "openai/gpt-5").`,
93
+ );
94
+ }
95
+ return { resolverKey, modelId };
96
+ }
97
+
98
+ /**
99
+ * Resolve a slug to its registry entry, raising a helpful error for unknown or
100
+ * bring-your-own-provider-only providers.
101
+ */
102
+ export function resolveProvider(slug: string, parsed: ParsedSlug): GatewayProviderInfo {
103
+ const info = findProviderBySlug(parsed.resolverKey);
104
+ if (!info) {
105
+ throw new GatewayDelegateError(
106
+ "config",
107
+ `Unknown gateway provider "${parsed.resolverKey}" (from slug "${slug}"). ` +
108
+ "See the AI Gateway provider directory for valid slugs, or use " +
109
+ "createGatewayProvider to bring your own @ai-sdk provider.",
110
+ );
111
+ }
112
+ if (!info.wireFormat) {
113
+ throw new GatewayDelegateError(
114
+ "config",
115
+ `Provider "${parsed.resolverKey}" is not chat/completions-shaped and has no built-in ` +
116
+ "parser. Reach it with createGatewayProvider (bring your own @ai-sdk provider).",
117
+ );
118
+ }
119
+ return info;
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Provider plugins (injected from sub-path modules)
124
+ // ---------------------------------------------------------------------------
125
+
126
+ /**
127
+ * Adapts a `@ai-sdk/*` provider to the delegate, keyed by the response wire
128
+ * format it parses. Imported from a sub-path module (e.g.
129
+ * `workers-ai-provider/openai`) so the AI SDK package stays an optional peer
130
+ * dependency. One plugin serves every registry provider of that wire format —
131
+ * the `openai` plugin covers the whole OpenAI-compatible long tail (deepseek,
132
+ * grok, groq, mistral, perplexity, openrouter, …).
133
+ */
134
+ export interface ProviderPlugin {
135
+ /** The response wire format this builder parses. */
136
+ readonly wireFormat: WireFormat;
137
+ /**
138
+ * Build the AI SDK model, wiring the gateway-dispatching `fetch`. `baseURL`
139
+ * (when provided by the registry) targets the provider's host so the request
140
+ * URL host-strips to its gateway-native endpoint — pass it to the underlying
141
+ * `@ai-sdk` provider.
142
+ */
143
+ create(args: {
144
+ modelId: string;
145
+ fetch: typeof globalThis.fetch;
146
+ baseURL?: string;
147
+ }): LanguageModelV3;
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Options + transport selection
152
+ // ---------------------------------------------------------------------------
153
+
154
+ export type Transport = "run" | "gateway";
155
+
156
+ export interface FallbackOptions {
157
+ /** `"client"` keeps resume (sequential run-path attempts); `"server"` uses the gateway path. */
158
+ mode: "client" | "server";
159
+ /** Ordered model slugs to try after the primary. */
160
+ models: string[];
161
+ }
162
+
163
+ export interface DispatchInfo {
164
+ transport: Transport;
165
+ resumeEnabled: boolean;
166
+ warnings: string[];
167
+ runId: string | null;
168
+ status: number | null;
169
+ cfStep: string | null;
170
+ cacheStatus: string | null;
171
+ logId: string | null;
172
+ }
173
+
174
+ export interface DelegateCallOptions {
175
+ /** Resumable streaming (run path). Defaults to the delegate's `resume` (true). */
176
+ resume?: boolean;
177
+ /** Cross-model fallback. `"server"` mode uses the gateway path (disables resume). */
178
+ fallback?: FallbackOptions;
179
+ /** Gateway-path response caching (seconds). Forces the gateway path. */
180
+ cacheTtl?: number;
181
+ /** Bypass gateway cache. Forces the gateway path. */
182
+ skipCache?: boolean;
183
+ /** Escape hatch: force a transport. */
184
+ transport?: Transport;
185
+ /**
186
+ * Run path only: behavior when the resume buffer has expired (404) after a
187
+ * mid-stream drop. `"error"` (default) surfaces a `GatewayDelegateError`;
188
+ * `"accept-partial"` ends the stream cleanly with whatever was delivered.
189
+ */
190
+ onResumeExpired?: ResumeExpiredPolicy;
191
+ /** Extra request headers (run path: `extraHeaders`; gateway path: entry headers). */
192
+ extraHeaders?: Record<string, string>;
193
+ /**
194
+ * Gateway path only: forward the upstream provider key instead of stripping it.
195
+ * Required for BYOK providers (not on unified billing). Supply the key via
196
+ * `extraHeaders` (e.g. `{ authorization: "Bearer …" }`); without `byok` the
197
+ * delegate strips provider auth headers so unified billing applies.
198
+ */
199
+ byok?: boolean;
200
+ /** Override the delegate's gateway for this model. */
201
+ gateway?: GatewayOptions | string;
202
+ /**
203
+ * Custom metadata attached to the gateway log for this request (spend
204
+ * attribution, tenant ids, etc.). Merges over any `metadata` already set via
205
+ * `gateway: { metadata }`. Applied on both transports (run path: gateway
206
+ * options; gateway path: `cf-aig-metadata` header). `bigint` values are
207
+ * coerced to strings for the header form.
208
+ */
209
+ metadata?: Record<string, number | string | boolean | null | bigint>;
210
+ /** Force gateway log collection on/off for this request (both transports). */
211
+ collectLog?: boolean;
212
+ /** Called once per dispatch with the resolved transport + gateway headers. */
213
+ onDispatch?: (info: DispatchInfo) => void;
214
+ /**
215
+ * Run path only: fired with the cumulative SSE event offset as the resumable
216
+ * stream advances. Pair with `onDispatch` (for `runId`) to persist
217
+ * `{ runId, eventOffset }` for cross-invocation re-attach after eviction.
218
+ * Throttle your own writes — this can fire per chunk.
219
+ */
220
+ onProgress?: (eventOffset: number) => void;
221
+ }
222
+
223
+ interface Selection {
224
+ transport: Transport;
225
+ resumeEnabled: boolean;
226
+ warnings: string[];
227
+ }
228
+
229
+ /**
230
+ * Resolve the transport from the requested options. Gateway-only features (server
231
+ * fallback, caching) force the gateway path and disable resume — with a loud
232
+ * warning if resume was merely defaulted, or a thrown error if it was explicitly
233
+ * requested.
234
+ */
235
+ export function selectTransport(
236
+ opts: DelegateCallOptions,
237
+ resumeExplicitlyTrue: boolean,
238
+ runCatalog = true,
239
+ gatewayAvailable = true,
240
+ ): Selection {
241
+ const warnings: string[] = [];
242
+ const wantsServerFallback = opts.fallback?.mode === "server";
243
+ const wantsCaching = opts.cacheTtl !== undefined || opts.skipCache === true;
244
+ const gatewayOnly = wantsServerFallback || wantsCaching;
245
+ const feature = wantsServerFallback ? 'fallback.mode:"server"' : "caching (cacheTtl/skipCache)";
246
+
247
+ // Run-path-only providers (on the run catalog, but not native gateway
248
+ // providers) have no gateway path at all — reject anything that would need it
249
+ // here, with a clear message, rather than letting it fail upstream.
250
+ if (runCatalog && !gatewayAvailable && (opts.transport === "gateway" || gatewayOnly)) {
251
+ const what = opts.transport === "gateway" ? 'transport:"gateway"' : feature;
252
+ throw new GatewayDelegateError(
253
+ "config",
254
+ `${what} is unavailable: this provider is on the unified run catalog but is not a ` +
255
+ "native gateway provider, so it has no gateway path (no caching, server-side " +
256
+ 'fallback, or transport:"gateway"). Use the default run path, or fallback.mode:"client".',
257
+ );
258
+ }
259
+
260
+ // BYOK providers are not on the resumable run catalog — they can only be
261
+ // reached through the gateway path.
262
+ if (!runCatalog) {
263
+ if (opts.transport === "run") {
264
+ throw new GatewayDelegateError(
265
+ "config",
266
+ 'transport:"run" is unavailable: this provider is not on the unified-billing run ' +
267
+ "catalog, so it can only be reached through the gateway path (BYOK).",
268
+ );
269
+ }
270
+ if (resumeExplicitlyTrue) {
271
+ throw new GatewayDelegateError(
272
+ "config",
273
+ "resume:true is unavailable: this provider is not on the resumable run catalog " +
274
+ "(cf-aig-run-id requires the unified-billing run path).",
275
+ );
276
+ }
277
+ return { transport: "gateway", resumeEnabled: false, warnings };
278
+ }
279
+
280
+ if (opts.transport === "run" && gatewayOnly) {
281
+ throw new GatewayDelegateError(
282
+ "config",
283
+ `transport:"run" cannot satisfy ${feature}: those features are only available on the ` +
284
+ 'gateway path. Use the gateway transport, or fallback.mode:"client".',
285
+ );
286
+ }
287
+ if (opts.transport === "gateway" && resumeExplicitlyTrue) {
288
+ throw new GatewayDelegateError(
289
+ "config",
290
+ 'transport:"gateway" cannot provide resume — cf-aig-run-id is only on the run path.',
291
+ );
292
+ }
293
+
294
+ if (gatewayOnly) {
295
+ if (resumeExplicitlyTrue) {
296
+ throw new GatewayDelegateError(
297
+ "config",
298
+ `resume:true conflicts with ${feature}: resume (cf-aig-run-id) is only on the run path, ` +
299
+ `which does not support ${wantsServerFallback ? "server-side fallback" : "caching"}. ` +
300
+ 'Use fallback.mode:"client" to keep resume, or drop resume.',
301
+ );
302
+ }
303
+ warnings.push(
304
+ `[workers-ai-provider] resume disabled: ${feature} requires the gateway path, which does ` +
305
+ 'not surface cf-aig-run-id. Use fallback.mode:"client" to keep resumable streaming.',
306
+ );
307
+ return { transport: "gateway", resumeEnabled: false, warnings };
308
+ }
309
+
310
+ const transport = opts.transport ?? "run";
311
+ return {
312
+ transport,
313
+ resumeEnabled: transport === "run" && opts.resume !== false,
314
+ warnings,
315
+ };
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Errors
320
+ // ---------------------------------------------------------------------------
321
+
322
+ export type GatewayDelegateErrorKind = "config" | "dispatch" | "provider" | "resume-expired";
323
+
324
+ export class GatewayDelegateError extends Error {
325
+ readonly kind: GatewayDelegateErrorKind;
326
+ override readonly cause?: unknown;
327
+
328
+ constructor(kind: GatewayDelegateErrorKind, message: string, cause?: unknown) {
329
+ super(message);
330
+ this.name = "GatewayDelegateError";
331
+ this.kind = kind;
332
+ this.cause = cause;
333
+ }
334
+ }
335
+
336
+ // ---------------------------------------------------------------------------
337
+ // Dispatch internals
338
+ // ---------------------------------------------------------------------------
339
+
340
+ // Always stripped on the gateway path (transport-level headers the binding sets).
341
+ const STRIP_HEADERS_BASE = new Set(["content-length", "host"]);
342
+
343
+ interface GatewayEntry {
344
+ provider: string;
345
+ endpoint: string;
346
+ headers: Record<string, string>;
347
+ query: Record<string, unknown>;
348
+ }
349
+
350
+ interface AiGatewayRunner {
351
+ run(body: unknown, options?: Record<string, unknown>): Promise<Response>;
352
+ }
353
+
354
+ function asText(body: BodyInit | null | undefined): string {
355
+ if (typeof body === "string") return body;
356
+ if (body instanceof Uint8Array) return new TextDecoder().decode(body);
357
+ if (body instanceof ArrayBuffer) return new TextDecoder().decode(body);
358
+ return "{}";
359
+ }
360
+
361
+ function headersToObject(h: HeadersInit | undefined): Record<string, string> {
362
+ const out: Record<string, string> = {};
363
+ if (!h) return out;
364
+ if (h instanceof Headers) {
365
+ for (const [k, v] of h) out[k] = v;
366
+ } else if (Array.isArray(h)) {
367
+ for (const [k, v] of h) out[k] = v;
368
+ } else {
369
+ Object.assign(out, h);
370
+ }
371
+ return out;
372
+ }
373
+
374
+ function normalizeGateway(gateway: GatewayOptions | string | undefined): {
375
+ id: string;
376
+ options: GatewayOptions;
377
+ } {
378
+ if (!gateway) {
379
+ throw new GatewayDelegateError(
380
+ "config",
381
+ "A gateway is required for the delegate (resume needs a gateway). " +
382
+ 'Pass `gateway: "<gateway-id>"` to createGatewayDelegate or per call.',
383
+ );
384
+ }
385
+ if (typeof gateway === "string") return { id: gateway, options: { id: gateway } };
386
+ return { id: gateway.id, options: gateway };
387
+ }
388
+
389
+ export interface GatewayDelegateConfig {
390
+ /** A Cloudflare AI binding (e.g. `env.AI`). Required — the gateway path needs `binding.gateway()`. */
391
+ binding: Ai;
392
+ /** Default gateway id (or options) for all models. Overridable per call. */
393
+ gateway?: GatewayOptions | string;
394
+ /** Provider plugins from sub-path modules (e.g. `[openai, anthropic]`). */
395
+ providers: ProviderPlugin[];
396
+ /** Default resume behavior when a call does not specify one. Defaults to `true`. */
397
+ resume?: boolean;
398
+ /** Default resume-expiry policy (run path). Defaults to `"error"`. */
399
+ onResumeExpired?: ResumeExpiredPolicy;
400
+ }
401
+
402
+ export interface GatewayDelegate {
403
+ (slug: string, options?: DelegateCallOptions): LanguageModelV3;
404
+ }
405
+
406
+ /**
407
+ * Create a gateway delegate. Returns a function that builds an AI SDK model for a
408
+ * `"<provider>/<model>"` slug, dispatched through AI Gateway on the transport the
409
+ * requested options imply.
410
+ */
411
+ export function createGatewayDelegate(config: GatewayDelegateConfig): GatewayDelegate {
412
+ if (!config?.binding) {
413
+ throw new GatewayDelegateError(
414
+ "config",
415
+ "createGatewayDelegate requires a `binding` (e.g. { binding: env.AI }).",
416
+ );
417
+ }
418
+ if (!config.providers?.length) {
419
+ throw new GatewayDelegateError(
420
+ "config",
421
+ "createGatewayDelegate requires at least one provider plugin, e.g. " +
422
+ '`providers: [openai]` from "workers-ai-provider/openai".',
423
+ );
424
+ }
425
+
426
+ const plugins = new Map<WireFormat, ProviderPlugin>();
427
+ for (const p of config.providers) plugins.set(p.wireFormat, p);
428
+ const defaultResume = config.resume ?? true;
429
+
430
+ const buildOne = (
431
+ slug: string,
432
+ options: DelegateCallOptions,
433
+ ): { model: LanguageModelV3; transport: Transport } => {
434
+ const parsed = parseSlug(slug);
435
+ const info = resolveProvider(slug, parsed);
436
+
437
+ const resumeExplicitlyTrue = options.resume === true;
438
+ const effectiveOptions: DelegateCallOptions = {
439
+ ...options,
440
+ resume: options.resume ?? defaultResume,
441
+ onResumeExpired: options.onResumeExpired ?? config.onResumeExpired,
442
+ };
443
+ const selection = selectTransport(
444
+ effectiveOptions,
445
+ resumeExplicitlyTrue,
446
+ info.runCatalog,
447
+ info.gatewayPath !== false,
448
+ );
449
+ for (const w of selection.warnings) console.warn(w);
450
+
451
+ // Pick the parser by transport. The unified-billing run path (`env.AI.run`)
452
+ // does NOT speak a uniform wire format: Cloudflare normalizes most providers
453
+ // to OpenAI chat-completions (so `google` is parsed with the `openai` plugin
454
+ // on the run path), but passes Anthropic through natively. So the run path
455
+ // uses the registry's `runWireFormat` (default "openai"), while the gateway
456
+ // path — which hits provider-native endpoints — uses the native `wireFormat`.
457
+ const wire: WireFormat =
458
+ selection.transport === "run"
459
+ ? (info.runWireFormat ?? "openai")
460
+ : (info.wireFormat as WireFormat);
461
+ const plugin = plugins.get(wire);
462
+ if (!plugin) {
463
+ throw new GatewayDelegateError(
464
+ "config",
465
+ selection.transport === "run"
466
+ ? `The run path for "${parsed.resolverKey}" (from slug "${slug}") returns ` +
467
+ `"${wire}"-wire responses, so it needs the "${wire}" plugin. ` +
468
+ `Install + pass it from "workers-ai-provider/${wire}". ` +
469
+ `Registered: ${[...plugins.keys()].join(", ") || "<none>"}.`
470
+ : `No provider plugin for wire format "${wire}" (needed by "${parsed.resolverKey}" ` +
471
+ `on the gateway path from slug "${slug}"). ` +
472
+ `Registered: ${[...plugins.keys()].join(", ") || "<none>"}. ` +
473
+ `Install + pass the matching plugin from "workers-ai-provider/${wire}".`,
474
+ );
475
+ }
476
+
477
+ const { id: gatewayId, options: gatewayOptions } = normalizeGateway(
478
+ options.gateway ?? config.gateway,
479
+ );
480
+
481
+ const fetchImpl =
482
+ selection.transport === "run"
483
+ ? makeRunFetch(
484
+ config.binding,
485
+ // Use the canonical run-catalog author (e.g. "grok" → "xai"), not the
486
+ // raw alias the caller typed, so `env.AI.run` resolves the model.
487
+ `${info.resolverKey}/${parsed.modelId}`,
488
+ gatewayOptions,
489
+ effectiveOptions,
490
+ selection,
491
+ options,
492
+ )
493
+ : makeGatewayFetch(
494
+ config.binding,
495
+ info,
496
+ gatewayId,
497
+ gatewayOptions,
498
+ effectiveOptions,
499
+ selection,
500
+ options,
501
+ );
502
+
503
+ return {
504
+ model: plugin.create({
505
+ modelId: parsed.modelId,
506
+ fetch: fetchImpl,
507
+ // baseURL only matters on the gateway path (host-strip to the native
508
+ // endpoint); the run path ignores the request URL entirely.
509
+ ...(selection.transport === "gateway" && info.baseURL
510
+ ? { baseURL: info.baseURL }
511
+ : {}),
512
+ }),
513
+ transport: selection.transport,
514
+ };
515
+ };
516
+
517
+ return (slug, options = {}) => {
518
+ // Client-side fallback: build a model per slug and wrap them so a failed
519
+ // pre-stream dispatch falls through to the next, each on its own transport
520
+ // (so resume is preserved per leg). Server-side fallback stays on the
521
+ // gateway path inside makeGatewayFetch.
522
+ if (options.fallback?.mode === "client") {
523
+ const { fallback, ...rest } = options;
524
+ const slugs = [slug, ...fallback.models];
525
+ const legs = slugs.map((s) => {
526
+ const { model, transport } = buildOne(s, rest);
527
+ return { slug: s, model, transport };
528
+ });
529
+ return createClientFallbackModel(legs);
530
+ }
531
+ return buildOne(slug, options).model;
532
+ };
533
+ }
534
+
535
+ function fireDispatch(resp: Response, selection: Selection, options: DelegateCallOptions): void {
536
+ if (!options.onDispatch) return;
537
+ options.onDispatch({
538
+ transport: selection.transport,
539
+ resumeEnabled: selection.resumeEnabled,
540
+ warnings: selection.warnings,
541
+ status: resp.status,
542
+ runId: resp.headers.get("cf-aig-run-id"),
543
+ cfStep: resp.headers.get("cf-aig-step"),
544
+ cacheStatus: resp.headers.get("cf-aig-cache-status"),
545
+ logId: resp.headers.get("cf-aig-log-id"),
546
+ });
547
+ }
548
+
549
+ type GatewayMetadata = Record<string, number | string | boolean | null | bigint>;
550
+
551
+ /** Merge call-level metadata over gateway-option metadata (call wins). */
552
+ function mergeMetadata(
553
+ base: GatewayMetadata | undefined,
554
+ override: GatewayMetadata | undefined,
555
+ ): GatewayMetadata | undefined {
556
+ if (!base && !override) return undefined;
557
+ return { ...base, ...override };
558
+ }
559
+
560
+ /** JSON-encode metadata for the `cf-aig-metadata` header (bigint → string). */
561
+ function serializeMetadata(metadata: GatewayMetadata): string {
562
+ return JSON.stringify(metadata, (_k, v) => (typeof v === "bigint" ? v.toString() : v));
563
+ }
564
+
565
+ function makeRunFetch(
566
+ binding: Ai,
567
+ slug: string,
568
+ gatewayOptions: GatewayOptions,
569
+ opts: DelegateCallOptions,
570
+ selection: Selection,
571
+ callOptions: DelegateCallOptions,
572
+ ): typeof globalThis.fetch {
573
+ return (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
574
+ const body = JSON.parse(asText(init?.body)) as Record<string, unknown>;
575
+ // The slug carries the model; drop the redundant body field (both are tolerated).
576
+ delete body.model;
577
+
578
+ // Fold first-class metadata/collectLog over anything supplied via
579
+ // `gateway: { ... }`; explicit call options win.
580
+ const mergedGateway: GatewayOptions = { ...gatewayOptions };
581
+ const mergedMeta = mergeMetadata(gatewayOptions.metadata, opts.metadata);
582
+ if (mergedMeta) mergedGateway.metadata = mergedMeta;
583
+ if (opts.collectLog !== undefined) mergedGateway.collectLog = opts.collectLog;
584
+
585
+ const runOptions = {
586
+ gateway: mergedGateway,
587
+ returnRawResponse: true,
588
+ ...(opts.extraHeaders ? { extraHeaders: opts.extraHeaders } : {}),
589
+ ...(init?.signal ? { signal: init.signal } : {}),
590
+ };
591
+
592
+ // The binding's `run` is heavily overloaded; narrow to the raw-Response
593
+ // streaming signature. Call as a METHOD on the binding — extracting it
594
+ // into a bare variable detaches `this` and the binding throws on a private
595
+ // field access ("Cannot set properties of undefined (setting '#options')").
596
+ const ai = binding as unknown as {
597
+ run(
598
+ model: string,
599
+ inputs: Record<string, unknown>,
600
+ options: Record<string, unknown>,
601
+ ): Promise<Response>;
602
+ };
603
+ const resp = await ai.run(slug, body, runOptions);
604
+ fireDispatch(resp, selection, callOptions);
605
+
606
+ // Wrap the stream so a transient mid-stream drop reconnects via the gateway
607
+ // resume endpoint transparently — the @ai-sdk parser never sees the break.
608
+ const runId = resp.headers.get("cf-aig-run-id");
609
+ if (selection.resumeEnabled && runId && resp.body) {
610
+ const resumable = createResumableStream({
611
+ binding,
612
+ gateway: gatewayOptions.id,
613
+ runId,
614
+ initial: resp.body,
615
+ onResumeExpired: opts.onResumeExpired,
616
+ ...(opts.onProgress ? { onProgress: opts.onProgress } : {}),
617
+ });
618
+ return new Response(resumable, { status: resp.status, headers: resp.headers });
619
+ }
620
+ return resp;
621
+ }) as typeof globalThis.fetch;
622
+ }
623
+
624
+ function makeGatewayFetch(
625
+ binding: Ai,
626
+ info: GatewayProviderInfo,
627
+ gatewayId: string,
628
+ gatewayOptions: GatewayOptions,
629
+ opts: DelegateCallOptions,
630
+ selection: Selection,
631
+ callOptions: DelegateCallOptions,
632
+ ): typeof globalThis.fetch {
633
+ // Strip the AI SDK's placeholder provider key unless BYOK forwards a real one;
634
+ // unified billing / the gateway's stored key authenticates upstream otherwise.
635
+ const strip = new Set(STRIP_HEADERS_BASE);
636
+ if (!opts.byok) for (const h of info.authHeaders) strip.add(h.toLowerCase());
637
+
638
+ return (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
639
+ const rawUrl = typeof input === "string" ? input : input.toString();
640
+ // Host-strip to the provider's gateway-native endpoint. The registry
641
+ // transform matches because the builder targeted the provider's baseURL;
642
+ // fall back to a generic pathname strip if it somehow doesn't.
643
+ const endpoint = info.transformEndpoint
644
+ ? info.transformEndpoint(rawUrl)
645
+ : new URL(rawUrl).pathname.replace(/^\//, "") + (new URL(rawUrl).search || "");
646
+ const body = JSON.parse(asText(init?.body)) as Record<string, unknown>;
647
+
648
+ const headers: Record<string, string> = {};
649
+ for (const [k, v] of Object.entries(headersToObject(init?.headers))) {
650
+ if (!strip.has(k.toLowerCase())) headers[k] = v;
651
+ }
652
+ if (opts.extraHeaders) Object.assign(headers, opts.extraHeaders);
653
+ // Best-effort gateway cache control (gateway-side config may still override).
654
+ if (opts.cacheTtl !== undefined) headers["cf-aig-cache-ttl"] = String(opts.cacheTtl);
655
+ if (opts.skipCache) headers["cf-aig-skip-cache"] = "true";
656
+ // Gateway log controls (mirror the run path's typed gateway options).
657
+ const metadata = mergeMetadata(gatewayOptions.metadata, opts.metadata);
658
+ if (metadata) headers["cf-aig-metadata"] = serializeMetadata(metadata);
659
+ if (opts.collectLog !== undefined) {
660
+ headers["cf-aig-collect-log"] = String(opts.collectLog);
661
+ }
662
+
663
+ const primary: GatewayEntry = {
664
+ provider: info.gatewayProviderId,
665
+ endpoint,
666
+ headers,
667
+ query: body,
668
+ };
669
+ const entries: GatewayEntry[] = [primary];
670
+
671
+ if (opts.fallback?.mode === "server") {
672
+ for (const fb of opts.fallback.models) {
673
+ const fbParsed = parseSlug(fb);
674
+ const fbInfo = resolveProvider(fb, fbParsed);
675
+ if (fbInfo.gatewayProviderId !== info.gatewayProviderId) {
676
+ throw new GatewayDelegateError(
677
+ "config",
678
+ `Cross-vendor server-side fallback (${info.gatewayProviderId} → ` +
679
+ `${fbInfo.gatewayProviderId}) is not supported yet. Use fallback.mode:"client", ` +
680
+ "or same-vendor fallback models.",
681
+ );
682
+ }
683
+ entries.push({ ...primary, query: { ...body, model: fbParsed.modelId } });
684
+ }
685
+ }
686
+
687
+ const gw = (binding as unknown as { gateway(id: string): AiGatewayRunner }).gateway(
688
+ gatewayId,
689
+ );
690
+ const runOptions: Record<string, unknown> = {};
691
+ if (init?.signal) runOptions.signal = init.signal;
692
+ const resp = await gw.run(entries, runOptions);
693
+ fireDispatch(resp, selection, callOptions);
694
+ return resp;
695
+ }) as typeof globalThis.fetch;
696
+ }