@effect-uai/core 0.3.0 → 0.5.0
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-CBuPHVKA.d.mts → AiError-CAX_48RU.d.mts} +27 -5
- package/dist/{AiError-CBuPHVKA.d.mts.map → AiError-CAX_48RU.d.mts.map} +1 -1
- package/dist/Audio-BfCTGnH3.d.mts +61 -0
- package/dist/Audio-BfCTGnH3.d.mts.map +1 -0
- package/dist/{Image-BZmKfIdq.d.mts → Image-HNmMpMTh.d.mts} +1 -1
- package/dist/{Image-BZmKfIdq.d.mts.map → Image-HNmMpMTh.d.mts.map} +1 -1
- package/dist/{Items-CB8Bo3FI.d.mts → Items-DqbaJoz7.d.mts} +5 -5
- package/dist/{Items-CB8Bo3FI.d.mts.map → Items-DqbaJoz7.d.mts.map} +1 -1
- package/dist/{StructuredFormat-BWq5Hd1O.d.mts → StructuredFormat-BbN4dosH.d.mts} +11 -4
- package/dist/StructuredFormat-BbN4dosH.d.mts.map +1 -0
- package/dist/{Tool-DjVufH7i.d.mts → Tool-Y0__Py1H.d.mts} +20 -4
- package/dist/Tool-Y0__Py1H.d.mts.map +1 -0
- package/dist/Turn-ChbL2foc.d.mts +388 -0
- package/dist/Turn-ChbL2foc.d.mts.map +1 -0
- package/dist/domain/AiError.d.mts +2 -2
- package/dist/domain/AiError.mjs +19 -3
- package/dist/domain/AiError.mjs.map +1 -1
- package/dist/domain/Audio.d.mts +2 -0
- package/dist/domain/Audio.mjs +14 -0
- package/dist/domain/Audio.mjs.map +1 -0
- 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/Music.d.mts +116 -0
- package/dist/domain/Music.d.mts.map +1 -0
- package/dist/domain/Music.mjs +29 -0
- package/dist/domain/Music.mjs.map +1 -0
- package/dist/domain/Transcript.d.mts +95 -0
- package/dist/domain/Transcript.d.mts.map +1 -0
- package/dist/domain/Transcript.mjs +22 -0
- package/dist/domain/Transcript.mjs.map +1 -0
- 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 +13 -7
- package/dist/index.mjs +7 -1
- 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 +77 -0
- package/dist/music-generator/MusicGenerator.d.mts.map +1 -0
- package/dist/music-generator/MusicGenerator.mjs +51 -0
- package/dist/music-generator/MusicGenerator.mjs.map +1 -0
- package/dist/music-generator/MusicGenerator.test.d.mts +1 -0
- package/dist/music-generator/MusicGenerator.test.mjs +154 -0
- package/dist/music-generator/MusicGenerator.test.mjs.map +1 -0
- 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 +96 -0
- package/dist/speech-synthesizer/SpeechSynthesizer.d.mts.map +1 -0
- package/dist/speech-synthesizer/SpeechSynthesizer.mjs +48 -0
- package/dist/speech-synthesizer/SpeechSynthesizer.mjs.map +1 -0
- package/dist/speech-synthesizer/SpeechSynthesizer.test.d.mts +1 -0
- package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs +112 -0
- package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs.map +1 -0
- package/dist/streaming/JSONL.d.mts +10 -3
- package/dist/streaming/JSONL.d.mts.map +1 -1
- package/dist/streaming/JSONL.mjs +15 -9
- 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 +39 -0
- package/dist/testing/MockMusicGenerator.d.mts.map +1 -0
- package/dist/testing/MockMusicGenerator.mjs +96 -0
- package/dist/testing/MockMusicGenerator.mjs.map +1 -0
- 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 +37 -0
- package/dist/testing/MockSpeechSynthesizer.d.mts.map +1 -0
- package/dist/testing/MockSpeechSynthesizer.mjs +95 -0
- package/dist/testing/MockSpeechSynthesizer.mjs.map +1 -0
- package/dist/testing/MockTranscriber.d.mts +37 -0
- package/dist/testing/MockTranscriber.d.mts.map +1 -0
- package/dist/testing/MockTranscriber.mjs +77 -0
- package/dist/testing/MockTranscriber.mjs.map +1 -0
- 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 +101 -0
- package/dist/transcriber/Transcriber.d.mts.map +1 -0
- package/dist/transcriber/Transcriber.mjs +49 -0
- package/dist/transcriber/Transcriber.mjs.map +1 -0
- package/dist/transcriber/Transcriber.test.d.mts +1 -0
- package/dist/transcriber/Transcriber.test.mjs +130 -0
- package/dist/transcriber/Transcriber.test.mjs.map +1 -0
- package/package.json +37 -1
- package/src/domain/AiError.ts +22 -1
- package/src/domain/Audio.ts +88 -0
- package/src/domain/Items.ts +1 -1
- package/src/domain/Music.ts +121 -0
- package/src/domain/Transcript.ts +83 -0
- 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/index.ts +6 -0
- 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/music-generator/MusicGenerator.test.ts +170 -0
- package/src/music-generator/MusicGenerator.ts +123 -0
- package/src/observability/Metrics.ts +1 -1
- package/src/speech-synthesizer/SpeechSynthesizer.test.ts +141 -0
- package/src/speech-synthesizer/SpeechSynthesizer.ts +131 -0
- package/src/streaming/JSONL.ts +16 -13
- package/src/structured-format/StructuredFormat.test.ts +105 -0
- package/src/structured-format/StructuredFormat.ts +14 -1
- package/src/testing/MockMusicGenerator.ts +168 -0
- package/src/testing/MockProvider.ts +126 -105
- package/src/testing/MockSpeechSynthesizer.ts +163 -0
- package/src/testing/MockTranscriber.ts +137 -0
- 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/src/transcriber/Transcriber.test.ts +125 -0
- package/src/transcriber/Transcriber.ts +127 -0
- package/dist/StructuredFormat-BWq5Hd1O.d.mts.map +0 -1
- package/dist/Tool-DjVufH7i.d.mts.map +0 -1
- package/dist/Turn-OPaILVIB.d.mts +0 -194
- package/dist/Turn-OPaILVIB.d.mts.map +0 -1
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { Effect, Layer, Ref, Stream } from "effect"
|
|
2
|
+
import * as AiError from "../domain/AiError.js"
|
|
3
|
+
import type { AudioChunk } from "../domain/Audio.js"
|
|
4
|
+
import type {
|
|
5
|
+
CommonGenerateMusicRequest,
|
|
6
|
+
CommonStreamGenerateMusicRequest,
|
|
7
|
+
MusicResult,
|
|
8
|
+
MusicSessionInput,
|
|
9
|
+
} from "../domain/Music.js"
|
|
10
|
+
import {
|
|
11
|
+
MusicGenerator,
|
|
12
|
+
MusicInteractiveSession,
|
|
13
|
+
type MusicGeneratorService,
|
|
14
|
+
} from "../music-generator/MusicGenerator.js"
|
|
15
|
+
|
|
16
|
+
export type MockMusicGeneratorRecorder = {
|
|
17
|
+
readonly generateCalls: ReadonlyArray<CommonGenerateMusicRequest>
|
|
18
|
+
readonly streamGenerationCalls: ReadonlyArray<CommonStreamGenerateMusicRequest>
|
|
19
|
+
readonly streamGenerationFromCalls: ReadonlyArray<CommonStreamGenerateMusicRequest>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type MockMusicGeneratorScript = {
|
|
23
|
+
/** One result per `generate` call, consumed in order. */
|
|
24
|
+
readonly results?: ReadonlyArray<MusicResult>
|
|
25
|
+
/** One chunk-list per `streamGeneration` call, consumed in order. */
|
|
26
|
+
readonly streamGenerationChunks?: ReadonlyArray<ReadonlyArray<AudioChunk>>
|
|
27
|
+
/** One chunk-list per `streamGenerationFrom` call, consumed in order. */
|
|
28
|
+
readonly streamGenerationFromChunks?: ReadonlyArray<ReadonlyArray<AudioChunk>>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const makeService = (
|
|
32
|
+
script: MockMusicGeneratorScript,
|
|
33
|
+
record: {
|
|
34
|
+
readonly generate: (req: CommonGenerateMusicRequest) => Effect.Effect<void>
|
|
35
|
+
readonly streamGeneration: (req: CommonStreamGenerateMusicRequest) => Effect.Effect<void>
|
|
36
|
+
readonly streamGenerationFrom: (req: CommonStreamGenerateMusicRequest) => Effect.Effect<void>
|
|
37
|
+
},
|
|
38
|
+
) =>
|
|
39
|
+
Effect.gen(function* () {
|
|
40
|
+
const gCursor = yield* Ref.make(0)
|
|
41
|
+
const sgCursor = yield* Ref.make(0)
|
|
42
|
+
const sgfCursor = yield* Ref.make(0)
|
|
43
|
+
const service: MusicGeneratorService = {
|
|
44
|
+
generate: (request) =>
|
|
45
|
+
Effect.gen(function* () {
|
|
46
|
+
yield* record.generate(request)
|
|
47
|
+
const i = yield* Ref.getAndUpdate(gCursor, (n) => n + 1)
|
|
48
|
+
const scripted = script.results ?? []
|
|
49
|
+
if (i >= scripted.length) {
|
|
50
|
+
return yield* new AiError.InvalidRequest({
|
|
51
|
+
provider: "mock",
|
|
52
|
+
raw: `MockMusicGenerator exhausted: ${scripted.length} results scripted, but call ${i + 1} was made`,
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
return scripted[i]!
|
|
56
|
+
}),
|
|
57
|
+
streamGeneration: (request) =>
|
|
58
|
+
Stream.unwrap(
|
|
59
|
+
Effect.gen(function* () {
|
|
60
|
+
yield* record.streamGeneration(request)
|
|
61
|
+
const i = yield* Ref.getAndUpdate(sgCursor, (n) => n + 1)
|
|
62
|
+
const scripted = script.streamGenerationChunks ?? []
|
|
63
|
+
if (i >= scripted.length) {
|
|
64
|
+
return Stream.fail(
|
|
65
|
+
new AiError.InvalidRequest({
|
|
66
|
+
provider: "mock",
|
|
67
|
+
raw: `MockMusicGenerator exhausted: ${scripted.length} streamGeneration lists scripted, but call ${i + 1} was made`,
|
|
68
|
+
}),
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
return Stream.fromIterable(scripted[i]!)
|
|
72
|
+
}),
|
|
73
|
+
),
|
|
74
|
+
streamGenerationFrom: <E, R>(
|
|
75
|
+
input: Stream.Stream<MusicSessionInput, E, R>,
|
|
76
|
+
request: CommonStreamGenerateMusicRequest,
|
|
77
|
+
): Stream.Stream<AudioChunk, AiError.AiError | E, R> =>
|
|
78
|
+
Stream.unwrap(
|
|
79
|
+
Effect.gen(function* () {
|
|
80
|
+
yield* record.streamGenerationFrom(request)
|
|
81
|
+
const i = yield* Ref.getAndUpdate(sgfCursor, (n) => n + 1)
|
|
82
|
+
const scripted = script.streamGenerationFromChunks ?? []
|
|
83
|
+
if (i >= scripted.length) {
|
|
84
|
+
const exhausted: Stream.Stream<AudioChunk, AiError.AiError | E, R> = Stream.fail(
|
|
85
|
+
new AiError.InvalidRequest({
|
|
86
|
+
provider: "mock",
|
|
87
|
+
raw: `MockMusicGenerator exhausted: ${scripted.length} streamGenerationFrom lists scripted, but call ${i + 1} was made`,
|
|
88
|
+
}),
|
|
89
|
+
)
|
|
90
|
+
return exhausted
|
|
91
|
+
}
|
|
92
|
+
// Drain the input fully before emitting scripted audio chunks,
|
|
93
|
+
// so consumers can assert on what session messages were pushed.
|
|
94
|
+
return Stream.drain(input).pipe(Stream.concat(Stream.fromIterable(scripted[i]!)))
|
|
95
|
+
}),
|
|
96
|
+
),
|
|
97
|
+
}
|
|
98
|
+
return service
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Layer providing the `MusicGenerator` service AND the
|
|
103
|
+
* `MusicInteractiveSession` capability marker. Use for the common case
|
|
104
|
+
* where code under test exercises `streamGenerationFrom`.
|
|
105
|
+
*/
|
|
106
|
+
export const layer = (
|
|
107
|
+
script: MockMusicGeneratorScript,
|
|
108
|
+
): {
|
|
109
|
+
readonly layer: Layer.Layer<MusicGenerator | MusicInteractiveSession>
|
|
110
|
+
readonly recorder: Effect.Effect<MockMusicGeneratorRecorder>
|
|
111
|
+
} => {
|
|
112
|
+
const gCalls = Ref.makeUnsafe<ReadonlyArray<CommonGenerateMusicRequest>>([])
|
|
113
|
+
const sgCalls = Ref.makeUnsafe<ReadonlyArray<CommonStreamGenerateMusicRequest>>([])
|
|
114
|
+
const sgfCalls = Ref.makeUnsafe<ReadonlyArray<CommonStreamGenerateMusicRequest>>([])
|
|
115
|
+
const generatorLayer = Layer.effect(
|
|
116
|
+
MusicGenerator,
|
|
117
|
+
makeService(script, {
|
|
118
|
+
generate: (req) => Ref.update(gCalls, (xs) => [...xs, req]),
|
|
119
|
+
streamGeneration: (req) => Ref.update(sgCalls, (xs) => [...xs, req]),
|
|
120
|
+
streamGenerationFrom: (req) => Ref.update(sgfCalls, (xs) => [...xs, req]),
|
|
121
|
+
}),
|
|
122
|
+
)
|
|
123
|
+
const live = Layer.merge(generatorLayer, Layer.succeed(MusicInteractiveSession, undefined))
|
|
124
|
+
return {
|
|
125
|
+
layer: live,
|
|
126
|
+
recorder: Effect.gen(function* () {
|
|
127
|
+
const generateCalls = yield* Ref.get(gCalls)
|
|
128
|
+
const streamGenerationCalls = yield* Ref.get(sgCalls)
|
|
129
|
+
const streamGenerationFromCalls = yield* Ref.get(sgfCalls)
|
|
130
|
+
return { generateCalls, streamGenerationCalls, streamGenerationFromCalls }
|
|
131
|
+
}),
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Variant that omits the `MusicInteractiveSession` marker — simulates a
|
|
137
|
+
* provider without bidirectional support (Lyria 3 sync, ElevenLabs,
|
|
138
|
+
* Mureka, MiniMax, Stable Audio, Suno). Calls to
|
|
139
|
+
* `streamGenerationFrom` in code under test should be a compile-time
|
|
140
|
+
* error against this Layer alone.
|
|
141
|
+
*/
|
|
142
|
+
export const layerWithoutInteractive = (
|
|
143
|
+
script: MockMusicGeneratorScript,
|
|
144
|
+
): {
|
|
145
|
+
readonly layer: Layer.Layer<MusicGenerator>
|
|
146
|
+
readonly recorder: Effect.Effect<MockMusicGeneratorRecorder>
|
|
147
|
+
} => {
|
|
148
|
+
const gCalls = Ref.makeUnsafe<ReadonlyArray<CommonGenerateMusicRequest>>([])
|
|
149
|
+
const sgCalls = Ref.makeUnsafe<ReadonlyArray<CommonStreamGenerateMusicRequest>>([])
|
|
150
|
+
const sgfCalls = Ref.makeUnsafe<ReadonlyArray<CommonStreamGenerateMusicRequest>>([])
|
|
151
|
+
const live = Layer.effect(
|
|
152
|
+
MusicGenerator,
|
|
153
|
+
makeService(script, {
|
|
154
|
+
generate: (req) => Ref.update(gCalls, (xs) => [...xs, req]),
|
|
155
|
+
streamGeneration: (req) => Ref.update(sgCalls, (xs) => [...xs, req]),
|
|
156
|
+
streamGenerationFrom: (req) => Ref.update(sgfCalls, (xs) => [...xs, req]),
|
|
157
|
+
}),
|
|
158
|
+
)
|
|
159
|
+
return {
|
|
160
|
+
layer: live,
|
|
161
|
+
recorder: Effect.gen(function* () {
|
|
162
|
+
const generateCalls = yield* Ref.get(gCalls)
|
|
163
|
+
const streamGenerationCalls = yield* Ref.get(sgCalls)
|
|
164
|
+
const streamGenerationFromCalls = yield* Ref.get(sgfCalls)
|
|
165
|
+
return { generateCalls, streamGenerationCalls, streamGenerationFromCalls }
|
|
166
|
+
}),
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { Duration, Effect, Layer, Ref, Schedule, Stream } from "effect"
|
|
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
|
-
import type
|
|
3
|
+
import { type Item, isOutputText } from "../domain/Items.js"
|
|
4
4
|
import { LanguageModel, type LanguageModelService } from "../language-model/LanguageModel.js"
|
|
5
|
-
import type
|
|
5
|
+
import { type Turn, TurnEvent } from "../domain/Turn.js"
|
|
6
6
|
|
|
7
7
|
export type MockOptions = {
|
|
8
8
|
/**
|
|
@@ -13,46 +13,48 @@ export type MockOptions = {
|
|
|
13
13
|
readonly deltaInterval?: Duration.Input
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export type Call = {
|
|
17
|
+
readonly history: ReadonlyArray<Item>
|
|
18
|
+
readonly turn: Turn
|
|
19
|
+
}
|
|
20
|
+
|
|
16
21
|
/**
|
|
17
22
|
* A scripted mock provider. Pre-canned `Turn` outputs are returned in order,
|
|
18
23
|
* one per call to `streamTurn`. Each scripted turn is split into synthetic
|
|
19
|
-
* deltas (text →
|
|
24
|
+
* deltas (text → ToolCallStart → ToolCallArgsDelta → ... → TurnComplete)
|
|
20
25
|
* so streaming consumers can see realistic delta shapes.
|
|
21
26
|
*/
|
|
22
27
|
export type MockRecorder = {
|
|
23
|
-
readonly calls: ReadonlyArray<
|
|
24
|
-
readonly history: ReadonlyArray<Item>
|
|
25
|
-
readonly turn: Turn
|
|
26
|
-
}>
|
|
28
|
+
readonly calls: ReadonlyArray<Call>
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Pure projection: Turn → ReadonlyArray<TurnEvent>
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
const itemToDeltas: (item: Item) => ReadonlyArray<TurnEvent> = Match.type<Item>().pipe(
|
|
36
|
+
Match.discriminators("type")({
|
|
37
|
+
message: (m): ReadonlyArray<TurnEvent> =>
|
|
38
|
+
m.role === "assistant"
|
|
39
|
+
? m.content.filter(isOutputText).map((b) => TurnEvent.TextDelta({ text: b.text }))
|
|
40
|
+
: [],
|
|
41
|
+
function_call: (fc) => [
|
|
42
|
+
TurnEvent.ToolCallStart({ call_id: fc.call_id, name: fc.name }),
|
|
43
|
+
TurnEvent.ToolCallArgsDelta({ call_id: fc.call_id, delta: fc.arguments }),
|
|
44
|
+
],
|
|
45
|
+
function_call_output: () => [],
|
|
46
|
+
reasoning: (r) =>
|
|
47
|
+
r.summary !== undefined
|
|
48
|
+
? [TurnEvent.ReasoningDelta({ text: r.summary, kind: "summary" as const })]
|
|
49
|
+
: [],
|
|
50
|
+
}),
|
|
51
|
+
Match.exhaustive,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const turnToDeltas = (turn: Turn): ReadonlyArray<TurnEvent> => [
|
|
55
|
+
...turn.items.flatMap(itemToDeltas),
|
|
56
|
+
TurnEvent.TurnComplete({ turn }),
|
|
57
|
+
]
|
|
56
58
|
|
|
57
59
|
const pacedDeltas = (turn: Turn, options?: MockOptions): Stream.Stream<TurnEvent> => {
|
|
58
60
|
const base = Stream.fromIterable(turnToDeltas(turn))
|
|
@@ -61,101 +63,120 @@ const pacedDeltas = (turn: Turn, options?: MockOptions): Stream.Stream<TurnEvent
|
|
|
61
63
|
: base.pipe(Stream.schedule(Schedule.spaced(options.deltaInterval)))
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Canonical service factory. One implementation; sync/Layer/recorder
|
|
68
|
+
// variants below are just different ways to wire the cursor + record hook.
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
const exhausted = (n: number, attempt: number): AiError.AiError =>
|
|
72
|
+
new AiError.InvalidRequest({
|
|
73
|
+
provider: "mock",
|
|
74
|
+
raw: `MockProvider exhausted: ${n} turns scripted, but call ${attempt} was made`,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const noRecord = (_: Call): Effect.Effect<void> => Effect.void
|
|
78
|
+
|
|
79
|
+
const buildService = (
|
|
65
80
|
scriptedTurns: ReadonlyArray<Turn>,
|
|
66
|
-
options
|
|
67
|
-
|
|
68
|
-
) =>
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
Effect.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
)
|
|
83
|
-
}
|
|
84
|
-
const turn = scriptedTurns[i]!
|
|
85
|
-
if (recordCall !== undefined) {
|
|
86
|
-
yield* recordCall(request.history, turn)
|
|
87
|
-
}
|
|
88
|
-
return pacedDeltas(turn, options)
|
|
89
|
-
}),
|
|
81
|
+
options: MockOptions | undefined,
|
|
82
|
+
cursor: Ref.Ref<number>,
|
|
83
|
+
record: (call: Call) => Effect.Effect<void>,
|
|
84
|
+
): LanguageModelService => ({
|
|
85
|
+
streamTurn: (request) =>
|
|
86
|
+
Stream.unwrap(
|
|
87
|
+
Ref.getAndUpdate(cursor, (n) => n + 1).pipe(
|
|
88
|
+
Effect.flatMap(
|
|
89
|
+
(i): Effect.Effect<Stream.Stream<TurnEvent, AiError.AiError>> =>
|
|
90
|
+
Option.match(Arr.get(scriptedTurns, i), {
|
|
91
|
+
onNone: () => Effect.succeed(Stream.fail(exhausted(scriptedTurns.length, i + 1))),
|
|
92
|
+
onSome: (turn) =>
|
|
93
|
+
record({ history: request.history, turn }).pipe(
|
|
94
|
+
Effect.as(pacedDeltas(turn, options)),
|
|
95
|
+
),
|
|
96
|
+
}),
|
|
90
97
|
),
|
|
91
|
-
|
|
92
|
-
|
|
98
|
+
),
|
|
99
|
+
),
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Recorder handle. Unsafe Ref is local: it backs both the `record` write
|
|
104
|
+
// hook (called inside the service) and the `recorder` read effect (called
|
|
105
|
+
// by the test). Both close over the same cell.
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
type RecorderHandle = {
|
|
109
|
+
readonly record: (call: Call) => Effect.Effect<void>
|
|
110
|
+
readonly recorder: Effect.Effect<MockRecorder>
|
|
111
|
+
}
|
|
93
112
|
|
|
113
|
+
const makeRecorderUnsafe = (): RecorderHandle => {
|
|
114
|
+
const ref = Ref.makeUnsafe<ReadonlyArray<Call>>([])
|
|
115
|
+
return {
|
|
116
|
+
record: (call) => Ref.update(ref, Arr.append(call)),
|
|
117
|
+
recorder: Ref.get(ref).pipe(Effect.map((calls): MockRecorder => ({ calls }))),
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Public API
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Layer that registers a `MockProvider` against the `LanguageModel` tag.
|
|
127
|
+
* Calls beyond the scripted turn count fail with `InvalidRequest`.
|
|
128
|
+
*/
|
|
94
129
|
export const layer = (
|
|
95
130
|
scriptedTurns: ReadonlyArray<Turn>,
|
|
96
131
|
options?: MockOptions,
|
|
97
|
-
): Layer.Layer<LanguageModel> =>
|
|
132
|
+
): Layer.Layer<LanguageModel> =>
|
|
133
|
+
Layer.effect(
|
|
134
|
+
LanguageModel,
|
|
135
|
+
Ref.make(0).pipe(
|
|
136
|
+
Effect.map((cursor) => buildService(scriptedTurns, options, cursor, noRecord)),
|
|
137
|
+
),
|
|
138
|
+
)
|
|
98
139
|
|
|
99
140
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* mid-stream via `Effect.provideService` instead of providing one model
|
|
103
|
-
* for the whole program via `Layer`.
|
|
141
|
+
* Like `layer`, but also exposes a recorder that captures every call
|
|
142
|
+
* (history + returned turn).
|
|
104
143
|
*/
|
|
105
|
-
export const
|
|
144
|
+
export const layerWithRecorder = (
|
|
106
145
|
scriptedTurns: ReadonlyArray<Turn>,
|
|
107
146
|
options?: MockOptions,
|
|
108
147
|
): {
|
|
109
|
-
readonly
|
|
148
|
+
readonly layer: Layer.Layer<LanguageModel>
|
|
110
149
|
readonly recorder: Effect.Effect<MockRecorder>
|
|
111
150
|
} => {
|
|
112
|
-
const
|
|
113
|
-
const callsRef = Ref.makeUnsafe<ReadonlyArray<{ history: ReadonlyArray<Item>; turn: Turn }>>([])
|
|
114
|
-
const service: LanguageModelService = {
|
|
115
|
-
streamTurn: (request) =>
|
|
116
|
-
Stream.unwrap(
|
|
117
|
-
Effect.gen(function* () {
|
|
118
|
-
const i = yield* Ref.getAndUpdate(cursor, (n) => n + 1)
|
|
119
|
-
if (i >= scriptedTurns.length) {
|
|
120
|
-
return Stream.fail(
|
|
121
|
-
new AiError.InvalidRequest({
|
|
122
|
-
provider: "mock",
|
|
123
|
-
raw: `MockProvider exhausted: ${scriptedTurns.length} turns scripted, but call ${i + 1} was made`,
|
|
124
|
-
}),
|
|
125
|
-
)
|
|
126
|
-
}
|
|
127
|
-
const turn = scriptedTurns[i]!
|
|
128
|
-
yield* Ref.update(callsRef, (xs) => [...xs, { history: request.history, turn }])
|
|
129
|
-
return pacedDeltas(turn, options)
|
|
130
|
-
}),
|
|
131
|
-
),
|
|
132
|
-
}
|
|
151
|
+
const { record, recorder } = makeRecorderUnsafe()
|
|
133
152
|
return {
|
|
134
|
-
|
|
135
|
-
|
|
153
|
+
layer: Layer.effect(
|
|
154
|
+
LanguageModel,
|
|
155
|
+
Ref.make(0).pipe(
|
|
156
|
+
Effect.map((cursor) => buildService(scriptedTurns, options, cursor, record)),
|
|
157
|
+
),
|
|
158
|
+
),
|
|
159
|
+
recorder,
|
|
136
160
|
}
|
|
137
161
|
}
|
|
138
162
|
|
|
139
163
|
/**
|
|
140
|
-
*
|
|
141
|
-
*
|
|
164
|
+
* Build the `LanguageModelService` value directly (no Layer), plus a
|
|
165
|
+
* recorder. Use this when you want to swap models mid-program via
|
|
166
|
+
* `Effect.provideService` instead of providing one model for the whole
|
|
167
|
+
* program via `Layer`.
|
|
142
168
|
*/
|
|
143
|
-
export const
|
|
169
|
+
export const make = (
|
|
144
170
|
scriptedTurns: ReadonlyArray<Turn>,
|
|
145
171
|
options?: MockOptions,
|
|
146
172
|
): {
|
|
147
|
-
readonly
|
|
173
|
+
readonly service: LanguageModelService
|
|
148
174
|
readonly recorder: Effect.Effect<MockRecorder>
|
|
149
175
|
} => {
|
|
150
|
-
const
|
|
151
|
-
const
|
|
152
|
-
LanguageModel,
|
|
153
|
-
makeService(scriptedTurns, options, (history, turn) =>
|
|
154
|
-
Ref.update(callsRef, (xs) => [...xs, { history, turn }]),
|
|
155
|
-
),
|
|
156
|
-
)
|
|
176
|
+
const cursor = Ref.makeUnsafe(0)
|
|
177
|
+
const { record, recorder } = makeRecorderUnsafe()
|
|
157
178
|
return {
|
|
158
|
-
|
|
159
|
-
recorder
|
|
179
|
+
service: buildService(scriptedTurns, options, cursor, record),
|
|
180
|
+
recorder,
|
|
160
181
|
}
|
|
161
182
|
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { Effect, Layer, Ref, Stream } from "effect"
|
|
2
|
+
import type { AudioBlob, AudioChunk } from "../domain/Audio.js"
|
|
3
|
+
import * as AiError from "../domain/AiError.js"
|
|
4
|
+
import {
|
|
5
|
+
SpeechSynthesizer,
|
|
6
|
+
TtsIncrementalText,
|
|
7
|
+
type CommonStreamSynthesizeRequest,
|
|
8
|
+
type CommonSynthesizeRequest,
|
|
9
|
+
type SpeechSynthesizerService,
|
|
10
|
+
} from "../speech-synthesizer/SpeechSynthesizer.js"
|
|
11
|
+
|
|
12
|
+
export type MockSynthesizerRecorder = {
|
|
13
|
+
readonly synthesizeCalls: ReadonlyArray<CommonSynthesizeRequest>
|
|
14
|
+
readonly streamSynthesisCalls: ReadonlyArray<CommonSynthesizeRequest>
|
|
15
|
+
readonly streamSynthesisFromCalls: ReadonlyArray<CommonStreamSynthesizeRequest>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type MockSynthesizerScript = {
|
|
19
|
+
/** One blob per `synthesize` call, consumed in order. */
|
|
20
|
+
readonly blobs?: ReadonlyArray<AudioBlob>
|
|
21
|
+
/** One chunk-list per `streamSynthesis` call, consumed in order. */
|
|
22
|
+
readonly streamSynthesisChunks?: ReadonlyArray<ReadonlyArray<AudioChunk>>
|
|
23
|
+
/** One chunk-list per `streamSynthesisFrom` call, consumed in order. */
|
|
24
|
+
readonly streamSynthesisFromChunks?: ReadonlyArray<ReadonlyArray<AudioChunk>>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const makeService = (
|
|
28
|
+
script: MockSynthesizerScript,
|
|
29
|
+
record: {
|
|
30
|
+
readonly synthesize: (req: CommonSynthesizeRequest) => Effect.Effect<void>
|
|
31
|
+
readonly streamSynthesis: (req: CommonSynthesizeRequest) => Effect.Effect<void>
|
|
32
|
+
readonly streamSynthesisFrom: (req: CommonStreamSynthesizeRequest) => Effect.Effect<void>
|
|
33
|
+
},
|
|
34
|
+
) =>
|
|
35
|
+
Effect.gen(function* () {
|
|
36
|
+
const bCursor = yield* Ref.make(0)
|
|
37
|
+
const ssCursor = yield* Ref.make(0)
|
|
38
|
+
const ssfCursor = yield* Ref.make(0)
|
|
39
|
+
const service: SpeechSynthesizerService = {
|
|
40
|
+
synthesize: (request) =>
|
|
41
|
+
Effect.gen(function* () {
|
|
42
|
+
yield* record.synthesize(request)
|
|
43
|
+
const i = yield* Ref.getAndUpdate(bCursor, (n) => n + 1)
|
|
44
|
+
const scripted = script.blobs ?? []
|
|
45
|
+
if (i >= scripted.length) {
|
|
46
|
+
return yield* new AiError.InvalidRequest({
|
|
47
|
+
provider: "mock",
|
|
48
|
+
raw: `MockSpeechSynthesizer exhausted: ${scripted.length} blobs scripted, but call ${i + 1} was made`,
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
return scripted[i]!
|
|
52
|
+
}),
|
|
53
|
+
streamSynthesis: (request) =>
|
|
54
|
+
Stream.unwrap(
|
|
55
|
+
Effect.gen(function* () {
|
|
56
|
+
yield* record.streamSynthesis(request)
|
|
57
|
+
const i = yield* Ref.getAndUpdate(ssCursor, (n) => n + 1)
|
|
58
|
+
const scripted = script.streamSynthesisChunks ?? []
|
|
59
|
+
if (i >= scripted.length) {
|
|
60
|
+
return Stream.fail(
|
|
61
|
+
new AiError.InvalidRequest({
|
|
62
|
+
provider: "mock",
|
|
63
|
+
raw: `MockSpeechSynthesizer exhausted: ${scripted.length} streamSynthesis lists scripted, but call ${i + 1} was made`,
|
|
64
|
+
}),
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
return Stream.fromIterable(scripted[i]!)
|
|
68
|
+
}),
|
|
69
|
+
),
|
|
70
|
+
streamSynthesisFrom: <E, R>(
|
|
71
|
+
textIn: Stream.Stream<string, E, R>,
|
|
72
|
+
request: CommonStreamSynthesizeRequest,
|
|
73
|
+
): Stream.Stream<AudioChunk, AiError.AiError | E, R> =>
|
|
74
|
+
Stream.unwrap(
|
|
75
|
+
Effect.gen(function* () {
|
|
76
|
+
yield* record.streamSynthesisFrom(request)
|
|
77
|
+
const i = yield* Ref.getAndUpdate(ssfCursor, (n) => n + 1)
|
|
78
|
+
const scripted = script.streamSynthesisFromChunks ?? []
|
|
79
|
+
if (i >= scripted.length) {
|
|
80
|
+
const exhausted: Stream.Stream<AudioChunk, AiError.AiError | E, R> = Stream.fail(
|
|
81
|
+
new AiError.InvalidRequest({
|
|
82
|
+
provider: "mock",
|
|
83
|
+
raw: `MockSpeechSynthesizer exhausted: ${scripted.length} streamSynthesisFrom lists scripted, but call ${i + 1} was made`,
|
|
84
|
+
}),
|
|
85
|
+
)
|
|
86
|
+
return exhausted
|
|
87
|
+
}
|
|
88
|
+
// Drain the input text fully before emitting scripted audio chunks,
|
|
89
|
+
// so consumers can assert on what text was pushed.
|
|
90
|
+
return Stream.drain(textIn).pipe(Stream.concat(Stream.fromIterable(scripted[i]!)))
|
|
91
|
+
}),
|
|
92
|
+
),
|
|
93
|
+
}
|
|
94
|
+
return service
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Layer providing the `SpeechSynthesizer` service AND the
|
|
99
|
+
* `TtsIncrementalText` capability marker. Use for the common case
|
|
100
|
+
* where code under test exercises `streamSynthesisFrom`.
|
|
101
|
+
*/
|
|
102
|
+
export const layer = (
|
|
103
|
+
script: MockSynthesizerScript,
|
|
104
|
+
): {
|
|
105
|
+
readonly layer: Layer.Layer<SpeechSynthesizer | TtsIncrementalText>
|
|
106
|
+
readonly recorder: Effect.Effect<MockSynthesizerRecorder>
|
|
107
|
+
} => {
|
|
108
|
+
const bCalls = Ref.makeUnsafe<ReadonlyArray<CommonSynthesizeRequest>>([])
|
|
109
|
+
const ssCalls = Ref.makeUnsafe<ReadonlyArray<CommonSynthesizeRequest>>([])
|
|
110
|
+
const ssfCalls = Ref.makeUnsafe<ReadonlyArray<CommonStreamSynthesizeRequest>>([])
|
|
111
|
+
const synthesizerLayer = Layer.effect(
|
|
112
|
+
SpeechSynthesizer,
|
|
113
|
+
makeService(script, {
|
|
114
|
+
synthesize: (req) => Ref.update(bCalls, (xs) => [...xs, req]),
|
|
115
|
+
streamSynthesis: (req) => Ref.update(ssCalls, (xs) => [...xs, req]),
|
|
116
|
+
streamSynthesisFrom: (req) => Ref.update(ssfCalls, (xs) => [...xs, req]),
|
|
117
|
+
}),
|
|
118
|
+
)
|
|
119
|
+
const live = Layer.merge(synthesizerLayer, Layer.succeed(TtsIncrementalText, undefined))
|
|
120
|
+
return {
|
|
121
|
+
layer: live,
|
|
122
|
+
recorder: Effect.gen(function* () {
|
|
123
|
+
const synthesizeCalls = yield* Ref.get(bCalls)
|
|
124
|
+
const streamSynthesisCalls = yield* Ref.get(ssCalls)
|
|
125
|
+
const streamSynthesisFromCalls = yield* Ref.get(ssfCalls)
|
|
126
|
+
return { synthesizeCalls, streamSynthesisCalls, streamSynthesisFromCalls }
|
|
127
|
+
}),
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Variant that omits the `TtsIncrementalText` marker — simulates a
|
|
133
|
+
* provider without incremental-text-in support (e.g. OpenAI, AWS
|
|
134
|
+
* Polly non-Generative). Calls to `streamSynthesisFrom` in code under
|
|
135
|
+
* test should be a compile-time error.
|
|
136
|
+
*/
|
|
137
|
+
export const layerWithoutIncremental = (
|
|
138
|
+
script: MockSynthesizerScript,
|
|
139
|
+
): {
|
|
140
|
+
readonly layer: Layer.Layer<SpeechSynthesizer>
|
|
141
|
+
readonly recorder: Effect.Effect<MockSynthesizerRecorder>
|
|
142
|
+
} => {
|
|
143
|
+
const bCalls = Ref.makeUnsafe<ReadonlyArray<CommonSynthesizeRequest>>([])
|
|
144
|
+
const ssCalls = Ref.makeUnsafe<ReadonlyArray<CommonSynthesizeRequest>>([])
|
|
145
|
+
const ssfCalls = Ref.makeUnsafe<ReadonlyArray<CommonStreamSynthesizeRequest>>([])
|
|
146
|
+
const live = Layer.effect(
|
|
147
|
+
SpeechSynthesizer,
|
|
148
|
+
makeService(script, {
|
|
149
|
+
synthesize: (req) => Ref.update(bCalls, (xs) => [...xs, req]),
|
|
150
|
+
streamSynthesis: (req) => Ref.update(ssCalls, (xs) => [...xs, req]),
|
|
151
|
+
streamSynthesisFrom: (req) => Ref.update(ssfCalls, (xs) => [...xs, req]),
|
|
152
|
+
}),
|
|
153
|
+
)
|
|
154
|
+
return {
|
|
155
|
+
layer: live,
|
|
156
|
+
recorder: Effect.gen(function* () {
|
|
157
|
+
const synthesizeCalls = yield* Ref.get(bCalls)
|
|
158
|
+
const streamSynthesisCalls = yield* Ref.get(ssCalls)
|
|
159
|
+
const streamSynthesisFromCalls = yield* Ref.get(ssfCalls)
|
|
160
|
+
return { synthesizeCalls, streamSynthesisCalls, streamSynthesisFromCalls }
|
|
161
|
+
}),
|
|
162
|
+
}
|
|
163
|
+
}
|