ai-gateway-proxy 0.2.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/README.md CHANGED
@@ -171,7 +171,7 @@ The path, whether from `extractPath` or the route param, must stay relative: one
171
171
 
172
172
  ### `afterResponse`
173
173
 
174
- - **Non-streamed responses:** receives the gateway's JSON body as `response`, and the value it returns is sent to the client instead, with the gateway's `usage` when the result has none, as on a stream, since an AI SDK client reads usage from every result. `undefined` or `null` forwards the upstream body. Any other result is checked against `GatewayResponse` (see "Result validation" below), and one that fails is answered with a 500 naming the problem instead of being sent.
174
+ - **Non-streamed responses:** receives the gateway's JSON body as `response`, and the value it returns is sent to the client instead, with the gateway's `usage` when the result has none, as on a stream, since an AI SDK client reads usage from every result. `undefined` or `null` forwards the upstream body. Any other result is checked against `GatewayResponse` (see "Result validation" below), and one that fails is answered with a 424 naming the problem instead of being sent. A hook that throws, or a gateway body that is not a JSON object, is a 424 too. The gateway has already answered, and billed, the call by then, and an AI SDK client retries a 5xx but not a 424, so the call is not paid for again.
175
175
  - **Streamed responses:** every event is forwarded to the client as it arrives. When the `finish` event comes, the hook receives the aggregated response, and the `finishReason`, `usage` and `providerMetadata` it returns replace those in the `finish` event; content changes have no effect because the content has already been sent. The aggregated response also carries `response`, the `id`, `modelId` and `timestamp` (the ISO string sent over the wire) of the stream's `response-metadata` parts, the latest value of each winning, so a hook can log the provider's response id or the model that answered; it is absent when no part carried them. If the hook throws, or returns a result that fails the check below, `undefined` or `null`, the error is logged and the original `finish` event is forwarded. If the stream ends, fails or is cancelled before its `finish` event, the hook runs once with the content received so far, `incomplete` set (`{ reason: "ended" }`, `{ reason: "error", error }` or `{ reason: "cancelled", cancelReason }`), `response.usage` `undefined` and `response.finishReason` `error` when the read failed or the stream sent an `error` part, `other` otherwise. A client that leaves counts as `cancelled` whether the framework cancels the response body or aborts `request.signal`, and is not logged. Nothing can be rewritten then, so its return value is ignored and a throw is logged. `response.usage` is typed `LanguageModelV4Usage | undefined`, so a hook that meters usage must handle `undefined`.
176
176
  - **Event streams and JSON Lines on other calls:** a successful `text/event-stream` or JSON Lines (`application/x-ndjson`, `application/jsonl`) response to a request without `ai-language-model-streaming: true`, such as the video model's `doGenerate` or the batch API's `doGetBatchResults`, is forwarded as is and the hook does not run, since it is not a language model response.
177
177
  - **No content:** a `204` or `205` response is forwarded with no body and no `content-type`, keeping the allowlisted headers, and the hook does not run, since there is no response to pass it.
@@ -187,7 +187,7 @@ A result is rejected when it is not an object or when a field is missing or has
187
187
  - `warnings`: absent or an array; `@ai-sdk/gateway` reads a missing one as none
188
188
  - `providerMetadata`: absent or an object
189
189
 
190
- `null` is accepted wherever a field may be absent, since a body that went through JSON cannot hold `undefined`. Other fields are not checked and are forwarded as returned. A rejected result is logged with `console.error` as `afterResponse hook returned an invalid response: <problem>`, and on a non-streamed request the client gets the same message as a 500 `proxy_error`.
190
+ `null` is accepted wherever a field may be absent, since a body that went through JSON cannot hold `undefined`. Other fields are not checked and are forwarded as returned. A rejected result is logged with `console.error` as `afterResponse hook returned an invalid response: <problem>`, and on a non-streamed request the client gets the same message as a 424 `proxy_error`.
191
191
 
