@effect-uai/mistral 0.8.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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +76 -0
  3. package/dist/Mistral.d.mts +48 -0
  4. package/dist/Mistral.d.mts.map +1 -0
  5. package/dist/Mistral.mjs +88 -0
  6. package/dist/Mistral.mjs.map +1 -0
  7. package/dist/MistralRealtimeTranscriber.d.mts +15 -0
  8. package/dist/MistralRealtimeTranscriber.d.mts.map +1 -0
  9. package/dist/MistralRealtimeTranscriber.mjs +34 -0
  10. package/dist/MistralRealtimeTranscriber.mjs.map +1 -0
  11. package/dist/MistralSynthesizer.d.mts +43 -0
  12. package/dist/MistralSynthesizer.d.mts.map +1 -0
  13. package/dist/MistralSynthesizer.mjs +121 -0
  14. package/dist/MistralSynthesizer.mjs.map +1 -0
  15. package/dist/MistralTranscriber.d.mts +44 -0
  16. package/dist/MistralTranscriber.d.mts.map +1 -0
  17. package/dist/MistralTranscriber.mjs +111 -0
  18. package/dist/MistralTranscriber.mjs.map +1 -0
  19. package/dist/audioCodec.d.mts +27 -0
  20. package/dist/audioCodec.d.mts.map +1 -0
  21. package/dist/audioCodec.mjs +82 -0
  22. package/dist/audioCodec.mjs.map +1 -0
  23. package/dist/chunk-CfYAbeIz.mjs +13 -0
  24. package/dist/codec.d.mts +100 -0
  25. package/dist/codec.d.mts.map +1 -0
  26. package/dist/codec.mjs +244 -0
  27. package/dist/codec.mjs.map +1 -0
  28. package/dist/codec.test.d.mts +1 -0
  29. package/dist/codec.test.mjs +13800 -0
  30. package/dist/codec.test.mjs.map +1 -0
  31. package/dist/http.d.mts +9 -0
  32. package/dist/http.d.mts.map +1 -0
  33. package/dist/http.mjs +41 -0
  34. package/dist/http.mjs.map +1 -0
  35. package/dist/index.d.mts +7 -0
  36. package/dist/index.mjs +7 -0
  37. package/dist/magic-string.es-PAZgLX4h.mjs +1013 -0
  38. package/dist/magic-string.es-PAZgLX4h.mjs.map +1 -0
  39. package/dist/models-DnDmylpj.d.mts +22 -0
  40. package/dist/models-DnDmylpj.d.mts.map +1 -0
  41. package/dist/models.d.mts +2 -0
  42. package/dist/models.mjs +1 -0
  43. package/dist/realtimeStt-ClXWXbXX.d.mts +21 -0
  44. package/dist/realtimeStt-ClXWXbXX.d.mts.map +1 -0
  45. package/dist/realtimeStt.d.mts +2 -0
  46. package/dist/realtimeStt.mjs +136 -0
  47. package/dist/realtimeStt.mjs.map +1 -0
  48. package/package.json +82 -0
  49. package/src/Mistral.ts +189 -0
  50. package/src/MistralRealtimeTranscriber.ts +60 -0
  51. package/src/MistralSynthesizer.ts +285 -0
  52. package/src/MistralTranscriber.ts +224 -0
  53. package/src/audioCodec.ts +123 -0
  54. package/src/codec.test.ts +121 -0
  55. package/src/codec.ts +338 -0
  56. package/src/http.ts +46 -0
  57. package/src/index.ts +6 -0
  58. package/src/models.ts +47 -0
  59. package/src/realtimeStt.ts +256 -0
