ai-retry 0.0.1 → 0.0.3

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
@@ -1,3 +1,5 @@
1
+ <a href="https://www.npmjs.com/package/ai-retry" alt="ai-retry"><img src="https://img.shields.io/npm/dt/ai-retry?label=ai-retry"></a> <a href="https://github.com/zirkelc/ai-retry/actions/workflows/ci.yml" alt="CI"><img src="https://img.shields.io/github/actions/workflow/status/zirkelc/ai-retry/ci.yml?branch=main"></a>
2
+
1
3
  # ai-retry: Retry and fallback mechanisms for AI SDK
2
4
 
3
5
  Automatically handle API failures, content filtering and timeouts by switching between different AI models.
@@ -23,14 +25,14 @@ npm install ai-retry
23
25
 
24
26
  Create a retryable model by providing a base model and a list of retryables or fallback models.
25
27
 
26
- > [!WARNING]
27
- > `ai-retry` currently only supports `generateText` and `generateObject` calls.
28
- > Streaming via `streamText` and `streamObject` is not supported yet.
28
+ > [!NOTE]
29
+ > `ai-retry` currently supports `generateText`, `generateObject`, `streamText`, and `streamObject` calls.
30
+ > Note that streaming retry has limitations: retries are only possible before content starts flowing or very early in the stream.
29
31
 
30
32
  ```typescript
31
33
  import { azure } from '@ai-sdk/azure';
32
34
  import { openai } from '@ai-sdk/openai';
33
- import { generateText } from 'ai';
35
+ import { generateText, streamText } from 'ai';
34
36
  import { createRetryable } from 'ai-retry';
35
37
  import { contentFilterTriggered, requestTimeout } from 'ai-retry/retryables';
36
38
 
@@ -47,13 +49,25 @@ const result = await generateText({
47
49
  model: retryableModel,
48
50
  prompt: 'Hello world!',
49
51
  });
50
- ```
51
52
 
53
+ // Or with streaming
54
+ const result = streamText({
55
+ model: retryableModel,
56
+ prompt: 'Write a story about a robot...',
57
+ });
58
+
59
+ for await (const chunk of result.textStream) {
60
+ console.log(chunk.text);
61
+ }
62
+ ```
52
63
 
53
64
  #### Content Filter
54
65
 
55
66
  Automatically switch to a different model when content filtering blocks your request.
56
67
 
68
+ > [!WARNING]
69
+ > This retryable currently does not work with streaming requests, because the content filter is only indicated in the final response.
70
+
57
71
  ```typescript
58
72
  import { contentFilterTriggered } from 'ai-retry/retryables';
59
73
 
@@ -118,6 +132,21 @@ const result = await generateText({
118
132
  });
