@ai-sdk/gateway 4.0.63 → 4.0.64

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.
@@ -207,6 +207,194 @@ const { text } = await generateText({
207
207
 
208
208
  AI Gateway language models can also be used in the `streamText` function and support structured data generation with [`Output`](/docs/reference/ai-sdk-core/output) (see [AI SDK Core](/docs/ai-sdk-core)).
209
209
 
210
+ ## Text Batches
211
+
212
+ <Note type="warning">
213
+ Text batch support is experimental and the API may change in patch releases.
214
+ </Note>
215
+
216
+ AI Gateway supports durable text batches through `experimental_startTextBatch`, `experimental_getBatchStatus`, and `experimental_getBatchResults`. See the [AI Gateway batch processing guide](https://vercel.com/docs/ai-gateway/models-and-providers/batch-processing) for supported models, limits, and the complete lifecycle.
217
+
218
+ Pass a publicly reachable HTTPS `webhookUrl` when starting a batch to receive a terminal notification instead of polling. AI Gateway sends `batch.completed`, `batch.failed`, or `batch.cancelled`. The event contains terminal status information but not batch results. Completion webhooks are an AI Gateway capability; direct Anthropic and OpenAI batch providers return an unsupported warning when this option is provided.
219
+
220
+ ```ts filename="start-batch.ts"
221
+ import { randomUUID } from 'node:crypto';
222
+ import {
223
+ experimental_startTextBatch as startTextBatch,
224
+ type Experimental_TextBatchReference as TextBatchReference,
225
+ type GatewayProviderMetadata,
226
+ } from 'ai';
227
+
228
+ // The receiver uses this token to look up the expected batch and signing
229
+ // secret before trusting anything in the webhook body.
230
+ const token = randomUUID();
231
+ const model = 'anthropic/claude-haiku-4.5' as const;
232
+
233
+ // Reserve the token before starting so an early delivery finds a retryable
234
+ // pending record rather than an unknown callback.
235
+ await reserveBatchWebhook(token);
236
+
237
+ const batch = await startTextBatch({
238
+ model,
239
+ requests: [
240
+ { id: 'france', prompt: 'What is the capital of France?' },
241
+ { id: 'germany', prompt: 'What is the capital of Germany?' },
242
+ ],
243
+ providerOptions: {
244
+ gateway: { idempotencyKey: token },
245
+ },
246
+ webhookUrl: `https://example.com/api/batch-webhook?token=${token}`,
247
+ });
248
+
249
+ const gatewayMetadata = batch.providerMetadata?.gateway as
250
+ | GatewayProviderMetadata
251
+ | undefined;
252
+ const signingSecret = gatewayMetadata?.asyncJob?.webhookSigningSecret;
253
+
254
+ if (typeof signingSecret !== 'string') {
255
+ throw new Error('AI Gateway did not return a webhook signing secret.');
256
+ }
257
+
258
+ // Keep sensitive provider metadata out of the receiver queue and logs.
259
+ const batchReference = {
260
+ version: batch.version,
261
+ type: batch.type,
262
+ id: batch.id,
263
+ provider: batch.provider,
264
+ modelId: batch.modelId,
265
+ } satisfies TextBatchReference;
266
+
267
+ await completeBatchWebhookReservation(token, {
268
+ batch: batchReference,
269
+ model,
270
+ signingSecret,
271
+ });
272
+ ```
273
+
274
+ The signing secret is returned only in the start response. Reserve the callback token before submitting the batch, then persist the secret, model, and minimal `batch` reference before the process exits. The stable `providerOptions.gateway.idempotencyKey` makes retrying an ambiguous start safe. Reuse the reserved token when retrying; do not generate a new one. Delete the reservation after a definitive start failure. While the reservation is still pending, the receiver should return a retryable response so an early delivery is sent again after setup completes.
275
+
276
+ The receiver must verify the `x-ai-gateway-signature` header against a bounded raw request body before trusting the event:
277
+
278
+ ```ts filename="app/api/batch-webhook/route.ts"
279
+ import { createHmac, timingSafeEqual } from 'node:crypto';
280
+
281
+ const MAX_AGE_SECONDS = 5 * 60;
282
+ const MAX_BODY_BYTES = 64 * 1024;
283
+
284
+ export async function POST(request: Request) {
285
+ const token = new URL(request.url).searchParams.get('token');
286
+ if (token == null) return new Response(null, { status: 401 });
287
+
288
+ const record = await loadBatchWebhook(token);
289
+ if (record == null) return new Response(null, { status: 401 });
290
+ if (record.state !== 'ready') return new Response(null, { status: 503 });
291
+
292
+ const declaredLength = request.headers.get('content-length');
293
+ if (declaredLength != null && Number(declaredLength) > MAX_BODY_BYTES) {
294
+ return new Response(null, { status: 413 });
295
+ }
296
+
297
+ const rawBody = await readBodyWithLimit(request, MAX_BODY_BYTES);
298
+ if (rawBody == null) return new Response(null, { status: 413 });
299
+
300
+ const signature = request.headers.get('x-ai-gateway-signature');
301
+ if (
302
+ signature == null ||
303
+ !verifySignature({
304
+ header: signature,
305
+ rawBody,
306
+ secret: record.signingSecret,
307
+ })
308
+ ) {
309
+ return new Response(null, { status: 401 });
310
+ }
311
+
312
+ let event: { data?: { jobId?: string; status?: string } };
313
+ try {
314
+ event = JSON.parse(rawBody);
315
+ } catch {
316
+ return new Response(null, { status: 400 });
317
+ }
318
+
319
+ const jobId = event.data?.jobId;
320
+ const status = event.data?.status;
321
+ if (
322
+ jobId !== record.batch.id ||
323
+ (status !== 'cancelled' && status !== 'completed' && status !== 'failed')
324
+ ) {
325
+ return new Response(null, { status: 401 });
326
+ }
327
+
328
+ // The header is not signed, so require it to match the signed event body.
329
+ const deliveryId = `${jobId}-${status}`;
330
+ if (request.headers.get('x-ai-gateway-idempotency-key') !== deliveryId) {
331
+ return new Response(null, { status: 401 });
332
+ }
333
+
334
+ await enqueueBatchResultFetch({
335
+ batch: record.batch,
336
+ deliveryId,
337
+ model: record.model,
338
+ });
339
+
340
+ return new Response(null, { status: 204 });
341
+ }
342
+
343
+ async function readBodyWithLimit(request: Request, maxBytes: number) {
344
+ if (request.body == null) return '';
345
+
346
+ const reader = request.body.getReader();
347
+ const decoder = new TextDecoder();
348
+ let body = '';
349
+ let size = 0;
350
+
351
+ try {
352
+ while (true) {
353
+ const { done, value } = await reader.read();
354
+ if (done) return body + decoder.decode();
355
+
356
+ size += value.byteLength;
357
+ if (size > maxBytes) {
358
+ await reader.cancel();
359
+ return undefined;
360
+ }
361
+
362
+ body += decoder.decode(value, { stream: true });
363
+ }
364
+ } finally {
365
+ reader.releaseLock();
366
+ }
367
+ }
368
+
369
+ function verifySignature({
370
+ header,
371
+ rawBody,
372
+ secret,
373
+ }: {
374
+ header: string;
375
+ rawBody: string;
376
+ secret: string;
377
+ }) {
378
+ const timestamp = header.match(/(?:^|,)t=(\d+)/)?.[1];
379
+ const digest = header.match(/(?:^|,)v1=([0-9a-f]{64})(?:,|$)/)?.[1];
380
+ if (timestamp == null || digest == null) return false;
381
+
382
+ const expected = createHmac('sha256', secret)
383
+ .update(`${timestamp}.${rawBody}`, 'utf8')
384
+ .digest('hex');
385
+ const provided = Buffer.from(digest, 'hex');
386
+ const computed = Buffer.from(expected, 'hex');
387
+ if (provided.length !== computed.length) return false;
388
+ if (!timingSafeEqual(provided, computed)) return false;
389
+
390
+ return Math.abs(Date.now() / 1000 - Number(timestamp)) <= MAX_AGE_SECONDS;
391
+ }
392
+ ```
393
+
394
+ The signature header has the form `t=<unix seconds>,v1=<hex digest>`, where `v1` is the HMAC-SHA256 digest of `"<t>.<raw body>"`. Verify a size-limited raw request body rather than a re-serialized object, use a timing-safe comparison, reject stale timestamps, and reject events whose `data.jobId` does not match the persisted `batch.id`.
395
+
396
+ AI Gateway expects a 2xx response within 10 seconds and retries failed deliveries. It does not follow redirects, so the callback endpoint itself must return the 2xx response. Retries carry the same `x-ai-gateway-idempotency-key` value (`<jobId>-<status>`), which receivers can use for deduplication. After verification, pass the persisted model and `batch` reference to `experimental_getBatchStatus` and `experimental_getBatchResults`. Delivery is best-effort, so periodically reconcile persisted batches by status as a fallback for a notification that exhausts its retries.
397
+
210
398
  ## Reranking Models
211
399
 
212
400
  You can create reranking models using the `rerankingModel` method on the provider instance:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ai-sdk/gateway",
3
3
  "private": false,
4
- "version": "4.0.63",
4
+ "version": "4.0.64",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "sideEffects": false,
@@ -30,18 +30,18 @@
30
30
  }
31
31
  },
32
32
  "dependencies": {
33
- "@vercel/oidc": "3.2.0",
34
- "@ai-sdk/provider": "4.0.7",
35
- "@ai-sdk/provider-utils": "5.0.29"
33
+ "@ai-sdk/provider": "4.0.8",
34
+ "@ai-sdk/provider-utils": "5.0.30",
35
+ "@vercel/oidc": "3.2.0"
36
36
  },
37
37
  "devDependencies": {
38
+ "@ai-sdk/test-server": "2.0.1",
38
39
  "@types/node": "22.19.19",
40
+ "@vercel/ai-tsconfig": "0.0.0",
39
41
  "tsup": "^8.5.1",
40
42
  "tsx": "4.23.12",
41
43
  "typescript": "5.8.3",
42
- "zod": "3.25.76",
43
- "@ai-sdk/test-server": "2.0.1",
44
- "@vercel/ai-tsconfig": "0.0.0"
44
+ "zod": "3.25.76"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "zod": "^3.25.76 || ^4.1.8"
@@ -63,6 +63,7 @@ export class GatewayBatchLanguageModel
63
63
  providerOptions,
64
64
  headers,
65
65
  abortSignal,
66
+ webhookUrl,
66
67
  }: BatchV4StartOptions<LanguageModelV4BatchRequest>): Promise<BatchV4StartResult> {
67
68
  const resolvedHeaders = this.config.headers
68
69
  ? await resolve(this.config.headers)
@@ -84,6 +85,7 @@ export class GatewayBatchLanguageModel
84
85
  : undefined,
85
86
  ),
86
87
  body: {
88
+ ...(webhookUrl != null && { callbackUrl: webhookUrl }),
87
89
  modelId: this.modelId,
88
90
  requests: requests.map(request => ({
89
91
  id: request.id,
@@ -0,0 +1,27 @@
1
+ import type { JSONValue } from '@ai-sdk/provider';
2
+
3
+ /**
4
+ * Metadata for an asynchronous AI Gateway job.
5
+ */
6
+ export type GatewayAsyncJobMetadata = {
7
+ readonly jobId: string;
8
+ readonly status: string;
9
+
10
+ /**
11
+ * Secret for verifying customer webhook deliveries. This is sensitive,
12
+ * start-response-only metadata and must not be logged or forwarded.
13
+ */
14
+ readonly webhookSigningSecret?: string;
15
+ readonly [key: string]: JSONValue | undefined;
16
+ };
17
+
18
+ /**
19
+ * Shape of the inner `providerMetadata.gateway` object returned by AI Gateway
20
+ * operations.
21
+ *
22
+ * Additional fields may be added without a package update.
23
+ */
24
+ export type GatewayProviderMetadata = {
25
+ readonly asyncJob?: GatewayAsyncJobMetadata;
26
+ readonly [key: string]: JSONValue | undefined;
27
+ };
package/src/index.ts CHANGED
@@ -41,6 +41,10 @@ export type {
41
41
  GatewayProvider,
42
42
  GatewayProviderSettings,
43
43
  } from './gateway-provider';
44
+ export type {
45
+ GatewayAsyncJobMetadata,
46
+ GatewayProviderMetadata,
47
+ } from './gateway-provider-metadata';
44
48
  export type {
45
49
  GatewayProviderOptions,
46
50
  /** @deprecated Use `GatewayProviderOptions` instead. */