@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/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
|
+
)
|