package/src/Mistral.ts ADDED
@@ -0,0 +1,189 @@
1
+ import { Context, Effect, Layer, Option, Redacted, Result, Schema, Stream } from "effect"
2
+ import { HttpClient, HttpClientRequest } from "effect/unstable/http"
3
+ import * as AiError from "@effect-uai/core/AiError"
4
+ import {
5
+ type CommonRequest,
6
+ LanguageModel,
7
+ type LanguageModelService,
8
+ turnFromStream,
9
+ } from "@effect-uai/core/LanguageModel"
10
+ import * as SSE from "@effect-uai/core/SSE"
11
+ import { descriptorsOf } from "@effect-uai/core/Tool"
12
+ import { type Turn, TurnEvent } from "@effect-uai/core/Turn"
13
+ import {
14
+ type Accumulator,
15
+ type WireChunk,
16
+ accumulatorToTurn,
17
+ applyChunk,
18
+ decodeChunk,
19
+ emptyAccumulator,
20
+ itemsToMessages,
21
+ responseFormatWire,
22
+ toolChoiceWire,
23
+ toolsWire,
24
+ } from "./codec.js"
25
+ import { httpStatusError } from "./http.js"
26
+ import type { MistralModel } from "./models.js"
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Public types
30
+ // ---------------------------------------------------------------------------
31
+
32
+ export type MistralRequest = Omit<CommonRequest, "model"> & {
33
+ /**
34
+ * Narrows `CommonRequest.model` (`string`) to the typed `MistralModel`
35
+ * literal union for autocomplete.
36
+ */
37
+ readonly model: MistralModel
38
+ /**
39
+ * Prepend a safety system prompt (`safe_prompt` on the wire). Off by default.
40
+ */
41
+ readonly safePrompt?: boolean
42
+ /** Deterministic sampling seed (`random_seed` on the wire). */
43
+ readonly randomSeed?: number
44
+ }
45
+
46
+ export type MistralService = {
47
+ /** Stream canonical `TurnEvent`s for one turn. */
48
+ readonly streamTurn: (request: MistralRequest) => Stream.Stream<TurnEvent, AiError.AiError>
49
+ /** Drain a single turn and return the assembled `Turn`. */
50
+ readonly turn: (request: MistralRequest) => Effect.Effect<Turn, AiError.AiError>
51
+ }
52
+
53
+ /**
54
+ * Provider-typed service tag. Yield this when you want Mistral-specific options
55
+ * (`safePrompt`, `randomSeed`); yield the generic `LanguageModel` tag for
56
+ * provider-portable code. Both are registered by `layer`.
57
+ */
58
+ export class Mistral extends Context.Service<Mistral, MistralService>()(
59
+ "@betalyra/effect-uai/providers/mistral/Mistral",
60
+ ) {}
61
+
62
+ export type Config = {
63
+ readonly apiKey: Redacted.Redacted
64
+ readonly baseUrl?: string
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Request body
69
+ // ---------------------------------------------------------------------------
70
+
71
+ const buildRequestBody = (request: MistralRequest): Record<string, unknown> => {
72
+ const tools = toolsWire(descriptorsOf(request.tools))
73
+ return {
74
+ model: request.model,
75
+ stream: true,
76
+ messages: itemsToMessages(request.history),
77
+ ...(request.temperature !== undefined && { temperature: request.temperature }),
78
+ ...(request.topP !== undefined && { top_p: request.topP }),
79
+ ...(request.maxOutputTokens !== undefined && { max_tokens: request.maxOutputTokens }),
80
+ ...(request.safePrompt !== undefined && { safe_prompt: request.safePrompt }),
81
+ ...(request.randomSeed !== undefined && { random_seed: request.randomSeed }),
82
+ ...(Option.isSome(tools) && { tools: tools.value }),
83
+ ...(request.toolChoice !== undefined && { tool_choice: toolChoiceWire(request.toolChoice) }),
84
+ ...(request.structured !== undefined && {
85
+ response_format: responseFormatWire(request.structured),
86
+ }),
87
+ }
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // SSE → WireChunk
92
+ // ---------------------------------------------------------------------------
93
+
94
+ const parseJsonUnknown = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))
95
+
96
+ /**
97
+ * Decode one SSE event's `data` payload into a `WireChunk`. Returns `None`
98
+ * for the `[DONE]` sentinel and for any payload that isn't a chunk we model,
99
+ * so a stray keep-alive never aborts the turn.
100
+ */
101
+ const decodeEvent = (data: string): Effect.Effect<Option.Option<WireChunk>> =>
102
+ data.trim() === "[DONE]"
103
+ ? Effect.succeedNone
104
+ : parseJsonUnknown(data).pipe(Effect.flatMap(decodeChunk), Effect.option)
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Stream
108
+ // ---------------------------------------------------------------------------
109
+
110
+ const buildStream = (cfg: Config) => {
111
+ const baseUrl = cfg.baseUrl ?? "https://api.mistral.ai"
112
+ const url = `${baseUrl}/v1/chat/completions`
113
+ return (
114
+ request: MistralRequest,
115
+ ): Stream.Stream<TurnEvent, AiError.AiError, HttpClient.HttpClient> =>
116
+ Stream.unwrap(
117
+ Effect.gen(function* () {
118
+ const client = yield* HttpClient.HttpClient
119
+ const httpRequest = HttpClientRequest.post(url).pipe(
120
+ HttpClientRequest.setHeader("authorization", `Bearer ${Redacted.value(cfg.apiKey)}`),
121
+ HttpClientRequest.bodyJsonUnsafe(buildRequestBody(request)),
122
+ HttpClientRequest.accept("text/event-stream"),
123
+ )
124
+ const response = yield* client
125
+ .execute(httpRequest)
126
+ .pipe(
127
+ Effect.mapError(
128
+ (cause): AiError.AiError =>
129
+ new AiError.Unavailable({ provider: "mistral", raw: cause }),
130
+ ),
131
+ )
132
+ if (response.status >= 400) {
133
+ const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
134
+ return Stream.fail(httpStatusError(response.status, text))
135
+ }
136
+
137
+ return response.stream.pipe(
138
+ Stream.mapError(
139
+ (cause): AiError.AiError =>
140
+ new AiError.Unavailable({ provider: "mistral", raw: cause }),
141
+ ),
142
+ SSE.fromBytes,
143
+ Stream.mapEffect((ev) => decodeEvent(ev.data)),
144
+ Stream.filterMap((chunk) =>
145
+ Option.isSome(chunk) ? Result.succeed(chunk.value) : Result.failVoid,
146
+ ),
147
+ Stream.mapAccum((): Accumulator => emptyAccumulator, applyChunk, {
148
+ onHalt: (acc) => [TurnEvent.TurnComplete({ turn: accumulatorToTurn(acc) })],
149
+ }),
150
+ )
151
+ }),
152
+ )
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Constructors
157
+ // ---------------------------------------------------------------------------
158
+
159
+ /** Build a `MistralService` value. For Layer-based setup, prefer `layer`. */
160
+ export const make = (cfg: Config): Effect.Effect<MistralService, never, HttpClient.HttpClient> =>
161
+ Effect.map(HttpClient.HttpClient.asEffect(), (client) => {
162
+ const streamTurn: MistralService["streamTurn"] = (request) =>
163
+ buildStream(cfg)(request).pipe(Stream.provideService(HttpClient.HttpClient, client))
164
+ return {
165
+ streamTurn,
166
+ turn: turnFromStream(streamTurn),
167
+ }
168
+ })
169
+
170
+ /**
171
+ * Layer that registers both the provider-specific `Mistral` tag and the
172
+ * generic `LanguageModel` tag, sharing one underlying implementation.
173
+ */
174
+ export const layer = (
175
+ cfg: Config,
176
+ ): Layer.Layer<Mistral | LanguageModel, never, HttpClient.HttpClient> => {
177
+ const typed = Layer.effect(Mistral, make(cfg))
178
+ const generic = Layer.effect(
179
+ LanguageModel,
180
+ Effect.map(
181
+ make(cfg),
182
+ (s): LanguageModelService => ({
183
+ streamTurn: (request) => s.streamTurn(request as MistralRequest),
184
+ turn: (request) => s.turn(request as MistralRequest),
185
+ }),
186
+ ),
187
+ )
188
+ return Layer.merge(typed, generic)
189
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Realtime variant of `MistralTranscriber`. Wires `streamTranscriptionFrom`
3
+ * to the Voxtral Realtime WebSocket endpoint and registers the `SttStreaming`
4
+ * capability marker, so live-transcription calls compile against this Layer
5
+ * alone (unlike the sync-only `MistralTranscriber` Layer).
6
+ *
7
+ * Pulls in `ws` (optional peer dep) transitively via `./realtimeStt.js`.
8
+ * Node/Bun only — the browser `WebSocket` API can't set the `Authorization`
9
+ * header the WS upgrade requires.
10
+ */
11
+ import { Effect, Layer } from "effect"
12
+ import { HttpClient } from "effect/unstable/http"
13
+ import {
14
+ type CommonTranscribeRequest,
15
+ SttStreaming,
16
+ Transcriber,
17
+ type TranscriberService,
18
+ } from "@effect-uai/core/Transcriber"
19
+ import {
20
+ MistralTranscriber,
21
+ type MistralTranscriberService,
22
+ type MistralTranscribeRequest,
23
+ transcribeImpl,
24
+ } from "./MistralTranscriber.js"
25
+ import { type Config, streamTranscription } from "./realtimeStt.js"
26
+
27
+ export type { Config } from "./realtimeStt.js"
28
+ export {
29
+ MistralTranscriber,
30
+ type MistralTranscriberService,
31
+ type MistralTranscribeRequest,
32
+ } from "./MistralTranscriber.js"
33
+
34
+ export const make = (
35
+ cfg: Config,
36
+ ): Effect.Effect<MistralTranscriberService, never, HttpClient.HttpClient> =>
37
+ Effect.map(HttpClient.HttpClient.asEffect(), (client) => ({
38
+ transcribe: (request) =>
39
+ transcribeImpl(cfg)(request).pipe(Effect.provideService(HttpClient.HttpClient, client)),
40
+ streamTranscriptionFrom: streamTranscription(cfg),
41
+ }))
42
+
43
+ export const layer = (
44
+ cfg: Config,
45
+ ): Layer.Layer<MistralTranscriber | Transcriber | SttStreaming, never, HttpClient.HttpClient> =>
46
+ Layer.mergeAll(
47
+ Layer.effect(MistralTranscriber, make(cfg)),
48
+ Layer.effect(
49
+ Transcriber,
50
+ Effect.map(
51
+ make(cfg),
52
+ (s): TranscriberService => ({
53
+ transcribe: (req: CommonTranscribeRequest) =>
54
+ s.transcribe(req as MistralTranscribeRequest),
55
+ streamTranscriptionFrom: s.streamTranscriptionFrom,
56
+ }),
57
+ ),
58
+ ),
59
+ Layer.succeed(SttStreaming, undefined),
60
+ )
@@ -0,0 +1,285 @@
1
+ import {
2
+ Array as Arr,
3
+ Context,
4
+ Effect,
5
+ Encoding,
6
+ Layer,
7
+ Redacted,
8
+ Result,
9
+ Schema,
10
+ Stream,
11
+ } from "effect"
12
+ import { HttpClient, HttpClientRequest } from "effect/unstable/http"
13
+ import * as AiError from "@effect-uai/core/AiError"
14
+ import type { AudioBlob, AudioChunk, AudioFormat } from "@effect-uai/core/Audio"
15
+ import * as Capabilities from "@effect-uai/core/Capabilities"
16
+ import {
17
+ type CommonStreamSynthesizeRequest,
18
+ type CommonSynthesizeRequest,
19
+ SpeechSynthesizer,
20
+ type SpeechSynthesizerService,
21
+ TtsIncrementalText,
22
+ } from "@effect-uai/core/SpeechSynthesizer"
23
+ import { containerToTtsFormat, type MistralTtsFormat, realizedTtsFormat } from "./audioCodec.js"
24
+ import { httpStatusError, transportFailure } from "./http.js"
25
+ import type { MistralTtsModel } from "./models.js"
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Public types
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Mistral-typed synthesize request. `model` narrows to `MistralTtsModel`;
33
+ * `voiceId` selects a saved Voxtral voice. `refAudio` (base64 of a 2-3s clip)
34
+ * triggers zero-shot voice cloning and takes precedence over `voiceId`.
35
+ */
36
+ export type MistralSynthesizeRequest = Omit<CommonSynthesizeRequest, "model"> & {
37
+ readonly model: MistralTtsModel
38
+ readonly refAudio?: string
39
+ }
40
+
41
+ export type MistralSynthesizerService = {
42
+ readonly synthesize: (
43
+ request: MistralSynthesizeRequest,
44
+ ) => Effect.Effect<AudioBlob, AiError.AiError>
45
+ readonly streamSynthesis: (
46
+ request: MistralSynthesizeRequest,
47
+ ) => Stream.Stream<AudioChunk, AiError.AiError>
48
+ readonly streamSynthesisFrom: <E, R>(
49
+ textIn: Stream.Stream<string, E, R>,
50
+ request: CommonStreamSynthesizeRequest,
51
+ ) => Stream.Stream<AudioChunk, AiError.AiError | E, R>
52
+ readonly synthesizeDialogue: SpeechSynthesizerService["synthesizeDialogue"]
53
+ readonly streamSynthesizeDialogue: SpeechSynthesizerService["streamSynthesizeDialogue"]
54
+ }
55
+
56
+ export class MistralSynthesizer extends Context.Service<
57
+ MistralSynthesizer,
58
+ MistralSynthesizerService
59
+ >()("@betalyra/effect-uai/providers/mistral/MistralSynthesizer") {}
60
+
61
+ export type Config = {
62
+ readonly apiKey: Redacted.Redacted
63
+ readonly baseUrl?: string
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Codec — request → JSON body
68
+ // ---------------------------------------------------------------------------
69
+
70
+ const defaultFormat: AudioFormat = { container: "mp3", encoding: "mp3", sampleRate: 24000 }
71
+
72
+ type WireBody = {
73
+ readonly model: string
74
+ readonly input: string
75
+ readonly response_format: MistralTtsFormat
76
+ readonly voice_id?: string
77
+ readonly ref_audio?: string
78
+ }
79
+
80
+ const buildBody = (
81
+ request: MistralSynthesizeRequest,
82
+ ): Effect.Effect<{ readonly body: WireBody; readonly format: AudioFormat }, AiError.AiError> =>
83
+ Effect.gen(function* () {
84
+ // Voxtral has no phoneme field; pronunciations are load-bearing, so reject
85
+ // rather than silently mispronounce.
86
+ if (request.pronunciations !== undefined && request.pronunciations.length > 0) {
87
+ return yield* Effect.fail(
88
+ new AiError.Unsupported({
89
+ provider: "mistral",
90
+ capability: "pronunciations",
91
+ reason:
92
+ "Voxtral TTS has no phoneme field. Use a provider with a phoneme path (Inworld, Google) for pronunciation overrides.",
93
+ }),
94
+ )
95
+ }
96
+ yield* Capabilities.warnDroppedWhen(request.speed, {
97
+ provider: "mistral",
98
+ capability: "speed",
99
+ field: "speed",
100
+ reason: "Voxtral TTS has no speed control; prosody follows the voice prompt.",
101
+ })
102
+ yield* Capabilities.warnDroppedWhen(request.languageCode, {
103
+ provider: "mistral",
104
+ capability: "languageCode",
105
+ field: "languageCode",
106
+ reason: "Voxtral TTS auto-detects language and clones cross-lingually.",
107
+ })
108
+ const responseFormat = yield* containerToTtsFormat(
109
+ (request.outputFormat ?? defaultFormat).container,
110
+ )
111
+ return {
112
+ body: {
113
+ model: request.model,
114
+ input: request.text,
115
+ response_format: responseFormat,
116
+ // Cloning clip wins over a saved voice id when both are present.
117
+ ...(request.refAudio !== undefined
118
+ ? { ref_audio: request.refAudio }
119
+ : { voice_id: request.voiceId }),
120
+ },
121
+ format: realizedTtsFormat(responseFormat),
122
+ }
123
+ })
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // HTTP plumbing
127
+ // ---------------------------------------------------------------------------
128
+
129
+ const buildHttpRequest = (cfg: Config, body: WireBody, accept: string) =>
130
+ HttpClientRequest.post(`${cfg.baseUrl ?? "https://api.mistral.ai"}/v1/audio/speech`).pipe(
131
+ HttpClientRequest.bearerToken(cfg.apiKey),
132
+ HttpClientRequest.accept(accept),
133
+ HttpClientRequest.bodyJsonUnsafe(body),
134
+ )
135
+
136
+ // One-shot response: base64 audio. Voxtral's streaming SSE wraps this same
137
+ // payload; since `streamSynthesisFrom` buffers the whole utterance anyway, we
138
+ // drive everything off the simpler one-shot endpoint and slice for pacing.
139
+ const WireResponse = Schema.Struct({ audio_data: Schema.String })
140
+ const decodeResponse = Schema.decodeUnknownEffect(WireResponse)
141
+
142
+ const decodeAudioData = (b64: string): Effect.Effect<Uint8Array, AiError.AiError> =>
143
+ Result.match(Encoding.decodeBase64(b64), {
144
+ onSuccess: Effect.succeed,
145
+ onFailure: (cause) =>
146
+ Effect.fail(
147
+ new AiError.InvalidRequest({ provider: "mistral", param: "audio_data", raw: cause }),
148
+ ),
149
+ })
150
+
151
+ const synthesizeImpl =
152
+ (cfg: Config) =>
153
+ (
154
+ request: MistralSynthesizeRequest,
155
+ ): Effect.Effect<AudioBlob, AiError.AiError, HttpClient.HttpClient> =>
156
+ Effect.gen(function* () {
157
+ const client = yield* HttpClient.HttpClient
158
+ const { body, format } = yield* buildBody(request)
159
+ const response = yield* client
160
+ .execute(buildHttpRequest(cfg, body, "application/json"))
161
+ .pipe(Effect.mapError(transportFailure))
162
+ if (response.status >= 400) {
163
+ const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
164
+ yield* Effect.logWarning("[voxtral-tts] request failed", {
165
+ status: response.status,
166
+ body: text,
167
+ })
168
+ return yield* Effect.fail(httpStatusError(response.status, text))
169
+ }
170
+ const json = yield* response.json.pipe(Effect.mapError(transportFailure))
171
+ const decoded = yield* decodeResponse(json).pipe(Effect.mapError(transportFailure))
172
+ const bytes = yield* decodeAudioData(decoded.audio_data)
173
+ yield* Effect.logDebug("[voxtral-tts] ok", {
174
+ bytes: bytes.byteLength,
175
+ voice: request.voiceId,
176
+ })
177
+ return { format, bytes }
178
+ })
179
+
180
+ // ~40 ms frames so the voice loop can pace playback and cut on interrupt.
181
+ const FRAME_BYTES = 8192
182
+
183
+ const sliceIntoChunks = (bytes: Uint8Array): ReadonlyArray<AudioChunk> =>
184
+ Arr.makeBy(Math.max(1, Math.ceil(bytes.byteLength / FRAME_BYTES)), (i) => ({
185
+ bytes: bytes.subarray(i * FRAME_BYTES, (i + 1) * FRAME_BYTES),
186
+ }))
187
+
188
+ const streamSynthesisImpl =
189
+ (cfg: Config) =>
190
+ (
191
+ request: MistralSynthesizeRequest,
192
+ ): Stream.Stream<AudioChunk, AiError.AiError, HttpClient.HttpClient> =>
193
+ Stream.unwrap(
194
+ Effect.map(synthesizeImpl(cfg)(request), (blob) =>
195
+ Stream.fromIterable(sliceIntoChunks(blob.bytes)),
196
+ ),
197
+ )
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Dialogue — unsupported (Voxtral has no multi-speaker endpoint)
201
+ // ---------------------------------------------------------------------------
202
+
203
+ const synthesizeDialogueUnsupported: SpeechSynthesizerService["synthesizeDialogue"] = () =>
204
+ Effect.fail(
205
+ new AiError.Unsupported({
206
+ provider: "mistral",
207
+ capability: "synthesizeDialogue",
208
+ reason: "Voxtral TTS has no multi-speaker dialogue endpoint.",
209
+ }),
210
+ )
211
+
212
+ const streamSynthesizeDialogueUnsupported: SpeechSynthesizerService["streamSynthesizeDialogue"] =
213
+ () =>
214
+ Stream.fail(
215
+ new AiError.Unsupported({
216
+ provider: "mistral",
217
+ capability: "streamSynthesizeDialogue",
218
+ reason: "Voxtral TTS has no multi-speaker dialogue endpoint.",
219
+ }),
220
+ )
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Constructors
224
+ // ---------------------------------------------------------------------------
225
+
226
+ export const make = (
227
+ cfg: Config,
228
+ ): Effect.Effect<MistralSynthesizerService, never, HttpClient.HttpClient> =>
229
+ Effect.map(HttpClient.HttpClient.asEffect(), (client) => ({
230
+ synthesize: (request) =>
231
+ synthesizeImpl(cfg)(request).pipe(Effect.provideService(HttpClient.HttpClient, client)),
232
+ streamSynthesis: (request) =>
233
+ streamSynthesisImpl(cfg)(request).pipe(Stream.provideService(HttpClient.HttpClient, client)),
234
+ // Voxtral has no incremental-text-in WS endpoint, so we fold the text
235
+ // stream into one utterance and hand it to the chunked streaming endpoint.
236
+ // Audio still streams out; only the text side buffers (one turn per call).
237
+ streamSynthesisFrom: (textIn, request) =>
238
+ Stream.unwrap(
239
+ Effect.map(
240
+ Stream.runFold(
241
+ textIn,
242
+ () => "",
243
+ (acc, s) => acc + s,
244
+ ),
245
+ (text) =>
246
+ streamSynthesisImpl(cfg)({ ...request, text } as MistralSynthesizeRequest).pipe(
247
+ Stream.provideService(HttpClient.HttpClient, client),
248
+ ),
249
+ ),
250
+ ),
251
+ synthesizeDialogue: synthesizeDialogueUnsupported,
252
+ streamSynthesizeDialogue: streamSynthesizeDialogueUnsupported,
253
+ }))
254
+
255
+ /**
256
+ * Layer registering `MistralSynthesizer`, the generic `SpeechSynthesizer` tag,
257
+ * and the `TtsIncrementalText` capability marker (so `streamSynthesisFrom`
258
+ * composes — note the text side buffers; see its docs).
259
+ */
260
+ export const layer = (
261
+ cfg: Config,
262
+ ): Layer.Layer<
263
+ MistralSynthesizer | SpeechSynthesizer | TtsIncrementalText,
264
+ never,
265
+ HttpClient.HttpClient
266
+ > =>
267
+ Layer.mergeAll(
268
+ Layer.effect(MistralSynthesizer, make(cfg)),
269
+ Layer.effect(
270
+ SpeechSynthesizer,
271
+ Effect.map(
272
+ make(cfg),
273
+ (s): SpeechSynthesizerService => ({
274
+ synthesize: (req: CommonSynthesizeRequest) =>
275
+ s.synthesize(req as MistralSynthesizeRequest),
276
+ streamSynthesis: (req: CommonSynthesizeRequest) =>
277
+ s.streamSynthesis(req as MistralSynthesizeRequest),
278
+ streamSynthesisFrom: s.streamSynthesisFrom,
279
+ synthesizeDialogue: s.synthesizeDialogue,
280
+ streamSynthesizeDialogue: s.streamSynthesizeDialogue,
281
+ }),
282
+ ),
283
+ ),
284
+ Layer.succeed(TtsIncrementalText, undefined),
285
+ )