@effect-uai/core 0.4.0 → 0.5.1

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 (124) hide show
  1. package/dist/{AiError-csR8Bhxx.d.mts → AiError-CAX_48RU.d.mts} +2 -2
  2. package/dist/{AiError-csR8Bhxx.d.mts.map → AiError-CAX_48RU.d.mts.map} +1 -1
  3. package/dist/{Image-DxyXqzAM.d.mts → Image-HNmMpMTh.d.mts} +4 -4
  4. package/dist/{Image-DxyXqzAM.d.mts.map → Image-HNmMpMTh.d.mts.map} +1 -1
  5. package/dist/{Items-Hg5AsYxl.d.mts → Items-BH8xUkoR.d.mts} +3 -3
  6. package/dist/{Items-Hg5AsYxl.d.mts.map → Items-BH8xUkoR.d.mts.map} +1 -1
  7. package/dist/{StructuredFormat-Cl41C56K.d.mts → StructuredFormat-BbN4dosH.d.mts} +11 -4
  8. package/dist/StructuredFormat-BbN4dosH.d.mts.map +1 -0
  9. package/dist/{Tool-B8B5qVEy.d.mts → Tool-87ViKCCO.d.mts} +20 -4
  10. package/dist/Tool-87ViKCCO.d.mts.map +1 -0
  11. package/dist/Turn-0CwCAyVe.d.mts +388 -0
  12. package/dist/Turn-0CwCAyVe.d.mts.map +1 -0
  13. package/dist/domain/AiError.d.mts +1 -1
  14. package/dist/domain/AiError.mjs +1 -1
  15. package/dist/domain/AiError.mjs.map +1 -1
  16. package/dist/domain/Image.d.mts +1 -1
  17. package/dist/domain/Items.d.mts +1 -1
  18. package/dist/domain/Items.mjs +1 -1
  19. package/dist/domain/Items.mjs.map +1 -1
  20. package/dist/domain/Turn.d.mts +2 -2
  21. package/dist/domain/Turn.mjs +22 -4
  22. package/dist/domain/Turn.mjs.map +1 -1
  23. package/dist/domain/Turn.test.d.mts +1 -0
  24. package/dist/domain/Turn.test.mjs +136 -0
  25. package/dist/domain/Turn.test.mjs.map +1 -0
  26. package/dist/embedding-model/Embedding.d.mts +15 -3
  27. package/dist/embedding-model/Embedding.d.mts.map +1 -1
  28. package/dist/embedding-model/Embedding.mjs.map +1 -1
  29. package/dist/embedding-model/EmbeddingModel.d.mts +33 -17
  30. package/dist/embedding-model/EmbeddingModel.d.mts.map +1 -1
  31. package/dist/embedding-model/EmbeddingModel.mjs.map +1 -1
  32. package/dist/embedding-model/EmbeddingModel.test.d.mts +1 -0
  33. package/dist/embedding-model/EmbeddingModel.test.mjs +59 -0
  34. package/dist/embedding-model/EmbeddingModel.test.mjs.map +1 -0
  35. package/dist/index.d.mts +6 -6
  36. package/dist/language-model/LanguageModel.d.mts +30 -8
  37. package/dist/language-model/LanguageModel.d.mts.map +1 -1
  38. package/dist/language-model/LanguageModel.mjs +33 -3
  39. package/dist/language-model/LanguageModel.mjs.map +1 -1
  40. package/dist/language-model/LanguageModel.test.d.mts +1 -0
  41. package/dist/language-model/LanguageModel.test.mjs +143 -0
  42. package/dist/language-model/LanguageModel.test.mjs.map +1 -0
  43. package/dist/loop/Loop.d.mts +94 -11
  44. package/dist/loop/Loop.d.mts.map +1 -1
  45. package/dist/loop/Loop.mjs +92 -26
  46. package/dist/loop/Loop.mjs.map +1 -1
  47. package/dist/loop/Loop.test.mjs +171 -3
  48. package/dist/loop/Loop.test.mjs.map +1 -1
  49. package/dist/music-generator/MusicGenerator.d.mts +1 -1
  50. package/dist/observability/Metrics.d.mts +1 -1
  51. package/dist/observability/Metrics.mjs +1 -1
  52. package/dist/observability/Metrics.mjs.map +1 -1
  53. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +1 -1
  54. package/dist/streaming/JSONL.d.mts +1 -1
  55. package/dist/streaming/JSONL.d.mts.map +1 -1
  56. package/dist/streaming/JSONL.mjs +7 -12
  57. package/dist/streaming/JSONL.mjs.map +1 -1
  58. package/dist/structured-format/StructuredFormat.d.mts +2 -2
  59. package/dist/structured-format/StructuredFormat.mjs +9 -1
  60. package/dist/structured-format/StructuredFormat.mjs.map +1 -1
  61. package/dist/structured-format/StructuredFormat.test.d.mts +1 -0
  62. package/dist/structured-format/StructuredFormat.test.mjs +70 -0
  63. package/dist/structured-format/StructuredFormat.test.mjs.map +1 -0
  64. package/dist/testing/MockMusicGenerator.d.mts.map +1 -1
  65. package/dist/testing/MockMusicGenerator.mjs +2 -2
  66. package/dist/testing/MockMusicGenerator.mjs.map +1 -1
  67. package/dist/testing/MockProvider.d.mts +23 -18
  68. package/dist/testing/MockProvider.d.mts.map +1 -1
  69. package/dist/testing/MockProvider.mjs +56 -72
  70. package/dist/testing/MockProvider.mjs.map +1 -1
  71. package/dist/testing/MockSpeechSynthesizer.d.mts.map +1 -1
  72. package/dist/testing/MockSpeechSynthesizer.mjs +2 -2
  73. package/dist/testing/MockSpeechSynthesizer.mjs.map +1 -1
  74. package/dist/testing/MockTranscriber.d.mts.map +1 -1
  75. package/dist/testing/MockTranscriber.mjs +2 -2
  76. package/dist/testing/MockTranscriber.mjs.map +1 -1
  77. package/dist/tool/HistoryCheck.d.mts +1 -1
  78. package/dist/tool/Outcome.d.mts +1 -1
  79. package/dist/tool/Resolvers.d.mts +65 -8
  80. package/dist/tool/Resolvers.d.mts.map +1 -1
  81. package/dist/tool/Resolvers.mjs +8 -12
  82. package/dist/tool/Resolvers.mjs.map +1 -1
  83. package/dist/tool/Resolvers.test.mjs +6 -5
  84. package/dist/tool/Resolvers.test.mjs.map +1 -1
  85. package/dist/tool/Tool.d.mts +2 -2
  86. package/dist/tool/Tool.mjs +18 -1
  87. package/dist/tool/Tool.mjs.map +1 -1
  88. package/dist/tool/Tool.test.d.mts +1 -0
  89. package/dist/tool/Tool.test.mjs +66 -0
  90. package/dist/tool/Tool.test.mjs.map +1 -0
  91. package/dist/tool/Toolkit.d.mts +4 -6
  92. package/dist/tool/Toolkit.d.mts.map +1 -1
  93. package/dist/tool/Toolkit.mjs +14 -43
  94. package/dist/tool/Toolkit.mjs.map +1 -1
  95. package/dist/transcriber/Transcriber.d.mts +1 -1
  96. package/package.json +1 -1
  97. package/src/domain/AiError.ts +1 -1
  98. package/src/domain/Items.ts +1 -1
  99. package/src/domain/Turn.test.ts +141 -0
  100. package/src/domain/Turn.ts +50 -43
  101. package/src/embedding-model/Embedding.ts +23 -0
  102. package/src/embedding-model/EmbeddingModel.test.ts +92 -0
  103. package/src/embedding-model/EmbeddingModel.ts +30 -20
  104. package/src/language-model/LanguageModel.test.ts +170 -0
  105. package/src/language-model/LanguageModel.ts +64 -1
  106. package/src/loop/Loop.test.ts +256 -3
  107. package/src/loop/Loop.ts +225 -49
  108. package/src/observability/Metrics.ts +1 -1
  109. package/src/streaming/JSONL.ts +9 -18
  110. package/src/structured-format/StructuredFormat.test.ts +105 -0
  111. package/src/structured-format/StructuredFormat.ts +14 -1
  112. package/src/testing/MockMusicGenerator.ts +4 -6
  113. package/src/testing/MockProvider.ts +126 -105
  114. package/src/testing/MockSpeechSynthesizer.ts +4 -6
  115. package/src/testing/MockTranscriber.ts +4 -6
  116. package/src/tool/Resolvers.test.ts +8 -5
  117. package/src/tool/Resolvers.ts +17 -19
  118. package/src/tool/Tool.test.ts +105 -0
  119. package/src/tool/Tool.ts +20 -0
  120. package/src/tool/Toolkit.ts +49 -50
  121. package/dist/StructuredFormat-Cl41C56K.d.mts.map +0 -1
  122. package/dist/Tool-B8B5qVEy.d.mts.map +0 -1
  123. package/dist/Turn-7geUcKsf.d.mts +0 -194
  124. package/dist/Turn-7geUcKsf.d.mts.map +0 -1
