@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.
- package/LICENSE +21 -0
- package/README.md +76 -0
- package/dist/Mistral.d.mts +48 -0
- package/dist/Mistral.d.mts.map +1 -0
- package/dist/Mistral.mjs +88 -0
- package/dist/Mistral.mjs.map +1 -0
- package/dist/MistralRealtimeTranscriber.d.mts +15 -0
- package/dist/MistralRealtimeTranscriber.d.mts.map +1 -0
- package/dist/MistralRealtimeTranscriber.mjs +34 -0
- package/dist/MistralRealtimeTranscriber.mjs.map +1 -0
- package/dist/MistralSynthesizer.d.mts +43 -0
- package/dist/MistralSynthesizer.d.mts.map +1 -0
- package/dist/MistralSynthesizer.mjs +121 -0
- package/dist/MistralSynthesizer.mjs.map +1 -0
- package/dist/MistralTranscriber.d.mts +44 -0
- package/dist/MistralTranscriber.d.mts.map +1 -0
- package/dist/MistralTranscriber.mjs +111 -0
- package/dist/MistralTranscriber.mjs.map +1 -0
- package/dist/audioCodec.d.mts +27 -0
- package/dist/audioCodec.d.mts.map +1 -0
- package/dist/audioCodec.mjs +82 -0
- package/dist/audioCodec.mjs.map +1 -0
- package/dist/chunk-CfYAbeIz.mjs +13 -0
- package/dist/codec.d.mts +100 -0
- package/dist/codec.d.mts.map +1 -0
- package/dist/codec.mjs +244 -0
- package/dist/codec.mjs.map +1 -0
- package/dist/codec.test.d.mts +1 -0
- package/dist/codec.test.mjs +13800 -0
- package/dist/codec.test.mjs.map +1 -0
- package/dist/http.d.mts +9 -0
- package/dist/http.d.mts.map +1 -0
- package/dist/http.mjs +41 -0
- package/dist/http.mjs.map +1 -0
- package/dist/index.d.mts +7 -0
- package/dist/index.mjs +7 -0
- package/dist/magic-string.es-PAZgLX4h.mjs +1013 -0
- package/dist/magic-string.es-PAZgLX4h.mjs.map +1 -0
- package/dist/models-DnDmylpj.d.mts +22 -0
- package/dist/models-DnDmylpj.d.mts.map +1 -0
- package/dist/models.d.mts +2 -0
- package/dist/models.mjs +1 -0
- package/dist/realtimeStt-ClXWXbXX.d.mts +21 -0
- package/dist/realtimeStt-ClXWXbXX.d.mts.map +1 -0
- package/dist/realtimeStt.d.mts +2 -0
- package/dist/realtimeStt.mjs +136 -0
- package/dist/realtimeStt.mjs.map +1 -0
- package/package.json +82 -0
- package/src/Mistral.ts +189 -0
- package/src/MistralRealtimeTranscriber.ts +60 -0
- package/src/MistralSynthesizer.ts +285 -0
- package/src/MistralTranscriber.ts +224 -0
- package/src/audioCodec.ts +123 -0
- package/src/codec.test.ts +121 -0
- package/src/codec.ts +338 -0
- package/src/http.ts +46 -0
- package/src/index.ts +6 -0
- package/src/models.ts +47 -0
- 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
|
+
)
|