@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.
@@ -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 { LanguageModel, type LanguageModelService } from "../language-model/LanguageModel.js"
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