192
192
  ### `onError`
193
193
 
@@ -254,9 +254,10 @@ Apart from forwarding the gateway's own responses, the handler can answer with a
254
254
  | 400 | `invalid_request_error` | `Invalid JSON body` | A request other than `GET` or `HEAD` has a non-empty body that is not JSON. |
255
255
  | 400 | `invalid_request_error` | `Invalid request body: a language model call must be a JSON object` | The body is JSON but not an object, such as an array or `null`, on a path that runs `beforeRequest`, `afterResponse` or `onError`, and one of them is set. No hook runs and the gateway is not called. |
256
256
  | 499 | none | none | The client aborted the request (`request.signal`) before the handler returned. The signal is passed to the upstream `fetch`, so the abort also cancels the upstream request. |
257
- | 500 | `proxy_error` | `Error proxying request to AI Gateway` | Anything else that throws: `extractPath`, `beforeRequest`, `beforeRawRequest`, the upstream `fetch`, a successful non-streamed response for `afterResponse` that is not a JSON object, `afterResponse` on a non-streamed request or `afterRawResponse`, or an `afterRawResponse` that read the body and returned no `Response`. |
257
+ | 500 | `proxy_error` | `Error proxying request to AI Gateway` | Anything else that throws: `extractPath`, `beforeRequest`, `beforeRawRequest`, the upstream `fetch` or `afterRawResponse`, or an `afterRawResponse` that read the body and returned no `Response`. |
258
258
  | 500 | `proxy_error` | `beforeRequest hook returned an invalid request: the result must be an object` | `beforeRequest` returned a value other than an object, `undefined` or `null`. The gateway is not called. |
259
- | 500 | `proxy_error` | `afterResponse hook returned an invalid response: <problem>` | `afterResponse` on a non-streamed request returned a result that fails the result validation above. |
259
+ | 424 | `proxy_error` | `Error processing the AI Gateway response` | After the gateway answered a non-streamed call with `afterResponse` set: `afterResponse` threw, or the success body was not a JSON object. The status is one AI SDK clients do not retry, since the gateway has billed the call. |
260
+ | 424 | `proxy_error` | `afterResponse hook returned an invalid response: <problem>` | `afterResponse` on a non-streamed request returned a result that fails the result validation above. |
260
261
  | 500 | `proxy_error` | `No response body` | A successful streamed response arrived without a body. |
261
262
  | 502 | `proxy_error` | `Upstream redirected, the proxy does not follow redirects` | The gateway answered with a 3xx status. The proxy sets `redirect: "manual"`, runs no `onError` and forwards no `Location`. |
262
263
 
