ai-gateway-proxy 0.1.0 → 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.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
- */ function getGatewayAuthToken() {
12
- return _async_to_generator._(function*() {
13
- const apiKey = process.env.AI_GATEWAY_API_KEY;
14
- if (apiKey) {
15
- return {
16
- token: apiKey,
17
- authMethod: "api-key"
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
- try {
21
- const oidcToken = yield oidc.getVercelOidcToken();
22
- return {
23
- token: oidcToken,
24
- authMethod: "oidc"
25
- };
26
- } catch (unused) {
27
- return null;
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 V3 protocol types.
37
- * @see https://github.com/vercel/ai/blob/main/packages/provider/src/language-model/v3/language-model-v3-stream-part.ts
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 LanguageModelV3StreamPart type from @ai-sdk/provider.
130
+ * Uses LanguageModelV4StreamPart type from @ai-sdk/provider.
42
131
  *
43
- * @see https://github.com/vercel/ai/blob/main/packages/provider/src/language-model/v3/language-model-v3-stream-part.ts
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
- this.warnings.push(...part.warnings);
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.textContent.set(part.id, {
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 text = this.textContent.get(part.id);
58
- if (text) {
59
- text.text += part.delta;
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 text = this.textContent.get(part.id);
66
- if (text) {
67
- this.content.push(text);
68
- this.textContent.delete(part.id);
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.reasoningContent.set(part.id, {
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 reasoning = this.reasoningContent.get(part.id);
81
- if (reasoning) {
82
- reasoning.text += part.delta;
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 reasoning = this.reasoningContent.get(part.id);
89
- if (reasoning) {
90
- this.content.push(reasoning);
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,540 @@ 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
- // Text content being built (keyed by id)
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: "stop",
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
- };
281
+ }
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;
165
287
  }
166
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 when finish event is received
172
- * 4. Modifies the finish event based on afterResponse result
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
- start (controller) {
185
- return _async_to_generator._(function*() {
186
- const reader = eventStream.getReader();
187
- try {
188
- while(true){
189
- const { done, value } = yield reader.read();
190
- if (done) break;
191
- const { data } = value;
192
- // Handle DONE marker (end of stream)
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;
422
+ }
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
+ });
232
438
  }
233
- })();
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/v1/ai";
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
+ // Successful bodies that are a sequence of values rather than one JSON value: event streams and JSON Lines.
515
+ const STREAM_CONTENT_TYPE = /^\s*(?:text\/event-stream|application\/(?:x-ndjson|jsonl|jsonlines|x-jsonlines))\s*(?:;|$)/i;
240
516
  /**
241
517
  * Extracts the path from the request using either a custom extractor or route params.
242
- */ function extractPathFromRequest(request, context, options) {
243
- return _async_to_generator._(function*() {
244
- if (options.extractPath) {
245
- return options.extractPath(request);
518
+ */ async function extractPathFromRequest(request, context, options) {
519
+ if (options.extractPath) {
520
+ return options.extractPath(request);
521
+ }
522
+ if (context?.params) {
523
+ const params = await context.params;
524
+ const segments = params[options.segmentsParam];
525
+ return Array.isArray(segments) ? segments.join("/") : segments ?? "";
526
+ }
527
+ return "";
528
+ }
529
+ function isSafePath(path) {
530
+ if (path.startsWith("/") || path.includes("://")) {
531
+ return false;
532
+ }
533
+ for (const segment of path.split("/")){
534
+ let decoded;
535
+ try {
536
+ decoded = decodeURIComponent(segment);
537
+ } catch {
538
+ return false;
246
539
  }
247
- if (context == null ? void 0 : context.params) {
248
- const params = yield context.params;
249
- const segments = params[options.segmentsParam];
250
- return Array.isArray(segments) ? segments.join("/") : segments != null ? segments : "";
540
+ if (decoded === "." || decoded === ".." || /[/\\\0]/.test(decoded)) {
541
+ return false;
251
542
  }
252
- return "";
253
- })();
543
+ }
544
+ return true;
254
545
  }
255
546
  /**
256
- * Builds the upstream URL from base URL, path, and query params.
547
+ * Builds the upstream URL from base URL, path, and query params, with its pathname relative to
548
+ * the base URL. Returns null when the path would leave the base URL.
257
549
  */ function buildUpstreamUrl(baseUrl, path, requestUrl) {
258
- const searchParams = new URL(requestUrl).searchParams;
259
- const queryString = searchParams.toString();
260
- return `${baseUrl}/${path}${queryString ? `?${queryString}` : ""}`;
550
+ if (!isSafePath(path)) {
551
+ return null;
552
+ }
553
+ const base = new URL(baseUrl.replace(/\/?$/, "/"));
554
+ // The "./" prefix keeps a segment containing ":" from being parsed as a scheme.
555
+ const url = new URL(`./${path}`, base);
556
+ if (url.origin !== base.origin || !url.pathname.startsWith(base.pathname)) {
557
+ return null;
558
+ }
559
+ const queryString = new URL(requestUrl).searchParams.toString();
560
+ if (queryString) {
561
+ url.search = queryString;
562
+ }
563
+ return {
564
+ url: url.toString(),
565
+ relativePath: url.pathname.slice(base.pathname.length)
566
+ };
567
+ }
568
+ /**
569
+ * Applies the proxy's own headers over the caller's `headers`. `Headers` matches names
570
+ * case-insensitively, so a custom `Authorization` cannot sit beside the gateway's `authorization`.
571
+ */ function withGatewayHeaders(customHeaders, gatewayHeaders) {
572
+ const headers = new Headers(customHeaders);
573
+ for (const [name, value] of Object.entries(gatewayHeaders)){
574
+ headers.set(name, value);
575
+ }
576
+ return Object.fromEntries(headers);
261
577
  }
262
578
  /**
263
- * Builds the headers for the upstream request.
264
- */ function buildRequestHeaders(auth, isStreaming, modelId, customHeaders) {
265
- return _extends._({
266
- "content-type": "application/json"
267
- }, customHeaders, {
579
+ * Builds the headers for the upstream request: the caller's `headers`, then the model call and
580
+ * request metadata headers from the incoming request, then the proxy's own.
581
+ */ function buildRequestHeaders(auth, request, customHeaders) {
582
+ const headers = new Headers(customHeaders);
583
+ for (const name of [
584
+ ...headers.keys()
585
+ ]){
586
+ if (MODEL_CALL_HEADER.test(name)) {
587
+ headers.delete(name);
588
+ }
589
+ }
590
+ for (const [name, value] of request.headers){
591
+ if (MODEL_CALL_HEADER.test(name) || REQUEST_METADATA_HEADER.test(name)) {
592
+ headers.set(name, value);
593
+ }
594
+ }
595
+ return withGatewayHeaders(headers, {
596
+ "content-type": "application/json",
268
597
  authorization: `Bearer ${auth.token}`,
269
598
  "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"
599
+ "ai-gateway-protocol-version": PROTOCOL_VERSION
273
600
  });
274
601
  }
275
602
  /**
276
- * Parses the error response from the gateway.
277
- */ function parseErrorBody(res) {
278
- return _async_to_generator._(function*() {
279
- try {
280
- return yield res.json();
281
- } catch (unused) {
282
- return {
283
- error: {
284
- message: res.statusText || "Unknown error",
285
- type: "gateway_error"
286
- }
287
- };
603
+ * Builds a response for an error the proxy raises itself, in the same shape as upstream errors.
604
+ */ function proxyErrorResponse(status, type, message) {
605
+ const body = {
606
+ error: {
607
+ message,
608
+ type
609
+ }
610
+ };
611
+ return new Response(JSON.stringify(body), {
612
+ status,
613
+ headers: {
614
+ "content-type": "application/json"
288
615
  }
289
- })();
616
+ });
617
+ }
618
+ function isRedirect(res) {
619
+ return res.type === "opaqueredirect" || res.status >= 300 && res.status < 400;
620
+ }
621
+ function redirectRefusedResponse(res) {
622
+ res.body?.cancel().catch(()=>{});
623
+ return proxyErrorResponse(502, "proxy_error", "Upstream redirected, the proxy does not follow redirects");
624
+ }
625
+ function proxyFailureResponse(request, error) {
626
+ if (request.signal.aborted) {
627
+ return new Response(null, {
628
+ status: 499
629
+ });
630
+ }
631
+ console.error("AI Gateway proxy request failed:", error);
632
+ return proxyErrorResponse(500, "proxy_error", "Error proxying request to AI Gateway");
290
633
  }
291
634
  /**
292
- * Creates an error response, optionally applying the onError hook.
293
- */ function createErrorResponse(res, errorBody, requestBody, onError) {
294
- return _async_to_generator._(function*() {
295
- if (onError) {
296
- const result = yield onError({
297
- request: requestBody,
298
- error: errorBody,
299
- status: res.status
300
- });
301
- if (result instanceof Response) {
302
- return result;
303
- }
304
- return new Response(JSON.stringify(result), {
305
- status: res.status,
306
- statusText: res.statusText,
307
- headers: {
308
- "content-type": "application/json"
309
- }
310
- });
635
+ * Forwards a GET or HEAD request, such as the gateway's `/config` model list, or a request whose
636
+ * body is missing or empty, without a body.
637
+ * The hooks are typed for language model calls, so none of them run here.
638
+ */ async function forwardBodylessRequest(request, upstream, auth, customHeaders) {
639
+ try {
640
+ const res = await fetch(upstream, {
641
+ method: request.method,
642
+ signal: request.signal,
643
+ headers: withGatewayHeaders(customHeaders, {
644
+ authorization: `Bearer ${auth.token}`,
645
+ "ai-gateway-auth-method": auth.authMethod,
646
+ "ai-gateway-protocol-version": PROTOCOL_VERSION
647
+ }),
648
+ redirect: "manual"
649
+ });
650
+ if (isRedirect(res)) {
651
+ return redirectRefusedResponse(res);
311
652
  }
312
- return new Response(JSON.stringify(errorBody), {
313
- status: res.status,
314
- statusText: res.statusText,
315
- headers: {
316
- "content-type": "application/json"
317
- }
653
+ return passThroughResponse(res);
654
+ } catch (error) {
655
+ return proxyFailureResponse(request, error);
656
+ }
657
+ }
658
+ function isGatewayError(value) {
659
+ if (typeof value !== "object" || value === null) {
660
+ return false;
661
+ }
662
+ const { error } = value;
663
+ return typeof error === "object" && error !== null && typeof error.message === "string";
664
+ }
665
+ /**
666
+ * Parses the error response from the gateway. A body that is not a JSON `{ error: { message } }`,
667
+ * such as an HTML page from an edge or load balancer, becomes a `gateway_error` naming the status
668
+ * and a short excerpt, so `onError` always receives the `GatewayError` shape.
669
+ */ async function parseErrorBody(res) {
670
+ const text = await res.text().catch(()=>"");
671
+ try {
672
+ const parsed = JSON.parse(text);
673
+ if (isGatewayError(parsed)) {
674
+ return parsed;
675
+ }
676
+ } catch {}
677
+ const excerpt = text.replace(/\s+/g, " ").trim().slice(0, MAX_ERROR_EXCERPT_LENGTH);
678
+ const message = excerpt ? `AI Gateway returned ${res.status}: ${excerpt}` : `AI Gateway returned ${res.status} ${res.statusText}`.trimEnd();
679
+ return {
680
+ error: {
681
+ message,
682
+ type: "gateway_error"
683
+ }
684
+ };
685
+ }
686
+ /**
687
+ * Builds response headers with the given content type, if any, plus the allowlisted upstream headers.
688
+ */ function responseHeaders(res, contentType) {
689
+ const headers = new Headers(contentType ? {
690
+ "content-type": contentType
691
+ } : undefined);
692
+ for (const [name, value] of res.headers){
693
+ if (FORWARDED_RESPONSE_HEADER.test(name)) {
694
+ headers.set(name, value);
695
+ }
696
+ }
697
+ return headers;
698
+ }
699
+ /**
700
+ * Sends the upstream body unread, with its `content-type` and the allowlisted upstream headers.
701
+ */ function passThroughResponse(res) {
702
+ return new Response(res.body, {
703
+ status: res.status,
704
+ statusText: res.statusText,
705
+ headers: responseHeaders(res, res.headers.get("content-type") ?? "application/json")
706
+ });
707
+ }
708
+ /**
709
+ * Runs onError and returns the body or Response to send, falling back to the upstream error body.
710
+ */ async function runOnError(onError, res, upstreamError, requestBody) {
711
+ let result;
712
+ try {
713
+ result = await onError({
714
+ request: requestBody,
715
+ error: upstreamError,
716
+ status: res.status
318
717
  });
319
- })();
718
+ } catch (error) {
719
+ // A 500 would hide the upstream status and its retry-after, and make a 400 look retryable.
720
+ console.error("onError hook failed, forwarding the upstream error body:", error);
721
+ return upstreamError;
722
+ }
723
+ if (result instanceof Response) {
724
+ return result;
725
+ }
726
+ // A JavaScript caller can return nothing, such as a hook that only logs.
727
+ if (result === undefined || result === null) {
728
+ console.error("onError hook returned no response, forwarding the upstream error body");
729
+ return upstreamError;
730
+ }
731
+ const problem = gatewayErrorProblem(result);
732
+ if (problem) {
733
+ console.error(`onError hook returned an invalid error, forwarding the upstream error body: ${problem}`);
734
+ return upstreamError;
735
+ }
736
+ return result;
737
+ }
738
+ /**
739
+ * Creates an error response, optionally applying the onError hook.
740
+ */ async function createErrorResponse(res, upstreamError, requestBody, onError) {
741
+ const errorBody = onError ? await runOnError(onError, res, upstreamError, requestBody) : upstreamError;
742
+ if (errorBody instanceof Response) {
743
+ return errorBody;
744
+ }
745
+ return new Response(JSON.stringify(errorBody), {
746
+ status: res.status,
747
+ statusText: res.statusText,
748
+ headers: responseHeaders(res, "application/json")
749
+ });
320
750
  }
321
751
  /**
322
752
  * Handles a streaming response from the gateway.
323
- */ function handleStreamingResponse(res, requestBody, afterResponse) {
753
+ */ function handleStreamingResponse(res, requestBody, afterResponse, signal) {
324
754
  if (!res.body) {
325
- return new Response("No response body", {
326
- status: 500
327
- });
755
+ return proxyErrorResponse(500, "proxy_error", "No response body");
328
756
  }
329
757
  const contentType = res.headers.get("content-type") || "text/event-stream";
330
758
  // Pass through directly if no afterResponse hook
@@ -332,100 +760,235 @@ const PROTOCOL_VERSION = "0.0.1";
332
760
  return new Response(res.body, {
333
761
  status: res.status,
334
762
  statusText: res.statusText,
335
- headers: {
336
- "content-type": contentType
337
- }
763
+ headers: responseHeaders(res, contentType)
338
764
  });
339
765
  }
340
766
  // Transform stream to call afterResponse on finish
341
- const transformedStream = createStreamTransformer(res.body, requestBody, afterResponse);
767
+ const transformedStream = createStreamTransformer(res.body, requestBody, afterResponse, signal);
342
768
  return new Response(transformedStream, {
343
769
  status: res.status,
344
770
  statusText: res.statusText,
345
- headers: {
346
- "content-type": contentType
347
- }
771
+ headers: responseHeaders(res, contentType)
348
772
  });
349
773
  }
350
774
  /**
351
- * Handles a non-streaming JSON response from the gateway.
352
- */ function handleJsonResponse(res, requestBody, afterResponse) {
353
- return _async_to_generator._(function*() {
354
- let responseBody = yield res.json();
355
- if (afterResponse) {
356
- responseBody = yield afterResponse({
357
- request: requestBody,
358
- response: responseBody
359
- });
775
+ * Runs afterResponse on a non-streaming JSON response from the gateway.
776
+ */ async function handleJsonResponse(res, requestBody, afterResponse) {
777
+ const parsed = await res.json();
778
+ if (!isRecord(parsed)) {
779
+ throw new TypeError("AI Gateway returned a successful body that is not a JSON object");
780
+ }
781
+ let responseBody = parsed;
782
+ // Read before the hook, which may delete it from the object it receives.
783
+ const upstreamUsage = responseBody.usage;
784
+ const modified = await afterResponse({
785
+ request: requestBody,
786
+ response: responseBody
787
+ });
788
+ // A JavaScript caller can return nothing.
789
+ if (modified === undefined || modified === null) {
790
+ console.error("afterResponse hook returned no response, forwarding the upstream body");
791
+ } else {
792
+ const problem = gatewayResponseProblem(modified);
793
+ if (problem) {
794
+ const message = `afterResponse hook returned an invalid response: ${problem}`;
795
+ console.error(message);
796
+ return proxyErrorResponse(500, "proxy_error", message);
360
797
  }
361
- return new Response(JSON.stringify(responseBody), {
798
+ // As on a stream's finish event: the AI SDK reads usage from every generate result, so it cannot be stripped.
799
+ responseBody = {
800
+ ...modified,
801
+ usage: modified.usage ?? upstreamUsage
802
+ };
803
+ }
804
+ return new Response(JSON.stringify(responseBody), {
805
+ status: res.status,
806
+ statusText: res.statusText,
807
+ headers: responseHeaders(res, "application/json")
808
+ });
809
+ }
810
+ /**
811
+ * Wraps the hooks so each call also receives the model id, which the stream transformer does not know.
812
+ */ function withModelId({ afterResponse, onError }, modelId) {
813
+ return {
814
+ afterResponse: afterResponse && ((context)=>afterResponse({
815
+ ...context,
816
+ modelId
817
+ })),
818
+ onError: onError && ((context)=>onError({
819
+ ...context,
820
+ modelId
821
+ }))
822
+ };
823
+ }
824
+ /**
825
+ * Builds the client response for a non-redirect upstream response, running the language model hooks given.
826
+ */ async function forwardUpstreamResponse(res, requestBody, isStreaming, { afterResponse, onError }, signal) {
827
+ if (!res.ok) {
828
+ const errorBody = await parseErrorBody(res);
829
+ return await createErrorResponse(res, errorBody, requestBody, onError);
830
+ }
831
+ // A 204 or 205 has no body to parse or hand to afterResponse, and a Response with it cannot carry one.
832
+ if (NULL_BODY_STATUS.has(res.status)) {
833
+ return new Response(null, {
362
834
  status: res.status,
363
835
  statusText: res.statusText,
364
- headers: {
365
- "content-type": "application/json"
366
- }
836
+ headers: responseHeaders(res)
367
837
  });
368
- })();
838
+ }
839
+ if (isStreaming) {
840
+ return handleStreamingResponse(res, requestBody, afterResponse, signal);
841
+ }
842
+ // Only afterResponse reads a success body; without it, or for an event stream or JSON Lines, it streams through.
843
+ if (!afterResponse || STREAM_CONTENT_TYPE.test(res.headers.get("content-type") ?? "")) {
844
+ return passThroughResponse(res);
845
+ }
846
+ return await handleJsonResponse(res, requestBody, afterResponse);
847
+ }
848
+ /**
849
+ * Cancels a body nothing will read, since an unread fetch body holds its upstream connection.
850
+ */ function discardUnreadBody(response) {
851
+ if (response.body && !response.bodyUsed && !response.body.locked) {
852
+ response.body.cancel().catch(()=>{});
853
+ }
854
+ }
855
+ /**
856
+ * Runs afterRawResponse and returns the response to send.
857
+ */ async function applyAfterRawResponse(afterRawResponse, context) {
858
+ let result;
859
+ try {
860
+ result = await afterRawResponse(context);
861
+ } catch (error) {
862
+ discardUnreadBody(context.response);
863
+ throw error;
864
+ }
865
+ if (result instanceof Response) {
866
+ // A replacement built on response.body still needs it.
867
+ if (result.body !== context.response.body) {
868
+ discardUnreadBody(context.response);
869
+ }
870
+ return result;
871
+ }
872
+ if (result !== undefined && result !== null) {
873
+ discardUnreadBody(context.response);
874
+ throw new TypeError("afterRawResponse hook must return a Response or nothing");
875
+ }
876
+ if (context.response.bodyUsed) {
877
+ throw new TypeError("afterRawResponse hook read the response body and returned no Response; read response.clone() instead");
878
+ }
879
+ return context.response;
369
880
  }
370
881
  /**
371
882
  * Creates a proxy handler for the Vercel AI Gateway.
372
883
  *
373
- * Uses AI SDK V3 Language Model.
884
+ * Uses AI SDK V4 Language Model.
374
885
  * @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)=>_async_to_generator._(function*() {
377
- // Authenticate with the gateway
378
- const auth = yield getGatewayAuthToken();
379
- if (!auth) {
380
- return new Response("AI Gateway authentication not configured", {
381
- status: 500
382
- });
383
- }
886
+ */ const createGatewayProxy = ({ baseUrl = DEFAULT_BASE_URL, headers, segmentsParam = "segments", extractPath, beforeRequest, afterResponse, onError, beforeRawRequest, afterRawResponse } = {})=>{
887
+ const handler = async (request, context)=>{
888
+ // Authenticate with the gateway
889
+ const auth = await getGatewayAuthToken();
890
+ if (!auth) {
891
+ // Not authentication_error: @ai-sdk/gateway replaces that message with advice about the client's own key.
892
+ return proxyErrorResponse(500, "proxy_error", "AI Gateway authentication not configured: set AI_GATEWAY_API_KEY or deploy on Vercel with OIDC enabled");
893
+ }
894
+ try {
384
895
  // Build upstream URL
385
- const path = yield extractPathFromRequest(request, context, {
896
+ const path = await extractPathFromRequest(request, context, {
386
897
  extractPath,
387
898
  segmentsParam
388
899
  });
389
- const upstream = buildUpstreamUrl(baseUrl, path, request.url);
900
+ const resolved = buildUpstreamUrl(baseUrl, path, request.url);
901
+ if (!resolved) {
902
+ return proxyErrorResponse(400, "invalid_request_error", "Invalid path");
903
+ }
904
+ const upstream = resolved.url;
905
+ if (request.method === "GET" || request.method === "HEAD" || request.body === null) {
906
+ return await forwardBodylessRequest(request, upstream, auth, headers);
907
+ }
908
+ // Node adapters give every method but GET and HEAD a body stream, so an empty one is bodyless too
909
+ const text = await request.text();
910
+ if (text === "") {
911
+ return await forwardBodylessRequest(request, upstream, auth, headers);
912
+ }
390
913
  // Parse request body
391
914
  let requestBody;
392
915
  try {
393
- requestBody = yield request.clone().json();
394
- } catch (unused) {
395
- return new Response("Invalid JSON body", {
396
- status: 400
916
+ requestBody = JSON.parse(text);
917
+ } catch {
918
+ return proxyErrorResponse(400, "invalid_request_error", "Invalid JSON body");
919
+ }
920
+ // Fail closed: only an exact listed path, after "?" and "#" are resolved away, skips the hooks.
921
+ const kind = NON_LANGUAGE_MODEL_PATHS.get(resolved.relativePath);
922
+ const runsHooks = kind === undefined && !READ_PATHS.has(resolved.relativePath);
923
+ // The language model hooks are typed for call options, so they never receive another JSON value.
924
+ if (runsHooks && (beforeRequest || afterResponse || onError) && !isRecord(requestBody)) {
925
+ return proxyErrorResponse(400, "invalid_request_error", "Invalid request body: a language model call must be a JSON object");
926
+ }
927
+ const rawContext = kind && {
928
+ kind,
929
+ path: resolved.relativePath,
930
+ modelId: request.headers.get("ai-model-id") ?? undefined
931
+ };
932
+ // A hook can replace or mutate the parsed body, so only then is it serialised again.
933
+ let bodyHandedToHook = false;
934
+ // The body does not name the model; @ai-sdk/gateway sends it in this header.
935
+ const modelId = request.headers.get("ai-language-model-id") ?? undefined;
936
+ if (beforeRequest && runsHooks) {
937
+ bodyHandedToHook = true;
938
+ const replaced = await beforeRequest({
939
+ request: requestBody,
940
+ modelId
397
941
  });
942
+ // A JavaScript caller can return nothing, such as a hook that only logs.
943
+ if (replaced === undefined || replaced === null) {
944
+ console.error("beforeRequest hook returned no request, sending the original body");
945
+ } else if (!isRecord(replaced)) {
946
+ const message = "beforeRequest hook returned an invalid request: the result must be an object";
947
+ console.error(message);
948
+ return proxyErrorResponse(500, "proxy_error", message);
949
+ } else {
950
+ requestBody = replaced;
951
+ }
398
952
  }
399
- // Apply beforeRequest hook
400
- if (beforeRequest) {
401
- requestBody = yield beforeRequest({
953
+ if (beforeRawRequest && rawContext) {
954
+ bodyHandedToHook = true;
955
+ const replaced = await beforeRawRequest({
956
+ ...rawContext,
402
957
  request: requestBody
403
958
  });
959
+ // null, like undefined, keeps the body rather than sending the literal null upstream.
960
+ if (replaced !== undefined && replaced !== null) {
961
+ requestBody = replaced;
962
+ }
404
963
  }
405
964
  const isStreaming = request.headers.get("ai-language-model-streaming") === "true";
406
- try {
407
- const res = yield fetch(upstream, {
408
- method: request.method,
409
- body: JSON.stringify(requestBody),
410
- duplex: "half",
411
- headers: buildRequestHeaders(auth, isStreaming, request.headers.get("ai-language-model-id"), headers)
412
- });
413
- // Handle error responses
414
- if (!res.ok) {
415
- const errorBody = yield parseErrorBody(res);
416
- return createErrorResponse(res, errorBody, requestBody, onError);
417
- }
418
- // Handle successful responses
419
- if (isStreaming) {
420
- return handleStreamingResponse(res, requestBody, afterResponse);
421
- }
422
- return handleJsonResponse(res, requestBody, afterResponse);
423
- } catch (unused) {
424
- return new Response("Error proxying request to AI Gateway", {
425
- status: 500
965
+ const res = await fetch(upstream, {
966
+ method: request.method,
967
+ body: bodyHandedToHook ? JSON.stringify(requestBody) : text,
968
+ duplex: "half",
969
+ signal: request.signal,
970
+ headers: buildRequestHeaders(auth, request, headers),
971
+ redirect: "manual"
972
+ });
973
+ if (isRedirect(res)) {
974
+ return redirectRefusedResponse(res);
975
+ }
976
+ const response = await forwardUpstreamResponse(res, requestBody, isStreaming, runsHooks ? withModelId({
977
+ afterResponse,
978
+ onError
979
+ }, modelId) : {}, request.signal);
980
+ if (afterRawResponse && rawContext) {
981
+ return await applyAfterRawResponse(afterRawResponse, {
982
+ ...rawContext,
983
+ request: requestBody,
984
+ response
426
985
  });
427
986
  }
428
- })();
987
+ return response;
988
+ } catch (error) {
989
+ return proxyFailureResponse(request, error);
990
+ }
991
+ };
429
992
  // Export handler for all HTTP methods
430
993
  handler.GET = handler;
431
994
  handler.POST = handler;