ai-gateway-proxy 0.0.2 → 0.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/dist/index.mjs CHANGED
@@ -1,95 +1,182 @@
1
- import { _ } from '@swc/helpers/_/_async_to_generator';
2
1
  import { getVercelOidcToken } from '@vercel/oidc';
3
- import { _ as _$1 } from '@swc/helpers/_/_extends';
4
2
  import { EventSourceParserStream } from '@ai-sdk/provider-utils';
5
3
 
6
4
  /**
7
5
  * Get authentication token for the AI Gateway.
8
6
  * Tries API key first (AI_GATEWAY_API_KEY env var), then falls back to Vercel OIDC.
9
- */ function getGatewayAuthToken() {
10
- return _(function*() {
11
- const apiKey = process.env.AI_GATEWAY_API_KEY;
12
- if (apiKey) {
13
- return {
14
- token: apiKey,
15
- authMethod: "api-key"
16
- };
7
+ * Returns `null` when neither is available, after logging why OIDC failed, and the proxy then answers 500.
8
+ */ async function getGatewayAuthToken() {
9
+ const apiKey = process.env.AI_GATEWAY_API_KEY;
10
+ if (apiKey) {
11
+ return {
12
+ token: apiKey,
13
+ authMethod: "api-key"
14
+ };
15
+ }
16
+ try {
17
+ const oidcToken = await getVercelOidcToken();
18
+ return {
19
+ token: oidcToken,
20
+ authMethod: "oidc"
21
+ };
22
+ } catch (error) {
23
+ // Only the message: the error object could carry token material.
24
+ console.error("AI Gateway auth: no AI_GATEWAY_API_KEY and Vercel OIDC token unavailable:", error instanceof Error ? error.message : String(error));
25
+ return null;
26
+ }
27
+ }
28
+
29
+ function isRecord(value) {
30
+ return typeof value === "object" && value !== null && !Array.isArray(value);
31
+ }
32
+ // JSON has no undefined, so null stands in for it wherever the type allows undefined.
33
+ function isNullish(value) {
34
+ return value === undefined || value === null;
35
+ }
36
+ function tokenCountsProblem(name, value, fields) {
37
+ if (!isRecord(value)) {
38
+ return `${name} must be an object`;
39
+ }
40
+ for (const field of fields){
41
+ if (!isNullish(value[field]) && typeof value[field] !== "number") {
42
+ return `${name}.${field} must be a number or undefined`;
17
43
  }
18
- try {
19
- const oidcToken = yield getVercelOidcToken();
20
- return {
21
- token: oidcToken,
22
- authMethod: "oidc"
23
- };
24
- } catch (unused) {
25
- return null;
44
+ }
45
+ return undefined;
46
+ }
47
+ /**
48
+ * Returns why an `afterResponse` result is not a `GatewayResponse`, or undefined when it is one.
49
+ */ function gatewayResponseProblem(value) {
50
+ if (!isRecord(value)) {
51
+ return "the result must be an object";
52
+ }
53
+ const { content, finishReason, usage, warnings, providerMetadata } = value;
54
+ if (!Array.isArray(content)) {
55
+ return "content must be an array";
56
+ }
57
+ if (!content.every((part)=>isRecord(part) && typeof part.type === "string")) {
58
+ return "content must hold objects with a string type";
59
+ }
60
+ if (!isRecord(finishReason) || typeof finishReason.unified !== "string") {
61
+ return "finishReason must be an object with a string unified";
62
+ }
63
+ if (!isNullish(finishReason.raw) && typeof finishReason.raw !== "string") {
64
+ return "finishReason.raw must be a string or undefined";
65
+ }
66
+ if (!isNullish(usage)) {
67
+ if (!isRecord(usage)) {
68
+ return "usage must be an object or undefined";
69
+ }
70
+ const problem = tokenCountsProblem("usage.inputTokens", usage.inputTokens, [
71
+ "total",
72
+ "noCache",
73
+ "cacheRead",
74
+ "cacheWrite"
75
+ ]) ?? tokenCountsProblem("usage.outputTokens", usage.outputTokens, [
76
+ "total",
77
+ "text",
78
+ "reasoning"
79
+ ]);
80
+ if (problem) {
81
+ return problem;
26
82
  }
27
- })();
83
+ }
84
+ // @ai-sdk/gateway reads a missing warnings field as none, so only a wrong type is rejected.
85
+ if (!isNullish(warnings) && !Array.isArray(warnings)) {
86
+ return "warnings must be an array";
87
+ }
88
+ if (!isNullish(providerMetadata) && !isRecord(providerMetadata)) {
89
+ return "providerMetadata must be an object or undefined";
90
+ }
91
+ return undefined;
92
+ }
93
+ /**
94
+ * Returns why an `onError` result that is not a `Response` is not a `GatewayError`, or undefined when it is one.
95
+ */ function gatewayErrorProblem(value) {
96
+ if (!isRecord(value)) {
97
+ return "the result must be an object or a Response";
98
+ }
99
+ const { error } = value;
100
+ if (!isRecord(error)) {
101
+ return "error must be an object";
102
+ }
103
+ if (typeof error.message !== "string") {
104
+ return "error.message must be a string";
105
+ }
106
+ // @ai-sdk/gateway accepts a missing type, so only a wrong type is rejected.
107
+ if (!isNullish(error.type) && typeof error.type !== "string") {
108
+ return "error.type must be a string or undefined";
109
+ }
110
+ if (!isNullish(error.code) && typeof error.code !== "string" && typeof error.code !== "number") {
111
+ return "error.code must be a string, a number or undefined";
112
+ }
113
+ if (!isNullish(value.generationId) && typeof value.generationId !== "string") {
114
+ return "generationId must be a string or undefined";
115
+ }
116
+ return undefined;
28
117
  }
29
118
 
30
119
  /**
31
120
  * Aggregator for collecting stream content.
32
121
  * Tracks text deltas, reasoning, tool calls, and other content to build the final GatewayResponse.
33
122
  *
34
- * Uses AI SDK V3 protocol types.
35
- * @see https://github.com/vercel/ai/blob/main/packages/provider/src/language-model/v3/language-model-v3-stream-part.ts
123
+ * Uses AI SDK V4 protocol types.
124
+ * @see https://github.com/vercel/ai/blob/main/packages/provider/src/language-model/v4/language-model-v4-stream-part.ts
36
125
  */ class StreamContentAggregator {
37
126
  /**
38
127
  * Process a stream part and update aggregated state.
39
- * Uses LanguageModelV3StreamPart type from @ai-sdk/provider.
128
+ * Uses LanguageModelV4StreamPart type from @ai-sdk/provider.
40
129
  *
41
- * @see https://github.com/vercel/ai/blob/main/packages/provider/src/language-model/v3/language-model-v3-stream-part.ts
130
+ * @see https://github.com/vercel/ai/blob/main/packages/provider/src/language-model/v4/language-model-v4-stream-part.ts
42
131
  */ process(part) {
43
132
  switch(part.type){
44
133
  case "stream-start":
45
- this.warnings.push(...part.warnings);
134
+ // The part comes off the wire, so a missing or malformed warnings field adds none.
135
+ if (Array.isArray(part.warnings)) {
136
+ this.warnings.push(...part.warnings);
137
+ }
46
138
  break;
47
139
  case "text-start":
48
- this.textContent.set(part.id, {
49
- type: "text",
50
- text: ""
51
- });
140
+ keepProviderMetadata(this.openText(part.id), part.providerMetadata);
52
141
  break;
53
142
  case "text-delta":
54
143
  {
55
- const text = this.textContent.get(part.id);
56
- if (text) {
57
- text.text += part.delta;
58
- }
144
+ const block = this.textContent.get(part.id) ?? this.openText(part.id);
145
+ block.text += part.delta;
146
+ keepProviderMetadata(block, part.providerMetadata);
147
+ break;
59
148
  }
60
- break;
61
149
  case "text-end":
62
150
  {
63
- const text = this.textContent.get(part.id);
64
- if (text) {
65
- this.content.push(text);
66
- this.textContent.delete(part.id);
151
+ const block = this.textContent.get(part.id);
152
+ if (block) {
153
+ keepProviderMetadata(block, part.providerMetadata);
154
+ if (Array.isArray(part.citations)) {
155
+ block.citations = part.citations;
156
+ }
67
157
  }
158
+ this.textContent.delete(part.id);
159
+ break;
68
160
  }
69
- break;
70
161
  case "reasoning-start":
71
- this.reasoningContent.set(part.id, {
72
- type: "reasoning",
73
- text: ""
74
- });
162
+ keepProviderMetadata(this.openReasoning(part.id), part.providerMetadata);
75
163
  break;
76
164
  case "reasoning-delta":
77
165
  {
78
- const reasoning = this.reasoningContent.get(part.id);
79
- if (reasoning) {
80
- reasoning.text += part.delta;
81
- }
166
+ const block = this.reasoningContent.get(part.id) ?? this.openReasoning(part.id);
167
+ block.text += part.delta;
168
+ keepProviderMetadata(block, part.providerMetadata);
169
+ break;
82
170
  }
83
- break;
84
171
  case "reasoning-end":
85
172
  {
86
- const reasoning = this.reasoningContent.get(part.id);
87
- if (reasoning) {
88
- this.content.push(reasoning);
89
- this.reasoningContent.delete(part.id);
173
+ const block = this.reasoningContent.get(part.id);
174
+ if (block) {
175
+ keepProviderMetadata(block, part.providerMetadata);
90
176
  }
177
+ this.reasoningContent.delete(part.id);
178
+ break;
91
179
  }
92
- break;
93
180
  case "tool-input-start":
94
181
  case "tool-input-delta":
95
182
  case "tool-input-end":
@@ -104,20 +191,25 @@ import { EventSourceParserStream } from '@ai-sdk/provider-utils';
104
191
  this.content.push(part);
105
192
  break;
106
193
  case "file":
194
+ case "custom":
195
+ case "reasoning-file":
107
196
  this.content.push(part);
108
197
  break;
109
198
  case "source":
110
199
  this.content.push(part);
111
200
  break;
201
+ case "response-metadata":
202
+ this.recordResponseMetadata(part);
203
+ break;
204
+ case "error":
205
+ // A later finish part still sets its own reason; this one stands only if none comes.
206
+ this.finishReason = {
207
+ unified: "error",
208
+ raw: undefined
209
+ };
210
+ break;
112
211
  case "finish":
113
- // Finalize any remaining content
114
- for (const [, text] of this.textContent){
115
- this.content.push(text);
116
- }
117
212
  this.textContent.clear();
118
- for (const [, reasoning] of this.reasoningContent){
119
- this.content.push(reasoning);
120
- }
121
213
  this.reasoningContent.clear();
122
214
  this.finishReason = part.finishReason;
123
215
  this.usage = part.usage;
@@ -125,251 +217,777 @@ import { EventSourceParserStream } from '@ai-sdk/provider-utils';
125
217
  break;
126
218
  }
127
219
  }
220
+ // Each field keeps its last string value, as in `ai`'s streamText; the timestamp is still the wire string.
221
+ recordResponseMetadata(part) {
222
+ for (const field of [
223
+ "id",
224
+ "modelId",
225
+ "timestamp"
226
+ ]){
227
+ const value = part[field];
228
+ if (typeof value === "string") {
229
+ this.response = {
230
+ ...this.response,
231
+ [field]: value
232
+ };
233
+ }
234
+ }
235
+ }
236
+ openText(id) {
237
+ const block = {
238
+ type: "text",
239
+ text: ""
240
+ };
241
+ this.textContent.set(id, block);
242
+ this.content.push(block);
243
+ return block;
244
+ }
245
+ openReasoning(id) {
246
+ const block = {
247
+ type: "reasoning",
248
+ text: ""
249
+ };
250
+ this.reasoningContent.set(id, block);
251
+ this.content.push(block);
252
+ return block;
253
+ }
128
254
  /**
129
- * Get the aggregated response.
255
+ * Get the aggregated response. Before a `finish` part, `usage` is undefined and
256
+ * `finishReason` is `error` after an `error` part and `other` otherwise.
130
257
  */ getResponse() {
131
258
  return {
132
259
  content: this.content,
133
260
  finishReason: this.finishReason,
134
261
  usage: this.usage,
135
262
  warnings: this.warnings,
136
- providerMetadata: this.providerMetadata
263
+ providerMetadata: this.providerMetadata,
264
+ ...this.response && {
265
+ response: this.response
266
+ }
137
267
  };
138
268
  }
139
269
  constructor(){
140
270
  this.content = [];
141
271
  this.warnings = [];
142
- // Text content being built (keyed by id)
272
+ // Open text and reasoning blocks by id. Each block already holds its slot in `content`.
143
273
  this.textContent = new Map();
144
- // Reasoning content being built (keyed by id)
145
274
  this.reasoningContent = new Map();
146
275
  this.finishReason = {
147
- unified: "stop",
276
+ unified: "other",
148
277
  raw: undefined
149
278
  };
150
- this.usage = {
151
- inputTokens: {
152
- total: undefined,
153
- noCache: undefined,
154
- cacheRead: undefined,
155
- cacheWrite: undefined
156
- },
157
- outputTokens: {
158
- total: undefined,
159
- text: undefined,
160
- reasoning: undefined
161
- }
162
- };
163
279
  }
164
280
  }
281
+ // The latest part with metadata wins, as in `ai`'s streamText.
282
+ function keepProviderMetadata(block, providerMetadata) {
283
+ if (providerMetadata != null) {
284
+ block.providerMetadata = providerMetadata;
285
+ }
286
+ }
287
+ /**
288
+ * Encodes event data as an SSE event. The parser joins multi-line data with "\n",
289
+ * so each line needs its own `data:` field to reach the client intact.
290
+ */ function sseEvent(data) {
291
+ return `${data.split("\n").map((line)=>`data: ${line}`).join("\n")}\n\n`;
292
+ }
165
293
  /**
166
294
  * Creates a transform stream that:
167
295
  * 1. Passes through all SSE events immediately
168
296
  * 2. Aggregates content using StreamContentAggregator
169
- * 3. Calls afterResponse when finish event is received
170
- * 4. Modifies the finish event based on afterResponse result
297
+ * 3. Calls afterResponse once: on the first finish event, or with partial content if the
298
+ * stream ends, fails or is cancelled first
299
+ * 4. Modifies every finish event based on that afterResponse result
300
+ *
301
+ * Pass the client request's `signal` when it is also the upstream fetch's signal: a read that
302
+ * fails because it aborted is then reported as a cancel rather than an upstream failure.
171
303
  *
172
304
  * Uses EventSourceParserStream from @ai-sdk/provider-utils for SSE parsing.
173
305
  * EventSourceParserStream is re-exported from the eventsource-parser package.
174
306
  * @see https://github.com/vercel/ai/blob/main/packages/provider-utils/src/parse-json-event-stream.ts
175
307
  * @see https://github.com/rexxars/eventsource-parser
176
- */ function createStreamTransformer(body, requestBody, afterResponse) {
308
+ */ function createStreamTransformer(body, requestBody, afterResponse, signal) {
177
309
  const encoder = new TextEncoder();
178
310
  const aggregator = new StreamContentAggregator();
179
- // Use EventSourceParserStream for SSE parsing (re-exported from eventsource-parser via @ai-sdk/provider-utils)
180
311
  const eventStream = body.pipeThrough(new TextDecoderStream()).pipeThrough(new EventSourceParserStream());
312
+ const reader = eventStream.getReader();
313
+ let cancelled = false;
314
+ let finishSeen = false;
315
+ let hookResult;
316
+ let incompleteReported = false;
317
+ let warnedNonJson = false;
318
+ /**
319
+ * Returns the data to forward for one upstream event, calling afterResponse on finish.
320
+ */ async function transformEvent(data) {
321
+ // Handle DONE marker (end of stream)
322
+ if (data === "DONE" || data === "[DONE]") {
323
+ return data;
324
+ }
325
+ // Parse the JSON event data. Data that is not a JSON object, such as `null`, is not a stream
326
+ // part either, so it passes through like non-JSON data rather than reaching the aggregator.
327
+ let json;
328
+ try {
329
+ json = JSON.parse(data);
330
+ } catch {}
331
+ if (typeof json !== "object" || json === null) {
332
+ if (!warnedNonJson) {
333
+ warnedNonJson = true;
334
+ console.warn("upstream stream event is not a JSON object, passing it through unparsed");
335
+ }
336
+ return data;
337
+ }
338
+ const parsed = json;
339
+ // Track content for aggregation
340
+ aggregator.process(parsed);
341
+ // Pass through every event but finish unchanged
342
+ if (parsed.type !== "finish") {
343
+ return data;
344
+ }
345
+ // A later finish reuses the first result, so a metering hook counts the stream once.
346
+ if (!finishSeen) {
347
+ finishSeen = true;
348
+ hookResult = await runAfterResponse();
349
+ }
350
+ if (!hookResult) {
351
+ return data;
352
+ }
353
+ const modifiedFinish = {
354
+ type: "finish",
355
+ finishReason: hookResult.finishReason,
356
+ usage: hookResult.usage ?? parsed.usage,
357
+ providerMetadata: hookResult.providerMetadata ?? undefined
358
+ };
359
+ return JSON.stringify(modifiedFinish);
360
+ }
361
+ /**
362
+ * Calls afterResponse on finish and returns its result, or undefined when it threw or is invalid.
363
+ */ async function runAfterResponse() {
364
+ let modifiedResponse;
365
+ try {
366
+ modifiedResponse = await afterResponse({
367
+ request: requestBody,
368
+ response: aggregator.getResponse()
369
+ });
370
+ } catch (error) {
371
+ console.error("afterResponse hook failed, forwarding original finish event:", error);
372
+ return undefined;
373
+ }
374
+ const problem = gatewayResponseProblem(modifiedResponse);
375
+ if (problem) {
376
+ console.error(`afterResponse hook returned an invalid response, forwarding original finish event: ${problem}`);
377
+ return undefined;
378
+ }
379
+ return modifiedResponse;
380
+ }
381
+ // No finish event is left to rewrite, so the result is ignored and a throw only logged.
382
+ async function reportIncomplete(incomplete) {
383
+ if (finishSeen || incompleteReported) {
384
+ return;
385
+ }
386
+ incompleteReported = true;
387
+ const response = aggregator.getResponse();
388
+ if (incomplete.reason === "error") {
389
+ response.finishReason = {
390
+ unified: "error",
391
+ raw: undefined
392
+ };
393
+ }
394
+ try {
395
+ await afterResponse({
396
+ request: requestBody,
397
+ response,
398
+ incomplete
399
+ });
400
+ } catch (error) {
401
+ console.error("afterResponse hook failed on an unfinished stream:", error);
402
+ }
403
+ }
404
+ // Reading in pull rather than start keeps backpressure: one upstream event per client read,
405
+ // so a slow client does not make the proxy buffer the whole response.
181
406
  return new ReadableStream({
182
- start (controller) {
183
- return _(function*() {
184
- const reader = eventStream.getReader();
185
- try {
186
- while(true){
187
- const { done, value } = yield reader.read();
188
- if (done) break;
189
- const { data } = value;
190
- // Handle DONE marker (end of stream)
191
- if (data === "DONE" || data === "[DONE]") {
192
- controller.enqueue(encoder.encode(`data: ${data}\n\n`));
193
- continue;
194
- }
195
- // Parse the JSON event data
196
- let parsed;
197
- try {
198
- parsed = JSON.parse(data);
199
- } catch (unused) {
200
- // Not valid JSON, pass through as-is
201
- controller.enqueue(encoder.encode(`data: ${data}\n\n`));
202
- continue;
203
- }
204
- // Track content for aggregation
205
- aggregator.process(parsed);
206
- // Handle finish event specially - call afterResponse hook
207
- if (parsed.type === "finish") {
208
- const modifiedResponse = yield afterResponse({
209
- request: requestBody,
210
- response: aggregator.getResponse()
211
- });
212
- // Emit modified finish event
213
- const modifiedFinish = {
214
- type: "finish",
215
- finishReason: modifiedResponse.finishReason,
216
- usage: modifiedResponse.usage,
217
- providerMetadata: modifiedResponse.providerMetadata
218
- };
219
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(modifiedFinish)}\n\n`));
220
- continue;
221
- }
222
- // Pass through event unchanged
223
- controller.enqueue(encoder.encode(`data: ${data}\n\n`));
407
+ async pull (controller) {
408
+ try {
409
+ const { done, value } = await reader.read();
410
+ if (done) {
411
+ // Awaited before close so a serverless handler stays alive until the hook finishes.
412
+ if (!cancelled) {
413
+ await reportIncomplete({
414
+ reason: "ended"
415
+ });
224
416
  }
225
417
  controller.close();
226
- } catch (error) {
227
- controller.error(error);
228
- } finally{
229
418
  reader.releaseLock();
419
+ return;
230
420
  }
231
- })();
421
+ controller.enqueue(encoder.encode(sseEvent(await transformEvent(value.data))));
422
+ } catch (error) {
423
+ // Aborting the fetch signal errors the upstream body, so the client left rather than the upstream failing.
424
+ if (!cancelled && signal?.aborted) {
425
+ cancelled = true;
426
+ await reportIncomplete({
427
+ reason: "cancelled",
428
+ cancelReason: signal.reason
429
+ });
430
+ } else if (!cancelled) {
431
+ console.error("upstream stream failed, erroring the client stream:", error);
432
+ await reportIncomplete({
433
+ reason: "error",
434
+ error
435
+ });
436
+ }
437
+ controller.error(error);
438
+ reader.releaseLock();
439
+ }
440
+ },
441
+ async cancel (reason) {
442
+ cancelled = true;
443
+ const upstream = reader.cancel(reason);
444
+ upstream.catch(()=>{});
445
+ await reportIncomplete({
446
+ reason: "cancelled",
447
+ cancelReason: reason
448
+ });
449
+ return upstream;
232
450
  }
233
451
  });
234
452
  }
235
453
 
454
+ const DEFAULT_BASE_URL = "https://ai-gateway.vercel.sh/v4/ai";
455
+ const PROTOCOL_VERSION = "0.0.1";
456
+ const MAX_ERROR_EXCERPT_LENGTH = 200;
457
+ // Request ids for matching gateway-side logs, and the rate limit headers the AI SDK backs off on.
458
+ const FORWARDED_RESPONSE_HEADER = /^(?:x-vercel-id|x-request-id|retry-after|retry-after-ms|x-ratelimit-.+)$/;
459
+ // Headers @ai-sdk/gateway sets to describe the model call; they must match the body the client built.
460
+ const MODEL_CALL_HEADER = /^(?:ai-language-model-.+|ai-model-id|ai-[a-z]+-model-specification-version)$/;
461
+ const REQUEST_METADATA_HEADER = /^(?:ai-o11y-.+|idempotency-key|accept)$/;
462
+ // Paths relative to baseUrl where @ai-sdk/gateway 4.x posts a call that is not a language model call.
463
+ const NON_LANGUAGE_MODEL_PATHS = new Map([
464
+ [
465
+ "embedding-model",
466
+ "embedding"
467
+ ],
468
+ [
469
+ "image-model",
470
+ "image"
471
+ ],
472
+ [
473
+ "video-model",
474
+ "video"
475
+ ],
476
+ [
477
+ "video-model/start",
478
+ "video"
479
+ ],
480
+ [
481
+ "speech-model",
482
+ "speech"
483
+ ],
484
+ [
485
+ "transcription-model",
486
+ "transcription"
487
+ ],
488
+ [
489
+ "reranking-model",
490
+ "reranking"
491
+ ],
492
+ [
493
+ "decision-model",
494
+ "decision"
495
+ ],
496
+ [
497
+ "batch/start",
498
+ "batch"
499
+ ]
500
+ ]);
501
+ // @ai-sdk/gateway's calls that read or cancel a job rather than start a model call.
502
+ const READ_PATHS = new Set([
503
+ "batch/status",
504
+ "batch/results",
505
+ "batch/cancel",
506
+ "video-model/status"
507
+ ]);
508
+ const NULL_BODY_STATUS = new Set([
509
+ 204,
510
+ 205
511
+ ]);
512
+ // Successful bodies that are a sequence of values rather than one JSON value: event streams and JSON Lines.
513
+ const STREAM_CONTENT_TYPE = /^\s*(?:text\/event-stream|application\/(?:x-ndjson|jsonl|jsonlines|x-jsonlines))\s*(?:;|$)/i;
514
+ /**
515
+ * Extracts the path from the request using either a custom extractor or route params.
516
+ */ async function extractPathFromRequest(request, context, options) {
517
+ if (options.extractPath) {
518
+ return options.extractPath(request);
519
+ }
520
+ if (context?.params) {
521
+ const params = await context.params;
522
+ const segments = params[options.segmentsParam];
523
+ return Array.isArray(segments) ? segments.join("/") : segments ?? "";
524
+ }
525
+ return "";
526
+ }
527
+ function isSafePath(path) {
528
+ if (path.startsWith("/") || path.includes("://")) {
529
+ return false;
530
+ }
531
+ for (const segment of path.split("/")){
532
+ let decoded;
533
+ try {
534
+ decoded = decodeURIComponent(segment);
535
+ } catch {
536
+ return false;
537
+ }
538
+ if (decoded === "." || decoded === ".." || /[/\\\0]/.test(decoded)) {
539
+ return false;
540
+ }
541
+ }
542
+ return true;
543
+ }
544
+ /**
545
+ * Builds the upstream URL from base URL, path, and query params, with its pathname relative to
546
+ * the base URL. Returns null when the path would leave the base URL.
547
+ */ function buildUpstreamUrl(baseUrl, path, requestUrl) {
548
+ if (!isSafePath(path)) {
549
+ return null;
550
+ }
551
+ const base = new URL(baseUrl.replace(/\/?$/, "/"));
552
+ // The "./" prefix keeps a segment containing ":" from being parsed as a scheme.
553
+ const url = new URL(`./${path}`, base);
554
+ if (url.origin !== base.origin || !url.pathname.startsWith(base.pathname)) {
555
+ return null;
556
+ }
557
+ const queryString = new URL(requestUrl).searchParams.toString();
558
+ if (queryString) {
559
+ url.search = queryString;
560
+ }
561
+ return {
562
+ url: url.toString(),
563
+ relativePath: url.pathname.slice(base.pathname.length)
564
+ };
565
+ }
566
+ /**
567
+ * Applies the proxy's own headers over the caller's `headers`. `Headers` matches names
568
+ * case-insensitively, so a custom `Authorization` cannot sit beside the gateway's `authorization`.
569
+ */ function withGatewayHeaders(customHeaders, gatewayHeaders) {
570
+ const headers = new Headers(customHeaders);
571
+ for (const [name, value] of Object.entries(gatewayHeaders)){
572
+ headers.set(name, value);
573
+ }
574
+ return Object.fromEntries(headers);
575
+ }
576
+ /**
577
+ * Builds the headers for the upstream request: the caller's `headers`, then the model call and
578
+ * request metadata headers from the incoming request, then the proxy's own.
579
+ */ function buildRequestHeaders(auth, request, customHeaders) {
580
+ const headers = new Headers(customHeaders);
581
+ for (const name of [
582
+ ...headers.keys()
583
+ ]){
584
+ if (MODEL_CALL_HEADER.test(name)) {
585
+ headers.delete(name);
586
+ }
587
+ }
588
+ for (const [name, value] of request.headers){
589
+ if (MODEL_CALL_HEADER.test(name) || REQUEST_METADATA_HEADER.test(name)) {
590
+ headers.set(name, value);
591
+ }
592
+ }
593
+ return withGatewayHeaders(headers, {
594
+ "content-type": "application/json",
595
+ authorization: `Bearer ${auth.token}`,
596
+ "ai-gateway-auth-method": auth.authMethod,
597
+ "ai-gateway-protocol-version": PROTOCOL_VERSION
598
+ });
599
+ }
600
+ /**
601
+ * Builds a response for an error the proxy raises itself, in the same shape as upstream errors.
602
+ */ function proxyErrorResponse(status, type, message) {
603
+ const body = {
604
+ error: {
605
+ message,
606
+ type
607
+ }
608
+ };
609
+ return new Response(JSON.stringify(body), {
610
+ status,
611
+ headers: {
612
+ "content-type": "application/json"
613
+ }
614
+ });
615
+ }
616
+ function isRedirect(res) {
617
+ return res.type === "opaqueredirect" || res.status >= 300 && res.status < 400;
618
+ }
619
+ function redirectRefusedResponse(res) {
620
+ res.body?.cancel().catch(()=>{});
621
+ return proxyErrorResponse(502, "proxy_error", "Upstream redirected, the proxy does not follow redirects");
622
+ }
623
+ function proxyFailureResponse(request, error) {
624
+ if (request.signal.aborted) {
625
+ return new Response(null, {
626
+ status: 499
627
+ });
628
+ }
629
+ console.error("AI Gateway proxy request failed:", error);
630
+ return proxyErrorResponse(500, "proxy_error", "Error proxying request to AI Gateway");
631
+ }
632
+ /**
633
+ * Forwards a GET or HEAD request, such as the gateway's `/config` model list, or a request whose
634
+ * body is missing or empty, without a body.
635
+ * The hooks are typed for language model calls, so none of them run here.
636
+ */ async function forwardBodylessRequest(request, upstream, auth, customHeaders) {
637
+ try {
638
+ const res = await fetch(upstream, {
639
+ method: request.method,
640
+ signal: request.signal,
641
+ headers: withGatewayHeaders(customHeaders, {
642
+ authorization: `Bearer ${auth.token}`,
643
+ "ai-gateway-auth-method": auth.authMethod,
644
+ "ai-gateway-protocol-version": PROTOCOL_VERSION
645
+ }),
646
+ redirect: "manual"
647
+ });
648
+ if (isRedirect(res)) {
649
+ return redirectRefusedResponse(res);
650
+ }
651
+ return passThroughResponse(res);
652
+ } catch (error) {
653
+ return proxyFailureResponse(request, error);
654
+ }
655
+ }
656
+ function isGatewayError(value) {
657
+ if (typeof value !== "object" || value === null) {
658
+ return false;
659
+ }
660
+ const { error } = value;
661
+ return typeof error === "object" && error !== null && typeof error.message === "string";
662
+ }
663
+ /**
664
+ * Parses the error response from the gateway. A body that is not a JSON `{ error: { message } }`,
665
+ * such as an HTML page from an edge or load balancer, becomes a `gateway_error` naming the status
666
+ * and a short excerpt, so `onError` always receives the `GatewayError` shape.
667
+ */ async function parseErrorBody(res) {
668
+ const text = await res.text().catch(()=>"");
669
+ try {
670
+ const parsed = JSON.parse(text);
671
+ if (isGatewayError(parsed)) {
672
+ return parsed;
673
+ }
674
+ } catch {}
675
+ const excerpt = text.replace(/\s+/g, " ").trim().slice(0, MAX_ERROR_EXCERPT_LENGTH);
676
+ const message = excerpt ? `AI Gateway returned ${res.status}: ${excerpt}` : `AI Gateway returned ${res.status} ${res.statusText}`.trimEnd();
677
+ return {
678
+ error: {
679
+ message,
680
+ type: "gateway_error"
681
+ }
682
+ };
683
+ }
684
+ /**
685
+ * Builds response headers with the given content type, if any, plus the allowlisted upstream headers.
686
+ */ function responseHeaders(res, contentType) {
687
+ const headers = new Headers(contentType ? {
688
+ "content-type": contentType
689
+ } : undefined);
690
+ for (const [name, value] of res.headers){
691
+ if (FORWARDED_RESPONSE_HEADER.test(name)) {
692
+ headers.set(name, value);
693
+ }
694
+ }
695
+ return headers;
696
+ }
697
+ /**
698
+ * Sends the upstream body unread, with its `content-type` and the allowlisted upstream headers.
699
+ */ function passThroughResponse(res) {
700
+ return new Response(res.body, {
701
+ status: res.status,
702
+ statusText: res.statusText,
703
+ headers: responseHeaders(res, res.headers.get("content-type") ?? "application/json")
704
+ });
705
+ }
706
+ /**
707
+ * Runs onError and returns the body or Response to send, falling back to the upstream error body.
708
+ */ async function runOnError(onError, res, upstreamError, requestBody) {
709
+ let result;
710
+ try {
711
+ result = await onError({
712
+ request: requestBody,
713
+ error: upstreamError,
714
+ status: res.status
715
+ });
716
+ } catch (error) {
717
+ // A 500 would hide the upstream status and its retry-after, and make a 400 look retryable.
718
+ console.error("onError hook failed, forwarding the upstream error body:", error);
719
+ return upstreamError;
720
+ }
721
+ if (result instanceof Response) {
722
+ return result;
723
+ }
724
+ // A JavaScript caller can return nothing, such as a hook that only logs.
725
+ if (result === undefined || result === null) {
726
+ console.error("onError hook returned no response, forwarding the upstream error body");
727
+ return upstreamError;
728
+ }
729
+ const problem = gatewayErrorProblem(result);
730
+ if (problem) {
731
+ console.error(`onError hook returned an invalid error, forwarding the upstream error body: ${problem}`);
732
+ return upstreamError;
733
+ }
734
+ return result;
735
+ }
736
+ /**
737
+ * Creates an error response, optionally applying the onError hook.
738
+ */ async function createErrorResponse(res, upstreamError, requestBody, onError) {
739
+ const errorBody = onError ? await runOnError(onError, res, upstreamError, requestBody) : upstreamError;
740
+ if (errorBody instanceof Response) {
741
+ return errorBody;
742
+ }
743
+ return new Response(JSON.stringify(errorBody), {
744
+ status: res.status,
745
+ statusText: res.statusText,
746
+ headers: responseHeaders(res, "application/json")
747
+ });
748
+ }
749
+ /**
750
+ * Handles a streaming response from the gateway.
751
+ */ function handleStreamingResponse(res, requestBody, afterResponse, signal) {
752
+ if (!res.body) {
753
+ return proxyErrorResponse(500, "proxy_error", "No response body");
754
+ }
755
+ const contentType = res.headers.get("content-type") || "text/event-stream";
756
+ // Pass through directly if no afterResponse hook
757
+ if (!afterResponse) {
758
+ return new Response(res.body, {
759
+ status: res.status,
760
+ statusText: res.statusText,
761
+ headers: responseHeaders(res, contentType)
762
+ });
763
+ }
764
+ // Transform stream to call afterResponse on finish
765
+ const transformedStream = createStreamTransformer(res.body, requestBody, afterResponse, signal);
766
+ return new Response(transformedStream, {
767
+ status: res.status,
768
+ statusText: res.statusText,
769
+ headers: responseHeaders(res, contentType)
770
+ });
771
+ }
772
+ /**
773
+ * Runs afterResponse on a non-streaming JSON response from the gateway.
774
+ */ async function handleJsonResponse(res, requestBody, afterResponse) {
775
+ const parsed = await res.json();
776
+ if (!isRecord(parsed)) {
777
+ throw new TypeError("AI Gateway returned a successful body that is not a JSON object");
778
+ }
779
+ let responseBody = parsed;
780
+ // Read before the hook, which may delete it from the object it receives.
781
+ const upstreamUsage = responseBody.usage;
782
+ const modified = await afterResponse({
783
+ request: requestBody,
784
+ response: responseBody
785
+ });
786
+ // A JavaScript caller can return nothing.
787
+ if (modified === undefined || modified === null) {
788
+ console.error("afterResponse hook returned no response, forwarding the upstream body");
789
+ } else {
790
+ const problem = gatewayResponseProblem(modified);
791
+ if (problem) {
792
+ const message = `afterResponse hook returned an invalid response: ${problem}`;
793
+ console.error(message);
794
+ return proxyErrorResponse(500, "proxy_error", message);
795
+ }
796
+ // As on a stream's finish event: the AI SDK reads usage from every generate result, so it cannot be stripped.
797
+ responseBody = {
798
+ ...modified,
799
+ usage: modified.usage ?? upstreamUsage
800
+ };
801
+ }
802
+ return new Response(JSON.stringify(responseBody), {
803
+ status: res.status,
804
+ statusText: res.statusText,
805
+ headers: responseHeaders(res, "application/json")
806
+ });
807
+ }
808
+ /**
809
+ * Wraps the hooks so each call also receives the model id, which the stream transformer does not know.
810
+ */ function withModelId({ afterResponse, onError }, modelId) {
811
+ return {
812
+ afterResponse: afterResponse && ((context)=>afterResponse({
813
+ ...context,
814
+ modelId
815
+ })),
816
+ onError: onError && ((context)=>onError({
817
+ ...context,
818
+ modelId
819
+ }))
820
+ };
821
+ }
822
+ /**
823
+ * Builds the client response for a non-redirect upstream response, running the language model hooks given.
824
+ */ async function forwardUpstreamResponse(res, requestBody, isStreaming, { afterResponse, onError }, signal) {
825
+ if (!res.ok) {
826
+ const errorBody = await parseErrorBody(res);
827
+ return await createErrorResponse(res, errorBody, requestBody, onError);
828
+ }
829
+ // A 204 or 205 has no body to parse or hand to afterResponse, and a Response with it cannot carry one.
830
+ if (NULL_BODY_STATUS.has(res.status)) {
831
+ return new Response(null, {
832
+ status: res.status,
833
+ statusText: res.statusText,
834
+ headers: responseHeaders(res)
835
+ });
836
+ }
837
+ if (isStreaming) {
838
+ return handleStreamingResponse(res, requestBody, afterResponse, signal);
839
+ }
840
+ // Only afterResponse reads a success body; without it, or for an event stream or JSON Lines, it streams through.
841
+ if (!afterResponse || STREAM_CONTENT_TYPE.test(res.headers.get("content-type") ?? "")) {
842
+ return passThroughResponse(res);
843
+ }
844
+ return await handleJsonResponse(res, requestBody, afterResponse);
845
+ }
846
+ /**
847
+ * Cancels a body nothing will read, since an unread fetch body holds its upstream connection.
848
+ */ function discardUnreadBody(response) {
849
+ if (response.body && !response.bodyUsed && !response.body.locked) {
850
+ response.body.cancel().catch(()=>{});
851
+ }
852
+ }
853
+ /**
854
+ * Runs afterRawResponse and returns the response to send.
855
+ */ async function applyAfterRawResponse(afterRawResponse, context) {
856
+ let result;
857
+ try {
858
+ result = await afterRawResponse(context);
859
+ } catch (error) {
860
+ discardUnreadBody(context.response);
861
+ throw error;
862
+ }
863
+ if (result instanceof Response) {
864
+ // A replacement built on response.body still needs it.
865
+ if (result.body !== context.response.body) {
866
+ discardUnreadBody(context.response);
867
+ }
868
+ return result;
869
+ }
870
+ if (result !== undefined && result !== null) {
871
+ discardUnreadBody(context.response);
872
+ throw new TypeError("afterRawResponse hook must return a Response or nothing");
873
+ }
874
+ if (context.response.bodyUsed) {
875
+ throw new TypeError("afterRawResponse hook read the response body and returned no Response; read response.clone() instead");
876
+ }
877
+ return context.response;
878
+ }
236
879
  /**
237
880
  * Creates a proxy handler for the Vercel AI Gateway.
238
881
  *
239
- * Uses AI SDK V3 Language Model.
882
+ * Uses AI SDK V4 Language Model.
240
883
  * @see https://github.com/vercel/ai/blob/main/packages/gateway/src/gateway-language-model.ts
241
- */ const createGatewayProxy = ({ baseUrl = "https://ai-gateway.vercel.sh/v1/ai", headers, beforeRequest, afterResponse, onError } = {})=>{
242
- const handler = (request, { params })=>_(function*() {
243
- var _ref;
244
- const auth = yield getGatewayAuthToken();
245
- if (!auth) {
246
- return new Response("AI Gateway authentication not configured", {
247
- status: 500
248
- });
884
+ */ const createGatewayProxy = ({ baseUrl = DEFAULT_BASE_URL, headers, segmentsParam = "segments", extractPath, beforeRequest, afterResponse, onError, beforeRawRequest, afterRawResponse } = {})=>{
885
+ const handler = async (request, context)=>{
886
+ // Authenticate with the gateway
887
+ const auth = await getGatewayAuthToken();
888
+ if (!auth) {
889
+ // Not authentication_error: @ai-sdk/gateway replaces that message with advice about the client's own key.
890
+ return proxyErrorResponse(500, "proxy_error", "AI Gateway authentication not configured: set AI_GATEWAY_API_KEY or deploy on Vercel with OIDC enabled");
891
+ }
892
+ try {
893
+ // Build upstream URL
894
+ const path = await extractPathFromRequest(request, context, {
895
+ extractPath,
896
+ segmentsParam
897
+ });
898
+ const resolved = buildUpstreamUrl(baseUrl, path, request.url);
899
+ if (!resolved) {
900
+ return proxyErrorResponse(400, "invalid_request_error", "Invalid path");
249
901
  }
250
- const { segments } = yield params;
251
- const path = (_ref = segments == null ? void 0 : segments.join("/")) != null ? _ref : "";
252
- const searchParams = new URL(request.url).searchParams;
253
- const upstream = `${baseUrl}/${path}${searchParams.toString() ? `?${searchParams.toString()}` : ""}`;
902
+ const upstream = resolved.url;
903
+ if (request.method === "GET" || request.method === "HEAD" || request.body === null) {
904
+ return await forwardBodylessRequest(request, upstream, auth, headers);
905
+ }
906
+ // Node adapters give every method but GET and HEAD a body stream, so an empty one is bodyless too
907
+ const text = await request.text();
908
+ if (text === "") {
909
+ return await forwardBodylessRequest(request, upstream, auth, headers);
910
+ }
911
+ // Parse request body
254
912
  let requestBody;
255
913
  try {
256
- requestBody = yield request.clone().json();
257
- } catch (unused) {
258
- return new Response("Invalid JSON body", {
259
- status: 400
914
+ requestBody = JSON.parse(text);
915
+ } catch {
916
+ return proxyErrorResponse(400, "invalid_request_error", "Invalid JSON body");
917
+ }
918
+ // Fail closed: only an exact listed path, after "?" and "#" are resolved away, skips the hooks.
919
+ const kind = NON_LANGUAGE_MODEL_PATHS.get(resolved.relativePath);
920
+ const runsHooks = kind === undefined && !READ_PATHS.has(resolved.relativePath);
921
+ // The language model hooks are typed for call options, so they never receive another JSON value.
922
+ if (runsHooks && (beforeRequest || afterResponse || onError) && !isRecord(requestBody)) {
923
+ return proxyErrorResponse(400, "invalid_request_error", "Invalid request body: a language model call must be a JSON object");
924
+ }
925
+ const rawContext = kind && {
926
+ kind,
927
+ path: resolved.relativePath,
928
+ modelId: request.headers.get("ai-model-id") ?? undefined
929
+ };
930
+ // A hook can replace or mutate the parsed body, so only then is it serialised again.
931
+ let bodyHandedToHook = false;
932
+ // The body does not name the model; @ai-sdk/gateway sends it in this header.
933
+ const modelId = request.headers.get("ai-language-model-id") ?? undefined;
934
+ if (beforeRequest && runsHooks) {
935
+ bodyHandedToHook = true;
936
+ const replaced = await beforeRequest({
937
+ request: requestBody,
938
+ modelId
260
939
  });
940
+ // A JavaScript caller can return nothing, such as a hook that only logs.
941
+ if (replaced === undefined || replaced === null) {
942
+ console.error("beforeRequest hook returned no request, sending the original body");
943
+ } else if (!isRecord(replaced)) {
944
+ const message = "beforeRequest hook returned an invalid request: the result must be an object";
945
+ console.error(message);
946
+ return proxyErrorResponse(500, "proxy_error", message);
947
+ } else {
948
+ requestBody = replaced;
949
+ }
261
950
  }
262
- // Apply beforeRequest hook
263
- if (beforeRequest) {
264
- requestBody = yield beforeRequest({
951
+ if (beforeRawRequest && rawContext) {
952
+ bodyHandedToHook = true;
953
+ const replaced = await beforeRawRequest({
954
+ ...rawContext,
265
955
  request: requestBody
266
956
  });
957
+ // null, like undefined, keeps the body rather than sending the literal null upstream.
958
+ if (replaced !== undefined && replaced !== null) {
959
+ requestBody = replaced;
960
+ }
267
961
  }
268
962
  const isStreaming = request.headers.get("ai-language-model-streaming") === "true";
269
- try {
270
- var _request_headers_get;
271
- const res = yield fetch(upstream, {
272
- method: request.method,
273
- body: JSON.stringify(requestBody),
274
- duplex: "half",
275
- headers: _$1({
276
- "content-type": "application/json"
277
- }, headers, {
278
- authorization: `Bearer ${auth.token}`,
279
- "ai-gateway-auth-method": auth.authMethod,
280
- "ai-gateway-protocol-version": "0.0.1",
281
- "ai-language-model-id": (_request_headers_get = request.headers.get("ai-language-model-id")) != null ? _request_headers_get : "",
282
- "ai-language-model-streaming": isStreaming ? "true" : "false"
283
- })
284
- });
285
- // Handle error responses
286
- if (!res.ok) {
287
- let errorBody;
288
- try {
289
- errorBody = yield res.json();
290
- } catch (unused) {
291
- errorBody = {
292
- error: {
293
- message: res.statusText || "Unknown error",
294
- type: "gateway_error"
295
- }
296
- };
297
- }
298
- if (onError) {
299
- const result = yield onError({
300
- request: requestBody,
301
- error: errorBody,
302
- status: res.status
303
- });
304
- // If onError returns a Response, use it directly
305
- if (result instanceof Response) {
306
- return result;
307
- }
308
- // Otherwise, return the modified error
309
- return new Response(JSON.stringify(result), {
310
- status: res.status,
311
- statusText: res.statusText,
312
- headers: {
313
- "content-type": "application/json"
314
- }
315
- });
316
- }
317
- return new Response(JSON.stringify(errorBody), {
318
- status: res.status,
319
- statusText: res.statusText,
320
- headers: {
321
- "content-type": "application/json"
322
- }
323
- });
324
- }
325
- // For streaming responses
326
- if (isStreaming) {
327
- if (!res.body) {
328
- return new Response("No response body", {
329
- status: 500
330
- });
331
- }
332
- // If no afterResponse hook, pass through directly
333
- if (!afterResponse) {
334
- return new Response(res.body, {
335
- status: res.status,
336
- statusText: res.statusText,
337
- headers: {
338
- "content-type": res.headers.get("content-type") || "text/event-stream"
339
- }
340
- });
341
- }
342
- // Transform the stream to call afterResponse on finish
343
- const transformedStream = createStreamTransformer(res.body, requestBody, afterResponse);
344
- return new Response(transformedStream, {
345
- status: res.status,
346
- statusText: res.statusText,
347
- headers: {
348
- "content-type": res.headers.get("content-type") || "text/event-stream"
349
- }
350
- });
351
- }
352
- // For non-streaming responses, parse and optionally transform
353
- let responseBody = yield res.json();
354
- if (afterResponse) {
355
- responseBody = yield afterResponse({
356
- request: requestBody,
357
- response: responseBody
358
- });
359
- }
360
- return new Response(JSON.stringify(responseBody), {
361
- status: res.status,
362
- statusText: res.statusText,
363
- headers: {
364
- "content-type": "application/json"
365
- }
366
- });
367
- } catch (unused) {
368
- return new Response("Error proxying request to AI Gateway", {
369
- status: 500
963
+ const res = await fetch(upstream, {
964
+ method: request.method,
965
+ body: bodyHandedToHook ? JSON.stringify(requestBody) : text,
966
+ duplex: "half",
967
+ signal: request.signal,
968
+ headers: buildRequestHeaders(auth, request, headers),
969
+ redirect: "manual"
970
+ });
971
+ if (isRedirect(res)) {
972
+ return redirectRefusedResponse(res);
973
+ }
974
+ const response = await forwardUpstreamResponse(res, requestBody, isStreaming, runsHooks ? withModelId({
975
+ afterResponse,
976
+ onError
977
+ }, modelId) : {}, request.signal);
978
+ if (afterRawResponse && rawContext) {
979
+ return await applyAfterRawResponse(afterRawResponse, {
980
+ ...rawContext,
981
+ request: requestBody,
982
+ response
370
983
  });
371
984
  }
372
- })();
985
+ return response;
986
+ } catch (error) {
987
+ return proxyFailureResponse(request, error);
988
+ }
989
+ };
990
+ // Export handler for all HTTP methods
373
991
  handler.GET = handler;
374
992
  handler.POST = handler;
375
993
  handler.PUT = handler;