workers-ai-provider
Version:
Workers AI Provider for the vercel AI SDK
926 lines (858 loc) • 34.6 kB
text/typescript
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;
}