@effect-uai/core 0.5.1 → 0.5.2
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/dist/index.d.mts +2 -1
- package/dist/index.mjs +2 -1
- package/dist/language-model/LanguageModel.d.mts +24 -22
- package/dist/language-model/LanguageModel.d.mts.map +1 -1
- package/dist/language-model/LanguageModel.mjs +16 -25
- package/dist/language-model/LanguageModel.mjs.map +1 -1
- package/dist/language-model/LanguageModel.test.mjs +10 -81
- package/dist/language-model/LanguageModel.test.mjs.map +1 -1
- package/dist/retry/Retry.d.mts +64 -0
- package/dist/retry/Retry.d.mts.map +1 -0
- package/dist/retry/Retry.mjs +69 -0
- package/dist/retry/Retry.mjs.map +1 -0
- package/dist/retry/Retry.test.d.mts +1 -0
- package/dist/retry/Retry.test.mjs +142 -0
- package/dist/retry/Retry.test.mjs.map +1 -0
- package/dist/testing/MockProvider.d.mts.map +1 -1
- package/dist/testing/MockProvider.mjs +14 -8
- package/dist/testing/MockProvider.mjs.map +1 -1
- package/package.json +5 -1
- package/src/index.ts +1 -0
- package/src/language-model/LanguageModel.test.ts +21 -102
- package/src/language-model/LanguageModel.ts +33 -56
- package/src/retry/Retry.test.ts +163 -0
- package/src/retry/Retry.ts +84 -0
- package/src/testing/MockProvider.ts +10 -5
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { Cause, Effect, Exit, Option, Ref, Schedule, Stream } from "effect"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import * as AiError from "../domain/AiError.js"
|
|
4
|
+
import * as Retry from "./Retry.js"
|
|
5
|
+
|
|
6
|
+
// Each call pulls the next plan entry; the last entry repeats forever
|
|
7
|
+
// (so "succeed on attempt N" plans naturally re-yield success after).
|
|
8
|
+
const planned = <A, E>(
|
|
9
|
+
attempts: Ref.Ref<number>,
|
|
10
|
+
plan: ReadonlyArray<Stream.Stream<A, E>>,
|
|
11
|
+
): Stream.Stream<A, E> =>
|
|
12
|
+
Stream.unwrap(
|
|
13
|
+
Ref.getAndUpdate(attempts, (n) => n + 1).pipe(
|
|
14
|
+
Effect.map((n) => plan[Math.min(n, plan.length - 1)]!),
|
|
15
|
+
),
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
const plannedEffect = <A, E>(
|
|
19
|
+
attempts: Ref.Ref<number>,
|
|
20
|
+
plan: ReadonlyArray<Effect.Effect<A, E>>,
|
|
21
|
+
): Effect.Effect<A, E> =>
|
|
22
|
+
Ref.getAndUpdate(attempts, (n) => n + 1).pipe(
|
|
23
|
+
Effect.flatMap((n) => plan[Math.min(n, plan.length - 1)]!),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
describe("Retry.stream", () => {
|
|
27
|
+
it("retries on RateLimited and yields success on the next attempt", async () => {
|
|
28
|
+
const program = Effect.gen(function* () {
|
|
29
|
+
const attempts = yield* Ref.make(0)
|
|
30
|
+
const s = planned(attempts, [
|
|
31
|
+
Stream.fail<AiError.AiError>(new AiError.RateLimited({ provider: "mock", raw: null })),
|
|
32
|
+
Stream.fromIterable(["a", "b"]),
|
|
33
|
+
]).pipe(Retry.stream(Schedule.recurs(3)))
|
|
34
|
+
const out = yield* Stream.runCollect(s)
|
|
35
|
+
const count = yield* Ref.get(attempts)
|
|
36
|
+
return { out: Array.from(out), count }
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const { out, count } = await Effect.runPromise(program)
|
|
40
|
+
expect(count).toBe(2)
|
|
41
|
+
expect(out).toEqual(["a", "b"])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("surfaces the underlying retryable failure when retries are exhausted", async () => {
|
|
45
|
+
const cause = new AiError.Unavailable({ provider: "mock", raw: null })
|
|
46
|
+
const s = Stream.fail<AiError.AiError>(cause).pipe(Retry.stream(Schedule.recurs(2)))
|
|
47
|
+
|
|
48
|
+
const exit = await Effect.runPromise(Effect.exit(Stream.runCollect(s)))
|
|
49
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
50
|
+
if (Exit.isFailure(exit)) {
|
|
51
|
+
expect(Cause.findErrorOption(exit.cause)).toEqual(Option.some(cause))
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("bypasses retry for non-retryable AiError (ContentFiltered)", async () => {
|
|
56
|
+
const program = Effect.gen(function* () {
|
|
57
|
+
const attempts = yield* Ref.make(0)
|
|
58
|
+
const cause = new AiError.ContentFiltered({ provider: "mock", raw: null })
|
|
59
|
+
const s = planned(attempts, [Stream.fail<AiError.AiError>(cause)]).pipe(
|
|
60
|
+
Retry.stream(Schedule.recurs(5)),
|
|
61
|
+
)
|
|
62
|
+
const exit = yield* Effect.exit(Stream.runCollect(s))
|
|
63
|
+
const count = yield* Ref.get(attempts)
|
|
64
|
+
return { exit, count, cause }
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const { exit, count, cause } = await Effect.runPromise(program)
|
|
68
|
+
expect(count).toBe(1)
|
|
69
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
70
|
+
if (Exit.isFailure(exit)) {
|
|
71
|
+
expect(Cause.findErrorOption(exit.cause)).toEqual(Option.some(cause))
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("preserves items emitted before a retryable failure (and replays on retry)", async () => {
|
|
76
|
+
// Documents the 'replays on retry' caveat: the entire stream re-runs.
|
|
77
|
+
const program = Effect.gen(function* () {
|
|
78
|
+
const attempts = yield* Ref.make(0)
|
|
79
|
+
const s = planned(attempts, [
|
|
80
|
+
Stream.concat(
|
|
81
|
+
Stream.succeed("partial"),
|
|
82
|
+
Stream.fail<AiError.AiError>(new AiError.Timeout({ provider: "mock", raw: null })),
|
|
83
|
+
),
|
|
84
|
+
Stream.fromIterable(["partial", "done"]),
|
|
85
|
+
]).pipe(Retry.stream(Schedule.recurs(1)))
|
|
86
|
+
const out = yield* Stream.runCollect(s)
|
|
87
|
+
return Array.from(out)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const out = await Effect.runPromise(program)
|
|
91
|
+
expect(out).toEqual(["partial", "partial", "done"])
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe("Retry.effect", () => {
|
|
96
|
+
it("retries on RateLimited and yields success on the next attempt", async () => {
|
|
97
|
+
const program = Effect.gen(function* () {
|
|
98
|
+
const attempts = yield* Ref.make(0)
|
|
99
|
+
const value = yield* plannedEffect(attempts, [
|
|
100
|
+
Effect.fail<AiError.AiError>(new AiError.RateLimited({ provider: "mock", raw: null })),
|
|
101
|
+
Effect.succeed("ok"),
|
|
102
|
+
]).pipe(Retry.effect(Schedule.recurs(3)))
|
|
103
|
+
const count = yield* Ref.get(attempts)
|
|
104
|
+
return { value, count }
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const { value, count } = await Effect.runPromise(program)
|
|
108
|
+
expect(count).toBe(2)
|
|
109
|
+
expect(value).toBe("ok")
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it("surfaces the underlying retryable failure when retries are exhausted", async () => {
|
|
113
|
+
const cause = new AiError.Unavailable({ provider: "mock", raw: null })
|
|
114
|
+
const eff = Effect.fail<AiError.AiError>(cause).pipe(Retry.effect(Schedule.recurs(2)))
|
|
115
|
+
|
|
116
|
+
const exit = await Effect.runPromise(Effect.exit(eff))
|
|
117
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
118
|
+
if (Exit.isFailure(exit)) {
|
|
119
|
+
expect(Cause.findErrorOption(exit.cause)).toEqual(Option.some(cause))
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("bypasses retry for non-retryable AiError (ContentFiltered)", async () => {
|
|
124
|
+
const program = Effect.gen(function* () {
|
|
125
|
+
const attempts = yield* Ref.make(0)
|
|
126
|
+
const cause = new AiError.ContentFiltered({ provider: "mock", raw: null })
|
|
127
|
+
const exit = yield* Effect.exit(
|
|
128
|
+
plannedEffect(attempts, [Effect.fail<AiError.AiError>(cause)]).pipe(
|
|
129
|
+
Retry.effect(Schedule.recurs(5)),
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
const count = yield* Ref.get(attempts)
|
|
133
|
+
return { exit, count, cause }
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
const { exit, count, cause } = await Effect.runPromise(program)
|
|
137
|
+
expect(count).toBe(1)
|
|
138
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
139
|
+
if (Exit.isFailure(exit)) {
|
|
140
|
+
expect(Cause.findErrorOption(exit.cause)).toEqual(Option.some(cause))
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it("does NOT replay successful prior work — at-most-once semantics", async () => {
|
|
145
|
+
// Contrast with Retry.stream: an Effect.retry only re-invokes the
|
|
146
|
+
// failing computation. No replay of work done before the failure
|
|
147
|
+
// (there's nothing to replay — Effects are single-shot).
|
|
148
|
+
const program = Effect.gen(function* () {
|
|
149
|
+
const attempts = yield* Ref.make(0)
|
|
150
|
+
const value = yield* plannedEffect(attempts, [
|
|
151
|
+
Effect.fail<AiError.AiError>(new AiError.Timeout({ provider: "mock", raw: null })),
|
|
152
|
+
Effect.fail<AiError.AiError>(new AiError.Timeout({ provider: "mock", raw: null })),
|
|
153
|
+
Effect.succeed("done"),
|
|
154
|
+
]).pipe(Retry.effect(Schedule.recurs(5)))
|
|
155
|
+
const count = yield* Ref.get(attempts)
|
|
156
|
+
return { value, count }
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
const { value, count } = await Effect.runPromise(program)
|
|
160
|
+
expect(count).toBe(3)
|
|
161
|
+
expect(value).toBe("done")
|
|
162
|
+
})
|
|
163
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Data, Effect, type Schedule, Stream } from "effect"
|
|
2
|
+
import type * as AiError from "../domain/AiError.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Subset-aware retry helpers for `AiError`.
|
|
6
|
+
*
|
|
7
|
+
* Effect's own `Effect.retry` / `Stream.retry` apply the schedule to
|
|
8
|
+
* every failure. For AI calls that's the wrong policy — replaying a
|
|
9
|
+
* `ContentFiltered` or `InvalidRequest` won't change the outcome and
|
|
10
|
+
* just wastes quota. These helpers gate the schedule on the
|
|
11
|
+
* {@link Retryable} subset (rate limits, transport hiccups, timeouts);
|
|
12
|
+
* everything else propagates unchanged.
|
|
13
|
+
*
|
|
14
|
+
* Two carriers:
|
|
15
|
+
* - {@link stream} — for `Stream<A, AiError, R>` (e.g. `streamTurn`,
|
|
16
|
+
* `streamSynthesis`, `streamTranscriptionFrom`). Caveat: the entire
|
|
17
|
+
* stream re-runs on retry, so deltas before the failure replay on
|
|
18
|
+
* the next attempt.
|
|
19
|
+
* - {@link effect} — for `Effect<A, AiError, R>` (e.g. `turn`, `embed`,
|
|
20
|
+
* `embedMany`, `synthesize`, `transcribe`). At-most-once.
|
|
21
|
+
*
|
|
22
|
+
* Composes with `Stream.retry` / `Effect.retry` directly (no naming
|
|
23
|
+
* collision since these live under the `Retry` namespace).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Wrapper around the retryable subset of `AiError`. Internal to the
|
|
28
|
+
* lift/retry/unlift dance — exported so callers can build their own
|
|
29
|
+
* variants if needed (e.g. a custom schedule that observes `cause`).
|
|
30
|
+
*/
|
|
31
|
+
export class Retryable extends Data.TaggedError("RetryableAi")<{
|
|
32
|
+
readonly cause: AiError.RateLimited | AiError.Unavailable | AiError.Timeout
|
|
33
|
+
}> {}
|
|
34
|
+
|
|
35
|
+
/** Type-narrowing predicate for the retryable subset. */
|
|
36
|
+
export const isRetryable = (
|
|
37
|
+
e: AiError.AiError,
|
|
38
|
+
): e is AiError.RateLimited | AiError.Unavailable | AiError.Timeout =>
|
|
39
|
+
e._tag === "RateLimited" || e._tag === "Unavailable" || e._tag === "Timeout"
|
|
40
|
+
|
|
41
|
+
// Lift events to Items, non-retryable failures to Terminal values
|
|
42
|
+
// (escape retry), retryable failures to wrapped errors (only thing
|
|
43
|
+
// retry sees).
|
|
44
|
+
type Lifted<A> =
|
|
45
|
+
| { readonly _tag: "Item"; readonly value: A }
|
|
46
|
+
| { readonly _tag: "Terminal"; readonly cause: AiError.AiError }
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Retry a `Stream<A, AiError, R>` on the retryable subset. Other
|
|
50
|
+
* failures bypass the schedule and propagate unchanged. The whole
|
|
51
|
+
* stream replays on each attempt — deltas emitted before the failure
|
|
52
|
+
* are re-emitted on the next try.
|
|
53
|
+
*/
|
|
54
|
+
export const stream =
|
|
55
|
+
<Out>(schedule: Schedule.Schedule<Out, Retryable>) =>
|
|
56
|
+
<A, R>(s: Stream.Stream<A, AiError.AiError, R>): Stream.Stream<A, AiError.AiError, R> =>
|
|
57
|
+
s.pipe(
|
|
58
|
+
Stream.map((value): Lifted<A> => ({ _tag: "Item", value })),
|
|
59
|
+
Stream.catchIf(
|
|
60
|
+
isRetryable,
|
|
61
|
+
(cause) => Stream.fail(new Retryable({ cause })),
|
|
62
|
+
(cause) => Stream.succeed<Lifted<A>>({ _tag: "Terminal", cause }),
|
|
63
|
+
),
|
|
64
|
+
Stream.retry(schedule),
|
|
65
|
+
Stream.catchTag("RetryableAi", (e) => Stream.fail<AiError.AiError>(e.cause)),
|
|
66
|
+
Stream.flatMap((item) =>
|
|
67
|
+
item._tag === "Item" ? Stream.succeed(item.value) : Stream.fail(item.cause),
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Retry an `Effect<A, AiError, R>` on the retryable subset. Other
|
|
73
|
+
* failures bypass the schedule and propagate unchanged. At-most-once
|
|
74
|
+
* — the underlying call only re-runs if the previous attempt failed
|
|
75
|
+
* with a retryable tag.
|
|
76
|
+
*
|
|
77
|
+
* Skips the lift/unlift dance the Stream variant needs: `Effect.retry`
|
|
78
|
+
* exposes a `while:` predicate so the error channel stays a plain
|
|
79
|
+
* `AiError` throughout.
|
|
80
|
+
*/
|
|
81
|
+
export const effect =
|
|
82
|
+
<Out>(schedule: Schedule.Schedule<Out, AiError.AiError>) =>
|
|
83
|
+
<A, R>(eff: Effect.Effect<A, AiError.AiError, R>): Effect.Effect<A, AiError.AiError, R> =>
|
|
84
|
+
Effect.retry(eff, { schedule, while: isRetryable })
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { Array as Arr, Duration, Effect, Layer, Match, Option, Ref, Schedule, Stream } from "effect"
|
|
2
2
|
import * as AiError from "../domain/AiError.js"
|
|
3
3
|
import { type Item, isOutputText } from "../domain/Items.js"
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
LanguageModel,
|
|
6
|
+
type LanguageModelService,
|
|
7
|
+
turnFromStream,
|
|
8
|
+
} from "../language-model/LanguageModel.js"
|
|
5
9
|
import { type Turn, TurnEvent } from "../domain/Turn.js"
|
|
6
10
|
|
|
7
11
|
export type MockOptions = {
|
|
@@ -81,8 +85,8 @@ const buildService = (
|
|
|
81
85
|
options: MockOptions | undefined,
|
|
82
86
|
cursor: Ref.Ref<number>,
|
|
83
87
|
record: (call: Call) => Effect.Effect<void>,
|
|
84
|
-
): LanguageModelService =>
|
|
85
|
-
streamTurn: (request) =>
|
|
88
|
+
): LanguageModelService => {
|
|
89
|
+
const streamTurn: LanguageModelService["streamTurn"] = (request) =>
|
|
86
90
|
Stream.unwrap(
|
|
87
91
|
Ref.getAndUpdate(cursor, (n) => n + 1).pipe(
|
|
88
92
|
Effect.flatMap(
|
|
@@ -96,8 +100,9 @@ const buildService = (
|
|
|
96
100
|
}),
|
|
97
101
|
),
|
|
98
102
|
),
|
|
99
|
-
)
|
|
100
|
-
}
|
|
103
|
+
)
|
|
104
|
+
return { streamTurn, turn: turnFromStream(streamTurn) }
|
|
105
|
+
}
|
|
101
106
|
|
|
102
107
|
// ---------------------------------------------------------------------------
|
|
103
108
|
// Recorder handle. Unsafe Ref is local: it backs both the `record` write
|