UNPKG

workers-ai-provider

Version:

Workers AI Provider for the vercel AI SDK

926 lines (858 loc) 34.6 kB
import type { LanguageModelV3, LanguageModelV3CallOptions } from "@ai-sdk/provider"; import { asText, buildGatewayEntry, createResumableStream, GatewayDelegateError, type GatewayCacheOptions, type GatewayEntry, type ResumeExpiredPolicy, } from "@cloudflare/gateway-core"; import { createClientFallbackModel } from "./client-fallback"; import { findProviderBySlug, type GatewayProviderInfo, type WireFormat } from "./gateway-providers"; export { createResumableStream, GatewayDelegateError, type GatewayDelegateErrorKind, type ResumableStreamOptions, type ResumeExpiredPolicy, } from "@cloudflare/gateway-core"; export { type FallbackAttempt, type GatewayErrorCode, type GatewayErrorContext, WorkersAIFallbackError, WorkersAIGatewayError, } from "./errors"; export { type FallbackLeg, createClientFallbackModel } from "./client-fallback"; export { type Billing, GATEWAY_PROVIDERS, type GatewayProviderInfo, type WireFormat, detectProviderByUrl, findProviderBySlug, wireableProviders, } from "./gateway-providers"; /** * Gateway delegate — route AI SDK catalog models through Cloudflare AI Gateway, * with capability-driven transport selection. * * Two transports back the same model, chosen from the requested options: * * - **Run path** `env.AI.run(slug, body, { returnRawResponse })` — resumable * streaming (`cf-aig-run-id`). The default. * - **Gateway path** `env.AI.gateway(id).run([entry, …fallback])` — server-side * fallback and caching. Does not surface `cf-aig-run-id`, so resume is off. * * The SAME `@ai-sdk/*` provider parses the response on either path, so there is no * per-provider or per-path response parsing here. Provider plugins (which import * `@ai-sdk/openai`, `@ai-sdk/anthropic`, …) are injected from sub-path modules * (`workers-ai-provider/openai`, …) so those AI SDK packages stay OPTIONAL peer * dependencies — you only install the ones you use. * * @example * ```ts * import { createGatewayDelegate } from "workers-ai-provider/gateway-delegate"; * import { openai } from "workers-ai-provider/openai"; * import { streamText } from "ai"; * * const wai = createGatewayDelegate({ * binding: env.AI, * gateway: "my-gateway", * providers: [openai], * }); * * const result = streamText({ model: wai("openai/gpt-5"), prompt: "Hello" }); * // result.response.headers["cf-aig-run-id"] is set — resume from there. * ``` */ // --------------------------------------------------------------------------- // Slug parsing // --------------------------------------------------------------------------- export interface ParsedSlug { /** First path segment — the registry resolver key (selects provider + wire format). */ resolverKey: string; /** Remaining segments — the provider-native model id. */ modelId: string; } /** * Parse a `vendor/model` slug. The first segment is the resolver key (which * registry entry handles it); the rest is the provider-native model id. Routing * providers keep multi-segment model ids, e.g. `openrouter/anthropic/claude`. */ export function parseSlug(slug: string): ParsedSlug { const slash = slug.indexOf("/"); if (slash === -1) { throw new GatewayDelegateError( "config", `Model slug "${slug}" has no resolver key. Use "<provider>/<model>" (e.g. "openai/gpt-5").`, ); } const resolverKey = slug.slice(0, slash); const modelId = slug.slice(slash + 1); if (!resolverKey || !modelId) { throw new GatewayDelegateError( "config", `Model slug "${slug}" is malformed. Use "<provider>/<model>" (e.g. "openai/gpt-5").`, ); } return { resolverKey, modelId }; } /** * Resolve a slug to its registry entry, raising a helpful error for unknown or * bring-your-own-provider-only providers. */ export function resolveProvider(slug: string, parsed: ParsedSlug): GatewayProviderInfo { const info = findProviderBySlug(parsed.resolverKey); if (!info) { throw new GatewayDelegateError( "config", `Unknown gateway provider "${parsed.resolverKey}" (from slug "${slug}"). ` + "See the AI Gateway provider directory for valid slugs, or use " + "createGatewayProvider to bring your own @ai-sdk provider.", ); } if (!info.wireFormat) { throw new GatewayDelegateError( "config", `Provider "${parsed.resolverKey}" is not chat/completions-shaped and has no built-in ` + "parser. Reach it with createGatewayProvider (bring your own @ai-sdk provider).", ); } return info; } // --------------------------------------------------------------------------- // Provider plugins (injected from sub-path modules) // --------------------------------------------------------------------------- /** * Adapts a `@ai-sdk/*` provider to the delegate, keyed by the response wire * format it parses. Imported from a sub-path module (e.g. * `workers-ai-provider/openai`) so the AI SDK package stays an optional peer * dependency. One plugin serves every registry provider of that wire format — * the `openai` plugin covers the whole OpenAI-compatible long tail (deepseek, * grok, groq, mistral, perplexity, openrouter, …). */ export interface ProviderPlugin { /** The response wire format this builder parses. */ readonly wireFormat: WireFormat; /** * Build the AI SDK model, wiring the gateway-dispatching `fetch`. `baseURL` * (when provided by the registry) targets the provider's host so the request * URL host-strips to its gateway-native endpoint — pass it to the underlying * `@ai-sdk` provider. */ create(args: { modelId: string; fetch: typeof globalThis.fetch; baseURL?: string; }): LanguageModelV3; } // --------------------------------------------------------------------------- // Options + transport selection // --------------------------------------------------------------------------- export type Transport = "run" | "gateway"; export interface FallbackOptions { /** `"client"` keeps resume (sequential run-path attempts); `"server"` uses the gateway path. */ mode: "client" | "server"; /** Ordered model slugs to try after the primary. */ models: string[]; } export interface DispatchInfo { transport: Transport; resumeEnabled: boolean; warnings: string[]; runId: string | null; status: number | null; cfStep: string | null; cacheStatus: string | null; logId: string | null; } export interface DelegateCallOptions { /** Resumable streaming (run path). Defaults to the delegate's `resume` (true). */ resume?: boolean; /** Cross-model fallback. `"server"` mode uses the gateway path (disables resume). */ fallback?: FallbackOptions; /** Gateway-path response caching (seconds). Forces the gateway path. */ cacheTtl?: number; /** Bypass gateway cache. Forces the gateway path. */ skipCache?: boolean; /** Escape hatch: force a transport. */ transport?: Transport; /** * Run path only: behavior when the resume buffer has expired (404) after a * mid-stream drop. `"error"` (default) surfaces a `GatewayDelegateError`; * `"accept-partial"` ends the stream cleanly with whatever was delivered. */ onResumeExpired?: ResumeExpiredPolicy; /** Extra request headers (run path: `extraHeaders`; gateway path: entry headers). */ extraHeaders?: Record<string, string>; /** * Gateway path only: forward the upstream provider key instead of stripping it. * Required for BYOK providers (not on unified billing). Supply the key via * `extraHeaders` (e.g. `{ authorization: "Bearer …" }`); without `byok` the * delegate strips provider auth headers so unified billing applies. */ byok?: boolean; /** * Gateway path only: BYOK stored-key alias to authenticate with * (`cf-aig-byok-alias`). Selects a non-`default` key you configured for the * provider on the gateway. Independent of `byok` (which controls whether the * caller's own provider auth header is forwarded vs. stripped). */ byokAlias?: string; /** * Per-request Zero Data Retention override (`cf-aig-zdr`), Unified Billing * only: `true` forces ZDR-capable upstreams, `false` disables it for this * request. Overrides the gateway-level ZDR default. Applied on both transports * (run path: passed through gateway options as a header; gateway path: entry header). */ zdr?: boolean; /** Override the delegate's gateway for this model. */ gateway?: GatewayOptions | string; /** * Custom metadata attached to the gateway log for this request (spend * attribution, tenant ids, etc.). Merges over any `metadata` already set via * `gateway: { metadata }`. Applied on both transports (run path: gateway * options; gateway path: `cf-aig-metadata` header). `bigint` values are * coerced to strings for the header form. */ metadata?: Record<string, number | string | boolean | null | bigint>; /** Force gateway log collection on/off for this request (both transports). */ collectLog?: boolean; /** Called once per dispatch with the resolved transport + gateway headers. */ onDispatch?: (info: DispatchInfo) => void; /** * Run path only: fired with the cumulative SSE event offset as the resumable * stream advances. Pair with `onDispatch` (for `runId`) to persist * `{ runId, eventOffset }` for cross-invocation re-attach after eviction. * Throttle your own writes — this can fire per chunk. */ onProgress?: (eventOffset: number) => void; } interface Selection { transport: Transport; resumeEnabled: boolean; warnings: string[]; } /** * Resolve the transport from the requested options. Gateway-only features (server * fallback, caching) force the gateway path and disable resume — with a loud * warning if resume was merely defaulted, or a thrown error if it was explicitly * requested. */ export function selectTransport( opts: DelegateCallOptions, resumeExplicitlyTrue: boolean, runCatalog = true, gatewayAvailable = true, ): Selection { const warnings: string[] = []; const wantsServerFallback = opts.fallback?.mode === "server"; const wantsCaching = opts.cacheTtl !== undefined || opts.skipCache === true; const gatewayOnly = wantsServerFallback || wantsCaching; const feature = wantsServerFallback ? 'fallback.mode:"server"' : "caching (cacheTtl/skipCache)"; // Run-path-only providers (on the run catalog, but not native gateway // providers) have no gateway path at all — reject anything that would need it // here, with a clear message, rather than letting it fail upstream. if ( runCatalog && !gatewayAvailable && (opts.transport === "gateway" || opts.byok || gatewayOnly) ) { const what = opts.transport === "gateway" ? 'transport:"gateway"' : opts.byok ? "byok" : feature; throw new GatewayDelegateError( "config", `${what} is unavailable: this provider is on the unified run catalog but is not a ` + "native gateway provider, so it has no gateway path (no caching, server-side " + 'fallback, BYOK, or transport:"gateway"). Use the default run path, or fallback.mode:"client".', ); } // BYOK providers are not on the resumable run catalog — they can only be // reached through the gateway path. if (!runCatalog) { if (opts.transport === "run") { throw new GatewayDelegateError( "config", 'transport:"run" is unavailable: this provider is not on the unified-billing run ' + "catalog, so it can only be reached through the gateway path (BYOK).", ); } if (resumeExplicitlyTrue) { throw new GatewayDelegateError( "config", "resume:true is unavailable: this provider is not on the resumable run catalog " + "(cf-aig-run-id requires the unified-billing run path).", ); } return { transport: "gateway", resumeEnabled: false, warnings }; } // BYOK forwards the caller's own provider key, which only the gateway path // supports — treat it like an explicit gateway transport (no resume-disabled // warning; the caller opted into a gateway-only mode). if (opts.byok) { if (opts.transport === "run") { throw new GatewayDelegateError( "config", 'transport:"run" cannot forward a BYOK key — BYOK is a gateway-path feature. ' + 'Drop transport:"run" (or set transport:"gateway").', ); } if (resumeExplicitlyTrue) { throw new GatewayDelegateError( "config", "byok cannot provide resume — cf-aig-run-id is only on the unified-billing run path.", ); } return { transport: "gateway", resumeEnabled: false, warnings }; } if (opts.transport === "run" && gatewayOnly) { throw new GatewayDelegateError( "config", `transport:"run" cannot satisfy ${feature}: those features are only available on the ` + 'gateway path. Use the gateway transport, or fallback.mode:"client".', ); } if (opts.transport === "gateway" && resumeExplicitlyTrue) { throw new GatewayDelegateError( "config", 'transport:"gateway" cannot provide resume — cf-aig-run-id is only on the run path.', ); } if (gatewayOnly) { if (resumeExplicitlyTrue) { throw new GatewayDelegateError( "config", `resume:true conflicts with ${feature}: resume (cf-aig-run-id) is only on the run path, ` + `which does not support ${wantsServerFallback ? "server-side fallback" : "caching"}. ` + 'Use fallback.mode:"client" to keep resume, or drop resume.', ); } warnings.push( `[workers-ai-provider] resume disabled: ${feature} requires the gateway path, which does ` + 'not surface cf-aig-run-id. Use fallback.mode:"client" to keep resumable streaming.', ); return { transport: "gateway", resumeEnabled: false, warnings }; } const transport = opts.transport ?? "run"; return { transport, resumeEnabled: transport === "run" && opts.resume !== false, warnings, }; } // --------------------------------------------------------------------------- // Dispatch internals // --------------------------------------------------------------------------- interface AiGatewayRunner { run(body: unknown, options?: Record<string, unknown>): Promise<Response>; } function normalizeGateway(gateway: GatewayOptions | string | undefined): { id: string; options: GatewayOptions; } { if (!gateway) { throw new GatewayDelegateError( "config", "A gateway is required for the delegate (resume needs a gateway). " + 'Pass `gateway: "<gateway-id>"` to createGatewayDelegate or per call.', ); } if (typeof gateway === "string") return { id: gateway, options: { id: gateway } }; return { id: gateway.id, options: gateway }; } export interface GatewayDelegateConfig { /** A Cloudflare AI binding (e.g. `env.AI`). Required — the gateway path needs `binding.gateway()`. */ binding: Ai; /** Default gateway id (or options) for all models. Overridable per call. */ gateway?: GatewayOptions | string; /** Provider plugins from sub-path modules (e.g. `[openai, anthropic]`). */ providers: ProviderPlugin[]; /** Default resume behavior when a call does not specify one. Defaults to `true`. */ resume?: boolean; /** Default resume-expiry policy (run path). Defaults to `"error"`. */ onResumeExpired?: ResumeExpiredPolicy; } export interface GatewayDelegate { (slug: string, options?: DelegateCallOptions): LanguageModelV3; } /** * Create a gateway delegate. Returns a function that builds an AI SDK model for a * `"<provider>/<model>"` slug, dispatched through AI Gateway on the transport the * requested options imply. */ export function createGatewayDelegate(config: GatewayDelegateConfig): GatewayDelegate { if (!config?.binding) { throw new GatewayDelegateError( "config", "createGatewayDelegate requires a `binding` (e.g. { binding: env.AI }).", ); } if (!config.providers?.length) { throw new GatewayDelegateError( "config", "createGatewayDelegate requires at least one provider plugin, e.g. " + '`providers: [openai]` from "workers-ai-provider/openai".', ); } const plugins = new Map<WireFormat, ProviderPlugin>(); for (const p of config.providers) plugins.set(p.wireFormat, p); const defaultResume = config.resume ?? true; const buildOne = ( slug: string, options: DelegateCallOptions, ): { model: LanguageModelV3; transport: Transport } => { const parsed = parseSlug(slug); const info = resolveProvider(slug, parsed); const resumeExplicitlyTrue = options.resume === true; const effectiveOptions: DelegateCallOptions = { ...options, resume: options.resume ?? defaultResume, onResumeExpired: options.onResumeExpired ?? config.onResumeExpired, }; const selection = selectTransport( effectiveOptions, resumeExplicitlyTrue, info.runCatalog, info.gatewayPath !== false, ); for (const w of selection.warnings) console.warn(w); // Pick the parser by transport. The unified-billing run path (`env.AI.run`) // does NOT speak a uniform wire format: Cloudflare normalizes most providers // to OpenAI chat-completions (so `google` is parsed with the `openai` plugin // on the run path), but passes Anthropic through natively. So the run path // uses the registry's `runWireFormat` (default "openai"), while the gateway // path — which hits provider-native endpoints — uses the native `wireFormat`. const wire: WireFormat = selection.transport === "run" ? (info.runWireFormat ?? "openai") : (info.wireFormat as WireFormat); const plugin = plugins.get(wire); if (!plugin) { throw new GatewayDelegateError( "config", selection.transport === "run" ? `The run path for "${parsed.resolverKey}" (from slug "${slug}") returns ` + `"${wire}"-wire responses, so it needs the "${wire}" plugin. ` + `Install + pass it from "workers-ai-provider/${wire}". ` + `Registered: ${[...plugins.keys()].join(", ") || "<none>"}.` : `No provider plugin for wire format "${wire}" (needed by "${parsed.resolverKey}" ` + `on the gateway path from slug "${slug}"). ` + `Registered: ${[...plugins.keys()].join(", ") || "<none>"}. ` + `Install + pass the matching plugin from "workers-ai-provider/${wire}".`, ); } const { id: gatewayId, options: gatewayOptions } = normalizeGateway( options.gateway ?? config.gateway, ); const fetchImpl = selection.transport === "run" ? makeRunFetch( config.binding, // Use the canonical run-catalog author (e.g. "grok""xai"), not the // raw alias the caller typed, so `env.AI.run` resolves the model. `${info.resolverKey}/${parsed.modelId}`, gatewayOptions, effectiveOptions, selection, options, ) : makeGatewayFetch( config.binding, info, gatewayId, gatewayOptions, effectiveOptions, selection, options, ); return { model: plugin.create({ modelId: parsed.modelId, fetch: fetchImpl, // baseURL only matters on the gateway path (host-strip to the native // endpoint); the run path ignores the request URL entirely. ...(selection.transport === "gateway" && info.baseURL ? { baseURL: info.baseURL } : {}), }), transport: selection.transport, }; }; return (slug, options = {}) => { // Client-side fallback: build a model per slug and wrap them so a failed // pre-stream dispatch falls through to the next, each on its own transport // (so resume is preserved per leg). if (options.fallback?.mode === "client") { const { fallback, ...rest } = options; const slugs = [slug, ...fallback.models]; const legs = slugs.map((s) => { const { model, transport } = buildOne(s, rest); return { slug: s, model, transport }; }); return createClientFallbackModel(legs); } // Server-side fallback: all legs ship in one gateway run; `cf-aig-step` // names the winner. Same-vendor legs are handled by makeGatewayFetch (it // just swaps `model` in one body); cross-vendor legs need each leg's own // builder to shape its native request, so route them through the // capture/redispatch engine (ported from ai-gateway-provider). if (options.fallback?.mode === "server") { const slugs = [slug, ...options.fallback.models]; const resolved = slugs.map((s) => { const parsed = parseSlug(s); return { slug: s, modelId: parsed.modelId, info: resolveProvider(s, parsed) }; }); const crossVendor = resolved.some( (r) => r.info.gatewayProviderId !== resolved[0]!.info.gatewayProviderId, ); if (crossVendor) { const resumeExplicitlyTrue = options.resume === true; const effectiveOptions: DelegateCallOptions = { ...options, resume: options.resume ?? defaultResume, onResumeExpired: options.onResumeExpired ?? config.onResumeExpired, }; const primaryInfo = resolved[0]!.info; // Forces the gateway path + disables resume (throws if resume:true). const selection = selectTransport( effectiveOptions, resumeExplicitlyTrue, primaryInfo.runCatalog, primaryInfo.gatewayPath !== false, ); for (const w of selection.warnings) console.warn(w); const { id: gatewayId, options: gatewayOptions } = normalizeGateway( options.gateway ?? config.gateway, ); const legs: ServerFallbackLeg[] = resolved.map((r) => { if (r.info.gatewayPath === false) { throw new GatewayDelegateError( "config", `Server-side fallback cannot use "${r.slug}": it is on the unified ` + "run catalog but has no native gateway path.", ); } const wire = r.info.wireFormat as WireFormat; const plugin = plugins.get(wire); if (!plugin) { throw new GatewayDelegateError( "config", `No provider plugin for wire format "${wire}" (needed by "${r.slug}" ` + `for server-side fallback). Registered: ` + `${[...plugins.keys()].join(", ") || "<none>"}.`, ); } return { slug: r.slug, modelId: r.modelId, info: r.info, plugin }; }); return makeServerFallbackModel({ binding: config.binding, gatewayId, gatewayOptions, legs, opts: effectiveOptions, selection, callOptions: options, }); } } return buildOne(slug, options).model; }; } function fireDispatch(resp: Response, selection: Selection, options: DelegateCallOptions): void { if (!options.onDispatch) return; options.onDispatch({ transport: selection.transport, resumeEnabled: selection.resumeEnabled, warnings: selection.warnings, status: resp.status, runId: resp.headers.get("cf-aig-run-id"), cfStep: resp.headers.get("cf-aig-step"), cacheStatus: resp.headers.get("cf-aig-cache-status"), logId: resp.headers.get("cf-aig-log-id"), }); } type GatewayMetadata = Record<string, number | string | boolean | null | bigint>; /** Merge call-level metadata over gateway-option metadata (call wins). */ function mergeMetadata( base: GatewayMetadata | undefined, override: GatewayMetadata | undefined, ): GatewayMetadata | undefined { if (!base && !override) return undefined; return { ...base, ...override }; } /** * Build the `cf-aig-*` request controls for a gateway-path entry. First-class * call options (`cacheTtl`, `skipCache`, `collectLog`, `metadata`, `byokAlias`, * `zdr`) are layered over the binding-style gateway options (`cacheKey`, * `eventId`, `requestTimeoutMs`, `retries`) so the gateway path reaches parity * with the run path, which forwards the whole `GatewayOptions` to `binding.run`. */ function buildGatewayControls( gatewayOptions: GatewayOptions, opts: DelegateCallOptions, ): GatewayCacheOptions { const metadata = mergeMetadata(gatewayOptions.metadata, opts.metadata); return { cacheTtl: opts.cacheTtl, skipCache: opts.skipCache, ...(gatewayOptions.cacheKey !== undefined ? { cacheKey: gatewayOptions.cacheKey } : {}), ...(metadata ? { metadata } : {}), ...(opts.collectLog !== undefined ? { collectLog: opts.collectLog } : {}), ...(gatewayOptions.eventId !== undefined ? { eventId: gatewayOptions.eventId } : {}), ...(gatewayOptions.requestTimeoutMs !== undefined ? { requestTimeoutMs: gatewayOptions.requestTimeoutMs } : {}), ...(gatewayOptions.retries ? { retries: gatewayOptions.retries } : {}), ...(opts.byokAlias !== undefined ? { byokAlias: opts.byokAlias } : {}), ...(opts.zdr !== undefined ? { zdr: opts.zdr } : {}), }; } function makeRunFetch( binding: Ai, slug: string, gatewayOptions: GatewayOptions, opts: DelegateCallOptions, selection: Selection, callOptions: DelegateCallOptions, ): typeof globalThis.fetch { return (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { const body = JSON.parse(asText(init?.body)) as Record<string, unknown>; // The slug carries the model; drop the redundant body field (both are tolerated). delete body.model; // Fold first-class metadata/collectLog over anything supplied via // `gateway: { ... }`; explicit call options win. const mergedGateway: GatewayOptions = { ...gatewayOptions }; const mergedMeta = mergeMetadata(gatewayOptions.metadata, opts.metadata); if (mergedMeta) mergedGateway.metadata = mergedMeta; if (opts.collectLog !== undefined) mergedGateway.collectLog = opts.collectLog; // `zdr` is a Unified-Billing (run path) feature, but it's not a field on the // binding's `GatewayOptions`, so pass it as a `cf-aig-zdr` extra header. const extraHeaders: Record<string, string> = { ...opts.extraHeaders }; if (opts.zdr !== undefined) extraHeaders["cf-aig-zdr"] = String(opts.zdr); const runOptions = { gateway: mergedGateway, returnRawResponse: true, ...(Object.keys(extraHeaders).length > 0 ? { extraHeaders } : {}), ...(init?.signal ? { signal: init.signal } : {}), }; // The binding's `run` is heavily overloaded; narrow to the raw-Response // streaming signature. Call as a METHOD on the binding — extracting it // into a bare variable detaches `this` and the binding throws on a private // field access ("Cannot set properties of undefined (setting '#options')"). const ai = binding as unknown as { run( model: string, inputs: Record<string, unknown>, options: Record<string, unknown>, ): Promise<Response>; }; const resp = await ai.run(slug, body, runOptions); fireDispatch(resp, selection, callOptions); // Wrap the stream so a transient mid-stream drop reconnects via the gateway // resume endpoint transparently — the @ai-sdk parser never sees the break. const runId = resp.headers.get("cf-aig-run-id"); if (selection.resumeEnabled && runId && resp.body) { const resumable = createResumableStream({ binding, gateway: gatewayOptions.id, runId, initial: resp.body, onResumeExpired: opts.onResumeExpired, ...(opts.onProgress ? { onProgress: opts.onProgress } : {}), }); return new Response(resumable, { status: resp.status, headers: resp.headers }); } return resp; }) as typeof globalThis.fetch; } function makeGatewayFetch( binding: Ai, info: GatewayProviderInfo, gatewayId: string, gatewayOptions: GatewayOptions, opts: DelegateCallOptions, selection: Selection, callOptions: DelegateCallOptions, ): typeof globalThis.fetch { return (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { const rawUrl = typeof input === "string" ? input : input.toString(); // Host-strip to the provider's gateway-native endpoint. The registry // transform matches because the builder targeted the provider's baseURL; // fall back to a generic pathname strip if it somehow doesn't. const endpoint = info.transformEndpoint ? info.transformEndpoint(rawUrl) : new URL(rawUrl).pathname.replace(/^\//, "") + (new URL(rawUrl).search || ""); const body = JSON.parse(asText(init?.body)) as Record<string, unknown>; // Strip the AI SDK's placeholder provider key unless BYOK forwards a real // one; unified billing / the gateway's stored key authenticates otherwise. const primary: GatewayEntry = buildGatewayEntry({ providerId: info.gatewayProviderId, endpoint, initHeaders: init?.headers, body, ...(opts.byok ? {} : { stripAuthHeaders: info.authHeaders }), ...(opts.extraHeaders ? { extraHeaders: opts.extraHeaders } : {}), cache: buildGatewayControls(gatewayOptions, opts), }); const entries: GatewayEntry[] = [primary]; // Same-vendor server fallback: every leg shares this provider + endpoint, so // each is just the primary entry with `model` swapped. (Cross-vendor chains // never reach here — the delegate routes them through makeServerFallbackModel, // which captures each leg's native request; this stays a defensive guard.) if (opts.fallback?.mode === "server") { for (const fb of opts.fallback.models) { const fbParsed = parseSlug(fb); const fbInfo = resolveProvider(fb, fbParsed); if (fbInfo.gatewayProviderId !== info.gatewayProviderId) { throw new GatewayDelegateError( "dispatch", `Internal: cross-vendor server fallback (${info.gatewayProviderId} → ` + `${fbInfo.gatewayProviderId}) reached the same-vendor gateway fetch. ` + "This should have been routed through makeServerFallbackModel.", ); } entries.push({ ...primary, query: { ...body, model: fbParsed.modelId } }); } } const gw = (binding as unknown as { gateway(id: string): AiGatewayRunner }).gateway( gatewayId, ); const runOptions: Record<string, unknown> = {}; if (init?.signal) runOptions.signal = init.signal; const resp = await gw.run(entries, runOptions); fireDispatch(resp, selection, callOptions); return resp; }) as typeof globalThis.fetch; } // --------------------------------------------------------------------------- // Cross-vendor server-side fallback (ported from ai-gateway-provider) // --------------------------------------------------------------------------- /** Sentinel thrown by the capture fetch to stop a leg before it hits the network. */ class ServerFallbackCaptureStop extends Error {} /** One leg of a server-side fallback chain (gateway path). */ interface ServerFallbackLeg { slug: string; modelId: string; info: GatewayProviderInfo; plugin: ProviderPlugin; } /** * Cross-vendor server-side fallback via the gateway universal endpoint. * * Unlike same-vendor fallback (which just swaps `model` in one body), legs here * are DIFFERENT providers with possibly different wire formats, so each leg's own * `@ai-sdk` model must shape its native request. Mirrors ai-gateway-provider's * `processModelRequest`: run each leg's builder with a capture `fetch` that * sentinel-throws before the network, reshape each captured request into a * `{ provider, endpoint, headers, query }` entry, dispatch all N as one * `env.AI.gateway(id).run([...])`, then read `cf-aig-step` to find the leg the * gateway actually served and feed the raw response back into that leg's model. */ function makeServerFallbackModel(params: { binding: Ai; gatewayId: string; gatewayOptions: GatewayOptions; legs: ServerFallbackLeg[]; opts: DelegateCallOptions; selection: Selection; callOptions: DelegateCallOptions; }): LanguageModelV3 { const { binding, gatewayId, gatewayOptions, legs, opts, selection, callOptions } = params; const first = legs[0]!; // A throwaway model only used to read static metadata (provider/modelId/urls); // its fetch is never invoked for those synchronous reads. const refModel = first.plugin.create({ modelId: first.modelId, fetch: (async () => { throw new ServerFallbackCaptureStop(); }) as typeof globalThis.fetch, ...(first.info.baseURL ? { baseURL: first.info.baseURL } : {}), }); const cache = buildGatewayControls(gatewayOptions, opts); const dispatch = async ( method: "doGenerate" | "doStream", options: LanguageModelV3CallOptions, ): Promise<unknown> => { // 1) Capture each leg's native request without hitting the network. const entries: GatewayEntry[] = []; for (const leg of legs) { let captured: { url: string; init?: RequestInit } | undefined; const captureFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { captured = { url: typeof input === "string" ? input : input.toString(), init, }; throw new ServerFallbackCaptureStop(); }) as typeof globalThis.fetch; const model = leg.plugin.create({ modelId: leg.modelId, fetch: captureFetch, ...(leg.info.baseURL ? { baseURL: leg.info.baseURL } : {}), }); try { await (model[method] as (o: LanguageModelV3CallOptions) => Promise<unknown>)( options, ); } catch (e) { if (!(e instanceof ServerFallbackCaptureStop)) throw e; } if (!captured) { throw new GatewayDelegateError( "dispatch", `Server-side fallback leg "${leg.slug}" produced no request to dispatch.`, ); } const url = captured.url; const endpoint = leg.info.transformEndpoint ? leg.info.transformEndpoint(url) : new URL(url).pathname.replace(/^\//, "") + (new URL(url).search || ""); const body = JSON.parse(asText(captured.init?.body)) as Record<string, unknown>; entries.push( buildGatewayEntry({ providerId: leg.info.gatewayProviderId, endpoint, initHeaders: captured.init?.headers, body, ...(opts.byok ? {} : { stripAuthHeaders: leg.info.authHeaders }), ...(opts.extraHeaders ? { extraHeaders: opts.extraHeaders } : {}), cache, }), ); } // 2) One gateway run with all legs; `cf-aig-step` names the winner. const gw = (binding as unknown as { gateway(id: string): AiGatewayRunner }).gateway( gatewayId, ); const abortSignal = (options as { abortSignal?: AbortSignal }).abortSignal; const runOptions: Record<string, unknown> = {}; if (abortSignal) runOptions.signal = abortSignal; const resp = await gw.run(entries, runOptions); fireDispatch(resp, selection, callOptions); // 3) Feed the raw winner response into its own model so its parser shapes // the result for that leg's wire format. const step = Number.parseInt(resp.headers.get("cf-aig-step") ?? "0", 10); const winner = legs[step] ?? first; const winnerModel = winner.plugin.create({ modelId: winner.modelId, fetch: (async () => resp) as typeof globalThis.fetch, ...(winner.info.baseURL ? { baseURL: winner.info.baseURL } : {}), }); return (winnerModel[method] as (o: LanguageModelV3CallOptions) => Promise<unknown>)( options, ); }; return { specificationVersion: "v3", provider: refModel.provider, modelId: refModel.modelId, supportedUrls: refModel.supportedUrls, doGenerate(options) { return dispatch("doGenerate", options) as ReturnType<LanguageModelV3["doGenerate"]>; }, doStream(options) { return dispatch("doStream", options) as ReturnType<LanguageModelV3["doStream"]>; }, } as LanguageModelV3; }