@effect-uai/core 0.5.0 → 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.
Files changed (40) hide show
  1. package/dist/{Items-DqbaJoz7.d.mts → Items-BH8xUkoR.d.mts} +6 -6
  2. package/dist/{Items-DqbaJoz7.d.mts.map → Items-BH8xUkoR.d.mts.map} +1 -1
  3. package/dist/{Tool-Y0__Py1H.d.mts → Tool-87ViKCCO.d.mts} +2 -2
  4. package/dist/{Tool-Y0__Py1H.d.mts.map → Tool-87ViKCCO.d.mts.map} +1 -1
  5. package/dist/{Turn-ChbL2foc.d.mts → Turn-0CwCAyVe.d.mts} +2 -2
  6. package/dist/{Turn-ChbL2foc.d.mts.map → Turn-0CwCAyVe.d.mts.map} +1 -1
  7. package/dist/domain/Items.d.mts +1 -1
  8. package/dist/domain/Turn.d.mts +1 -1
  9. package/dist/index.d.mts +5 -4
  10. package/dist/index.mjs +2 -1
  11. package/dist/language-model/LanguageModel.d.mts +27 -25
  12. package/dist/language-model/LanguageModel.d.mts.map +1 -1
  13. package/dist/language-model/LanguageModel.mjs +16 -25
  14. package/dist/language-model/LanguageModel.mjs.map +1 -1
  15. package/dist/language-model/LanguageModel.test.mjs +10 -81
  16. package/dist/language-model/LanguageModel.test.mjs.map +1 -1
  17. package/dist/loop/Loop.d.mts +1 -1
  18. package/dist/retry/Retry.d.mts +64 -0
  19. package/dist/retry/Retry.d.mts.map +1 -0
  20. package/dist/retry/Retry.mjs +69 -0
  21. package/dist/retry/Retry.mjs.map +1 -0
  22. package/dist/retry/Retry.test.d.mts +1 -0
  23. package/dist/retry/Retry.test.mjs +142 -0
  24. package/dist/retry/Retry.test.mjs.map +1 -0
  25. package/dist/testing/MockProvider.d.mts +2 -2
  26. package/dist/testing/MockProvider.d.mts.map +1 -1
  27. package/dist/testing/MockProvider.mjs +14 -8
  28. package/dist/testing/MockProvider.mjs.map +1 -1
  29. package/dist/tool/HistoryCheck.d.mts +1 -1
  30. package/dist/tool/Outcome.d.mts +1 -1
  31. package/dist/tool/Resolvers.d.mts +1 -1
  32. package/dist/tool/Tool.d.mts +1 -1
  33. package/dist/tool/Toolkit.d.mts +2 -2
  34. package/package.json +5 -1
  35. package/src/index.ts +1 -0
  36. package/src/language-model/LanguageModel.test.ts +21 -102
  37. package/src/language-model/LanguageModel.ts +33 -56
  38. package/src/retry/Retry.test.ts +163 -0
  39. package/src/retry/Retry.ts +84 -0
  40. package/src/testing/MockProvider.ts +10 -5
@@ -1,4 +1,4 @@
1
- import { Array as Arr, Context, Data, Effect, Option, type Schedule, Stream } from "effect"
1
+ import { Array as Arr, Context, Effect, Option, Stream } from "effect"
2
2
  import * as AiError from "../domain/AiError.js"
3
3
  import type { Item } from "../domain/Items.js"
4
4
  import type * as StructuredFormat from "../structured-format/StructuredFormat.js"