@@ -0,0 +1,141 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import * as Items from "./Items.js"
3
+ import * as Turn from "./Turn.js"
4
+
5
+ const turnOf = (items: Turn.Turn["items"]): Turn.Turn => ({
6
+ items,
7
+ usage: { input_tokens: 0, output_tokens: 0 },
8
+ stop_reason: "stop",
9
+ })
10
+
11
+ describe("Turn.assistantTexts", () => {
12
+ it("returns each output_text block in order across a single assistant message", () => {
13
+ const turn = turnOf([
14
+ {
15
+ type: "message",
16
+ role: "assistant",
17
+ content: [
18
+ { type: "output_text", text: "first" },
19
+ { type: "output_text", text: "second" },
20
+ ],
21
+ },
22
+ ])
23
+
24
+ expect(Turn.assistantTexts(turn)).toEqual(["first", "second"])
25
+ })
26
+
27
+ it("preserves order across multiple assistant messages", () => {
28
+ const turn = turnOf([
29
+ Items.assistantText("alpha"),
30
+ Items.assistantText("beta"),
31
+ Items.assistantText("gamma"),
32
+ ])
33
+
34
+ expect(Turn.assistantTexts(turn)).toEqual(["alpha", "beta", "gamma"])
35
+ })
36
+
37
+ it("drops refusal blocks and non-assistant messages", () => {
38
+ const turn = turnOf([
39
+ Items.userText("ignored"),
40
+ {
41
+ type: "message",
42
+ role: "assistant",
43
+ content: [
44
+ { type: "output_text", text: "kept" },
45
+ { type: "refusal", text: "I can't help with that." },
46
+ ],
47
+ },
48
+ ])
49
+
50
+ expect(Turn.assistantTexts(turn)).toEqual(["kept"])
51
+ })
52
+
53
+ it("returns an empty array when there's nothing to extract", () => {
54
+ expect(Turn.assistantTexts(turnOf([]))).toEqual([])
55
+ expect(Turn.assistantTexts(turnOf([Items.userText("only user")]))).toEqual([])
56
+ })
57
+
58
+ it("composes with caller-chosen separators", () => {
59
+ const turn = turnOf([Items.assistantText("hello"), Items.assistantText("world")])
60
+
61
+ expect(Turn.assistantTexts(turn).join("")).toBe("helloworld")
62
+ expect(Turn.assistantTexts(turn).join(" ")).toBe("hello world")
63
+ expect(Turn.assistantTexts(turn).join("\n")).toBe("hello\nworld")
64
+ })
65
+ })
66
+
67
+ describe("Turn.assistantText", () => {
68
+ it("concatenates output_text across a single assistant message's content blocks", () => {
69
+ const turn = turnOf([
70
+ {
71
+ type: "message",
72
+ role: "assistant",
73
+ content: [
74
+ { type: "output_text", text: "hello " },
75
+ { type: "output_text", text: "world" },
76
+ ],
77
+ },
78
+ ])
79
+
80
+ expect(Turn.assistantText(turn)).toBe("hello world")
81
+ })
82
+
83
+ it("concatenates output_text across multiple assistant messages", () => {
84
+ const turn = turnOf([Items.assistantText("first "), Items.assistantText("second")])
85
+
86
+ expect(Turn.assistantText(turn)).toBe("first second")
87
+ })
88
+
89
+ it("ignores non-assistant messages", () => {
90
+ const turn = turnOf([
91
+ Items.userText("ignored"),
92
+ Items.systemText("also ignored"),
93
+ Items.assistantText("kept"),
94
+ ])
95
+
96
+ expect(Turn.assistantText(turn)).toBe("kept")
97
+ })
98
+
99
+ it("ignores non-output_text content blocks (refusals, etc.)", () => {
100
+ const turn = turnOf([
101
+ {
102
+ type: "message",
103
+ role: "assistant",
104
+ content: [
105
+ { type: "output_text", text: "before " },
106
+ { type: "refusal", text: "I can't help with that." },
107
+ { type: "output_text", text: "after" },
108
+ ],
109
+ },
110
+ ])
111
+
112
+ expect(Turn.assistantText(turn)).toBe("before after")
113
+ })
114
+
115
+ it("ignores function_call items even when interleaved with messages", () => {
116
+ const turn = turnOf([
117
+ Items.assistantText("text "),
118
+ { type: "function_call", call_id: "c1", name: "search", arguments: "{}" },
119
+ Items.assistantText("more text"),
120
+ ])
121
+
122
+ expect(Turn.assistantText(turn)).toBe("text more text")
123
+ })
124
+
125
+ it("returns the empty string when no assistant messages exist", () => {
126
+ expect(Turn.assistantText(turnOf([]))).toBe("")
127
+ expect(Turn.assistantText(turnOf([Items.userText("only user")]))).toBe("")
128
+ })
129
+
130
+ it("returns the empty string when the assistant message has no output_text blocks", () => {
131
+ const turn = turnOf([
132
+ {
133
+ type: "message",
134
+ role: "assistant",
135
+ content: [{ type: "refusal", text: "I can't help." }],
136
+ },
137
+ ])
138
+
139
+ expect(Turn.assistantText(turn)).toBe("")
140
+ })
141
+ })
@@ -27,54 +27,43 @@ export type Turn = typeof Turn.Type
27
27
  /**
28
28
  * Canonical events emitted while a single turn is being generated. Most
29
29
  * variants are streaming deltas (text, reasoning, tool-call args); the
30
- * terminal `turn_complete` carries the assembled `Turn`. Lifecycle members
30
+ * terminal `TurnComplete` carries the assembled `Turn`. Lifecycle members
31
31
  * aren't deltas, hence the union name.
32
+ *
33
+ * `ReasoningDelta.kind`: `trace` is the model's raw chain-of-thought;
34
+ * `summary` is a model-written summary intended for display. OpenAI
35
+ * Responses emits both; Anthropic and Gemini only emit `trace`.
36
+ *
37
+ * `RefusalDelta`: the model declined to answer. OpenAI Responses emits
38
+ * this as its own event; Anthropic surfaces refusals via `stop_reason`
39
+ * and Gemini collapses them into `finishReason: SAFETY` — both go
40
+ * without a `RefusalDelta`.
41
+ *
42
+ * `UsageUpdate`: mid-stream cumulative usage. Anthropic emits this on
43
+ * `message_start` and `message_delta`; other providers may only deliver
44
+ * usage via `TurnComplete.turn.usage`.
32
45
  */