package/dist/index.cjs CHANGED
@@ -511,6 +511,8 @@ const NULL_BODY_STATUS = new Set([
511
511
  204,
512
512
  205
513
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;
514
516
  // Successful bodies that are a sequence of values rather than one JSON value: event streams and JSON Lines.
515
517
  const STREAM_CONTENT_TYPE = /^\s*(?:text\/event-stream|application\/(?:x-ndjson|jsonl|jsonlines|x-jsonlines))\s*(?:;|$)/i;
516
518
  /**
@@ -773,7 +775,20 @@ function isGatewayError(value) {
773
775
  }
774
776
  /**
775
777
  * Runs afterResponse on a non-streaming JSON response from the gateway.
776
- */ async function handleJsonResponse(res, requestBody, afterResponse) {
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) {
777
792
  const parsed = await res.json();
778
793
  if (!isRecord(parsed)) {
779
794
  throw new TypeError("AI Gateway returned a successful body that is not a JSON object");
@@ -793,7 +808,7 @@ function isGatewayError(value) {
793
808
  if (problem) {
794
809
  const message = `afterResponse hook returned an invalid response: ${problem}`;
795
810
  console.error(message);
796
- return proxyErrorResponse(500, "proxy_error", message);
811
+ return proxyErrorResponse(ANSWERED_CALL_FAILURE_STATUS, "proxy_error", message);
797
812
  }
798
813
  // As on a stream's finish event: the AI SDK reads usage from every generate result, so it cannot be stripped.
799
814
  responseBody = {
@@ -843,7 +858,7 @@ function isGatewayError(value) {
843
858
  if (!afterResponse || STREAM_CONTENT_TYPE.test(res.headers.get("content-type") ?? "")) {
844
859
  return passThroughResponse(res);
845
860
  }
846
- return await handleJsonResponse(res, requestBody, afterResponse);
861
+ return await handleJsonResponse(res, requestBody, afterResponse, signal);
847
862
  }
848
863
  /**
849
864
  * Cancels a body nothing will read, since an unread fetch body holds its upstream connection.
package/dist/index.mjs CHANGED
@@ -509,6 +509,8 @@ const NULL_BODY_STATUS = new Set([
509
509
  204,
510
510
  205
511
511
  ]);
512
+ // AI SDK clients retry 408, 409, 429 and any 5xx, which would pay for the answered call again.
513
+ const ANSWERED_CALL_FAILURE_STATUS = 424;
512
514
  // Successful bodies that are a sequence of values rather than one JSON value: event streams and JSON Lines.
513
515
  const STREAM_CONTENT_TYPE = /^\s*(?:text\/event-stream|application\/(?:x-ndjson|jsonl|jsonlines|x-jsonlines))\s*(?:;|$)/i;
514
516
  /**
@@ -771,7 +773,20 @@ function isGatewayError(value) {
771
773
  }
772
774
  /**
773
775
  * Runs afterResponse on a non-streaming JSON response from the gateway.
774
- */ async function handleJsonResponse(res, requestBody, afterResponse) {
776
+ * The gateway has answered, and billed, the call, so a failure here gets a status clients do not retry.
777
+ */ async function handleJsonResponse(res, requestBody, afterResponse, signal) {
778
+ try {
779
+ return await runAfterResponse(res, requestBody, afterResponse);
780
+ } catch (error) {
781
+ // The handler answers a client abort with 499.
782
+ if (signal.aborted) {
783
+ throw error;
784
+ }
785
+ console.error("AI Gateway proxy failed after the gateway answered:", error);
786
+ return proxyErrorResponse(ANSWERED_CALL_FAILURE_STATUS, "proxy_error", "Error processing the AI Gateway response");
787
+ }
788
+ }
789
+ async function runAfterResponse(res, requestBody, afterResponse) {
775
790
  const parsed = await res.json();
776
791
  if (!isRecord(parsed)) {
777
792
  throw new TypeError("AI Gateway returned a successful body that is not a JSON object");
@@ -791,7 +806,7 @@ function isGatewayError(value) {
791
806
  if (problem) {
792
807
  const message = `afterResponse hook returned an invalid response: ${problem}`;
793
808
  console.error(message);
794
- return proxyErrorResponse(500, "proxy_error", message);
809
+ return proxyErrorResponse(ANSWERED_CALL_FAILURE_STATUS, "proxy_error", message);
795
810
  }
796
811
  // As on a stream's finish event: the AI SDK reads usage from every generate result, so it cannot be stripped.
797
812
  responseBody = {
@@ -841,7 +856,7 @@ function isGatewayError(value) {
841
856
  if (!afterResponse || STREAM_CONTENT_TYPE.test(res.headers.get("content-type") ?? "")) {
842
857
  return passThroughResponse(res);
843
858
  }
844
- return await handleJsonResponse(res, requestBody, afterResponse);
859
+ return await handleJsonResponse(res, requestBody, afterResponse, signal);
845
860
  }
846
861
  /**
847
862
  * Cancels a body nothing will read, since an unread fetch body holds its upstream connection.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-gateway-proxy",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "license": "MIT",