@@ -38,6 +38,16 @@ export type CommonRequest = {
38
38
 
39
39
  export type LanguageModelService = {
40
40
  readonly streamTurn: (request: CommonRequest) => Stream.Stream<TurnEvent, AiError.AiError>
41
+ /**
42
+ * Drain a single turn and return the assembled `Turn` from the
43
+ * terminal `TurnComplete` event. Fails with `IncompleteTurn` if the
44
+ * stream ends without one.
45
+ *
46
+ * Most providers derive this from `streamTurn` via
47
+ * {@link turnFromStream}; providers with a native non-streaming
48
+ * endpoint may override with a cheaper direct call.
49
+ */
50
+ readonly turn: (request: CommonRequest) => Effect.Effect<Turn, AiError.AiError>
41
51
  }
42
52
 
43
53
  export class LanguageModel extends Context.Service<LanguageModel, LanguageModelService>()(
@@ -53,64 +63,31 @@ export const streamTurn = (
53
63
  Stream.unwrap(Effect.map(LanguageModel.asEffect(), (m) => m.streamTurn(request)))
54
64
 
55
65
  /**
56
- * Drain `streamTurn` and return the assembled `Turn` from the terminal
57
- * `TurnComplete` event. Fails with `IncompleteTurn` if the stream ends
58
- * without one. Derived from `streamTurn`; providers get it for free.
66
+ * Drain a single turn and return the assembled `Turn`. Delegates to the
67
+ * service's `turn` method — providers with a native complete endpoint
68
+ * can override; the rest get the default streamTurn-drain via
69
+ * {@link turnFromStream}.
59
70
  */
60
- export const turn = (
61
- request: CommonRequest,
62
- ): Effect.Effect<Turn, AiError.AiError | AiError.IncompleteTurn, LanguageModel> =>
63
- streamTurn(request).pipe(
64
- Stream.runCollect,
65
- Effect.flatMap((events) =>
66
- Arr.findLast(events, isTurnComplete).pipe(
67
- Option.match({
68
- onNone: () => Effect.fail(new AiError.IncompleteTurn({})),
69
- onSome: (e) => Effect.succeed(e.turn),
70
- }),
71
- ),
72
- ),
73
- )
74
-
75
- // ---------------------------------------------------------------------------
76
- // retry — retry the retryable subset of AiError, let other failures escape
77
- // ---------------------------------------------------------------------------
78
-
79
- /** Internal wrapper around the retryable subset of `AiError`. */
80
- export class Retryable extends Data.TaggedError("RetryableAi")<{
81
- readonly cause: AiError.RateLimited | AiError.Unavailable | AiError.Timeout
82
- }> {}
83
-
84
- const isRetryable = (
85
- e: AiError.AiError,
86
- ): e is AiError.RateLimited | AiError.Unavailable | AiError.Timeout =>
87
- e._tag === "RateLimited" || e._tag === "Unavailable" || e._tag === "Timeout"
88
-
89
- // Lift events to Items, non-retryable failures to Terminal values (escape
90
- // retry), retryable failures to wrapped errors (only thing retry sees).
91
- type Lifted<A> =
92
- | { readonly _tag: "Item"; readonly value: A }
93
- | { readonly _tag: "Terminal"; readonly cause: AiError.AiError }
71
+ export const turn = (request: CommonRequest): Effect.Effect<Turn, AiError.AiError, LanguageModel> =>
72
+ Effect.flatMap(LanguageModel.asEffect(), (m) => m.turn(request))
94
73
 
95
74
  /**
96
- * Retry a stream of `AiError` on the retryable subset
97
- * (`RateLimited | Unavailable | Timeout`). Other failures bypass the
98
- * schedule and propagate unchanged. Like all `Stream.retry`, the entire
99
- * stream re-runs — deltas before the failure replay on the next attempt.
75
+ * Build a `turn` implementation from a `streamTurn` implementation.
76
+ * Providers without a native non-streaming endpoint use this to
77
+ * populate the service's `turn` field. Generic over the request type so
78
+ * provider-typed services (with their narrowed request) can reuse it.
100
79
  */
101
- export const retry =
102
- <Out>(schedule: Schedule.Schedule<Out, Retryable>) =>
103
- <A, R>(stream: Stream.Stream<A, AiError.AiError, R>): Stream.Stream<A, AiError.AiError, R> =>
104
- stream.pipe(
105
- Stream.map((value): Lifted<A> => ({ _tag: "Item", value })),
106
- Stream.catchIf(
107
- isRetryable,
108
- (cause) => Stream.fail(new Retryable({ cause })),
109
- (cause) => Stream.succeed<Lifted<A>>({ _tag: "Terminal", cause }),
110
- ),
111
- Stream.retry(schedule),
112
- Stream.catchTag("RetryableAi", (e) => Stream.fail<AiError.AiError>(e.cause)),
113
- Stream.flatMap((item) =>
114
- item._tag === "Item" ? Stream.succeed(item.value) : Stream.fail(item.cause),
80
+ export const turnFromStream =
81
+ <Req>(streamTurn: (request: Req) => Stream.Stream<TurnEvent, AiError.AiError>) =>
82
+ (request: Req): Effect.Effect<Turn, AiError.AiError> =>
83
+ streamTurn(request).pipe(
84
+ Stream.runCollect,
85
+ Effect.flatMap((events) =>
86
+ Arr.findLast(events, isTurnComplete).pipe(
87
+ Option.match({
88
+ onNone: () => Effect.fail(new AiError.IncompleteTurn({})),
89
+ onSome: (e) => Effect.succeed(e.turn),
90
+ }),
91
+ ),
115
92
  ),
116
93
  )
@@ -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