33
- export type TurnEvent =
34
- | { readonly type: "text_delta"; readonly text: string }
35
- | {
36
- readonly type: "reasoning_delta"
37
- readonly text: string
38
- /**
39
- * `trace` is the model's raw chain-of-thought; `summary` is a
40
- * model-written summary intended for display. OpenAI Responses emits
41
- * both as separate wire events; Anthropic and Gemini only emit
42
- * `trace`. Consumers who just want any reasoning text match once;
43
- * those who want only summaries filter `kind === "summary"`.
44
- */
45
- readonly kind: "trace" | "summary"
46
- }
47
- /**
48
- * The model declined to answer. `text` is the (streamed) explanation.
49
- * Distinct from the failure channel: a refusal is normal model output and
50
- * the stream still completes with `turn_complete`. OpenAI Responses emits
51
- * this; Anthropic surfaces refusals via `stop_reason`, and Gemini collapses
52
- * them into `finishReason: SAFETY` - both go without a `refusal_delta`.
53
- */
54
- | { readonly type: "refusal_delta"; readonly text: string }
55
- | { readonly type: "tool_call_start"; readonly call_id: string; readonly name: string }
56
- | { readonly type: "tool_call_args_delta"; readonly call_id: string; readonly delta: string }
57
- /**
58
- * Mid-stream cumulative usage. Carries the full `Usage` (including cache
59
- * token fields when the provider surfaces them) so consumers can drive
60
- * live budget / cost tracking without waiting for `turn_complete`.
61
- * Anthropic emits this on `message_start` and `message_delta`; other
62
- * providers may not emit any `usage_update` and only deliver usage via
63
- * `turn_complete.turn.usage`.
64
- */
65
- | { readonly type: "usage_update"; readonly usage: Usage }
66
- | { readonly type: "turn_complete"; readonly turn: Turn }
46
+ export type TurnEvent = Data.TaggedEnum<{
47
+ TextDelta: { readonly text: string }
48
+ ReasoningDelta: { readonly text: string; readonly kind: "trace" | "summary" }
49
+ RefusalDelta: { readonly text: string }
50
+ ToolCallStart: { readonly call_id: string; readonly name: string }
51
+ ToolCallArgsDelta: { readonly call_id: string; readonly delta: string }
52
+ UsageUpdate: { readonly usage: Usage }
53
+ TurnComplete: { readonly turn: Turn }
54
+ }>
55
+
56
+ export const TurnEvent = Data.taggedEnum<TurnEvent>()
67
57
 
