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
|
-
> [!
|
|
27
|
-
> `ai-retry` currently
|
|
28
|
-
>
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
136
|
+
* Check if the result should trigger a retry
|
|
143
137
|
*/
|
|
144
|
-
|
|
138
|
+
const resultAttempt = {
|
|
139
|
+
type: "result",
|
|
140
|
+
result,
|
|
141
|
+
model: this.currentModel
|
|
142
|
+
};
|
|
145
143
|
/**
|
|
146
|
-
*
|
|
144
|
+
* Add the current attempt to the list before checking for retries
|
|
147
145
|
*/
|
|
148
|
-
|
|
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
|
-
*
|
|
168
|
+
* No retry needed, remove the attempt since it was successful
|
|
151
169
|
*/
|
|
152
|
-
|
|
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
|
-
|
|
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-
|
|
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/
|
|
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/
|
|
25
|
-
|
|
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,
|
|
53
|
+
export { AnthropicErrorResponse, anthropicServiceOverloaded, contentFilterTriggered, requestNotRetryable, requestTimeout, serviceOverloaded };
|
package/dist/retryables/index.js
CHANGED
|
@@ -1,10 +1,37 @@
|
|
|
1
|
-
import { isErrorAttempt, isResultAttempt } from "../create-retryable-model-
|
|
1
|
+
import { isErrorAttempt, isObject, isResultAttempt, isString } from "../create-retryable-model-C4nAHxnW.js";
|
|
2
2
|
import { isAbortError } from "@ai-sdk/provider-utils";
|
|
3
|
-
import { APICallError
|
|
3
|
+
import { APICallError } from "ai";
|
|
4
4
|
|
|
5
|
-
//#region src/
|
|
6
|
-
|
|
7
|
-
|
|
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/
|
|
78
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
+
}
|