@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.
- package/dist/{AiError-csR8Bhxx.d.mts → AiError-CAX_48RU.d.mts} +2 -2
- package/dist/{AiError-csR8Bhxx.d.mts.map → AiError-CAX_48RU.d.mts.map} +1 -1
- package/dist/{Image-DxyXqzAM.d.mts → Image-HNmMpMTh.d.mts} +4 -4
- package/dist/{Image-DxyXqzAM.d.mts.map → Image-HNmMpMTh.d.mts.map} +1 -1
- package/dist/{Items-Hg5AsYxl.d.mts → Items-BH8xUkoR.d.mts} +3 -3
- package/dist/{Items-Hg5AsYxl.d.mts.map → Items-BH8xUkoR.d.mts.map} +1 -1
- package/dist/{StructuredFormat-Cl41C56K.d.mts → StructuredFormat-BbN4dosH.d.mts} +11 -4
- package/dist/StructuredFormat-BbN4dosH.d.mts.map +1 -0
- package/dist/{Tool-B8B5qVEy.d.mts → Tool-87ViKCCO.d.mts} +20 -4
- package/dist/Tool-87ViKCCO.d.mts.map +1 -0
- package/dist/Turn-0CwCAyVe.d.mts +388 -0
- package/dist/Turn-0CwCAyVe.d.mts.map +1 -0
- package/dist/domain/AiError.d.mts +1 -1
- package/dist/domain/AiError.mjs +1 -1
- package/dist/domain/AiError.mjs.map +1 -1
- package/dist/domain/Image.d.mts +1 -1
- package/dist/domain/Items.d.mts +1 -1
- package/dist/domain/Items.mjs +1 -1
- package/dist/domain/Items.mjs.map +1 -1
- package/dist/domain/Turn.d.mts +2 -2
- package/dist/domain/Turn.mjs +22 -4
- package/dist/domain/Turn.mjs.map +1 -1
- package/dist/domain/Turn.test.d.mts +1 -0
- package/dist/domain/Turn.test.mjs +136 -0
- package/dist/domain/Turn.test.mjs.map +1 -0
- package/dist/embedding-model/Embedding.d.mts +15 -3
- package/dist/embedding-model/Embedding.d.mts.map +1 -1
- package/dist/embedding-model/Embedding.mjs.map +1 -1
- package/dist/embedding-model/EmbeddingModel.d.mts +33 -17
- package/dist/embedding-model/EmbeddingModel.d.mts.map +1 -1
- package/dist/embedding-model/EmbeddingModel.mjs.map +1 -1
- package/dist/embedding-model/EmbeddingModel.test.d.mts +1 -0
- package/dist/embedding-model/EmbeddingModel.test.mjs +59 -0
- package/dist/embedding-model/EmbeddingModel.test.mjs.map +1 -0
- package/dist/index.d.mts +6 -6
- package/dist/language-model/LanguageModel.d.mts +30 -8
- package/dist/language-model/LanguageModel.d.mts.map +1 -1
- package/dist/language-model/LanguageModel.mjs +33 -3
- package/dist/language-model/LanguageModel.mjs.map +1 -1
- package/dist/language-model/LanguageModel.test.d.mts +1 -0
- package/dist/language-model/LanguageModel.test.mjs +143 -0
- package/dist/language-model/LanguageModel.test.mjs.map +1 -0
- package/dist/loop/Loop.d.mts +94 -11
- package/dist/loop/Loop.d.mts.map +1 -1
- package/dist/loop/Loop.mjs +92 -26
- package/dist/loop/Loop.mjs.map +1 -1
- package/dist/loop/Loop.test.mjs +171 -3
- package/dist/loop/Loop.test.mjs.map +1 -1
- package/dist/music-generator/MusicGenerator.d.mts +1 -1
- package/dist/observability/Metrics.d.mts +1 -1
- package/dist/observability/Metrics.mjs +1 -1
- package/dist/observability/Metrics.mjs.map +1 -1
- package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +1 -1
- package/dist/streaming/JSONL.d.mts +1 -1
- package/dist/streaming/JSONL.d.mts.map +1 -1
- package/dist/streaming/JSONL.mjs +7 -12
- package/dist/streaming/JSONL.mjs.map +1 -1
- package/dist/structured-format/StructuredFormat.d.mts +2 -2
- package/dist/structured-format/StructuredFormat.mjs +9 -1
- package/dist/structured-format/StructuredFormat.mjs.map +1 -1
- package/dist/structured-format/StructuredFormat.test.d.mts +1 -0
- package/dist/structured-format/StructuredFormat.test.mjs +70 -0
- package/dist/structured-format/StructuredFormat.test.mjs.map +1 -0
- package/dist/testing/MockMusicGenerator.d.mts.map +1 -1
- package/dist/testing/MockMusicGenerator.mjs +2 -2
- package/dist/testing/MockMusicGenerator.mjs.map +1 -1
- package/dist/testing/MockProvider.d.mts +23 -18
- package/dist/testing/MockProvider.d.mts.map +1 -1
- package/dist/testing/MockProvider.mjs +56 -72
- package/dist/testing/MockProvider.mjs.map +1 -1
- package/dist/testing/MockSpeechSynthesizer.d.mts.map +1 -1
- package/dist/testing/MockSpeechSynthesizer.mjs +2 -2
- package/dist/testing/MockSpeechSynthesizer.mjs.map +1 -1
- package/dist/testing/MockTranscriber.d.mts.map +1 -1
- package/dist/testing/MockTranscriber.mjs +2 -2
- package/dist/testing/MockTranscriber.mjs.map +1 -1
- package/dist/tool/HistoryCheck.d.mts +1 -1
- package/dist/tool/Outcome.d.mts +1 -1
- package/dist/tool/Resolvers.d.mts +65 -8
- package/dist/tool/Resolvers.d.mts.map +1 -1
- package/dist/tool/Resolvers.mjs +8 -12
- package/dist/tool/Resolvers.mjs.map +1 -1
- package/dist/tool/Resolvers.test.mjs +6 -5
- package/dist/tool/Resolvers.test.mjs.map +1 -1
- package/dist/tool/Tool.d.mts +2 -2
- package/dist/tool/Tool.mjs +18 -1
- package/dist/tool/Tool.mjs.map +1 -1
- package/dist/tool/Tool.test.d.mts +1 -0
- package/dist/tool/Tool.test.mjs +66 -0
- package/dist/tool/Tool.test.mjs.map +1 -0
- package/dist/tool/Toolkit.d.mts +4 -6
- package/dist/tool/Toolkit.d.mts.map +1 -1
- package/dist/tool/Toolkit.mjs +14 -43
- package/dist/tool/Toolkit.mjs.map +1 -1
- package/dist/transcriber/Transcriber.d.mts +1 -1
- package/package.json +1 -1
- package/src/domain/AiError.ts +1 -1
- package/src/domain/Items.ts +1 -1
- package/src/domain/Turn.test.ts +141 -0
- package/src/domain/Turn.ts +50 -43
- package/src/embedding-model/Embedding.ts +23 -0
- package/src/embedding-model/EmbeddingModel.test.ts +92 -0
- package/src/embedding-model/EmbeddingModel.ts +30 -20
- package/src/language-model/LanguageModel.test.ts +170 -0
- package/src/language-model/LanguageModel.ts +64 -1
- package/src/loop/Loop.test.ts +256 -3
- package/src/loop/Loop.ts +225 -49
- package/src/observability/Metrics.ts +1 -1
- package/src/streaming/JSONL.ts +9 -18
- package/src/structured-format/StructuredFormat.test.ts +105 -0
- package/src/structured-format/StructuredFormat.ts +14 -1
- package/src/testing/MockMusicGenerator.ts +4 -6
- package/src/testing/MockProvider.ts +126 -105
- package/src/testing/MockSpeechSynthesizer.ts +4 -6
- package/src/testing/MockTranscriber.ts +4 -6
- package/src/tool/Resolvers.test.ts +8 -5
- package/src/tool/Resolvers.ts +17 -19
- package/src/tool/Tool.test.ts +105 -0
- package/src/tool/Tool.ts +20 -0
- package/src/tool/Toolkit.ts +49 -50
- package/dist/StructuredFormat-Cl41C56K.d.mts.map +0 -1
- package/dist/Tool-B8B5qVEy.d.mts.map +0 -1
- package/dist/Turn-7geUcKsf.d.mts +0 -194
- 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
|
+
})
|
package/src/domain/Turn.ts
CHANGED
|
@@ -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 `
|
|
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
|
-
|
|
35
|
-
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 `
|
|
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 `
|
|
62
|
+
* Both variants carry a `_tag` discriminator.
|
|
73
63
|
*/
|
|
74
64
|
export type InteractionEvent = TurnEvent | FunctionCallOutput
|
|
75
65
|
|
|
76
|
-
export const isTurnComplete =
|
|
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 `
|
|
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 {
|
|
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. `
|
|
21
|
+
* compile time (e.g. `JinaEmbedEncoding = "float32" | "binary" | "sparse" |
|
|
22
22
|
* "multivector"`). On the generic `EmbeddingModel` path, callers can
|
|
23
|
-
* pass any `
|
|
23
|
+
* pass any `EmbedEncoding` and the provider's API will reject mismatches at
|
|
24
24
|
* runtime.
|
|
25
25
|
*/
|
|
26
|
-
export type
|
|
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
|
|
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?:
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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:
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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
|
|
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
|
+
})
|