68
58
  /**
69
59
  * What flows out of an agent loop body to its consumer per turn: every
70
- * `TurnEvent` the provider emits (including the terminal `turn_complete`
60
+ * `TurnEvent` the provider emits (including the terminal `TurnComplete`
71
61
  * carrying the assembled `Turn`), plus the output of any tool the loop ran.
72
- * Both variants carry a `type` discriminator.
62
+ * Both variants carry a `_tag` discriminator.
73
63
  */
74
64
  export type InteractionEvent = TurnEvent | FunctionCallOutput
75
65
 
76
- export const isTurnComplete = (d: TurnEvent): d is Extract<TurnEvent, { type: "turn_complete" }> =>
77
- d.type === "turn_complete"
66
+ export const isTurnComplete = TurnEvent.$is("TurnComplete")
78
67
 
79
68
  export const functionCalls = (turn: Turn): ReadonlyArray<FunctionCall> =>
80
69
  turn.items.filter((i): i is FunctionCall => i.type === "function_call")
@@ -85,6 +74,26 @@ export const reasonings = (turn: Turn): ReadonlyArray<Reasoning> =>
85
74
  export const assistantMessages = (turn: Turn): ReadonlyArray<Message> =>
86
75
  turn.items.filter((i): i is Message => i.type === "message" && i.role === "assistant")
87
76
 
77
+ /**
78
+ * Every `output_text` payload across every assistant message in the turn,
79
+ * preserving order. Refusals and other content blocks are dropped — use
80
+ * `assistantMessages` if you need to inspect them. The primitive for
81
+ * "give me the assistant's text"; callers decide how to combine
82
+ * (typically `.join("")` for prose or `.join(" ")` for log strings).
83
+ */
84
+ export const assistantTexts = (turn: Turn): ReadonlyArray<string> =>
85
+ assistantMessages(turn)
86
+ .flatMap((m) => m.content)
87
+ .filter(isOutputText)
88
+ .map((b) => b.text)
89
+
90
+ /**
91
+ * Sugar over `assistantTexts(turn).join("")` — the common case for
92
+ * summarizers, classifiers, judge calls, and structured-output backstops
93
+ * that want one concatenated string.
94
+ */
95
+ export const assistantText = (turn: Turn): string => assistantTexts(turn).join("")
96
+
88
97
  /**
89
98
  * Append a completed turn and optional follow-up items to a state record's
90
99
  * history. Recipes use this at the point where structured tool results are
@@ -104,7 +113,7 @@ export const appendTurn = <S extends { readonly history: ReadonlyArray<Item> }>(
104
113
  // ---------------------------------------------------------------------------
105
114
 
106
115
  /**
107
- * Project a `TurnEvent` stream onto its `text_delta` payloads. Other
116
+ * Project a `TurnEvent` stream onto its `TextDelta` payloads. Other
108
117
  * variants are dropped. Composes with `Lines.lines` +
109
118
  * `decodeJsonLines` for prompted-JSONL streaming.
110
119
  */
@@ -112,9 +121,7 @@ export const textDeltas = <E, R>(
112
121
  self: Stream.Stream<TurnEvent, E, R>,
113
122
  ): Stream.Stream<string, E, R> =>
