ai-retry 0.0.2 → 0.1.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 +31 -12
- package/dist/create-retryable-model-C3mm1hSN.js +308 -0
- package/dist/{create-retryable-model-DzDFqgQO.d.ts → create-retryable-model-DtspEawi.d.ts} +15 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/retryables/index.d.ts +9 -6
- package/dist/retryables/index.js +22 -32
- package/package.json +11 -8
- package/dist/create-retryable-model-CnrFowSg.js +0 -221
package/README.md
CHANGED
|
@@ -24,15 +24,12 @@ npm install ai-retry
|
|
|
24
24
|
### Usage
|
|
25
25
|
|
|
26
26
|
Create a retryable model by providing a base model and a list of retryables or fallback models.
|
|
27
|
-
|
|
28
|
-
> [!WARNING]
|
|
29
|
-
> `ai-retry` currently only supports `generateText` and `generateObject` calls.
|
|
30
|
-
> Streaming via `streamText` and `streamObject` is not supported yet.
|
|
27
|
+
When an error occurs, it will evaluate each retryable in order and use the first one that indicates a retry should be attempted with a different model.
|
|
31
28
|
|
|
32
29
|
```typescript
|
|
33
30
|
import { azure } from '@ai-sdk/azure';
|
|
34
31
|
import { openai } from '@ai-sdk/openai';
|
|
35
|
-
import { generateText } from 'ai';
|
|
32
|
+
import { generateText, streamText } from 'ai';
|
|
36
33
|
import { createRetryable } from 'ai-retry';
|
|
37
34
|
import { contentFilterTriggered, requestTimeout } from 'ai-retry/retryables';
|
|
38
35
|
|
|
@@ -49,13 +46,25 @@ const result = await generateText({
|
|
|
49
46
|
model: retryableModel,
|
|
50
47
|
prompt: 'Hello world!',
|
|
51
48
|
});
|
|
52
|
-
```
|
|
53
49
|
|
|
50
|
+
// Or with streaming
|
|
51
|
+
const result = streamText({
|
|
52
|
+
model: retryableModel,
|
|
53
|
+
prompt: 'Write a story about a robot...',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
for await (const chunk of result.textStream) {
|
|
57
|
+
console.log(chunk.text);
|
|
58
|
+
}
|
|
59
|
+
```
|
|
54
60
|
|
|
55
61
|
#### Content Filter
|
|
56
62
|
|
|
57
63
|
Automatically switch to a different model when content filtering blocks your request.
|
|
58
64
|
|
|
65
|
+
> [!WARNING]
|
|
66
|
+
> This retryable currently does not work with streaming requests, because the content filter is only indicated in the final response.
|
|
67
|
+
|
|
59
68
|
```typescript
|
|
60
69
|
import { contentFilterTriggered } from 'ai-retry/retryables';
|
|
61
70
|
|
|
@@ -122,10 +131,10 @@ const result = await generateText({
|
|
|
122
131
|
|
|
123
132
|
#### Service Overloaded
|
|
124
133
|
|
|
125
|
-
Handle service overload errors (
|
|
134
|
+
Handle service overload errors (status code 529) by switching to a provider.
|
|
126
135
|
|
|
127
136
|
> [!NOTE]
|
|
128
|
-
>
|
|
137
|
+
> You can use this retryable to handle Anthropic's overloaded errors.
|
|
129
138
|
|
|
130
139
|
```typescript
|
|
131
140
|
import { serviceOverloaded } from 'ai-retry/retryables';
|
|
@@ -244,14 +253,25 @@ const retryableModel = createRetryable({
|
|
|
244
253
|
model: openai('gpt-4-mini'),
|
|
245
254
|
retries: [/* your retryables */],
|
|
246
255
|
onError: (context) => {
|
|
247
|
-
console.
|
|
256
|
+
console.error(`Attempt ${context.attempts.length} with ${context.current.model.provider}/${context.current.model.modelId} failed:`,
|
|
257
|
+
context.current.error
|
|
258
|
+
);
|
|
248
259
|
},
|
|
249
260
|
onRetry: (context) => {
|
|
250
|
-
console.log(`Retrying with model ${context.current.model.provider}/${context.current.model.modelId}...`);
|
|
261
|
+
console.log(`Retrying attempt ${context.attempts.length + 1} with model ${context.current.model.provider}/${context.current.model.modelId}...`);
|
|
251
262
|
},
|
|
252
263
|
});
|
|
253
264
|
```
|
|
254
265
|
|
|
266
|
+
### Streaming
|
|
267
|
+
|
|
268
|
+
Errors during streaming requests can occur in two ways:
|
|
269
|
+
|
|
270
|
+
1. When the stream is initially created (e.g. network error, API error, etc.) by calling `streamText`.
|
|
271
|
+
2. While the stream is being processed (e.g. timeout, API error, etc.) by reading from the returned `result.textStream` async iterable.
|
|
272
|
+
|
|
273
|
+
In the second case, errors during stream processing will not always be retried, because the stream might have already emitted some actual content and the consumer might have processed it. Retrying will be stopped as soon as the first content chunk (e.g. types of `text-delta`, `tool-call`, etc.) is emitted. The type of chunks considered as content are the same as the ones that are passed to [onChunk()](https://github.com/vercel/ai/blob/1fe4bd4144bff927f5319d9d206e782a73979ccb/packages/ai/src/generate-text/stream-text.ts#L684-L697).
|
|
274
|
+
|
|
255
275
|
### Retryables
|
|
256
276
|
|
|
257
277
|
A retryable is a function that receives the current attempt and determines whether to retry with a different model based on the error/result and any previous attempts.
|
|
@@ -261,7 +281,7 @@ There are several built-in retryables:
|
|
|
261
281
|
- [`requestTimeout`](./src/retryables/request-timeout.ts): Request timeout occurred.
|
|
262
282
|
- [`requestNotRetryable`](./src/retryables/request-not-retryable.ts): Request failed with a non-retryable error.
|
|
263
283
|
- [`serviceOverloaded`](./src/retryables/service-overloaded.ts): Response with status code 529 (service overloaded).
|
|
264
|
-
-
|
|
284
|
+
- Use this retryable to handle Anthropic's overloaded errors.
|
|
265
285
|
|
|
266
286
|
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.
|
|
267
287
|
|
|
@@ -309,7 +329,6 @@ The `RetryContext` object contains information about the current attempt and all
|
|
|
309
329
|
interface RetryContext {
|
|
310
330
|
current: RetryAttempt;
|
|
311
331
|
attempts: Array<RetryAttempt>;
|
|
312
|
-
totalAttempts: number;
|
|
313
332
|
}
|
|
314
333
|
```
|
|
315
334
|
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { getErrorMessage } from "@ai-sdk/provider-utils";
|
|
2
|
+
import { RetryError } from "ai";
|
|
3
|
+
|
|
4
|
+
//#region src/get-model-key.ts
|
|
5
|
+
/**
|
|
6
|
+
* Generate a unique key for a LanguageModelV2 instance.
|
|
7
|
+
*/
|
|
8
|
+
const getModelKey = (model) => {
|
|
9
|
+
return `${model.provider}/${model.modelId}`;
|
|
10
|
+
};
|
|
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
|
+
/**
|
|
18
|
+
* Check if a stream part is a content part (e.g., text delta, reasoning delta, source, tool call, tool result).
|
|
19
|
+
* These types are also emitted by `onChunk` callbacks.
|
|
20
|
+
* @see https://github.com/vercel/ai/blob/1fe4bd4144bff927f5319d9d206e782a73979ccb/packages/ai/src/generate-text/stream-text.ts#L686-L697
|
|
21
|
+
*/
|
|
22
|
+
const isStreamContentPart = (part) => {
|
|
23
|
+
return part.type === "text-delta" || part.type === "reasoning-delta" || part.type === "source" || part.type === "tool-call" || part.type === "tool-result" || part.type === "tool-input-start" || part.type === "tool-input-delta" || part.type === "raw";
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
//#endregion
|
|
27
|
+
//#region src/create-retryable-model.ts
|
|
28
|
+
/**
|
|
29
|
+
* Type guard to check if a retry attempt is an error attempt
|
|
30
|
+
*/
|
|
31
|
+
function isErrorAttempt(attempt) {
|
|
32
|
+
return attempt.type === "error";
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Type guard to check if a retry attempt is a result attempt
|
|
36
|
+
*/
|
|
37
|
+
function isResultAttempt(attempt) {
|
|
38
|
+
return attempt.type === "result";
|
|
39
|
+
}
|
|
40
|
+
var RetryableModel = class {
|
|
41
|
+
specificationVersion = "v2";
|
|
42
|
+
baseModel;
|
|
43
|
+
currentModel;
|
|
44
|
+
options;
|
|
45
|
+
get modelId() {
|
|
46
|
+
return this.currentModel.modelId;
|
|
47
|
+
}
|
|
48
|
+
get provider() {
|
|
49
|
+
return this.currentModel.provider;
|
|
50
|
+
}
|
|
51
|
+
get supportedUrls() {
|
|
52
|
+
return this.currentModel.supportedUrls;
|
|
53
|
+
}
|
|
54
|
+
constructor(options) {
|
|
55
|
+
this.options = options;
|
|
56
|
+
this.baseModel = options.model;
|
|
57
|
+
this.currentModel = options.model;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Find the next model to retry with based on the retry context
|
|
61
|
+
*/
|
|
62
|
+
async findNextModel(context) {
|
|
63
|
+
/**
|
|
64
|
+
* Filter retryables based on attempt type:
|
|
65
|
+
* - Result-based attempts: Only consider function retryables (skip plain models)
|
|
66
|
+
* - Error-based attempts: Consider all retryables (functions + plain models)
|
|
67
|
+
*/
|
|
68
|
+
const applicableRetries = isResultAttempt(context.current) ? this.options.retries.filter((retry) => typeof retry === "function") : this.options.retries;
|
|
69
|
+
/**
|
|
70
|
+
* Iterate through the applicable retryables to find a model to retry with
|
|
71
|
+
*/
|
|
72
|
+
for (const retry of applicableRetries) {
|
|
73
|
+
const retryModel = typeof retry === "function" ? await retry(context) : {
|
|
74
|
+
model: retry,
|
|
75
|
+
maxAttempts: 1
|
|
76
|
+
};
|
|
77
|
+
if (retryModel) {
|
|
78
|
+
/**
|
|
79
|
+
* The model key uniquely identifies a model instance (provider + modelId)
|
|
80
|
+
*/
|
|
81
|
+
const retryModelKey = getModelKey(retryModel.model);
|
|
82
|
+
/**
|
|
83
|
+
* Find all attempts with the same model
|
|
84
|
+
*/
|
|
85
|
+
const retryAttempts = context.attempts.filter((a) => getModelKey(a.model) === retryModelKey);
|
|
86
|
+
const maxAttempts = retryModel.maxAttempts ?? 1;
|
|
87
|
+
/**
|
|
88
|
+
* Check if the model can still be retried based on maxAttempts
|
|
89
|
+
*/
|
|
90
|
+
if (retryAttempts.length < maxAttempts) return retryModel.model;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Execute a function with retry logic for handling errors
|
|
96
|
+
*/
|
|
97
|
+
async withRetry(input) {
|
|
98
|
+
/**
|
|
99
|
+
* Track all attempts.
|
|
100
|
+
*/
|
|
101
|
+
const attempts = input.attempts ?? [];
|
|
102
|
+
while (true) {
|
|
103
|
+
/**
|
|
104
|
+
* The previous attempt that triggered a retry, or undefined if this is the first attempt
|
|
105
|
+
*/
|
|
106
|
+
const previousAttempt = attempts.at(-1);
|
|
107
|
+
/**
|
|
108
|
+
* Call the onRetry handler if provided.
|
|
109
|
+
* Skip on the first attempt since no previous attempt exists yet.
|
|
110
|
+
*/
|
|
111
|
+
if (previousAttempt) {
|
|
112
|
+
const currentAttempt = {
|
|
113
|
+
...previousAttempt,
|
|
114
|
+
model: this.currentModel
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* Create a shallow copy of the attempts for testing purposes
|
|
118
|
+
*/
|
|
119
|
+
const updatedAttempts = [...attempts];
|
|
120
|
+
const context = {
|
|
121
|
+
current: currentAttempt,
|
|
122
|
+
attempts: updatedAttempts,
|
|
123
|
+
totalAttempts: updatedAttempts.length
|
|
124
|
+
};
|
|
125
|
+
this.options.onRetry?.(context);
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
/**
|
|
129
|
+
* Call the function that may need to be retried
|
|
130
|
+
*/
|
|
131
|
+
const result = await input.fn();
|
|
132
|
+
/**
|
|
133
|
+
* Check if the result should trigger a retry (only for generate results, not streams)
|
|
134
|
+
*/
|
|
135
|
+
if (isGenerateResult(result)) {
|
|
136
|
+
const { nextModel, attempt } = await this.handleResult(result, attempts);
|
|
137
|
+
attempts.push(attempt);
|
|
138
|
+
if (nextModel) {
|
|
139
|
+
this.currentModel = nextModel;
|
|
140
|
+
/**
|
|
141
|
+
* Continue to the next iteration to retry
|
|
142
|
+
*/
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
result,
|
|
148
|
+
attempts
|
|
149
|
+
};
|
|
150
|
+
} catch (error) {
|
|
151
|
+
const { nextModel, attempt } = await this.handleError(error, attempts);
|
|
152
|
+
attempts.push(attempt);
|
|
153
|
+
this.currentModel = nextModel;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Handle a successful result and determine if a retry is needed
|
|
159
|
+
*/
|
|
160
|
+
async handleResult(result, attempts) {
|
|
161
|
+
const resultAttempt = {
|
|
162
|
+
type: "result",
|
|
163
|
+
result,
|
|
164
|
+
model: this.currentModel
|
|
165
|
+
};
|
|
166
|
+
/**
|
|
167
|
+
* Save the current attempt
|
|
168
|
+
*/
|
|
169
|
+
const updatedAttempts = [...attempts, resultAttempt];
|
|
170
|
+
const resultContext = {
|
|
171
|
+
current: resultAttempt,
|
|
172
|
+
attempts: updatedAttempts,
|
|
173
|
+
totalAttempts: updatedAttempts.length
|
|
174
|
+
};
|
|
175
|
+
return {
|
|
176
|
+
nextModel: await this.findNextModel(resultContext),
|
|
177
|
+
attempt: resultAttempt
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Handle an error and determine if a retry is needed
|
|
182
|
+
*/
|
|
183
|
+
async handleError(error, attempts) {
|
|
184
|
+
const errorAttempt = {
|
|
185
|
+
type: "error",
|
|
186
|
+
error,
|
|
187
|
+
model: this.currentModel
|
|
188
|
+
};
|
|
189
|
+
/**
|
|
190
|
+
* Save the current attempt
|
|
191
|
+
*/
|
|
192
|
+
const updatedAttempts = [...attempts, errorAttempt];
|
|
193
|
+
const context = {
|
|
194
|
+
current: errorAttempt,
|
|
195
|
+
attempts: updatedAttempts,
|
|
196
|
+
totalAttempts: updatedAttempts.length
|
|
197
|
+
};
|
|
198
|
+
this.options.onError?.(context);
|
|
199
|
+
const nextModel = await this.findNextModel(context);
|
|
200
|
+
/**
|
|
201
|
+
* Handler didn't return any models to try next, rethrow the error.
|
|
202
|
+
* If we retried the request, wrap the error into a `RetryError` for better visibility.
|
|
203
|
+
*/
|
|
204
|
+
if (!nextModel) {
|
|
205
|
+
if (updatedAttempts.length > 1) throw this.prepareRetryError(error, updatedAttempts);
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
nextModel,
|
|
210
|
+
attempt: errorAttempt
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
async doGenerate(options) {
|
|
214
|
+
/**
|
|
215
|
+
* Always start with the original model
|
|
216
|
+
*/
|
|
217
|
+
this.currentModel = this.baseModel;
|
|
218
|
+
const { result } = await this.withRetry({ fn: async () => await this.currentModel.doGenerate(options) });
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
async doStream(options) {
|
|
222
|
+
/**
|
|
223
|
+
* Always start with the original model
|
|
224
|
+
*/
|
|
225
|
+
this.currentModel = this.baseModel;
|
|
226
|
+
/**
|
|
227
|
+
* Perform the initial call to doStream with retry logic to handle errors before any data is streamed.
|
|
228
|
+
*/
|
|
229
|
+
let { result, attempts } = await this.withRetry({ fn: async () => await this.currentModel.doStream(options) });
|
|
230
|
+
/**
|
|
231
|
+
* Wrap the original stream to handle retries if an error occurs during streaming.
|
|
232
|
+
*/
|
|
233
|
+
const retryableStream = new ReadableStream({ start: async (controller) => {
|
|
234
|
+
let reader;
|
|
235
|
+
let isStreaming = false;
|
|
236
|
+
while (true) try {
|
|
237
|
+
reader = result.stream.getReader();
|
|
238
|
+
while (true) {
|
|
239
|
+
const { done, value } = await reader.read();
|
|
240
|
+
if (done) break;
|
|
241
|
+
/**
|
|
242
|
+
* If the stream part is an error and no data has been streamed yet, we can retry
|
|
243
|
+
* Throw the error to trigger the retry logic in withRetry
|
|
244
|
+
*/
|
|
245
|
+
if (value.type === "error") {
|
|
246
|
+
if (!isStreaming) throw value.error;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Mark that streaming has started once we receive actual content
|
|
250
|
+
*/
|
|
251
|
+
if (isStreamContentPart(value)) isStreaming = true;
|
|
252
|
+
/**
|
|
253
|
+
* Enqueue the chunk to the consumer of the stream
|
|
254
|
+
*/
|
|
255
|
+
controller.enqueue(value);
|
|
256
|
+
}
|
|
257
|
+
controller.close();
|
|
258
|
+
break;
|
|
259
|
+
} catch (error) {
|
|
260
|
+
/**
|
|
261
|
+
* Check if the error from the stream can be retried.
|
|
262
|
+
* Otherwise it will rethrow the error.
|
|
263
|
+
*/
|
|
264
|
+
const { nextModel, attempt } = await this.handleError(error, attempts);
|
|
265
|
+
this.currentModel = nextModel;
|
|
266
|
+
/**
|
|
267
|
+
* Save the attempt
|
|
268
|
+
*/
|
|
269
|
+
attempts.push(attempt);
|
|
270
|
+
/**
|
|
271
|
+
* Retry the request by calling doStream again.
|
|
272
|
+
* This will create a new stream.
|
|
273
|
+
*/
|
|
274
|
+
const retriedResult = await this.withRetry({
|
|
275
|
+
fn: async () => await this.currentModel.doStream(options),
|
|
276
|
+
attempts
|
|
277
|
+
});
|
|
278
|
+
/**
|
|
279
|
+
* Cancel the previous reader and stream if we are retrying
|
|
280
|
+
*/
|
|
281
|
+
await reader?.cancel();
|
|
282
|
+
result = retriedResult.result;
|
|
283
|
+
attempts = retriedResult.attempts;
|
|
284
|
+
} finally {
|
|
285
|
+
reader?.releaseLock();
|
|
286
|
+
}
|
|
287
|
+
} });
|
|
288
|
+
return {
|
|
289
|
+
...result,
|
|
290
|
+
stream: retryableStream
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
prepareRetryError(error, attempts) {
|
|
294
|
+
const errorMessage = getErrorMessage(error);
|
|
295
|
+
const errors = attempts.flatMap((a) => isErrorAttempt(a) ? a.error : `Result with finishReason: ${a.result.finishReason}`);
|
|
296
|
+
return new RetryError(new RetryError({
|
|
297
|
+
message: `Failed after ${attempts.length} attempts. Last error: ${errorMessage}`,
|
|
298
|
+
reason: "maxRetriesExceeded",
|
|
299
|
+
errors
|
|
300
|
+
}));
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
function createRetryable(config) {
|
|
304
|
+
return new RetryableModel(config);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
//#endregion
|
|
308
|
+
export { createRetryable, getModelKey, isErrorAttempt, isObject, isResultAttempt, isString };
|
|
@@ -8,15 +8,30 @@ type LanguageModelV2Generate = Awaited<ReturnType<LanguageModelV2['doGenerate']>
|
|
|
8
8
|
* The context provided to Retryables with the current attempt and all previous attempts.
|
|
9
9
|
*/
|
|
10
10
|
interface RetryContext<CURRENT extends RetryAttempt = RetryAttempt> {
|
|
11
|
+
/**
|
|
12
|
+
* Current attempt that caused the retry
|
|
13
|
+
*/
|
|
11
14
|
current: CURRENT;
|
|
15
|
+
/**
|
|
16
|
+
* All attempts made so far, including the current one
|
|
17
|
+
*/
|
|
12
18
|
attempts: Array<RetryAttempt>;
|
|
19
|
+
/**
|
|
20
|
+
* @deprecated Use `attempts.length` instead
|
|
21
|
+
*/
|
|
13
22
|
totalAttempts: number;
|
|
14
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* A retry attempt with an error
|
|
26
|
+
*/
|
|
15
27
|
type RetryErrorAttempt = {
|
|
16
28
|
type: 'error';
|
|
17
29
|
error: unknown;
|
|
18
30
|
model: LanguageModelV2;
|
|
19
31
|
};
|
|
32
|
+
/**
|
|
33
|
+
* A retry attempt with a successful result
|
|
34
|
+
*/
|
|
20
35
|
type RetryResultAttempt = {
|
|
21
36
|
type: 'result';
|
|
22
37
|
result: LanguageModelV2Generate;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CreateRetryableOptions, RetryAttempt, RetryContext, RetryModel, Retryable, createRetryable, isErrorAttempt, isResultAttempt } from "./create-retryable-model-
|
|
1
|
+
import { CreateRetryableOptions, RetryAttempt, RetryContext, RetryModel, Retryable, createRetryable, isErrorAttempt, isResultAttempt } from "./create-retryable-model-DtspEawi.js";
|
|
2
2
|
import { LanguageModelV2 } from "@ai-sdk/provider";
|
|
3
3
|
|
|
4
4
|
//#region src/get-model-key.d.ts
|
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-C3mm1hSN.js";
|
|
2
2
|
|
|
3
3
|
export { createRetryable, getModelKey, isErrorAttempt, isResultAttempt };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { RetryModel, Retryable } from "../create-retryable-model-
|
|
1
|
+
import { RetryModel, Retryable } from "../create-retryable-model-DtspEawi.js";
|
|
2
2
|
import { LanguageModelV2 } from "@ai-sdk/provider";
|
|
3
3
|
|
|
4
4
|
//#region src/retryables/anthropic-service-overloaded.d.ts
|
|
@@ -22,6 +22,8 @@ type AnthropicErrorResponse = {
|
|
|
22
22
|
* HTTP 200 OK
|
|
23
23
|
* {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}
|
|
24
24
|
* ```
|
|
25
|
+
*
|
|
26
|
+
* @deprecated Use `serviceOverloaded` instead
|
|
25
27
|
*/
|
|
26
28
|
declare function anthropicServiceOverloaded(model: LanguageModelV2, options?: Omit<RetryModel, 'model'>): Retryable;
|
|
27
29
|
//#endregion
|
|
@@ -44,13 +46,14 @@ declare function requestNotRetryable(model: LanguageModelV2, options?: Omit<Retr
|
|
|
44
46
|
*/
|
|
45
47
|
declare function requestTimeout(model: LanguageModelV2, options?: Omit<RetryModel, 'model'>): Retryable;
|
|
46
48
|
//#endregion
|
|
47
|
-
//#region src/retryables/response-schema-mismatch.d.ts
|
|
48
|
-
declare function responseSchemaMismatch(model: LanguageModelV2, options?: Omit<RetryModel, 'model'>): Retryable;
|
|
49
|
-
//#endregion
|
|
50
49
|
//#region src/retryables/service-overloaded.d.ts
|
|
51
50
|
/**
|
|
52
|
-
* Fallback to a different model if the provider returns
|
|
51
|
+
* Fallback to a different model if the provider returns an overloaded error.
|
|
52
|
+
* This retryable handles the following cases:
|
|
53
|
+
* - Response with status code 529
|
|
54
|
+
* - Response with `type: "overloaded_error"`
|
|
55
|
+
* - Response with a `message` containing "overloaded"
|
|
53
56
|
*/
|
|
54
57
|
declare function serviceOverloaded(model: LanguageModelV2, options?: Omit<RetryModel, 'model'>): Retryable;
|
|
55
58
|
//#endregion
|
|
56
|
-
export { AnthropicErrorResponse, anthropicServiceOverloaded, contentFilterTriggered, requestNotRetryable, requestTimeout,
|
|
59
|
+
export { AnthropicErrorResponse, anthropicServiceOverloaded, contentFilterTriggered, requestNotRetryable, requestTimeout, serviceOverloaded };
|
package/dist/retryables/index.js
CHANGED
|
@@ -1,12 +1,7 @@
|
|
|
1
|
-
import { isErrorAttempt, isResultAttempt } from "../create-retryable-model-
|
|
1
|
+
import { isErrorAttempt, isObject, isResultAttempt, isString } from "../create-retryable-model-C3mm1hSN.js";
|
|
2
2
|
import { isAbortError } from "@ai-sdk/provider-utils";
|
|
3
|
-
import { APICallError
|
|
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";
|
|
8
|
-
|
|
9
|
-
//#endregion
|
|
10
5
|
//#region src/retryables/anthropic-service-overloaded.ts
|
|
11
6
|
/**
|
|
12
7
|
* Fallback if Anthropic returns an "overloaded" error with HTTP 200.
|
|
@@ -15,6 +10,8 @@ const isString = (value) => typeof value === "string";
|
|
|
15
10
|
* HTTP 200 OK
|
|
16
11
|
* {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}
|
|
17
12
|
* ```
|
|
13
|
+
*
|
|
14
|
+
* @deprecated Use `serviceOverloaded` instead
|
|
18
15
|
*/
|
|
19
16
|
function anthropicServiceOverloaded(model, options) {
|
|
20
17
|
return (context) => {
|
|
@@ -26,14 +23,11 @@ function anthropicServiceOverloaded(model, options) {
|
|
|
26
23
|
maxAttempts: 1,
|
|
27
24
|
...options
|
|
28
25
|
};
|
|
29
|
-
if (
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
...options
|
|
35
|
-
};
|
|
36
|
-
} catch {}
|
|
26
|
+
if (isObject(error) && isString(error.type) && error.type === "overloaded_error") return {
|
|
27
|
+
model,
|
|
28
|
+
maxAttempts: 1,
|
|
29
|
+
...options
|
|
30
|
+
};
|
|
37
31
|
}
|
|
38
32
|
};
|
|
39
33
|
}
|
|
@@ -105,25 +99,14 @@ function requestTimeout(model, options) {
|
|
|
105
99
|
};
|
|
106
100
|
}
|
|
107
101
|
|
|
108
|
-
//#endregion
|
|
109
|
-
//#region src/retryables/response-schema-mismatch.ts
|
|
110
|
-
function responseSchemaMismatch(model, options) {
|
|
111
|
-
return (context) => {
|
|
112
|
-
const { current } = context;
|
|
113
|
-
if (isErrorAttempt(current)) {
|
|
114
|
-
if (NoObjectGeneratedError.isInstance(current.error) && current.error.finishReason === "stop" && TypeValidationError.isInstance(current.error.cause)) return {
|
|
115
|
-
model,
|
|
116
|
-
maxAttempts: 1,
|
|
117
|
-
...options
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
|
|
123
102
|
//#endregion
|
|
124
103
|
//#region src/retryables/service-overloaded.ts
|
|
125
104
|
/**
|
|
126
|
-
* Fallback to a different model if the provider returns
|
|
105
|
+
* Fallback to a different model if the provider returns an overloaded error.
|
|
106
|
+
* This retryable handles the following cases:
|
|
107
|
+
* - Response with status code 529
|
|
108
|
+
* - Response with `type: "overloaded_error"`
|
|
109
|
+
* - Response with a `message` containing "overloaded"
|
|
127
110
|
*/
|
|
128
111
|
function serviceOverloaded(model, options) {
|
|
129
112
|
return (context) => {
|
|
@@ -135,9 +118,16 @@ function serviceOverloaded(model, options) {
|
|
|
135
118
|
maxAttempts: 1,
|
|
136
119
|
...options
|
|
137
120
|
};
|
|
121
|
+
if (isObject(error)) {
|
|
122
|
+
if (isString(error.type) && error.type === "overloaded_error" || isString(error.message) && error.message.toLowerCase().includes("overloaded")) return {
|
|
123
|
+
model,
|
|
124
|
+
maxAttempts: 1,
|
|
125
|
+
...options
|
|
126
|
+
};
|
|
127
|
+
}
|
|
138
128
|
}
|
|
139
129
|
};
|
|
140
130
|
}
|
|
141
131
|
|
|
142
132
|
//#endregion
|
|
143
|
-
export { anthropicServiceOverloaded, contentFilterTriggered, requestNotRetryable, requestTimeout,
|
|
133
|
+
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.1.0",
|
|
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",
|
|
@@ -55,11 +64,5 @@
|
|
|
55
64
|
"dependencies": {
|
|
56
65
|
"@ai-sdk/provider": "^2.0.0",
|
|
57
66
|
"@ai-sdk/provider-utils": "^3.0.9"
|
|
58
|
-
},
|
|
59
|
-
"scripts": {
|
|
60
|
-
"publish:alpha": "pnpm version prerelease --preid alpha && pnpm publish --tag alpha",
|
|
61
|
-
"build": "tsdown",
|
|
62
|
-
"test": "vitest",
|
|
63
|
-
"lint": "biome check . --write"
|
|
64
67
|
}
|
|
65
|
-
}
|
|
68
|
+
}
|
|
@@ -1,221 +0,0 @@
|
|
|
1
|
-
import { getErrorMessage } from "@ai-sdk/provider-utils";
|
|
2
|
-
import { RetryError } from "ai";
|
|
3
|
-
|
|
4
|
-
//#region src/get-model-key.ts
|
|
5
|
-
/**
|
|
6
|
-
* Generate a unique key for a LanguageModelV2 instance.
|
|
7
|
-
*/
|
|
8
|
-
const getModelKey = (model) => {
|
|
9
|
-
return `${model.provider}/${model.modelId}`;
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
//#endregion
|
|
13
|
-
//#region src/create-retryable-model.ts
|
|
14
|
-
/**
|
|
15
|
-
* Type guard to check if a retry attempt is an error attempt
|
|
16
|
-
*/
|
|
17
|
-
function isErrorAttempt(attempt) {
|
|
18
|
-
return attempt.type === "error";
|
|
19
|
-
}
|
|
20
|
-
/**
|
|
21
|
-
* Type guard to check if a retry attempt is a result attempt
|
|
22
|
-
*/
|
|
23
|
-
function isResultAttempt(attempt) {
|
|
24
|
-
return attempt.type === "result";
|
|
25
|
-
}
|
|
26
|
-
var RetryableModel = class {
|
|
27
|
-
specificationVersion = "v2";
|
|
28
|
-
baseModel;
|
|
29
|
-
currentModel;
|
|
30
|
-
options;
|
|
31
|
-
get modelId() {
|
|
32
|
-
return this.currentModel.modelId;
|
|
33
|
-
}
|
|
34
|
-
get provider() {
|
|
35
|
-
return this.currentModel.provider;
|
|
36
|
-
}
|
|
37
|
-
get supportedUrls() {
|
|
38
|
-
return this.currentModel.supportedUrls;
|
|
39
|
-
}
|
|
40
|
-
constructor(options) {
|
|
41
|
-
this.options = options;
|
|
42
|
-
this.baseModel = options.model;
|
|
43
|
-
this.currentModel = options.model;
|
|
44
|
-
}
|
|
45
|
-
/**
|
|
46
|
-
* Find the next model to retry with based on the retry context
|
|
47
|
-
*/
|
|
48
|
-
async findNextModel(context) {
|
|
49
|
-
/**
|
|
50
|
-
* Filter retryables based on attempt type:
|
|
51
|
-
* - Result-based attempts: Only consider function retryables (skip plain models)
|
|
52
|
-
* - Error-based attempts: Consider all retryables (functions + plain models)
|
|
53
|
-
*/
|
|
54
|
-
const applicableRetries = isResultAttempt(context.current) ? this.options.retries.filter((retry) => typeof retry === "function") : this.options.retries;
|
|
55
|
-
/**
|
|
56
|
-
* Iterate through the applicable retryables to find a model to retry with
|
|
57
|
-
*/
|
|
58
|
-
for (const retry of applicableRetries) {
|
|
59
|
-
const retryModel = typeof retry === "function" ? await retry(context) : {
|
|
60
|
-
model: retry,
|
|
61
|
-
maxAttempts: 1
|
|
62
|
-
};
|
|
63
|
-
if (retryModel) {
|
|
64
|
-
/**
|
|
65
|
-
* The model key uniquely identifies a model instance (provider + modelId)
|
|
66
|
-
*/
|
|
67
|
-
const retryModelKey = getModelKey(retryModel.model);
|
|
68
|
-
/**
|
|
69
|
-
* Find all attempts with the same model
|
|
70
|
-
*/
|
|
71
|
-
const retryAttempts = context.attempts.filter((a) => getModelKey(a.model) === retryModelKey);
|
|
72
|
-
const maxAttempts = retryModel.maxAttempts ?? 1;
|
|
73
|
-
/**
|
|
74
|
-
* Check if the model can still be retried based on maxAttempts
|
|
75
|
-
*/
|
|
76
|
-
if (retryAttempts.length < maxAttempts) return retryModel.model;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
async doGenerate(options) {
|
|
81
|
-
/**
|
|
82
|
-
* Always start with the original model
|
|
83
|
-
*/
|
|
84
|
-
this.currentModel = this.baseModel;
|
|
85
|
-
/**
|
|
86
|
-
* Track number of attempts
|
|
87
|
-
*/
|
|
88
|
-
let totalAttempts = 0;
|
|
89
|
-
/**
|
|
90
|
-
* Track all attempts.
|
|
91
|
-
*/
|
|
92
|
-
const attempts = [];
|
|
93
|
-
/**
|
|
94
|
-
* The previous attempt that triggered a retry, or undefined if this is the first attempt
|
|
95
|
-
*/
|
|
96
|
-
let previousAttempt;
|
|
97
|
-
while (true) {
|
|
98
|
-
/**
|
|
99
|
-
* Call the onRetry handler if provided.
|
|
100
|
-
* Skip on the first attempt since no previous attempt exists yet.
|
|
101
|
-
*/
|
|
102
|
-
if (previousAttempt) {
|
|
103
|
-
/**
|
|
104
|
-
* Context for the onRetry handler
|
|
105
|
-
*/
|
|
106
|
-
const context = {
|
|
107
|
-
current: {
|
|
108
|
-
...previousAttempt,
|
|
109
|
-
model: this.currentModel
|
|
110
|
-
},
|
|
111
|
-
attempts,
|
|
112
|
-
totalAttempts
|
|
113
|
-
};
|
|
114
|
-
/**
|
|
115
|
-
* Call the onRetry handler if provided
|
|
116
|
-
*/
|
|
117
|
-
this.options.onRetry?.(context);
|
|
118
|
-
}
|
|
119
|
-
totalAttempts++;
|
|
120
|
-
try {
|
|
121
|
-
const result = await this.currentModel.doGenerate(options);
|
|
122
|
-
/**
|
|
123
|
-
* Check if the result should trigger a retry
|
|
124
|
-
*/
|
|
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) {
|
|
141
|
-
/**
|
|
142
|
-
* Set the model for the next attempt
|
|
143
|
-
*/
|
|
144
|
-
this.currentModel = nextModel;
|
|
145
|
-
/**
|
|
146
|
-
* Set the previous attempt that triggered this retry
|
|
147
|
-
*/
|
|
148
|
-
previousAttempt = resultAttempt;
|
|
149
|
-
/**
|
|
150
|
-
* Continue to the next iteration to retry
|
|
151
|
-
*/
|
|
152
|
-
continue;
|
|
153
|
-
}
|
|
154
|
-
/**
|
|
155
|
-
* No retry needed, remove the attempt since it was successful and return the result
|
|
156
|
-
*/
|
|
157
|
-
attempts.pop();
|
|
158
|
-
return result;
|
|
159
|
-
} catch (error) {
|
|
160
|
-
/**
|
|
161
|
-
* Current attempt with current error
|
|
162
|
-
*/
|
|
163
|
-
const errorAttempt = {
|
|
164
|
-
type: "error",
|
|
165
|
-
error,
|
|
166
|
-
model: this.currentModel
|
|
167
|
-
};
|
|
168
|
-
/**
|
|
169
|
-
* Save the current attempt
|
|
170
|
-
*/
|
|
171
|
-
attempts.push(errorAttempt);
|
|
172
|
-
/**
|
|
173
|
-
* Context for the retryables and onError handler
|
|
174
|
-
*/
|
|
175
|
-
const context = {
|
|
176
|
-
current: errorAttempt,
|
|
177
|
-
attempts,
|
|
178
|
-
totalAttempts
|
|
179
|
-
};
|
|
180
|
-
/**
|
|
181
|
-
* Call the onError handler if provided
|
|
182
|
-
*/
|
|
183
|
-
this.options.onError?.(context);
|
|
184
|
-
const nextModel = await this.findNextModel(context);
|
|
185
|
-
/**
|
|
186
|
-
* Handler didn't return any models to try next, rethrow the error.
|
|
187
|
-
* If we retried the request, wrap the error into a `RetryError` for better visibility.
|
|
188
|
-
*/
|
|
189
|
-
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
|
-
}
|
|
199
|
-
throw error;
|
|
200
|
-
}
|
|
201
|
-
/**
|
|
202
|
-
* Set the model for the next attempt
|
|
203
|
-
*/
|
|
204
|
-
this.currentModel = nextModel;
|
|
205
|
-
/**
|
|
206
|
-
* Set the previous attempt that triggered this retry
|
|
207
|
-
*/
|
|
208
|
-
previousAttempt = errorAttempt;
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
async doStream(options) {
|
|
213
|
-
throw new Error("Streaming not implemented");
|
|
214
|
-
}
|
|
215
|
-
};
|
|
216
|
-
function createRetryable(config) {
|
|
217
|
-
return new RetryableModel(config);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
//#endregion
|
|
221
|
-
export { createRetryable, getModelKey, isErrorAttempt, isResultAttempt };
|