workers-ai-provider 3.1.13 → 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.
- package/README.md +183 -31
- package/dist/anthropic.d.mts +14 -0
- package/dist/anthropic.mjs +21 -0
- package/dist/anthropic.mjs.map +1 -0
- package/dist/gateway-delegate-BfaUTwDZ.d.mts +385 -0
- package/dist/gateway-provider-1USFWm7c.mjs +583 -0
- package/dist/gateway-provider-1USFWm7c.mjs.map +1 -0
- package/dist/gateway-provider.d.mts +80 -0
- package/dist/gateway-provider.mjs +2 -0
- package/dist/google.d.mts +14 -0
- package/dist/google.mjs +21 -0
- package/dist/google.mjs.map +1 -0
- package/dist/index.d.mts +64 -7
- package/dist/index.mjs +967 -327
- package/dist/index.mjs.map +1 -1
- package/dist/openai.d.mts +20 -0
- package/dist/openai.mjs +27 -0
- package/dist/openai.mjs.map +1 -0
- package/package.json +47 -6
- package/src/anthropic.ts +17 -0
- package/src/client-fallback.ts +70 -0
- package/src/convert-to-workersai-chat-messages.ts +33 -7
- package/src/errors.ts +216 -0
- package/src/gateway-delegate.ts +696 -0
- package/src/gateway-provider.ts +167 -0
- package/src/gateway-providers.ts +457 -0
- package/src/google.ts +19 -0
- package/src/index.ts +180 -9
- package/src/openai.ts +25 -0
- package/src/resumable-stream.ts +223 -0
- package/src/streaming.ts +103 -30
- package/src/utils.ts +206 -6
- package/src/workersai-chat-language-model.ts +87 -26
- package/src/workersai-chat-settings.ts +1 -1
- package/src/workersai-models.ts +11 -3
|
@@ -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
|
+
}
|