114
123
  self.pipe(
115
- Stream.filterMap((ev) =>
116
- ev.type === "text_delta" ? Result.succeed(ev.text) : Result.failVoid,
117
- ),
124
+ Stream.filterMap((ev) => (ev._tag === "TextDelta" ? Result.succeed(ev.text) : Result.failVoid)),
118
125
  )
119
126
 
120
127
  // ---------------------------------------------------------------------------
@@ -107,6 +107,29 @@ export const isBinary = (e: Embedding): e is BinaryEmbedding => e._tag === "bina
107
107
  export const isSparse = (e: Embedding): e is SparseEmbedding => e._tag === "sparse"
108
108
  export const isMultivector = (e: Embedding): e is MultivectorEmbedding => e._tag === "multivector"
109
109
 
110
+ /**
111
+ * Maps an `encoding` request field to the corresponding response embedding
112
+ * variant. `undefined` (no encoding requested) defaults to `Float32Embedding`,
113
+ * which is what every provider returns when the caller doesn't ask for
114
+ * anything else. Widened `E` falls back to the full `Embedding` union — the
115
+ * caller has to narrow at use site, which honestly reflects what they know
116
+ * at compile time.
117
+ *
118
+ * Used by `EmbedResponse<E>` / `EmbedManyResponse<E>` to give callers a
119
+ * precise embedding type without a runtime narrowing helper.
120
+ */
121
+ export type EmbeddingFor<E> = [E] extends [undefined | "float32"]
122
+ ? Float32Embedding
123
+ : [E] extends ["int8"]
124
+ ? Int8Embedding
125
+ : [E] extends ["binary"]
126
+ ? BinaryEmbedding
127
+ : [E] extends ["sparse"]
128
+ ? SparseEmbedding
129
+ : [E] extends ["multivector"]
130
+ ? MultivectorEmbedding
131
+ : Embedding
132
+
110
133
  /**
111
134
  * Token usage for one embed / embedMany call. One value per HTTP request,
112
135
  * not per input vector. Most providers populate `inputTokens`; the field
@@ -0,0 +1,92 @@
1
+ import { Effect } from "effect"
2
+ import { describe, expectTypeOf, it } from "vitest"
3
+ import type * as AiError from "../domain/AiError.js"
4
+ import type {
5
+ BinaryEmbedding,
6
+ Float32Embedding,
7
+ Int8Embedding,
8
+ MultivectorEmbedding,
9
+ SparseEmbedding,
10
+ } from "./Embedding.js"
11
+ import {
12
+ type EmbedEncoding,
13
+ embed,
14
+ embedMany,
15
+ type EmbedManyResponse,
16
+ type EmbedResponse,
17
+ EmbeddingModel,
18
+ } from "./EmbeddingModel.js"
19
+
20
+ // Type-level tests only. The actual narrowing happens at the type system
21
+ // boundary — the runtime impl is exercised in provider-specific test files.
22
+ describe("EmbedResponse<E> conditional narrowing", () => {
23
+ it("defaults to Float32Embedding when E is undefined", () => {
24
+ expectTypeOf<EmbedResponse>().toEqualTypeOf<{
25
+ readonly embedding: Float32Embedding
26
+ readonly usage: import("./Embedding.js").Usage
27
+ }>()
28
+ })
29
+
30
+ it("maps each EmbedEncoding literal to the matching Embedding variant", () => {
31
+ expectTypeOf<EmbedResponse<"float32">["embedding"]>().toEqualTypeOf<Float32Embedding>()
32
+ expectTypeOf<EmbedResponse<"int8">["embedding"]>().toEqualTypeOf<Int8Embedding>()
33
+ expectTypeOf<EmbedResponse<"binary">["embedding"]>().toEqualTypeOf<BinaryEmbedding>()
34
+ expectTypeOf<EmbedResponse<"sparse">["embedding"]>().toEqualTypeOf<SparseEmbedding>()
35
+ expectTypeOf<EmbedResponse<"multivector">["embedding"]>().toEqualTypeOf<MultivectorEmbedding>()
36
+ })
37
+
38
+ it("falls back to the open Embedding union when E is the full EmbedEncoding", () => {
39
+ expectTypeOf<EmbedResponse<EmbedEncoding>["embedding"]>().toEqualTypeOf<
40
+ Float32Embedding | Int8Embedding | BinaryEmbedding | SparseEmbedding | MultivectorEmbedding
41
+ >()
42
+ })
43
+ })
44
+
45
+ describe("EmbedManyResponse<E> mirrors EmbedResponse<E>", () => {
46
+ it("defaults to ReadonlyArray<Float32Embedding>", () => {
47
+ expectTypeOf<EmbedManyResponse["embeddings"]>().toEqualTypeOf<ReadonlyArray<Float32Embedding>>()
48
+ })
49
+
50
+ it("narrows per encoding", () => {
51
+ expectTypeOf<EmbedManyResponse<"int8">["embeddings"]>().toEqualTypeOf<
52
+ ReadonlyArray<Int8Embedding>
53
+ >()
54
+ })
55
+ })
56
+
57
+ describe("embed / embedMany free exports preserve E in their return type", () => {
58
+ it("embed with no encoding returns EmbedResponse<undefined> = Float32", () => {
59
+ const result = embed({ input: "x", model: "m" })
60
+ expectTypeOf(result).toEqualTypeOf<
61
+ Effect.Effect<EmbedResponse<undefined>, AiError.AiError, EmbeddingModel>
62
+ >()
63
+ })
64
+
65
+ it("embed with encoding: int8 returns EmbedResponse<int8>", () => {
66
+ const result = embed({ input: "x", model: "m", encoding: "int8" })
67
+ expectTypeOf(result).toEqualTypeOf<
68
+ Effect.Effect<EmbedResponse<"int8">, AiError.AiError, EmbeddingModel>
69
+ >()
70
+ })
71
+
72
+ it("embedMany with encoding: multivector returns EmbedManyResponse<multivector>", () => {
73
+ const result = embedMany({ inputs: ["x"], model: "m", encoding: "multivector" })
74
+ expectTypeOf(result).toEqualTypeOf<
75
+ Effect.Effect<EmbedManyResponse<"multivector">, AiError.AiError, EmbeddingModel>
76
+ >()
77
+ })
78
+
79
+ it("when the request is widened to CommonEmbedRequest, the response is the open union", () => {
80
+ // Demonstrates the documented case: annotating the request type erases
81
+ // the literal `encoding` and the response falls back to `Embedding`.
82
+ const req: { input: string; model: string; encoding?: EmbedEncoding } = {
83
+ input: "x",
84
+ model: "m",
85
+ encoding: "int8",
86
+ }
87
+ const result = embed(req)
88
+ expectTypeOf(result).toEqualTypeOf<
89
+ Effect.Effect<EmbedResponse<EmbedEncoding>, AiError.AiError, EmbeddingModel>
90
+ >()
91
+ })
92
+ })
@@ -1,6 +1,6 @@
1
1
  import { Context, Effect } from "effect"
2
2
  import * as AiError from "../domain/AiError.js"
3
- import type { Embedding, EmbedInput, Usage } from "./Embedding.js"
3
+ import type { EmbeddingFor, EmbedInput, Usage } from "./Embedding.js"
4
4
 
5
5
  /**
6
6
  * Output representation requested from the provider.
@@ -18,12 +18,12 @@ import type { Embedding, EmbedInput, Usage } from "./Embedding.js"
18
18
  * style) scoring via `Vector.maxSim`. Currently Jina v4 only.
19
19
  *
20
20
  * Each provider's typed request narrows this to its supported set at
21
- * compile time (e.g. `JinaEncoding = "float32" | "binary" | "sparse" |
21
+ * compile time (e.g. `JinaEmbedEncoding = "float32" | "binary" | "sparse" |
22
22
  * "multivector"`). On the generic `EmbeddingModel` path, callers can
23
- * pass any `Encoding` and the provider's API will reject mismatches at
23
+ * pass any `EmbedEncoding` and the provider's API will reject mismatches at
24
24
  * runtime.
25
25
  */