119
133
  ```
120
134
 
135
+ #### Service Overloaded
136
+
137
+ Handle service overload errors (HTTP code 529) by switching to a provider.
138
+
139
+ ```typescript
140
+ import { serviceOverloaded } from 'ai-retry/retryables';
141
+
142
+ const retryableModel = createRetryable({
143
+ model: azure('gpt-4'),
144
+ retries: [
145
+ serviceOverloaded(openai('gpt-4')), // Switch to OpenAI if Azure is overloaded
146
+ ],
147
+ });
148
+ ```
149
+
121
150
  #### Request Not Retryable
122
151
 
123
152
  Handle cases where the base model fails with a non-retryable error.
@@ -240,6 +269,7 @@ There are several built-in retryables:
240
269
  - [`contentFilterTriggered`](./src/retryables/content-filter-triggered.ts): Content filter was triggered based on the prompt or completion.
241
270
  - [`requestTimeout`](./src/retryables/request-timeout.ts): Request timeout occurred.
242
271
  - [`requestNotRetryable`](./src/retryables/request-not-retryable.ts): Request failed with a non-retryable error.
272
+ - [`serviceOverloaded`](./src/retryables/service-overloaded.ts): Response with status code 529 (service overloaded).
243
273
 
244
274
  By default, each retryable will only attempt to retry once per model to avoid infinite loops. You can customize this behavior by returning a `maxAttempts` value from your retryable function.
245
275
 
@@ -9,6 +9,12 @@ const getModelKey = (model) => {
9
9
  return `${model.provider}/${model.modelId}`;
10
10
  };
11
11
 
12
+ //#endregion
13
+ //#region src/utils.ts
14
+ const isObject = (value) => typeof value === "object" && value !== null;
15
+ const isString = (value) => typeof value === "string";
16
+ const isGenerateResult = (result) => "content" in result;
17
+
12
18
  //#endregion
13
19
  //#region src/create-retryable-model.ts
14
20
  /**
@@ -77,19 +83,22 @@ var RetryableModel = class {
77
83
  }
78
84
  }
79
85
  }
80
- async doGenerate(options) {
86
+ /**
87
+ * Execute a function with retry logic for handling errors
88
+ */
89
+ async executeWithRetry(fn, retryState) {
81
90
  /**
82
91
  * Always start with the original model
83
92
  */
84
- this.currentModel = this.baseModel;
93
+ this.currentModel = retryState?.currentModel ?? this.baseModel;
85
94
  /**
86
95
  * Track number of attempts
87
96
  */
88
- let totalAttempts = 0;
97
+ let totalAttempts = retryState?.totalAttempts ?? 0;
89
98
  /**
90
99
  * Track all attempts.
91
100
  */
92
- const attempts = [];
101
+ const attempts = retryState?.attempts ?? [];
93
102
  /**
94
103
  * The previous attempt that triggered a retry, or undefined if this is the first attempt
95
104
  */
@@ -118,43 +127,48 @@ var RetryableModel = class {
118
127
  }
119
128
  totalAttempts++;
120
129
  try {
121
- const result = await this.currentModel.doGenerate(options);
130
+ const result = await fn();
122
131
  /**
123
- * Check if the result should trigger a retry
132
+ * Check if the result should trigger a retry (only for generate results, not streams)
124
133
  */
125
- const resultAttempt = {
126
- type: "result",
127
- result,
128
- model: this.currentModel
129
- };
130
- /**
131
- * Add the current attempt to the list before checking for retries
132
- */
133
- attempts.push(resultAttempt);
134
- const resultContext = {
135
- current: resultAttempt,
136
- attempts,
137
- totalAttempts
138
- };
139
- const nextModel = await this.findNextModel(resultContext);
140
- if (nextModel) {
134
+ if (isGenerateResult(result)) {
141
135
  /**
142
- * Set the model for the next attempt
136
+ * Check if the result should trigger a retry
143
137
  */
144
- this.currentModel = nextModel;
138
+ const resultAttempt = {
139
+ type: "result",
140
+ result,
141
+ model: this.currentModel
142
+ };
145
143
  /**
146
- * Set the previous attempt that triggered this retry
144
+ * Add the current attempt to the list before checking for retries
147
145
  */
148
- previousAttempt = resultAttempt;
146
+ attempts.push(resultAttempt);
147
+ const resultContext = {
148
+ current: resultAttempt,
149
+ attempts,
150
+ totalAttempts
151
+ };
152
+ const nextModel = await this.findNextModel(resultContext);
153
+ if (nextModel) {
154
+ /**
155
+ * Set the model for the next attempt
156
+ */
157
+ this.currentModel = nextModel;
158
+ /**
159
+ * Set the previous attempt that triggered this retry
160
+ */
161
+ previousAttempt = resultAttempt;
162
+ /**
163
+ * Continue to the next iteration to retry
164
+ */
165
+ continue;
166
+ }
149
167
  /**
150
- * Continue to the next iteration to retry
168
+ * No retry needed, remove the attempt since it was successful
151
169
  */
152
- continue;
170
+ attempts.pop();
153
171
  }
154
- /**
155
- * No retry needed, remove the attempt since it was successful and return the result
156
- */
157
- attempts.pop();
158
172
  return result;
159
173
  } catch (error) {
160
174
  /**
@@ -187,15 +201,7 @@ var RetryableModel = class {
187
201
  * If we retried the request, wrap the error into a `RetryError` for better visibility.
188
202
  */
189
203
  if (!nextModel) {
190
- if (totalAttempts > 1) {
191
- const errorMessage = getErrorMessage(error);
192
- const errors = attempts.flatMap((a) => isErrorAttempt(a) ? a.error : `Result with finishReason: ${a.result.finishReason}`);
193
- throw new RetryError({
194
- message: `Failed after ${totalAttempts} attempts. Last error: ${errorMessage}`,
195
- reason: "maxRetriesExceeded",
196
- errors
197
- });
198
- }
204
+ if (totalAttempts > 1) throw this.prepareRetryError(error, attempts);
199
205
  throw error;
200
206
  }
201
207
  /**
@@ -209,8 +215,20 @@ var RetryableModel = class {
209
215
  }
210
216
  }
211
217
  }
218
+ async doGenerate(options) {
219
+ return this.executeWithRetry(async () => await this.currentModel.doGenerate(options));
220
+ }
212
221
  async doStream(options) {
213
- throw new Error("Streaming not implemented");
222
+ return this.executeWithRetry(async () => await this.currentModel.doStream(options));
223
+ }
224
+ prepareRetryError(error, attempts) {
225
+ const errorMessage = getErrorMessage(error);
226
+ const errors = attempts.flatMap((a) => isErrorAttempt(a) ? a.error : `Result with finishReason: ${a.result.finishReason}`);
227
+ return new RetryError(new RetryError({
228
+ message: `Failed after ${attempts.length} attempts. Last error: ${errorMessage}`,
229
+ reason: "maxRetriesExceeded",
230
+ errors
231
+ }));
214
232
  }
215
233
  };
216
234
  function createRetryable(config) {
@@ -218,4 +236,4 @@ function createRetryable(config) {
218
236
  }
219
237
 
220
238
  //#endregion
221
- export { createRetryable, getModelKey, isErrorAttempt, isResultAttempt };
239
+ export { createRetryable, getModelKey, isErrorAttempt, isObject, isResultAttempt, isString };
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- import { createRetryable, getModelKey, isErrorAttempt, isResultAttempt } from "./create-retryable-model-CnrFowSg.js";
1
+ import { createRetryable, getModelKey, isErrorAttempt, isResultAttempt } from "./create-retryable-model-C4nAHxnW.js";
2
2
 
3
3
  export { createRetryable, getModelKey, isErrorAttempt, isResultAttempt };
@@ -1,8 +1,31 @@
1
1
  import { RetryModel, Retryable } from "../create-retryable-model-DzDFqgQO.js";
2
2
  import { LanguageModelV2 } from "@ai-sdk/provider";
3
3
 
4
- //#region src/retryables/content-filter-triggered.d.ts
4
+ //#region src/retryables/anthropic-service-overloaded.d.ts
5
5
 
6
+ /**
7
+ * Type for Anthropic error responses.
8
+ *
9
+ * @see https://docs.claude.com/en/api/errors#error-shapes
10
+ */
11
+ type AnthropicErrorResponse = {
12
+ type: 'error';
13
+ error: {
14
+ type: string;
15
+ message: string;
16
+ };
17
+ };
18
+ /**
19
+ * Fallback if Anthropic returns an "overloaded" error with HTTP 200.
20
+ *
21
+ * ```
22
+ * HTTP 200 OK
23
+ * {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}
24
+ * ```
25
+ */
26
+ declare function anthropicServiceOverloaded(model: LanguageModelV2, options?: Omit<RetryModel, 'model'>): Retryable;
27
+ //#endregion
28
+ //#region src/retryables/content-filter-triggered.d.ts
6
29
  /**
7
30
  * Fallback to a different model if the content filter was triggered.
8
31
  */
@@ -21,7 +44,10 @@ declare function requestNotRetryable(model: LanguageModelV2, options?: Omit<Retr
21
44
  */
22
45
  declare function requestTimeout(model: LanguageModelV2, options?: Omit<RetryModel, 'model'>): Retryable;
23
46
  //#endregion
24
- //#region src/retryables/response-schema-mismatch.d.ts
25
- declare function responseSchemaMismatch(model: LanguageModelV2, options?: Omit<RetryModel, 'model'>): Retryable;
47
+ //#region src/retryables/service-overloaded.d.ts
48
+ /**
49
+ * Fallback to a different model if the provider returns a HTTP 529 error.
50
+ */
51
+ declare function serviceOverloaded(model: LanguageModelV2, options?: Omit<RetryModel, 'model'>): Retryable;
26
52
  //#endregion
27
- export { contentFilterTriggered, requestNotRetryable, requestTimeout, responseSchemaMismatch };
53
+ export { AnthropicErrorResponse, anthropicServiceOverloaded, contentFilterTriggered, requestNotRetryable, requestTimeout, serviceOverloaded };
@@ -1,10 +1,37 @@
1
- import { isErrorAttempt, isResultAttempt } from "../create-retryable-model-CnrFowSg.js";
1
+ import { isErrorAttempt, isObject, isResultAttempt, isString } from "../create-retryable-model-C4nAHxnW.js";
2
2
  import { isAbortError } from "@ai-sdk/provider-utils";
3
- import { APICallError, NoObjectGeneratedError, TypeValidationError } from "ai";
3
+ import { APICallError } from "ai";
4
4
 
5
- //#region src/utils.ts
6
- const isObject = (value) => typeof value === "object" && value !== null;
7
- const isString = (value) => typeof value === "string";
5
+ //#region src/retryables/anthropic-service-overloaded.ts
6
+ /**
7
+ * Fallback if Anthropic returns an "overloaded" error with HTTP 200.
8
+ *
9
+ * ```
10
+ * HTTP 200 OK
11
+ * {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}
12
+ * ```
13
+ */
14
+ function anthropicServiceOverloaded(model, options) {
15
+ return (context) => {
16
+ const { current } = context;
17
+ if (isErrorAttempt(current)) {
18
+ const { error } = current;
19
+ if (APICallError.isInstance(error) && error.statusCode === 529) return {
20
+ model,
21
+ maxAttempts: 1,
22
+ ...options
23
+ };
24
+ if (APICallError.isInstance(error) && error.statusCode === 200) try {
25
+ const responseBody = JSON.parse(error.responseBody ?? "");
26
+ if (responseBody.error && isObject(responseBody.error) && isString(responseBody.error.type) && responseBody.error.type === "overloaded_error") return {
27
+ model,
28
+ maxAttempts: 1,
29
+ ...options
30
+ };
31
+ } catch {}
32
+ }
33
+ };
34
+ }
8
35
 
9
36
  //#endregion
10
37
  //#region src/retryables/content-filter-triggered.ts
@@ -74,12 +101,16 @@ function requestTimeout(model, options) {
74
101
  }
75
102
 
76
103
  //#endregion
77
- //#region src/retryables/response-schema-mismatch.ts
78
- function responseSchemaMismatch(model, options) {
104
+ //#region src/retryables/service-overloaded.ts
105
+ /**
106
+ * Fallback to a different model if the provider returns a HTTP 529 error.
107
+ */
108
+ function serviceOverloaded(model, options) {
79
109
  return (context) => {
80
110
  const { current } = context;
81
111
  if (isErrorAttempt(current)) {
82
- if (NoObjectGeneratedError.isInstance(current.error) && current.error.finishReason === "stop" && TypeValidationError.isInstance(current.error.cause)) return {
112
+ const { error } = current;
113
+ if (APICallError.isInstance(error) && error.statusCode === 529) return {
83
114
  model,
84
115
  maxAttempts: 1,
85
116
  ...options
@@ -89,4 +120,4 @@ function responseSchemaMismatch(model, options) {
89
120
  }
90
121
 
91
122
  //#endregion
92
- export { contentFilterTriggered, requestNotRetryable, requestTimeout, responseSchemaMismatch };
123
+ export { anthropicServiceOverloaded, contentFilterTriggered, requestNotRetryable, requestTimeout, serviceOverloaded };
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "ai-retry",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "AI SDK Retry",
5
+ "packageManager": "pnpm@9.0.0",
5
6
  "main": "./dist/index.js",
6
7
  "module": "./dist/index.js",
7
8
  "types": "./dist/index.d.ts",
@@ -17,6 +18,14 @@
17
18
  "publishConfig": {
18
19
  "access": "public"
19
20
  },
21
+ "scripts": {
22
+ "prepublishOnly": "pnpm build",
23
+ "publish:alpha": "pnpm version prerelease --preid alpha && pnpm publish --tag alpha",
24
+ "build": "tsdown",
25
+ "test": "vitest",
26
+ "lint": "biome check . --write",
27
+ "prepare": "husky"
28
+ },
20
29
  "keywords": [
21
30
  "ai",
22
31
  "ai-sdk",
@@ -33,6 +42,7 @@
33
42
  "ai": "5.x"
34
43
  },
35
44
  "devDependencies": {
45
+ "@ai-sdk/anthropic": "^2.0.18",
36
46
  "@ai-sdk/azure": "^2.0.30",
37
47
  "@ai-sdk/openai": "^2.0.30",
38
48
  "@arethetypeswrong/cli": "^0.18.2",
@@ -54,11 +64,5 @@
54
64
  "dependencies": {
55
65
  "@ai-sdk/provider": "^2.0.0",
56
66
  "@ai-sdk/provider-utils": "^3.0.9"
57
- },
58
- "scripts": {
59
- "publish:alpha": "pnpm version prerelease --preid alpha && pnpm publish --tag alpha",
60
- "build": "tsdown",
61
- "test": "vitest",
62
- "lint": "biome check . --write"
63
67
  }
64
- }
68
+ }