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