26
- export type Encoding = "float32" | "int8" | "binary" | "sparse" | "multivector"
26
+ export type EmbedEncoding = "float32" | "int8" | "binary" | "sparse" | "multivector"
27
27
 
28
28
  /**
29
29
  * Cross-provider single-embed request. Mirrors the shape of
@@ -57,11 +57,11 @@ export type CommonEmbedRequest = {
57
57
  */
58
58
  readonly dimensions?: number
59
59
  /**
60
- * Output representation - see {@link Encoding}. Dense float32 is the
60
+ * Output representation - see {@link EmbedEncoding}. Dense float32 is the
61
61
  * default; provider layers reject unsupported values up front with
62
62
  * `InvalidRequest`.
63
63
  */
64
- readonly encoding?: Encoding
64
+ readonly encoding?: EmbedEncoding
65
65
  }
66
66
 
67
67
  /**
@@ -73,21 +73,31 @@ export type CommonEmbedManyRequest = Omit<CommonEmbedRequest, "input"> & {
73
73
  readonly inputs: ReadonlyArray<EmbedInput>
74
74
  }
75
75
 
76
- export type EmbedResponse = {
77
- readonly embedding: Embedding
76
+ /**
77
+ * Single-embed response. The `embedding` type is determined by the
78
+ * request's `encoding` field via `EmbeddingFor<E>` — callers that don't
79
+ * specify an encoding get a `Float32Embedding` directly with no runtime
80
+ * narrowing. Defaults to `undefined` for back-compat with consumers that
81
+ * use the bare `EmbedResponse` name.
82
+ */
83
+ export type EmbedResponse<E extends EmbedEncoding | undefined = undefined> = {
84
+ readonly embedding: EmbeddingFor<E>
78
85
  readonly usage: Usage
79
86
  }
80
87
 
