ai-retry 0.1.1 → 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 +97 -47
- package/dist/index.d.ts +7 -4
- package/dist/index.js +417 -2
- package/dist/retryables/index.d.ts +9 -34
- package/dist/retryables/index.js +4 -34
- package/dist/types-BrJaHkFh.d.ts +67 -0
- package/dist/utils-lRsC105f.js +27 -0
- package/package.json +1 -1
- package/dist/create-retryable-model-DtspEawi.d.ts +0 -74
- package/dist/create-retryable-model-YqmeNfbq.js +0 -308
package/README.md
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
<
|
|
1
|
+
<div align='center'>
|
|
2
2
|
|
|
3
|
-
# ai-retry
|
|
3
|
+
# ai-retry
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
<p align="center">Retry and fallback mechanisms for AI SDK</p>
|
|
6
|
+
<p align="center">
|
|
7
|
+
<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>
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
Automatically handle API failures, content filtering, timeouts and other errors by switching between different AI models and providers.
|
|
6
13
|
|
|
7
14
|
`ai-retry` wraps the provided base model with a set of retry conditions (retryables). When a request fails with an error or the response is not satisfying, it iterates through the given retryables to find a suitable fallback model. It automatically tracks which models have been tried and how many attempts have been made to prevent infinite loops.
|
|
8
15
|
|
|
@@ -26,16 +33,17 @@ npm install ai-retry
|
|
|
26
33
|
Create a retryable model by providing a base model and a list of retryables or fallback models.
|
|
27
34
|
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.
|
|
28
35
|
|
|
36
|
+
> [!NOTE]
|
|
37
|
+
> `ai-retry` supports both language models and embedding models.
|
|
38
|
+
|
|
29
39
|
```typescript
|
|
30
|
-
import { azure } from '@ai-sdk/azure';
|
|
31
40
|
import { openai } from '@ai-sdk/openai';
|
|
32
41
|
import { generateText, streamText } from 'ai';
|
|
33
42
|
import { createRetryable } from 'ai-retry';
|
|
34
|
-
import { contentFilterTriggered, requestTimeout } from 'ai-retry/retryables';
|
|
35
43
|
|
|
36
44
|
// Create a retryable model
|
|
37
45
|
const retryableModel = createRetryable({
|
|
38
|
-
model:
|
|
46
|
+
model: openai('gpt-4-mini'), // Base model
|
|
39
47
|
retries: [
|
|
40
48
|
// Retry strategies and fallbacks...
|
|
41
49
|
],
|
|
@@ -47,6 +55,8 @@ const result = await generateText({
|
|
|
47
55
|
prompt: 'Hello world!',
|
|
48
56
|
});
|
|
49
57
|
|
|
58
|
+
console.log(result.text);
|
|
59
|
+
|
|
50
60
|
// Or with streaming
|
|
51
61
|
const result = streamText({
|
|
52
62
|
model: retryableModel,
|
|
@@ -58,52 +68,47 @@ for await (const chunk of result.textStream) {
|
|
|
58
68
|
}
|
|
59
69
|
```
|
|
60
70
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
Automatically switch to a different model when content filtering blocks your request.
|
|
64
|
-
|
|
65
|
-
> [!WARNING]
|
|
66
|
-
> This retryable currently does not work with streaming requests, because the content filter is only indicated in the final response.
|
|
71
|
+
This also works with embedding models:
|
|
67
72
|
|
|
68
73
|
```typescript
|
|
69
|
-
import {
|
|
74
|
+
import { openai } from '@ai-sdk/openai';
|
|
75
|
+
import { embed } from 'ai';
|
|
76
|
+
import { createRetryable } from 'ai-retry';
|
|
70
77
|
|
|
78
|
+
// Create a retryable model
|
|
71
79
|
const retryableModel = createRetryable({
|
|
72
|
-
model:
|
|
80
|
+
model: openai.textEmbedding('text-embedding-3-large'), // Base model
|
|
73
81
|
retries: [
|
|
74
|
-
|
|
82
|
+
// Retry strategies and fallbacks...
|
|
75
83
|
],
|
|
76
84
|
});
|
|
85
|
+
|
|
86
|
+
// Use like any other AI SDK model
|
|
87
|
+
const result = await embed({
|
|
88
|
+
model: retryableModel,
|
|
89
|
+
value: 'Hello world!',
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
console.log(result.embedding);
|
|
77
93
|
```
|
|
78
94
|
|
|
79
|
-
|
|
80
|
-
##### Response Schema Mismatch
|
|
95
|
+
#### Content Filter
|
|
81
96
|
|
|
82
|
-
|
|
97
|
+
Automatically switch to a different model when content filtering blocks your request.
|
|
98
|
+
|
|
99
|
+
> [!WARNING]
|
|
100
|
+
> This retryable currently does not work with streaming requests, because the content filter is only indicated in the final response.
|
|
83
101
|
|
|
84
102
|
```typescript
|
|
85
|
-
import {
|
|
103
|
+
import { contentFilterTriggered } from 'ai-retry/retryables';
|
|
86
104
|
|
|
87
105
|
const retryableModel = createRetryable({
|
|
88
106
|
model: azure('gpt-4-mini'),
|
|
89
107
|
retries: [
|
|
90
|
-
|
|
108
|
+
contentFilterTriggered(openai('gpt-4-mini')), // Try OpenAI if Azure filters
|
|
91
109
|
],
|
|
92
110
|
});
|
|
93
|
-
|
|
94
|
-
const result = await generateObject({
|
|
95
|
-
model: retryableModel,
|
|
96
|
-
schema: z.object({
|
|
97
|
-
recipe: z.object({
|
|
98
|
-
name: z.string(),
|
|
99
|
-
ingredients: z.array(z.object({ name: z.string(), amount: z.string() })),
|
|
100
|
-
steps: z.array(z.string()),
|
|
101
|
-
}),
|
|
102
|
-
}),
|
|
103
|
-
prompt: 'Generate a lasagna recipe.',
|
|
104
|
-
});
|
|
105
111
|
```
|
|
106
|
-
-->
|
|
107
112
|
|
|
108
113
|
#### Request Timeout
|
|
109
114
|
|
|
@@ -242,6 +247,42 @@ try {
|
|
|
242
247
|
}
|
|
243
248
|
```
|
|
244
249
|
|
|
250
|
+
#### Retry Delays
|
|
251
|
+
|
|
252
|
+
You can add delays before retrying to handle rate limiting or give services time to recover. The delay respects abort signals, so requests can still be cancelled during the delay period.
|
|
253
|
+
|
|
254
|
+
```typescript
|
|
255
|
+
const retryableModel = createRetryable({
|
|
256
|
+
model: openai('gpt-4'),
|
|
257
|
+
retries: [
|
|
258
|
+
// Wait 1 second before retrying
|
|
259
|
+
() => ({
|
|
260
|
+
model: openai('gpt-4'),
|
|
261
|
+
delay: 1_000
|
|
262
|
+
}),
|
|
263
|
+
// Wait 2 seconds before trying a different provider
|
|
264
|
+
() => ({
|
|
265
|
+
model: anthropic('claude-3-haiku-20240307'),
|
|
266
|
+
delay: 2_000
|
|
267
|
+
}),
|
|
268
|
+
],
|
|
269
|
+
});
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
You can also use delays with built-in retryables:
|
|
273
|
+
|
|
274
|
+
```typescript
|
|
275
|
+
import { serviceOverloaded } from 'ai-retry/retryables';
|
|
276
|
+
|
|
277
|
+
const retryableModel = createRetryable({
|
|
278
|
+
model: openai('gpt-4'),
|
|
279
|
+
retries: [
|
|
280
|
+
// Wait 5 seconds before retrying on service overload
|
|
281
|
+
serviceOverloaded(openai('gpt-4'), { maxAttempts: 3, delay: 5_000 }),
|
|
282
|
+
],
|
|
283
|
+
});
|
|
284
|
+
```
|
|
285
|
+
|
|
245
286
|
#### Logging
|
|
246
287
|
|
|
247
288
|
You can use the following callbacks to log retry attempts and errors:
|
|
@@ -287,16 +328,16 @@ By default, each retryable will only attempt to retry once per model to avoid in
|
|
|
287
328
|
|
|
288
329
|
### API Reference
|
|
289
330
|
|
|
290
|
-
#### `createRetryable(options:
|
|
331
|
+
#### `createRetryable(options: RetryableModelOptions): LanguageModelV2 | EmbeddingModelV2`
|
|
291
332
|
|
|
292
|
-
Creates a retryable language
|
|
333
|
+
Creates a retryable model that works with both language models and embedding models.
|
|
293
334
|
|
|
294
335
|
```ts
|
|
295
|
-
interface
|
|
296
|
-
model:
|
|
297
|
-
retries: Array<Retryable |
|
|
298
|
-
onError?: (context: RetryContext) => void;
|
|
299
|
-
onRetry?: (context: RetryContext) => void;
|
|
336
|
+
interface RetryableModelOptions<MODEL extends LanguageModelV2 | EmbeddingModelV2> {
|
|
337
|
+
model: MODEL;
|
|
338
|
+
retries: Array<Retryable<MODEL> | MODEL>;
|
|
339
|
+
onError?: (context: RetryContext<MODEL>) => void;
|
|
340
|
+
onRetry?: (context: RetryContext<MODEL>) => void;
|
|
300
341
|
}
|
|
301
342
|
```
|
|
302
343
|
|
|
@@ -306,21 +347,27 @@ A `Retryable` is a function that receives a `RetryContext` with the current erro
|
|
|
306
347
|
It should evaluate the error/result and decide whether to retry by returning a `RetryModel` or to skip by returning `undefined`.
|
|
307
348
|
|
|
308
349
|
```ts
|
|
309
|
-
type Retryable = (
|
|
350
|
+
type Retryable = (
|
|
351
|
+
context: RetryContext
|
|
352
|
+
) => RetryModel | Promise<RetryModel> | undefined;
|
|
310
353
|
```
|
|
311
354
|
|
|
312
355
|
#### `RetryModel`
|
|
313
356
|
|
|
314
|
-
A `RetryModel` specifies the model to retry and
|
|
315
|
-
By default, each retryable will only attempt to retry once per model. This can be customized by setting the `maxAttempts` property.
|
|
357
|
+
A `RetryModel` specifies the model to retry and optional settings like `maxAttempts` and `delay`.
|
|
316
358
|
|
|
317
359
|
```typescript
|
|
318
360
|
interface RetryModel {
|
|
319
|
-
model: LanguageModelV2;
|
|
320
|
-
maxAttempts?: number;
|
|
361
|
+
model: LanguageModelV2 | EmbeddingModelV2;
|
|
362
|
+
maxAttempts?: number; // Maximum retry attempts per model (default: 1)
|
|
363
|
+
delay?: number; // Delay in milliseconds before retrying
|
|
321
364
|
}
|
|
322
365
|
```
|
|
323
366
|
|
|
367
|
+
**Options:**
|
|
368
|
+
- `maxAttempts`: Maximum number of times this model can be retried. Default is 1.
|
|
369
|
+
- `delay`: Delay in milliseconds to wait before retrying. Useful for rate limiting or giving services time to recover. The delay respects abort signals from the request.
|
|
370
|
+
|
|
324
371
|
#### `RetryContext`
|
|
325
372
|
|
|
326
373
|
The `RetryContext` object contains information about the current attempt and all previous attempts.
|
|
@@ -337,10 +384,13 @@ interface RetryContext {
|
|
|
337
384
|
A `RetryAttempt` represents a single attempt with a specific model, which can be either an error or a successful result that triggered a retry.
|
|
338
385
|
|
|
339
386
|
```typescript
|
|
340
|
-
|
|
341
|
-
|
|
387
|
+
// For both language and embedding models
|
|
388
|
+
type RetryAttempt =
|
|
389
|
+
| { type: 'error'; error: unknown; model: LanguageModelV2 | EmbeddingModelV2 }
|
|
342
390
|
| { type: 'result'; result: LanguageModelV2Generate; model: LanguageModelV2 };
|
|
343
391
|
|
|
392
|
+
// Note: Result-based retries only apply to language models, not embedding models
|
|
393
|
+
|
|
344
394
|
// Type guards for discriminating attempts
|
|
345
395
|
function isErrorAttempt(attempt: RetryAttempt): attempt is RetryErrorAttempt;
|
|
346
396
|
function isResultAttempt(attempt: RetryAttempt): attempt is RetryResultAttempt;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { LanguageModelV2 } from "@ai-sdk/provider";
|
|
1
|
+
import { EmbeddingModelV2, EmbeddingModelV2CallOptions, EmbeddingModelV2Embed, LanguageModelV2, LanguageModelV2Generate, LanguageModelV2Stream, Retries, RetryAttempt, RetryContext, RetryErrorAttempt, RetryModel, RetryResultAttempt, Retryable, RetryableModelOptions } from "./types-BrJaHkFh.js";
|
|
3
2
|
|
|
3
|
+
//#region src/create-retryable-model.d.ts
|
|
4
|
+
declare function createRetryable<MODEL extends LanguageModelV2>(options: RetryableModelOptions<MODEL>): LanguageModelV2;
|
|
5
|
+
declare function createRetryable<MODEL extends EmbeddingModelV2>(options: RetryableModelOptions<MODEL>): EmbeddingModelV2;
|
|
6
|
+
//#endregion
|
|
4
7
|
//#region src/get-model-key.d.ts
|
|
5
8
|
/**
|
|
6
9
|
* Generate a unique key for a LanguageModelV2 instance.
|
|
7
10
|
*/
|
|
8
|
-
declare const getModelKey: (model: LanguageModelV2) => string;
|
|
11
|
+
declare const getModelKey: (model: LanguageModelV2 | EmbeddingModelV2) => string;
|
|
9
12
|
//#endregion
|
|
10
|
-
export {
|
|
13
|
+
export { EmbeddingModelV2, EmbeddingModelV2CallOptions, EmbeddingModelV2Embed, LanguageModelV2, LanguageModelV2Generate, LanguageModelV2Stream, Retries, RetryAttempt, RetryContext, RetryErrorAttempt, RetryModel, RetryResultAttempt, Retryable, RetryableModelOptions, createRetryable, getModelKey };
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,418 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isErrorAttempt, isGenerateResult, isResultAttempt, isStreamContentPart } from "./utils-lRsC105f.js";
|
|
2
|
+
import { delay } from "@ai-sdk/provider-utils";
|
|
3
|
+
import { getErrorMessage } from "@ai-sdk/provider";
|
|
4
|
+
import { RetryError } from "ai";
|
|
2
5
|
|
|
3
|
-
|
|
6
|
+
//#region src/get-model-key.ts
|
|
7
|
+
/**
|
|
8
|
+
* Generate a unique key for a LanguageModelV2 instance.
|
|
9
|
+
*/
|
|
10
|
+
const getModelKey = (model) => {
|
|
11
|
+
return `${model.provider}/${model.modelId}`;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region src/find-retry-model.ts
|
|
16
|
+
/**
|
|
17
|
+
* Find the next model to retry with based on the retry context
|
|
18
|
+
*/
|
|
19
|
+
async function findRetryModel(retries, context) {
|
|
20
|
+
/**
|
|
21
|
+
* Filter retryables based on attempt type:
|
|
22
|
+
* - Result-based attempts: Only consider function retryables (skip plain models)
|
|
23
|
+
* - Error-based attempts: Consider all retryables (functions + plain models)
|
|
24
|
+
*/
|
|
25
|
+
const applicableRetries = isResultAttempt(context.current) ? retries.filter((retry) => typeof retry === "function") : retries;
|
|
26
|
+
/**
|
|
27
|
+
* Iterate through the applicable retryables to find a model to retry with
|
|
28
|
+
*/
|
|
29
|
+
for (const retry of applicableRetries) {
|
|
30
|
+
const retryModel = typeof retry === "function" ? await retry(context) : {
|
|
31
|
+
model: retry,
|
|
32
|
+
maxAttempts: 1
|
|
33
|
+
};
|
|
34
|
+
if (retryModel) {
|
|
35
|
+
/**
|
|
36
|
+
* The model key uniquely identifies a model instance (provider + modelId)
|
|
37
|
+
*/
|
|
38
|
+
const retryModelKey = getModelKey(retryModel.model);
|
|
39
|
+
/**
|
|
40
|
+
* Find all attempts with the same model
|
|
41
|
+
*/
|
|
42
|
+
const retryAttempts = context.attempts.filter((a) => getModelKey(a.model) === retryModelKey);
|
|
43
|
+
const maxAttempts = retryModel.maxAttempts ?? 1;
|
|
44
|
+
/**
|
|
45
|
+
* Check if the model can still be retried based on maxAttempts
|
|
46
|
+
*/
|
|
47
|
+
if (retryAttempts.length < maxAttempts) return retryModel;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region src/prepare-retry-error.ts
|
|
54
|
+
/**
|
|
55
|
+
* Prepare a RetryError that includes all errors from previous attempts.
|
|
56
|
+
*/
|
|
57
|
+
function prepareRetryError(error, attempts) {
|
|
58
|
+
const errorMessage = getErrorMessage(error);
|
|
59
|
+
const errors = attempts.flatMap((a) => isErrorAttempt(a) ? a.error : `Result with finishReason: ${a.result.finishReason}`);
|
|
60
|
+
return new RetryError({
|
|
61
|
+
message: `Failed after ${attempts.length} attempts. Last error: ${errorMessage}`,
|
|
62
|
+
reason: "maxRetriesExceeded",
|
|
63
|
+
errors
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
//#endregion
|
|
68
|
+
//#region src/retryable-embedding-model.ts
|
|
69
|
+
var RetryableEmbeddingModel = class {
|
|
70
|
+
specificationVersion = "v2";
|
|
71
|
+
baseModel;
|
|
72
|
+
currentModel;
|
|
73
|
+
options;
|
|
74
|
+
get modelId() {
|
|
75
|
+
return this.currentModel.modelId;
|
|
76
|
+
}
|
|
77
|
+
get provider() {
|
|
78
|
+
return this.currentModel.provider;
|
|
79
|
+
}
|
|
80
|
+
get maxEmbeddingsPerCall() {
|
|
81
|
+
return this.currentModel.maxEmbeddingsPerCall;
|
|
82
|
+
}
|
|
83
|
+
get supportsParallelCalls() {
|
|
84
|
+
return this.currentModel.supportsParallelCalls;
|
|
85
|
+
}
|
|
86
|
+
constructor(options) {
|
|
87
|
+
this.options = options;
|
|
88
|
+
this.baseModel = options.model;
|
|
89
|
+
this.currentModel = options.model;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Execute a function with retry logic for handling errors
|
|
93
|
+
*/
|
|
94
|
+
async withRetry(input) {
|
|
95
|
+
/**
|
|
96
|
+
* Track all attempts.
|
|
97
|
+
*/
|
|
98
|
+
const attempts = input.attempts ?? [];
|
|
99
|
+
while (true) {
|
|
100
|
+
/**
|
|
101
|
+
* The previous attempt that triggered a retry, or undefined if this is the first attempt
|
|
102
|
+
*/
|
|
103
|
+
const previousAttempt = attempts.at(-1);
|
|
104
|
+
/**
|
|
105
|
+
* Call the onRetry handler if provided.
|
|
106
|
+
* Skip on the first attempt since no previous attempt exists yet.
|
|
107
|
+
*/
|
|
108
|
+
if (previousAttempt) {
|
|
109
|
+
const currentAttempt = {
|
|
110
|
+
...previousAttempt,
|
|
111
|
+
model: this.currentModel
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* Create a shallow copy of the attempts for testing purposes
|
|
115
|
+
*/
|
|
116
|
+
const updatedAttempts = [...attempts];
|
|
117
|
+
const context = {
|
|
118
|
+
current: currentAttempt,
|
|
119
|
+
attempts: updatedAttempts
|
|
120
|
+
};
|
|
121
|
+
this.options.onRetry?.(context);
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
return {
|
|
125
|
+
result: await input.fn(),
|
|
126
|
+
attempts
|
|
127
|
+
};
|
|
128
|
+
} catch (error) {
|
|
129
|
+
const { retryModel, attempt } = await this.handleError(error, attempts);
|
|
130
|
+
attempts.push(attempt);
|
|
131
|
+
if (retryModel.delay) await delay(retryModel.delay, { abortSignal: input.abortSignal });
|
|
132
|
+
this.currentModel = retryModel.model;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Handle an error and determine if a retry is needed
|
|
138
|
+
*/
|
|
139
|
+
async handleError(error, attempts) {
|
|
140
|
+
const errorAttempt = {
|
|
141
|
+
type: "error",
|
|
142
|
+
error,
|
|
143
|
+
model: this.currentModel
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Save the current attempt
|
|
147
|
+
*/
|
|
148
|
+
const updatedAttempts = [...attempts, errorAttempt];
|
|
149
|
+
const context = {
|
|
150
|
+
current: errorAttempt,
|
|
151
|
+
attempts: updatedAttempts
|
|
152
|
+
};
|
|
153
|
+
this.options.onError?.(context);
|
|
154
|
+
const retryModel = await findRetryModel(this.options.retries, context);
|
|
155
|
+
/**
|
|
156
|
+
* Handler didn't return any models to try next, rethrow the error.
|
|
157
|
+
* If we retried the request, wrap the error into a `RetryError` for better visibility.
|
|
158
|
+
*/
|
|
159
|
+
if (!retryModel) {
|
|
160
|
+
if (updatedAttempts.length > 1) throw prepareRetryError(error, updatedAttempts);
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
retryModel,
|
|
165
|
+
attempt: errorAttempt
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
async doEmbed(options) {
|
|
169
|
+
/**
|
|
170
|
+
* Always start with the original model
|
|
171
|
+
*/
|
|
172
|
+
this.currentModel = this.baseModel;
|
|
173
|
+
const { result } = await this.withRetry({
|
|
174
|
+
fn: async () => await this.currentModel.doEmbed(options),
|
|
175
|
+
abortSignal: options.abortSignal
|
|
176
|
+
});
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
//#endregion
|
|
182
|
+
//#region src/retryable-language-model.ts
|
|
183
|
+
var RetryableLanguageModel = class {
|
|
184
|
+
specificationVersion = "v2";
|
|
185
|
+
baseModel;
|
|
186
|
+
currentModel;
|
|
187
|
+
options;
|
|
188
|
+
get modelId() {
|
|
189
|
+
return this.currentModel.modelId;
|
|
190
|
+
}
|
|
191
|
+
get provider() {
|
|
192
|
+
return this.currentModel.provider;
|
|
193
|
+
}
|
|
194
|
+
get supportedUrls() {
|
|
195
|
+
return this.currentModel.supportedUrls;
|
|
196
|
+
}
|
|
197
|
+
constructor(options) {
|
|
198
|
+
this.options = options;
|
|
199
|
+
this.baseModel = options.model;
|
|
200
|
+
this.currentModel = options.model;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Execute a function with retry logic for handling errors
|
|
204
|
+
*/
|
|
205
|
+
async withRetry(input) {
|
|
206
|
+
/**
|
|
207
|
+
* Track all attempts.
|
|
208
|
+
*/
|
|
209
|
+
const attempts = input.attempts ?? [];
|
|
210
|
+
while (true) {
|
|
211
|
+
/**
|
|
212
|
+
* The previous attempt that triggered a retry, or undefined if this is the first attempt
|
|
213
|
+
*/
|
|
214
|
+
const previousAttempt = attempts.at(-1);
|
|
215
|
+
/**
|
|
216
|
+
* Call the onRetry handler if provided.
|
|
217
|
+
* Skip on the first attempt since no previous attempt exists yet.
|
|
218
|
+
*/
|
|
219
|
+
if (previousAttempt) {
|
|
220
|
+
const currentAttempt = {
|
|
221
|
+
...previousAttempt,
|
|
222
|
+
model: this.currentModel
|
|
223
|
+
};
|
|
224
|
+
/**
|
|
225
|
+
* Create a shallow copy of the attempts for testing purposes
|
|
226
|
+
*/
|
|
227
|
+
const updatedAttempts = [...attempts];
|
|
228
|
+
const context = {
|
|
229
|
+
current: currentAttempt,
|
|
230
|
+
attempts: updatedAttempts
|
|
231
|
+
};
|
|
232
|
+
this.options.onRetry?.(context);
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
/**
|
|
236
|
+
* Call the function that may need to be retried
|
|
237
|
+
*/
|
|
238
|
+
const result = await input.fn();
|
|
239
|
+
/**
|
|
240
|
+
* Check if the result should trigger a retry (only for generate results, not streams)
|
|
241
|
+
*/
|
|
242
|
+
if (isGenerateResult(result)) {
|
|
243
|
+
const { retryModel, attempt } = await this.handleResult(result, attempts);
|
|
244
|
+
attempts.push(attempt);
|
|
245
|
+
if (retryModel) {
|
|
246
|
+
if (retryModel.delay) await delay(retryModel.delay, { abortSignal: input.abortSignal });
|
|
247
|
+
this.currentModel = retryModel.model;
|
|
248
|
+
/**
|
|
249
|
+
* Continue to the next iteration to retry
|
|
250
|
+
*/
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
result,
|
|
256
|
+
attempts
|
|
257
|
+
};
|
|
258
|
+
} catch (error) {
|
|
259
|
+
const { retryModel, attempt } = await this.handleError(error, attempts);
|
|
260
|
+
attempts.push(attempt);
|
|
261
|
+
if (retryModel.delay) await delay(retryModel.delay, { abortSignal: input.abortSignal });
|
|
262
|
+
this.currentModel = retryModel.model;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Handle a successful result and determine if a retry is needed
|
|
268
|
+
*/
|
|
269
|
+
async handleResult(result, attempts) {
|
|
270
|
+
const resultAttempt = {
|
|
271
|
+
type: "result",
|
|
272
|
+
result,
|
|
273
|
+
model: this.currentModel
|
|
274
|
+
};
|
|
275
|
+
/**
|
|
276
|
+
* Save the current attempt
|
|
277
|
+
*/
|
|
278
|
+
const updatedAttempts = [...attempts, resultAttempt];
|
|
279
|
+
const context = {
|
|
280
|
+
current: resultAttempt,
|
|
281
|
+
attempts: updatedAttempts
|
|
282
|
+
};
|
|
283
|
+
return {
|
|
284
|
+
retryModel: await findRetryModel(this.options.retries, context),
|
|
285
|
+
attempt: resultAttempt
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Handle an error and determine if a retry is needed
|
|
290
|
+
*/
|
|
291
|
+
async handleError(error, attempts) {
|
|
292
|
+
const errorAttempt = {
|
|
293
|
+
type: "error",
|
|
294
|
+
error,
|
|
295
|
+
model: this.currentModel
|
|
296
|
+
};
|
|
297
|
+
/**
|
|
298
|
+
* Save the current attempt
|
|
299
|
+
*/
|
|
300
|
+
const updatedAttempts = [...attempts, errorAttempt];
|
|
301
|
+
const context = {
|
|
302
|
+
current: errorAttempt,
|
|
303
|
+
attempts: updatedAttempts
|
|
304
|
+
};
|
|
305
|
+
this.options.onError?.(context);
|
|
306
|
+
const retryModel = await findRetryModel(this.options.retries, context);
|
|
307
|
+
/**
|
|
308
|
+
* Handler didn't return any models to try next, rethrow the error.
|
|
309
|
+
* If we retried the request, wrap the error into a `RetryError` for better visibility.
|
|
310
|
+
*/
|
|
311
|
+
if (!retryModel) {
|
|
312
|
+
if (updatedAttempts.length > 1) throw prepareRetryError(error, updatedAttempts);
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
retryModel,
|
|
317
|
+
attempt: errorAttempt
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
async doGenerate(options) {
|
|
321
|
+
/**
|
|
322
|
+
* Always start with the original model
|
|
323
|
+
*/
|
|
324
|
+
this.currentModel = this.baseModel;
|
|
325
|
+
const { result } = await this.withRetry({
|
|
326
|
+
fn: async () => await this.currentModel.doGenerate(options),
|
|
327
|
+
abortSignal: options.abortSignal
|
|
328
|
+
});
|
|
329
|
+
return result;
|
|
330
|
+
}
|
|
331
|
+
async doStream(options) {
|
|
332
|
+
/**
|
|
333
|
+
* Always start with the original model
|
|
334
|
+
*/
|
|
335
|
+
this.currentModel = this.baseModel;
|
|
336
|
+
/**
|
|
337
|
+
* Perform the initial call to doStream with retry logic to handle errors before any data is streamed.
|
|
338
|
+
*/
|
|
339
|
+
let { result, attempts } = await this.withRetry({
|
|
340
|
+
fn: async () => await this.currentModel.doStream(options),
|
|
341
|
+
abortSignal: options.abortSignal
|
|
342
|
+
});
|
|
343
|
+
/**
|
|
344
|
+
* Wrap the original stream to handle retries if an error occurs during streaming.
|
|
345
|
+
*/
|
|
346
|
+
const retryableStream = new ReadableStream({ start: async (controller) => {
|
|
347
|
+
let reader;
|
|
348
|
+
let isStreaming = false;
|
|
349
|
+
while (true) try {
|
|
350
|
+
reader = result.stream.getReader();
|
|
351
|
+
while (true) {
|
|
352
|
+
const { done, value } = await reader.read();
|
|
353
|
+
if (done) break;
|
|
354
|
+
/**
|
|
355
|
+
* If the stream part is an error and no data has been streamed yet, we can retry
|
|
356
|
+
* Throw the error to trigger the retry logic in withRetry
|
|
357
|
+
*/
|
|
358
|
+
if (value.type === "error") {
|
|
359
|
+
if (!isStreaming) throw value.error;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Mark that streaming has started once we receive actual content
|
|
363
|
+
*/
|
|
364
|
+
if (isStreamContentPart(value)) isStreaming = true;
|
|
365
|
+
/**
|
|
366
|
+
* Enqueue the chunk to the consumer of the stream
|
|
367
|
+
*/
|
|
368
|
+
controller.enqueue(value);
|
|
369
|
+
}
|
|
370
|
+
controller.close();
|
|
371
|
+
break;
|
|
372
|
+
} catch (error) {
|
|
373
|
+
/**
|
|
374
|
+
* Check if the error from the stream can be retried.
|
|
375
|
+
* Otherwise it will rethrow the error.
|
|
376
|
+
*/
|
|
377
|
+
const { retryModel, attempt } = await this.handleError(error, attempts);
|
|
378
|
+
/**
|
|
379
|
+
* Save the attempt
|
|
380
|
+
*/
|
|
381
|
+
attempts.push(attempt);
|
|
382
|
+
if (retryModel.delay) await delay(retryModel.delay, { abortSignal: options.abortSignal });
|
|
383
|
+
this.currentModel = retryModel.model;
|
|
384
|
+
/**
|
|
385
|
+
* Retry the request by calling doStream again.
|
|
386
|
+
* This will create a new stream.
|
|
387
|
+
*/
|
|
388
|
+
const retriedResult = await this.withRetry({
|
|
389
|
+
fn: async () => await this.currentModel.doStream(options),
|
|
390
|
+
attempts,
|
|
391
|
+
abortSignal: options.abortSignal
|
|
392
|
+
});
|
|
393
|
+
/**
|
|
394
|
+
* Cancel the previous reader and stream if we are retrying
|
|
395
|
+
*/
|
|
396
|
+
await reader?.cancel();
|
|
397
|
+
result = retriedResult.result;
|
|
398
|
+
attempts = retriedResult.attempts;
|
|
399
|
+
} finally {
|
|
400
|
+
reader?.releaseLock();
|
|
401
|
+
}
|
|
402
|
+
} });
|
|
403
|
+
return {
|
|
404
|
+
...result,
|
|
405
|
+
stream: retryableStream
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
//#endregion
|
|
411
|
+
//#region src/create-retryable-model.ts
|
|
412
|
+
function createRetryable(options) {
|
|
413
|
+
if ("doEmbed" in options.model) return new RetryableEmbeddingModel(options);
|
|
414
|
+
return new RetryableLanguageModel(options);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
//#endregion
|
|
418
|
+
export { createRetryable, getModelKey };
|
|
@@ -1,50 +1,25 @@
|
|
|
1
|
-
import { RetryModel, Retryable } from "../
|
|
2
|
-
import { LanguageModelV2 } from "@ai-sdk/provider";
|
|
1
|
+
import { EmbeddingModelV2, LanguageModelV2, RetryModel, Retryable } from "../types-BrJaHkFh.js";
|
|
3
2
|
|
|
4
|
-
//#region src/retryables/anthropic-service-overloaded.d.ts
|
|
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
|
-
* @deprecated Use `serviceOverloaded` instead
|
|
27
|
-
*/
|
|
28
|
-
declare function anthropicServiceOverloaded(model: LanguageModelV2, options?: Omit<RetryModel, 'model'>): Retryable;
|
|
29
|
-
//#endregion
|
|
30
3
|
//#region src/retryables/content-filter-triggered.d.ts
|
|
4
|
+
|
|
31
5
|
/**
|
|
32
6
|
* Fallback to a different model if the content filter was triggered.
|
|
33
7
|
*/
|
|
34
|
-
declare function contentFilterTriggered(model:
|
|
8
|
+
declare function contentFilterTriggered<MODEL extends LanguageModelV2>(model: MODEL, options?: Omit<RetryModel<MODEL>, 'model'>): Retryable<MODEL>;
|
|
35
9
|
//#endregion
|
|
36
10
|
//#region src/retryables/request-not-retryable.d.ts
|
|
37
11
|
/**
|
|
38
12
|
* Fallback to a different model if the error is non-retryable.
|
|
39
13
|
*/
|
|
40
|
-
declare function requestNotRetryable(model:
|
|
14
|
+
declare function requestNotRetryable<MODEL extends LanguageModelV2 | EmbeddingModelV2>(model: MODEL, options?: Omit<RetryModel<MODEL>, 'model'>): Retryable<MODEL>;
|
|
41
15
|
//#endregion
|
|
42
16
|
//#region src/retryables/request-timeout.d.ts
|
|
43
17
|
/**
|
|
44
18
|
* Fallback to a different model after a timeout/abort error.
|
|
45
|
-
* Use in combination with the `abortSignal` option
|
|
19
|
+
* Use in combination with the `abortSignal` option.
|
|
20
|
+
* Works with both `LanguageModelV2` and `EmbeddingModelV2`.
|
|
46
21
|
*/
|
|
47
|
-
declare function requestTimeout(model:
|
|
22
|
+
declare function requestTimeout<MODEL extends LanguageModelV2 | EmbeddingModelV2>(model: MODEL, options?: Omit<RetryModel<MODEL>, 'model'>): Retryable<MODEL>;
|
|
48
23
|
//#endregion
|
|
49
24
|
//#region src/retryables/service-overloaded.d.ts
|
|
50
25
|
/**
|
|
@@ -54,6 +29,6 @@ declare function requestTimeout(model: LanguageModelV2, options?: Omit<RetryMode
|
|
|
54
29
|
* - Response with `type: "overloaded_error"`
|
|
55
30
|
* - Response with a `message` containing "overloaded"
|
|
56
31
|
*/
|
|
57
|
-
declare function serviceOverloaded(model:
|
|
32
|
+
declare function serviceOverloaded<MODEL extends LanguageModelV2 | EmbeddingModelV2>(model: MODEL, options?: Omit<RetryModel<MODEL>, 'model'>): Retryable<MODEL>;
|
|
58
33
|
//#endregion
|
|
59
|
-
export {
|
|
34
|
+
export { contentFilterTriggered, requestNotRetryable, requestTimeout, serviceOverloaded };
|
package/dist/retryables/index.js
CHANGED
|
@@ -1,38 +1,7 @@
|
|
|
1
|
-
import { isErrorAttempt, isObject, isResultAttempt, isString } from "../
|
|
1
|
+
import { isErrorAttempt, isObject, isResultAttempt, isString } from "../utils-lRsC105f.js";
|
|
2
2
|
import { isAbortError } from "@ai-sdk/provider-utils";
|
|
3
3
|
import { APICallError } from "ai";
|
|
4
4
|
|
|
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
|
-
* @deprecated Use `serviceOverloaded` instead
|
|
15
|
-
*/
|
|
16
|
-
function anthropicServiceOverloaded(model, options) {
|
|
17
|
-
return (context) => {
|
|
18
|
-
const { current } = context;
|
|
19
|
-
if (isErrorAttempt(current)) {
|
|
20
|
-
const { error } = current;
|
|
21
|
-
if (APICallError.isInstance(error) && error.statusCode === 529) return {
|
|
22
|
-
model,
|
|
23
|
-
maxAttempts: 1,
|
|
24
|
-
...options
|
|
25
|
-
};
|
|
26
|
-
if (isObject(error) && isString(error.type) && error.type === "overloaded_error") return {
|
|
27
|
-
model,
|
|
28
|
-
maxAttempts: 1,
|
|
29
|
-
...options
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
//#endregion
|
|
36
5
|
//#region src/retryables/content-filter-triggered.ts
|
|
37
6
|
/**
|
|
38
7
|
* Fallback to a different model if the content filter was triggered.
|
|
@@ -81,7 +50,8 @@ function requestNotRetryable(model, options) {
|
|
|
81
50
|
//#region src/retryables/request-timeout.ts
|
|
82
51
|
/**
|
|
83
52
|
* Fallback to a different model after a timeout/abort error.
|
|
84
|
-
* Use in combination with the `abortSignal` option
|
|
53
|
+
* Use in combination with the `abortSignal` option.
|
|
54
|
+
* Works with both `LanguageModelV2` and `EmbeddingModelV2`.
|
|
85
55
|
*/
|
|
86
56
|
function requestTimeout(model, options) {
|
|
87
57
|
return (context) => {
|
|
@@ -130,4 +100,4 @@ function serviceOverloaded(model, options) {
|
|
|
130
100
|
}
|
|
131
101
|
|
|
132
102
|
//#endregion
|
|
133
|
-
export {
|
|
103
|
+
export { contentFilterTriggered, requestNotRetryable, requestTimeout, serviceOverloaded };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { EmbeddingModelV2, LanguageModelV2 } from "@ai-sdk/provider";
|
|
2
|
+
|
|
3
|
+
//#region src/types.d.ts
|
|
4
|
+
type EmbeddingModelV2$1<VALUE = any> = EmbeddingModelV2<VALUE>;
|
|
5
|
+
/**
|
|
6
|
+
* Options for creating a retryable model.
|
|
7
|
+
*/
|
|
8
|
+
interface RetryableModelOptions<MODEL extends LanguageModelV2 | EmbeddingModelV2$1> {
|
|
9
|
+
model: MODEL;
|
|
10
|
+
retries: Retries<MODEL>;
|
|
11
|
+
onError?: (context: RetryContext<MODEL>) => void;
|
|
12
|
+
onRetry?: (context: RetryContext<MODEL>) => void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The context provided to Retryables with the current attempt and all previous attempts.
|
|
16
|
+
*/
|
|
17
|
+
type RetryContext<MODEL extends LanguageModelV2 | EmbeddingModelV2$1> = {
|
|
18
|
+
/**
|
|
19
|
+
* Current attempt that caused the retry
|
|
20
|
+
*/
|
|
21
|
+
current: RetryAttempt<MODEL>;
|
|
22
|
+
/**
|
|
23
|
+
* All attempts made so far, including the current one
|
|
24
|
+
*/
|
|
25
|
+
attempts: Array<RetryAttempt<MODEL>>;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* A retry attempt with an error
|
|
29
|
+
*/
|
|
30
|
+
type RetryErrorAttempt<MODEL extends LanguageModelV2 | EmbeddingModelV2$1> = {
|
|
31
|
+
type: 'error';
|
|
32
|
+
error: unknown;
|
|
33
|
+
result?: undefined;
|
|
34
|
+
model: MODEL;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* A retry attempt with a successful result
|
|
38
|
+
*/
|
|
39
|
+
type RetryResultAttempt = {
|
|
40
|
+
type: 'result';
|
|
41
|
+
result: LanguageModelV2Generate;
|
|
42
|
+
error?: undefined;
|
|
43
|
+
model: LanguageModelV2;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* A retry attempt with either an error or a result and the model used
|
|
47
|
+
*/
|
|
48
|
+
type RetryAttempt<MODEL extends LanguageModelV2 | EmbeddingModelV2$1> = RetryErrorAttempt<MODEL> | RetryResultAttempt;
|
|
49
|
+
/**
|
|
50
|
+
* A model to retry with and the maximum number of attempts for that model.
|
|
51
|
+
*/
|
|
52
|
+
type RetryModel<MODEL extends LanguageModelV2 | EmbeddingModelV2$1> = {
|
|
53
|
+
model: MODEL;
|
|
54
|
+
maxAttempts?: number;
|
|
55
|
+
delay?: number;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* A function that determines whether to retry with a different model based on the current attempt and all previous attempts.
|
|
59
|
+
*/
|
|
60
|
+
type Retryable<MODEL extends LanguageModelV2 | EmbeddingModelV2$1> = (context: RetryContext<MODEL>) => RetryModel<MODEL> | Promise<RetryModel<MODEL>> | undefined;
|
|
61
|
+
type Retries<MODEL extends LanguageModelV2 | EmbeddingModelV2$1> = Array<Retryable<MODEL> | MODEL>;
|
|
62
|
+
type LanguageModelV2Generate = Awaited<ReturnType<LanguageModelV2['doGenerate']>>;
|
|
63
|
+
type LanguageModelV2Stream = Awaited<ReturnType<LanguageModelV2['doStream']>>;
|
|
64
|
+
type EmbeddingModelV2CallOptions<VALUE> = Parameters<EmbeddingModelV2$1<VALUE>['doEmbed']>[0];
|
|
65
|
+
type EmbeddingModelV2Embed<VALUE> = Awaited<ReturnType<EmbeddingModelV2$1<VALUE>['doEmbed']>>;
|
|
66
|
+
//#endregion
|
|
67
|
+
export { EmbeddingModelV2$1 as EmbeddingModelV2, EmbeddingModelV2CallOptions, EmbeddingModelV2Embed, type LanguageModelV2, LanguageModelV2Generate, LanguageModelV2Stream, Retries, RetryAttempt, RetryContext, RetryErrorAttempt, RetryModel, RetryResultAttempt, Retryable, RetryableModelOptions };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
//#region src/utils.ts
|
|
2
|
+
const isObject = (value) => typeof value === "object" && value !== null;
|
|
3
|
+
const isString = (value) => typeof value === "string";
|
|
4
|
+
const isGenerateResult = (result) => "content" in result;
|
|
5
|
+
/**
|
|
6
|
+
* Type guard to check if a retry attempt is an error attempt
|
|
7
|
+
*/
|
|
8
|
+
function isErrorAttempt(attempt) {
|
|
9
|
+
return attempt.type === "error";
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Type guard to check if a retry attempt is a result attempt
|
|
13
|
+
*/
|
|
14
|
+
function isResultAttempt(attempt) {
|
|
15
|
+
return attempt.type === "result";
|
|
16
|
+
}
|
|
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
|
+
export { isErrorAttempt, isGenerateResult, isObject, isResultAttempt, isStreamContentPart, isString };
|
package/package.json
CHANGED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
import { LanguageModelV2 } from "@ai-sdk/provider";
|
|
2
|
-
|
|
3
|
-
//#region src/types.d.ts
|
|
4
|
-
type LanguageModelV2Generate = Awaited<ReturnType<LanguageModelV2['doGenerate']>>;
|
|
5
|
-
//#endregion
|
|
6
|
-
//#region src/create-retryable-model.d.ts
|
|
7
|
-
/**
|
|
8
|
-
* The context provided to Retryables with the current attempt and all previous attempts.
|
|
9
|
-
*/
|
|
10
|
-
interface RetryContext<CURRENT extends RetryAttempt = RetryAttempt> {
|
|
11
|
-
/**
|
|
12
|
-
* Current attempt that caused the retry
|
|
13
|
-
*/
|
|
14
|
-
current: CURRENT;
|
|
15
|
-
/**
|
|
16
|
-
* All attempts made so far, including the current one
|
|
17
|
-
*/
|
|
18
|
-
attempts: Array<RetryAttempt>;
|
|
19
|
-
/**
|
|
20
|
-
* @deprecated Use `attempts.length` instead
|
|
21
|
-
*/
|
|
22
|
-
totalAttempts: number;
|
|
23
|
-
}
|
|
24
|
-
/**
|
|
25
|
-
* A retry attempt with an error
|
|
26
|
-
*/
|
|
27
|
-
type RetryErrorAttempt = {
|
|
28
|
-
type: 'error';
|
|
29
|
-
error: unknown;
|
|
30
|
-
model: LanguageModelV2;
|
|
31
|
-
};
|
|
32
|
-
/**
|
|
33
|
-
* A retry attempt with a successful result
|
|
34
|
-
*/
|
|
35
|
-
type RetryResultAttempt = {
|
|
36
|
-
type: 'result';
|
|
37
|
-
result: LanguageModelV2Generate;
|
|
38
|
-
model: LanguageModelV2;
|
|
39
|
-
};
|
|
40
|
-
/**
|
|
41
|
-
* A retry attempt with either an error or a result and the model used
|
|
42
|
-
*/
|
|
43
|
-
type RetryAttempt = RetryErrorAttempt | RetryResultAttempt;
|
|
44
|
-
/**
|
|
45
|
-
* Type guard to check if a retry attempt is an error attempt
|
|
46
|
-
*/
|
|
47
|
-
declare function isErrorAttempt(attempt: RetryAttempt): attempt is RetryErrorAttempt;
|
|
48
|
-
/**
|
|
49
|
-
* Type guard to check if a retry attempt is a result attempt
|
|
50
|
-
*/
|
|
51
|
-
declare function isResultAttempt(attempt: RetryAttempt): attempt is RetryResultAttempt;
|
|
52
|
-
/**
|
|
53
|
-
* A model to retry with and the maximum number of attempts for that model.
|
|
54
|
-
*/
|
|
55
|
-
type RetryModel = {
|
|
56
|
-
model: LanguageModelV2;
|
|
57
|
-
maxAttempts?: number;
|
|
58
|
-
};
|
|
59
|
-
/**
|
|
60
|
-
* A function that determines whether to retry with a different model based on the current attempt and all previous attempts.
|
|
61
|
-
*/
|
|
62
|
-
type Retryable = (context: RetryContext) => RetryModel | Promise<RetryModel> | undefined;
|
|
63
|
-
/**
|
|
64
|
-
* Options for creating a retryable model.
|
|
65
|
-
*/
|
|
66
|
-
interface CreateRetryableOptions {
|
|
67
|
-
model: LanguageModelV2;
|
|
68
|
-
retries: Array<Retryable | LanguageModelV2>;
|
|
69
|
-
onError?: (context: RetryContext<RetryErrorAttempt>) => void;
|
|
70
|
-
onRetry?: (context: RetryContext<RetryErrorAttempt | RetryResultAttempt>) => void;
|
|
71
|
-
}
|
|
72
|
-
declare function createRetryable(config: CreateRetryableOptions): LanguageModelV2;
|
|
73
|
-
//#endregion
|
|
74
|
-
export { CreateRetryableOptions, RetryAttempt, RetryContext, RetryModel, Retryable, createRetryable, isErrorAttempt, isResultAttempt };
|
|
@@ -1,308 +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/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({
|
|
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 };
|