@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/codec.ts ADDED
@@ -0,0 +1,338 @@
1
+ import { Array as Arr, Encoding, Match, Option, Schema } from "effect"
2
+ import type {
3
+ ContentBlock,
4
+ HistoryItem,
5
+ InputImage,
6
+ StopReason,
7
+ Usage,
8
+ } from "@effect-uai/core/Items"
9
+ import type { StructuredFormat } from "@effect-uai/core/StructuredFormat"
10
+ import type { ToolDescriptor } from "@effect-uai/core/Tool"
11
+ import type { Turn } from "@effect-uai/core/Turn"
12
+ import { TurnEvent } from "@effect-uai/core/Turn"
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // History → Mistral chat `messages`
16
+ //
17
+ // Mistral speaks the OpenAI chat-completions dialect: a flat list of
18
+ // `{ role, content, ... }` objects where an assistant turn's tool calls live
19
+ // on the assistant message (`tool_calls`) and each tool result is its own
20
+ // `{ role: "tool", tool_call_id, content }` message. Our history is a flatter
21
+ // item list (assistant message, then separate `function_call` items), so we
22
+ // fold consecutive `function_call`s onto the preceding assistant message.
23
+ // ---------------------------------------------------------------------------
24
+
25
+ type WireMessage = {
26
+ role: string
27
+ content: string | ReadonlyArray<Record<string, unknown>>
28
+ tool_calls?: ReadonlyArray<Record<string, unknown>>
29
+ tool_call_id?: string
30
+ }
31
+
32
+ /** A `data:` URI for inline image bytes; URLs pass through untouched. */
33
+ const imageSourceToUrl = Match.type<InputImage["source"]>().pipe(
34
+ Match.tag("url", (s) => s.url),
35
+ Match.tag("base64", (s) => `data:${s.mimeType};base64,${s.base64}`),
36
+ Match.tag("bytes", (s) => `data:${s.mimeType};base64,${Encoding.encodeBase64(s.bytes)}`),
37
+ Match.exhaustive,
38
+ )
39
+
40
+ const blockToPart = Match.type<ContentBlock>().pipe(
41
+ Match.discriminatorsExhaustive("type")({
42
+ input_text: (b) => ({ type: "text", text: b.text }),
43
+ output_text: (b) => ({ type: "text", text: b.text }),
44
+ refusal: (b) => ({ type: "text", text: b.text }),
45
+ input_image: (b) => ({ type: "image_url", image_url: imageSourceToUrl(b.source) }),
46
+ }),
47
+ )
48
+
49
+ const isTextBlock = (b: ContentBlock): boolean =>
50
+ b.type === "input_text" || b.type === "output_text" || b.type === "refusal"
51
+
52
+ const textOfBlock = (b: ContentBlock): string => (b.type === "input_image" ? "" : b.text)
53
+
54
+ /**
55
+ * Collapse a message's content to a plain string when it is text-only
56
+ * (the common case Mistral prefers), otherwise emit the multimodal parts
57
+ * array.
58
+ */
59
+ const encodeContent = (
60
+ content: ReadonlyArray<ContentBlock>,
61
+ ): string | ReadonlyArray<Record<string, unknown>> =>
62
+ Arr.every(content, isTextBlock) ? content.map(textOfBlock).join("") : content.map(blockToPart)
63
+
64
+ const toolCallWire = (call_id: string, name: string, args: string): Record<string, unknown> => ({
65
+ id: call_id,
66
+ type: "function",
67
+ function: { name, arguments: args },
68
+ })
69
+
70
+ const appendToolCall = (
71
+ acc: ReadonlyArray<WireMessage>,
72
+ call_id: string,
73
+ name: string,
74
+ args: string,
75
+ ): ReadonlyArray<WireMessage> => {
76
+ const last = acc[acc.length - 1]
77
+ const wire = toolCallWire(call_id, name, args)
78
+ // Attach to the immediately-preceding assistant message (not a tool result),
79
+ // matching the OpenAI/Mistral shape where tool calls ride the assistant turn.
80
+ if (last !== undefined && last.role === "assistant" && last.tool_call_id === undefined) {
81
+ return [...acc.slice(0, -1), { ...last, tool_calls: [...(last.tool_calls ?? []), wire] }]
82
+ }
83
+ return [...acc, { role: "assistant", content: "", tool_calls: [wire] }]
84
+ }
85
+
86
+ const foldItem = (acc: ReadonlyArray<WireMessage>, item: HistoryItem): ReadonlyArray<WireMessage> =>
87
+ Match.value(item).pipe(
88
+ Match.discriminatorsExhaustive("type")({
89
+ message: (m) => [...acc, { role: m.role, content: encodeContent(m.content) }],
90
+ function_call: (f) => appendToolCall(acc, f.call_id, f.name, f.arguments),
91
+ function_call_output: (o) => [
92
+ ...acc,
93
+ { role: "tool", tool_call_id: o.call_id, content: o.output },
94
+ ],
95
+ // Reasoning items are not replayed to the chat-completions endpoint.
96
+ reasoning: () => acc,
97
+ }),
98
+ )
99
+
100
+ /** Convert our `HistoryItem[]` history into Mistral chat `messages`. */
101
+ export const itemsToMessages = (items: ReadonlyArray<HistoryItem>): ReadonlyArray<WireMessage> =>
102
+ Arr.reduce(items, [] as ReadonlyArray<WireMessage>, foldItem)
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // tools / tool_choice / response_format
106
+ // ---------------------------------------------------------------------------
107
+
108
+ export const toolsWire = (
109
+ descriptors: ReadonlyArray<ToolDescriptor>,
110
+ ): Option.Option<ReadonlyArray<Record<string, unknown>>> =>
111
+ descriptors.length > 0
112
+ ? Option.some(
113
+ descriptors.map((t) => ({
114
+ type: "function",
115
+ function: {
116
+ name: t.name,
117
+ description: t.description,
118
+ parameters: t.inputSchema,
119
+ ...(t.strict !== undefined && { strict: t.strict }),
120
+ },
121
+ })),
122
+ )
123
+ : Option.none()
124
+
125
+ type ToolChoice =
126
+ | "auto"
127
+ | "required"
128
+ | "none"
129
+ | { readonly type: "function"; readonly name: string }
130
+
131
+ // Mistral uses "any" for forced tool use (OpenAI's "required").
132
+ export const toolChoiceWire = (choice: ToolChoice): string | Record<string, unknown> =>
133
+ Match.value(choice).pipe(
134
+ Match.when("auto", () => "auto" as const),
135
+ Match.when("required", () => "any" as const),
136
+ Match.when("none", () => "none" as const),
137
+ Match.orElse((c) => ({ type: "function", function: { name: c.name } })),
138
+ )
139
+
140
+ export const responseFormatWire = (
141
+ structured: StructuredFormat<unknown>,
142
+ ): Record<string, unknown> => ({
143
+ type: "json_schema",
144
+ json_schema: {
145
+ name: structured.name,
146
+ schema: structured.schema["~standard"].jsonSchema.input({ target: "draft-2020-12" }),
147
+ ...(structured.strict !== undefined && { strict: structured.strict }),
148
+ },
149
+ })
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Streaming decode: chat.completion.chunk → TurnEvent
153
+ // ---------------------------------------------------------------------------
154
+
155
+ const WireFunction = Schema.Struct({
156
+ name: Schema.optional(Schema.NullOr(Schema.String)),
157
+ arguments: Schema.optional(Schema.NullOr(Schema.String)),
158
+ })
159
+
160
+ const WireToolCall = Schema.Struct({
161
+ index: Schema.optional(Schema.Number),
162
+ id: Schema.optional(Schema.NullOr(Schema.String)),
163
+ function: Schema.optional(WireFunction),
164
+ })
165
+
166
+ const WireDelta = Schema.Struct({
167
+ role: Schema.optional(Schema.NullOr(Schema.String)),
168
+ content: Schema.optional(Schema.NullOr(Schema.String)),
169
+ tool_calls: Schema.optional(Schema.NullOr(Schema.Array(WireToolCall))),
170
+ })
171
+
172
+ const WireChoice = Schema.Struct({
173
+ index: Schema.optional(Schema.Number),
174
+ delta: Schema.optional(WireDelta),
175
+ finish_reason: Schema.optional(Schema.NullOr(Schema.String)),
176
+ })
177
+
178
+ const WireUsage = Schema.Struct({
179
+ prompt_tokens: Schema.optional(Schema.Number),
180
+ completion_tokens: Schema.optional(Schema.Number),
181
+ total_tokens: Schema.optional(Schema.Number),
182
+ })
183
+
184
+ export const WireChunk = Schema.Struct({
185
+ choices: Schema.optional(Schema.Array(WireChoice)),
186
+ usage: Schema.optional(Schema.NullOr(WireUsage)),
187
+ })
188
+ export type WireChunk = typeof WireChunk.Type
189
+
190
+ export const decodeChunk = Schema.decodeUnknownEffect(WireChunk)
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Accumulator
194
+ // ---------------------------------------------------------------------------
195
+
196
+ type ToolAcc = {
197
+ readonly call_id: string
198
+ readonly name: string
199
+ readonly arguments: string
200
+ }
201
+
202
+ export type Accumulator = {
203
+ readonly text: string
204
+ /** Tool calls keyed by their wire `index` (defaults to position when absent). */
205
+ readonly tools: ReadonlyMap<number, ToolAcc>
206
+ readonly order: ReadonlyArray<number>
207
+ readonly usage: Usage
208
+ readonly finishReason: Option.Option<string>
209
+ }
210
+
211
+ export const emptyAccumulator: Accumulator = {
212
+ text: "",
213
+ tools: new Map(),
214
+ order: [],
215
+ usage: {},
216
+ finishReason: Option.none(),
217
+ }
218
+
219
+ /** State threaded through the fold plus the events the step produced. */
220
+ type Step = readonly [Accumulator, ReadonlyArray<TurnEvent>]
221
+
222
+ const usageFrom = (wire: typeof WireUsage.Type): Usage => ({
223
+ ...(wire.prompt_tokens !== undefined && { input_tokens: wire.prompt_tokens }),
224
+ ...(wire.completion_tokens !== undefined && { output_tokens: wire.completion_tokens }),
225
+ ...(wire.total_tokens !== undefined && { total_tokens: wire.total_tokens }),
226
+ })
227
+
228
+ const reasonToStop = Match.type<string>().pipe(
229
+ Match.when("stop", () => "stop" as const),
230
+ Match.when("length", () => "max_tokens" as const),
231
+ Match.when("model_length", () => "max_tokens" as const),
232
+ Match.when("tool_calls", () => "tool_calls" as const),
233
+ Match.when("content_filter", () => "content_filter" as const),
234
+ Match.orElse(() => "stop" as const),
235
+ )
236
+
237
+ const stopReasonOf = (acc: Accumulator): StopReason =>
238
+ Option.match(acc.finishReason, {
239
+ onNone: () => (acc.tools.size > 0 ? "tool_calls" : "stop"),
240
+ onSome: reasonToStop,
241
+ })
242
+
243
+ const withTool = (acc: Accumulator, index: number, tool: ToolAcc, isNew: boolean): Accumulator => ({
244
+ ...acc,
245
+ tools: new Map(acc.tools).set(index, tool),
246
+ order: isNew ? [...acc.order, index] : acc.order,
247
+ })
248
+
249
+ const applyContent = (acc: Accumulator, delta: typeof WireDelta.Type | undefined): Step => {
250
+ const content = delta?.content
251
+ return content !== undefined && content !== null && content.length > 0
252
+ ? [{ ...acc, text: acc.text + content }, [TurnEvent.TextDelta({ text: content })]]
253
+ : [acc, []]
254
+ }
255
+
256
+ const applyToolCall = (acc: Accumulator, tc: typeof WireToolCall.Type, position: number): Step => {
257
+ const index = tc.index ?? position
258
+ const argsDelta = tc.function?.arguments ?? ""
259
+ const existing = acc.tools.get(index)
260
+ if (existing === undefined) {
261
+ const call_id = tc.id ?? `call_${index}`
262
+ const name = tc.function?.name ?? ""
263
+ return [
264
+ withTool(acc, index, { call_id, name, arguments: argsDelta }, true),
265
+ [
266
+ TurnEvent.ToolCallStart({ call_id, name }),
267
+ ...(argsDelta.length > 0
268
+ ? [TurnEvent.ToolCallArgsDelta({ call_id, delta: argsDelta })]
269
+ : []),
270
+ ],
271
+ ]
272
+ }
273
+ return argsDelta.length > 0
274
+ ? [
275
+ withTool(acc, index, { ...existing, arguments: existing.arguments + argsDelta }, false),
276
+ [TurnEvent.ToolCallArgsDelta({ call_id: existing.call_id, delta: argsDelta })],
277
+ ]
278
+ : [acc, []]
279
+ }
280
+
281
+ const chain = (step: Step, next: (acc: Accumulator) => Step): Step => {
282
+ const [acc, events] = step
283
+ const [acc2, more] = next(acc)
284
+ return [acc2, [...events, ...more]]
285
+ }
286
+
287
+ const applyChoice = (step: Step, choice: typeof WireChoice.Type): Step => {
288
+ const withContent = chain(step, (acc) => applyContent(acc, choice.delta))
289
+ const withTools = Arr.reduce(choice.delta?.tool_calls ?? [], withContent, (s: Step, tc, i) =>
290
+ chain(s, (acc) => applyToolCall(acc, tc, i)),
291
+ )
292
+ const reason = choice.finish_reason
293
+ return reason !== undefined && reason !== null
294
+ ? [{ ...withTools[0], finishReason: Option.some(reason) }, withTools[1]]
295
+ : withTools
296
+ }
297
+
298
+ /**
299
+ * Fold one decoded chunk into the accumulator, returning the next state and
300
+ * the `TurnEvent`s the chunk produced. Mirrors Anthropic's `deltasFromEvent`,
301
+ * but over chat-completions deltas.
302
+ */
303
+ export const applyChunk = (acc: Accumulator, chunk: WireChunk): Step => {
304
+ const afterChoices = Arr.reduce(chunk.choices ?? [], [acc, []] as Step, applyChoice)
305
+ const usage = chunk.usage
306
+ return usage !== undefined && usage !== null
307
+ ? chain(afterChoices, (a) => {
308
+ const u = usageFrom(usage)
309
+ return [{ ...a, usage: u }, [TurnEvent.UsageUpdate({ usage: u })]]
310
+ })
311
+ : afterChoices
312
+ }
313
+
314
+ /** Assemble the terminal `Turn` from the accumulated state. */
315
+ export const accumulatorToTurn = (acc: Accumulator): Turn => {
316
+ const message: ReadonlyArray<HistoryItem> =
317
+ acc.text.length > 0
318
+ ? [{ type: "message", role: "assistant", content: [{ type: "output_text", text: acc.text }] }]
319
+ : []
320
+ const toolCalls: ReadonlyArray<HistoryItem> = acc.order.flatMap((index) => {
321
+ const t = acc.tools.get(index)
322
+ return t === undefined
323
+ ? []
324
+ : [
325
+ {
326
+ type: "function_call" as const,
327
+ call_id: t.call_id,
328
+ name: t.name,
329
+ arguments: t.arguments,
330
+ },
331
+ ]
332
+ })
333
+ return {
334
+ items: [...message, ...toolCalls],
335
+ usage: acc.usage,
336
+ stop_reason: stopReasonOf(acc),
337
+ }
338
+ }
package/src/http.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { Match } from "effect"
2
+ import * as AiError from "@effect-uai/core/AiError"
3
+
4
+ /** Map an HTTP status from any Mistral endpoint to an `AiError` variant. */
5
+ export const httpStatusError: (status: number, body: string) => AiError.AiError = (status, body) =>
6
+ Match.value(status).pipe(
7
+ Match.when(
8
+ 429,
9
+ (): AiError.AiError => new AiError.RateLimited({ provider: "mistral", raw: body }),
10
+ ),
11
+ Match.whenOr(
12
+ 408,
13
+ 504,
14
+ (): AiError.AiError => new AiError.Timeout({ provider: "mistral", raw: body }),
15
+ ),
16
+ Match.when(
17
+ 401,
18
+ (): AiError.AiError =>
19
+ new AiError.AuthFailed({ provider: "mistral", subtype: "auth", raw: body }),
20
+ ),
21
+ Match.when(
22
+ 403,
23
+ (): AiError.AiError =>
24
+ new AiError.AuthFailed({ provider: "mistral", subtype: "permission", raw: body }),
25
+ ),
26
+ Match.when(
27
+ 402,
28
+ (): AiError.AiError =>
29
+ new AiError.AuthFailed({ provider: "mistral", subtype: "billing", raw: body }),
30
+ ),
31
+ Match.when(
32
+ 413,
33
+ (): AiError.AiError => new AiError.ContextLengthExceeded({ provider: "mistral", raw: body }),
34
+ ),
35
+ Match.when(
36
+ (n) => n >= 500,
37
+ (n): AiError.AiError =>
38
+ new AiError.Unavailable({ provider: "mistral", status: n, raw: body }),
39
+ ),
40
+ Match.orElse(
41
+ (): AiError.AiError => new AiError.InvalidRequest({ provider: "mistral", raw: body }),
42
+ ),
43
+ )
44
+
45
+ export const transportFailure = (cause: unknown): AiError.AiError =>
46
+ new AiError.Unavailable({ provider: "mistral", raw: cause })
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * as Mistral from "./Mistral.js"
2
+ export * as MistralTranscriber from "./MistralTranscriber.js"
3
+ export * as MistralRealtimeTranscriber from "./MistralRealtimeTranscriber.js"
4
+ export * as MistralSynthesizer from "./MistralSynthesizer.js"
5
+ export * as codec from "./codec.js"
6
+ export * from "./models.js"
package/src/models.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Known Mistral model identifiers usable via the chat-completions API (as of
3
+ * mid-2026). The `(string & {})` tail keeps autocomplete on the literals while
4
+ * still accepting any string, so newly-released models work without an SDK
5
+ * update.
6
+ *
7
+ * Reference: https://docs.mistral.ai/getting-started/models/models_overview
8
+ *
9
+ * Prefer the `-latest` aliases for evergreen pins; the dated snapshots are
10
+ * available when you need a frozen version.
11
+ */
12
+ export type MistralModel =
13
+ | "mistral-large-latest"
14
+ | "mistral-medium-latest"
15
+ | "mistral-small-latest"
16
+ | "magistral-medium-latest"
17
+ | "magistral-small-latest"
18
+ | "ministral-8b-latest"
19
+ | "ministral-3b-latest"
20
+ | "open-mistral-nemo"
21
+ | "codestral-latest"
22
+ // eslint-disable-next-line @typescript-eslint/ban-types
23
+ | (string & {})
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Voxtral audio models (Mistral's STT / TTS family)
27
+ // Reference: https://docs.mistral.ai/studio-api/audio/overview
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /** Batch / offline transcription models. */
31
+ export type MistralTranscribeModel =
32
+ | "voxtral-mini-latest"
33
+ | "voxtral-mini-2507"
34
+ // eslint-disable-next-line @typescript-eslint/ban-types
35
+ | (string & {})
36
+
37
+ /** Realtime (streaming) transcription models. */
38
+ export type MistralRealtimeModel =
39
+ | "voxtral-mini-transcribe-realtime-2602"
40
+ // eslint-disable-next-line @typescript-eslint/ban-types
41
+ | (string & {})
42
+
43
+ /** Text-to-speech models. */
44
+ export type MistralTtsModel =
45
+ | "voxtral-mini-tts-2603"
46
+ // eslint-disable-next-line @typescript-eslint/ban-types
47
+ | (string & {})
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Voxtral Realtime STT over WebSocket.
3
+ *
4
+ * Protocol mirrors the `mistralai[realtime]` Python client:
5
+ * - URL: `wss://api.mistral.ai/v1/audio/transcriptions/realtime?model=…`
6
+ * (the model is a query param, not a session field).
7
+ * - Auth: `Authorization: Bearer …` header on the upgrade. The browser
8
+ * `WebSocket` API can't set headers, so this module uses the `ws` peer
9
+ * dep; `ws` is only pulled in transitively via
10
+ * `MistralRealtimeTranscriber` (the sync `MistralTranscriber` stays free
11
+ * of it).
12
+ * - Client → server (JSON): `session.update` (audio_format +
13
+ * target_streaming_delay_ms), then `input_audio.append` with base64 PCM,
14
+ * and `input_audio.end` when the mic stream stops.
15
+ * - Server → client (JSON): `session.created` / `session.updated`,
16
+ * `transcription.text.delta` (`{ text }`), `transcription.done`
17
+ * (`{ text }`), `transcription.segment`, `transcription.language`,
18
+ * and `error` (`{ error: { message, code } }`).
19
+ *
20
+ * Voxtral Realtime is a *continuous* transcriber: it streams text deltas and
21
+ * only emits one `transcription.done` at end-of-audio (no server-side
22
+ * utterance segmentation). To satisfy the `Transcriber` contract's `final`
23
+ * semantics for conversational use, this adapter synthesizes a `final` event
24
+ * when the deltas go quiet for `utteranceSilence`.
25
+ */
26
+ import {
27
+ Cause,
28
+ Clock,
29
+ Duration,
30
+ Effect,
31
+ Encoding,
32
+ Match,
33
+ Queue,
34
+ Redacted,
35
+ Ref,
36
+ Schema,
37
+ Stream,
38
+ } from "effect"
39
+ import * as Socket from "effect/unstable/socket/Socket"
40
+ import * as AiError from "@effect-uai/core/AiError"
41
+ import type { AudioFormat } from "@effect-uai/core/Audio"
42
+ import * as JSONL from "@effect-uai/core/JSONL"
43
+ import type { TranscriptEvent } from "@effect-uai/core/Transcript"
44
+ import type { CommonStreamTranscribeRequest } from "@effect-uai/core/Transcriber"
45
+ import { WebSocket as WSWebSocket } from "ws"
46
+
47
+ export type Config = {
48
+ readonly apiKey: Redacted.Redacted
49
+ readonly baseUrl?: string
50
+ /** Target latency knob (`target_streaming_delay_ms`); lower = faster, less accurate. */
51
+ readonly targetStreamingDelay?: Duration.Duration
52
+ /**
53
+ * Silence gap after which the accumulated deltas are committed as a synthetic
54
+ * `final`. Voxtral Realtime does no utterance segmentation, so this is how the
55
+ * adapter delimits turns. Default 700 ms.
56
+ */
57
+ readonly utteranceSilence?: Duration.Duration
58
+ }
59
+
60
+ const DEFAULT_SILENCE = Duration.millis(700)
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // AudioFormat gate — Voxtral Realtime ingests pcm_s16le @ 16000 mono only.
64
+ // ---------------------------------------------------------------------------
65
+
66
+ const ensureInputFormat = (format: AudioFormat): Effect.Effect<void, AiError.AiError> =>
67
+ format.container === "raw" && format.encoding === "pcm_s16le" && format.sampleRate === 16000
68
+ ? Effect.void
69
+ : Effect.fail(
70
+ new AiError.Unsupported({
71
+ provider: "mistral",
72
+ capability: "inputFormat",
73
+ reason: `Voxtral Realtime accepts pcm_s16le @ 16000 mono only. Got ${JSON.stringify(format)}.`,
74
+ }),
75
+ )
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // URL + client frame builders
79
+ // ---------------------------------------------------------------------------
80
+
81
+ const buildWsUrl = (cfg: Config, model: string) => {
82
+ const base = (cfg.baseUrl ?? "https://api.mistral.ai").replace(/^http/, "ws")
83
+ return `${base}/v1/audio/transcriptions/realtime?model=${encodeURIComponent(model)}`
84
+ }
85
+
86
+ const sessionUpdateFrame = (cfg: Config) =>
87
+ JSON.stringify({
88
+ type: "session.update",
89
+ session: {
90
+ audio_format: { encoding: "pcm_s16le", sample_rate: 16000 },
91
+ ...(cfg.targetStreamingDelay !== undefined && {
92
+ target_streaming_delay_ms: Duration.toMillis(cfg.targetStreamingDelay),
93
+ }),
94
+ },
95
+ })
96
+
97
+ const audioAppendFrame = (bytes: Uint8Array) =>
98
+ JSON.stringify({ type: "input_audio.append", audio: Encoding.encodeBase64(bytes) })
99
+
100
+ const audioEndFrame = JSON.stringify({ type: "input_audio.end" })
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Wire schemas (server → client)
104
+ // ---------------------------------------------------------------------------
105
+
106
+ const ServerEvent = Schema.Union([
107
+ Schema.Struct({ type: Schema.Literal("session.created") }),
108
+ Schema.Struct({ type: Schema.Literal("session.updated") }),
109
+ Schema.Struct({ type: Schema.Literal("transcription.language") }),
110
+ Schema.Struct({ type: Schema.Literal("transcription.segment") }),
111
+ Schema.Struct({ type: Schema.Literal("transcription.text.delta"), text: Schema.String }),
112
+ Schema.Struct({ type: Schema.Literal("transcription.done"), text: Schema.String }),
113
+ Schema.Struct({
114
+ type: Schema.Literal("error"),
115
+ error: Schema.Struct({
116
+ message: Schema.String,
117
+ code: Schema.optional(Schema.NullOr(Schema.Number)),
118
+ }),
119
+ }),
120
+ ])
121
+ const decodeServerEvent = Schema.decodeUnknownEffect(ServerEvent)
122
+
123
+ /** Mutable turn state shared by the message handler and the silence finalizer. */
124
+ type TurnState = {
125
+ readonly text: Ref.Ref<string>
126
+ readonly lastActivityMs: Ref.Ref<number>
127
+ }
128
+
129
+ const emitFinal = (
130
+ queue: Queue.Queue<TranscriptEvent, Cause.Done>,
131
+ state: TurnState,
132
+ override?: string,
133
+ ): Effect.Effect<void> =>
134
+ Effect.gen(function* () {
135
+ const acc = yield* Ref.getAndSet(state.text, "")
136
+ const text = (override !== undefined && override.length > 0 ? override : acc).trim()
137
+ if (text.length > 0) yield* Queue.offer(queue, { _tag: "final", text })
138
+ })
139
+
140
+ const handleServerMessage =
141
+ (queue: Queue.Queue<TranscriptEvent, Cause.Done>, state: TurnState) => (raw: string) =>
142
+ Effect.gen(function* () {
143
+ const json = yield* JSONL.parseSafe(raw)
144
+ if (json === undefined) return
145
+ const decoded = yield* decodeServerEvent(json).pipe(Effect.option)
146
+ if (decoded._tag === "None") return
147
+ yield* Match.value(decoded.value).pipe(
148
+ Match.when({ type: "transcription.text.delta" }, (m) =>
149
+ Effect.gen(function* () {
150
+ const now = yield* Clock.currentTimeMillis
151
+ const next = yield* Ref.updateAndGet(state.text, (t) => t + m.text)
152
+ yield* Ref.set(state.lastActivityMs, now)
153
+ // Cumulative partial so the UI shows the growing sentence.
154
+ yield* Queue.offer(queue, { _tag: "partial", text: next })
155
+ }),
156
+ ),
157
+ // End-of-audio: commit whatever's left as the final utterance.
158
+ Match.when({ type: "transcription.done" }, (m) => emitFinal(queue, state, m.text)),
159
+ Match.when({ type: "error" }, (m) =>
160
+ Queue.offer(queue, {
161
+ _tag: "error",
162
+ ...(m.error.code != null && { code: String(m.error.code) }),
163
+ message: m.error.message,
164
+ }),
165
+ ),
166
+ // session.created / .updated / language / segment: no user-visible event.
167
+ Match.orElse(() => Effect.void),
168
+ )
169
+ })
170
+
171
+ // Background loop: commit a synthetic final once the deltas go quiet.
172
+ const silenceFinalizer = (
173
+ queue: Queue.Queue<TranscriptEvent, Cause.Done>,
174
+ state: TurnState,
175
+ silence: Duration.Duration,
176
+ ): Effect.Effect<never> => {
177
+ const silenceMs = Duration.toMillis(silence)
178
+ return Effect.gen(function* () {
179
+ const now = yield* Clock.currentTimeMillis
180
+ const last = yield* Ref.get(state.lastActivityMs)
181
+ const acc = yield* Ref.get(state.text)
182
+ if (acc.trim().length > 0 && now - last >= silenceMs) yield* emitFinal(queue, state)
183
+ yield* Effect.sleep("150 millis")
184
+ }).pipe(Effect.forever)
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Stream<Uint8Array> → Stream<TranscriptEvent>
189
+ // ---------------------------------------------------------------------------
190
+
191
+ // `@types/ws`'s WebSocket extends Node's EventEmitter while
192
+ // `globalThis.WebSocket` extends EventTarget; the browser-style surface
193
+ // Effect's Socket reads (`addEventListener` / `send` / `close`) is identical at
194
+ // runtime, hence the single contained cast.
195
+ const authedWsConstructor =
196
+ (cfg: Config): Socket.WebSocketConstructor["Service"] =>
197
+ (url) =>
198
+ new WSWebSocket(url, undefined, {
199
+ headers: { Authorization: `Bearer ${Redacted.value(cfg.apiKey)}` },
200
+ }) as unknown as globalThis.WebSocket
201
+
202
+ export const streamTranscription =
203
+ (cfg: Config) =>
204
+ <E, R>(
205
+ audioIn: Stream.Stream<Uint8Array, E, R>,
206
+ request: CommonStreamTranscribeRequest,
207
+ ): Stream.Stream<TranscriptEvent, AiError.AiError | E, R> =>
208
+ Stream.unwrap(
209
+ Effect.gen(function* () {
210
+ yield* ensureInputFormat(request.inputFormat)
211
+ const socket = yield* Socket.makeWebSocket(buildWsUrl(cfg, request.model), {
212
+ // Effect's Socket treats all close codes as errors by default —
213
+ // whitelist standard clean-close codes (1000 / 1001 / 1005).
214
+ closeCodeIsError: (code) => code !== 1000 && code !== 1001 && code !== 1005,
215
+ }).pipe(Effect.provideService(Socket.WebSocketConstructor, authedWsConstructor(cfg)))
216
+ const queue = yield* Queue.bounded<TranscriptEvent, Cause.Done>(64)
217
+ const state: TurnState = {
218
+ text: yield* Ref.make(""),
219
+ lastActivityMs: yield* Ref.make(0),
220
+ }
221
+ const write = yield* socket.writer
222
+
223
+ // session.update first, then stream base64 PCM frames, then signal end.
224
+ // Fork-scoped so the Stream's downstream scope tears them down on
225
+ // disconnect / cancel.
226
+ yield* Effect.gen(function* () {
227
+ yield* write(sessionUpdateFrame(cfg))
228
+ yield* Stream.runForEach(audioIn, (bytes) => write(audioAppendFrame(bytes)))
229
+ yield* write(audioEndFrame)
230
+ }).pipe(
231
+ Effect.tapError((cause) =>
232
+ Effect.logWarning("[voxtral-realtime] audio send failed", { cause }),
233
+ ),
234
+ Effect.ignore,
235
+ Effect.forkScoped,
236
+ )
237
+
238
+ yield* silenceFinalizer(queue, state, cfg.utteranceSilence ?? DEFAULT_SILENCE).pipe(
239
+ Effect.forkScoped,
240
+ )
241
+
242
+ // `Queue.end` flushes pending events then ends the stream cleanly;
243
+ // `Queue.shutdown` would drop queued items and interrupt takes. A
244
+ // connection/read failure is logged (otherwise the stream would end
245
+ // silently with no transcripts).
246
+ yield* socket.runString(handleServerMessage(queue, state)).pipe(
247
+ Effect.tapError((cause) =>
248
+ Effect.logWarning("[voxtral-realtime] socket closed", { cause }),
249
+ ),
250
+ Effect.ensuring(Queue.end(queue)),
251
+ Effect.forkScoped,
252
+ )
253
+
254
+ return Stream.fromQueue(queue)
255
+ }),
256
+ )