81
- export type EmbedManyResponse = {
82
- readonly embeddings: ReadonlyArray<Embedding>
88
+ /** Batch-embed response. Same encoding rule as {@link EmbedResponse}. */
89
+ export type EmbedManyResponse<E extends EmbedEncoding | undefined = undefined> = {
90
+ readonly embeddings: ReadonlyArray<EmbeddingFor<E>>
83
91
  readonly usage: Usage
84
92
  }
85
93
 
86
94
  export type EmbeddingModelService = {
87
- readonly embed: (request: CommonEmbedRequest) => Effect.Effect<EmbedResponse, AiError.AiError>
88
- readonly embedMany: (
89
- request: CommonEmbedManyRequest,
90
- ) => Effect.Effect<EmbedManyResponse, AiError.AiError>
95
+ readonly embed: <E extends EmbedEncoding | undefined = undefined>(
96
+ request: Omit<CommonEmbedRequest, "encoding"> & { readonly encoding?: E },
97
+ ) => Effect.Effect<EmbedResponse<E>, AiError.AiError>
98
+ readonly embedMany: <E extends EmbedEncoding | undefined = undefined>(
99
+ request: Omit<CommonEmbedManyRequest, "encoding"> & { readonly encoding?: E },
100
+ ) => Effect.Effect<EmbedManyResponse<E>, AiError.AiError>
91
101
  }
92
102
 
93
103
  export class EmbeddingModel extends Context.Service<EmbeddingModel, EmbeddingModelService>()(
@@ -95,13 +105,13 @@ export class EmbeddingModel extends Context.Service<EmbeddingModel, EmbeddingMod
95
105
  ) {}
96
106
 
97
107
  /** Embed a single input. */
98
- export const embed = (
99
- request: CommonEmbedRequest,
100
- ): Effect.Effect<EmbedResponse, AiError.AiError, EmbeddingModel> =>
108
+ export const embed = <E extends EmbedEncoding | undefined = undefined>(
109
+ request: Omit<CommonEmbedRequest, "encoding"> & { readonly encoding?: E },
110
+ ): Effect.Effect<EmbedResponse<E>, AiError.AiError, EmbeddingModel> =>
101
111
  Effect.flatMap(EmbeddingModel.asEffect(), (m) => m.embed(request))
102
112
 
103
113
  /** Embed a batch in one provider call. Same `task` for every input. */
104
- export const embedMany = (
105
- request: CommonEmbedManyRequest,
106
- ): Effect.Effect<EmbedManyResponse, AiError.AiError, EmbeddingModel> =>
114
+ export const embedMany = <E extends EmbedEncoding | undefined = undefined>(
115
+ request: Omit<CommonEmbedManyRequest, "encoding"> & { readonly encoding?: E },
116
+ ): Effect.Effect<EmbedManyResponse<E>, AiError.AiError, EmbeddingModel> =>
107
117
  Effect.flatMap(EmbeddingModel.asEffect(), (m) => m.embedMany(request))
@@ -0,0 +1,170 @@
1
+ import { Cause, Effect, Exit, Layer, Option, Ref, Schedule, Stream } from "effect"
2
+ import { describe, expect, it } from "vitest"
3
+ import * as AiError from "../domain/AiError.js"
4
+ import * as Items from "../domain/Items.js"
5
+ import { type Turn, TurnEvent } from "../domain/Turn.js"
6
+ import { LanguageModel, retry, turn } from "./LanguageModel.js"
7
+ import * as MockProvider from "../testing/MockProvider.js"
8
+
9
+ const oneTextTurn = (text: string): Turn => ({
10
+ items: [Items.assistantText(text)],
11
+ usage: { input_tokens: 1, output_tokens: 1 },
12
+ stop_reason: "stop",
13
+ })
14
+
15
+ describe("LanguageModel.turn", () => {
16
+ it("returns the assembled Turn from the terminal TurnComplete event", async () => {
17
+ const expected = oneTextTurn("hello world")
18
+ const program = turn({ history: [Items.userText("hi")], model: "mock" })
19
+
20
+ const result = await Effect.runPromise(
21
+ program.pipe(Effect.provide(MockProvider.layer([expected]))),
22
+ )
23
+
24
+ expect(result).toEqual(expected)
25
+ })
26
+
27
+ it("fails with IncompleteTurn when the stream ends without TurnComplete", async () => {
28
+ // Custom service whose stream emits a single TextDelta and then ends.
29
+ const broken = Layer.succeed(LanguageModel, {
30
+ streamTurn: () => Stream.fromIterable<TurnEvent>([TurnEvent.TextDelta({ text: "partial" })]),
31
+ })
32
+
33
+ const program = turn({ history: [Items.userText("hi")], model: "mock" })
34
+ const exit = await Effect.runPromise(Effect.exit(program.pipe(Effect.provide(broken))))
35
+
36
+ expect(Exit.isFailure(exit)).toBe(true)
37
+ if (Exit.isFailure(exit)) {
38
+ const failure = Cause.findErrorOption(exit.cause)
39
+ expect(Option.isSome(failure)).toBe(true)
40
+ if (Option.isSome(failure)) {
41
+ expect(failure.value).toBeInstanceOf(AiError.IncompleteTurn)
42
+ }
43
+ }
44
+ })
45
+
46
+ it("propagates an AiError raised by streamTurn", async () => {
47
+ const rateLimited = new AiError.RateLimited({ provider: "mock", raw: null })
48
+ const failing = Layer.succeed(LanguageModel, {
49
+ streamTurn: () => Stream.fail<AiError.AiError>(rateLimited),
50
+ })
51
+
52
+ const program = turn({ history: [], model: "mock" })
53
+ const exit = await Effect.runPromise(Effect.exit(program.pipe(Effect.provide(failing))))
54
+
55
+ expect(Exit.isFailure(exit)).toBe(true)
56
+ if (Exit.isFailure(exit)) {
57
+ expect(Cause.findErrorOption(exit.cause)).toEqual(Option.some(rateLimited))
58
+ }
59
+ })
60
+
61
+ it("returns the LAST TurnComplete when the stream contains multiple (defensive)", async () => {
62
+ // A misbehaving provider might emit two TurnComplete events; turn
63
+ // should pick the last one (the most recent assembled Turn).
64
+ const first = oneTextTurn("first")
65
+ const second = oneTextTurn("second")
66
+ const weird = Layer.succeed(LanguageModel, {
67
+ streamTurn: () =>
68
+ Stream.fromIterable<TurnEvent>([
69
+ TurnEvent.TurnComplete({ turn: first }),
70
+ TurnEvent.TurnComplete({ turn: second }),
71
+ ]),
72
+ })
73
+
74
+ const program = turn({ history: [], model: "mock" })
75
+ const result = await Effect.runPromise(program.pipe(Effect.provide(weird)))
76
+
77
+ expect(result).toEqual(second)
78
+ })
79
+ })
80
+
81
+ describe("LanguageModel.retry", () => {
82
+ const textDelta = (text: string): TurnEvent => TurnEvent.TextDelta({ text })
83
+ const textTurn = (text: string): Turn => ({
84
+ items: [Items.assistantText(text)],
85
+ usage: { input_tokens: 0, output_tokens: 0 },
86
+ stop_reason: "stop",
87
+ })
88
+ const completeEvent = (text: string): TurnEvent =>
89
+ TurnEvent.TurnComplete({ turn: textTurn(text) })
90
+
91
+ // Builds a stream that emits a failure or success based on attempt counter.
92
+ // Each call to the returned Effect produces a fresh attempt stream.
93
+ const attemptStream = (
94
+ attempts: Ref.Ref<number>,
95
+ plan: ReadonlyArray<Stream.Stream<TurnEvent, AiError.AiError>>,
96
+ ): Stream.Stream<TurnEvent, AiError.AiError> =>
97
+ Stream.unwrap(
98
+ Ref.getAndUpdate(attempts, (n) => n + 1).pipe(
99
+ Effect.map((n) => plan[Math.min(n, plan.length - 1)]!),
100
+ ),
101
+ )
102
+
103
+ it("retries on RateLimited and yields the success on the next attempt", async () => {
104
+ const program = Effect.gen(function* () {
105
+ const attempts = yield* Ref.make(0)
106
+ const stream = attemptStream(attempts, [
107
+ Stream.fail(new AiError.RateLimited({ provider: "mock", raw: null })),
108
+ Stream.fromIterable([textDelta("ok"), completeEvent("ok")]),
109
+ ]).pipe(retry(Schedule.recurs(3)))
110
+ const events = yield* Stream.runCollect(stream)
111
+ const count = yield* Ref.get(attempts)
112
+ return { events: Array.from(events), count }
113
+ })
114
+
115
+ const { events, count } = await Effect.runPromise(program)
116
+ expect(count).toBe(2)
117
+ expect(events.map((e) => e._tag)).toEqual(["TextDelta", "TurnComplete"])
118
+ })
119
+
120
+ it("surfaces the underlying retryable failure when retries are exhausted", async () => {
121
+ const cause = new AiError.Unavailable({ provider: "mock", raw: null })
122
+ const stream = Stream.fail<AiError.AiError>(cause).pipe(retry(Schedule.recurs(2)))
123
+
124
+ const exit = await Effect.runPromise(Effect.exit(Stream.runCollect(stream)))
125
+ expect(Exit.isFailure(exit)).toBe(true)
126
+ if (Exit.isFailure(exit)) {
127
+ expect(Cause.findErrorOption(exit.cause)).toEqual(Option.some(cause))
128
+ }
129
+ })
130
+
131
+ it("bypasses retry for non-retryable AiError (ContentFiltered)", async () => {
132
+ const program = Effect.gen(function* () {
133
+ const attempts = yield* Ref.make(0)
134
+ const cause = new AiError.ContentFiltered({ provider: "mock", raw: null })
135
+ const stream = attemptStream(attempts, [Stream.fail(cause)]).pipe(retry(Schedule.recurs(5)))
136
+ const exit = yield* Effect.exit(Stream.runCollect(stream))
137
+ const count = yield* Ref.get(attempts)
138
+ return { exit, count, cause }
139
+ })
140
+
141
+ const { exit, count, cause } = await Effect.runPromise(program)
142
+ expect(count).toBe(1) // no retry happened
143
+ expect(Exit.isFailure(exit)).toBe(true)
144
+ if (Exit.isFailure(exit)) {
145
+ expect(Cause.findErrorOption(exit.cause)).toEqual(Option.some(cause))
146
+ }
147
+ })
148
+
149
+ it("preserves deltas emitted before a retryable failure (and replays on retry)", async () => {
150
+ // Documents the 'replays on retry' caveat in the JSDoc — first attempt
151
+ // emits a delta then fails; second attempt is a clean success. Consumer
152
+ // sees the first delta twice (once from the failed attempt, once from
153
+ // the replay).
154
+ const program = Effect.gen(function* () {
155
+ const attempts = yield* Ref.make(0)
156
+ const stream = attemptStream(attempts, [
157
+ Stream.concat(
158
+ Stream.succeed<TurnEvent>(textDelta("partial")),
159
+ Stream.fail(new AiError.Timeout({ provider: "mock", raw: null })),
160
+ ),
161
+ Stream.fromIterable([textDelta("partial"), completeEvent("done")]),
162
+ ]).pipe(retry(Schedule.recurs(1)))
163
+ const events = yield* Stream.runCollect(stream)
164
+ return Array.from(events)
165
+ })
166
+
167
+ const events = await Effect.runPromise(program)
168
+ expect(events.map((e) => e._tag)).toEqual(["TextDelta", "TextDelta", "TurnComplete"])
169
+ })
170
+ })