@ai-sdk/mistral
Version:
340 lines (332 loc) • 11.9 kB
text/typescript
import {
InvalidArgumentError,
type Experimental_TranscriptionModelV4StreamOptions as TranscriptionModelV4StreamOptions,
type Experimental_TranscriptionModelV4StreamPart as TranscriptionModelV4StreamPart,
type SharedV4Warning,
} from '@ai-sdk/provider';
import {
connectToWebSocket,
convertUint8ArrayToBase64,
convertBase64ToUint8Array,
lazySchema,
parseProviderOptions,
safeParseJSON,
safeValidateTypes,
toWebSocketUrl,
waitForWebSocketBufferDrain,
zodSchema,
type WebSocketConnection,
type WebSocketConstructor,
} from '@ai-sdk/provider-utils';
import { z } from 'zod/v4';
import { mistralTranscriptionModelOptions } from './mistral-transcription-model-options';
export async function streamMistralTranscription({
modelId,
baseURL,
headers,
webSocket,
currentDate,
...options
}: TranscriptionModelV4StreamOptions & {
modelId: string;
baseURL: string;
webSocket?: WebSocketConstructor;
currentDate: Date;
}) {
const mistralOptions = await parseProviderOptions({
provider: 'mistral',
providerOptions: options.providerOptions,
schema: mistralTranscriptionModelOptions,
});
const { type, rate = 16000 } = options.inputAudioFormat;
if (type !== 'audio/pcm' || !Number.isInteger(rate) || rate <= 0) {
throw new InvalidArgumentError({
argument: 'inputAudioFormat',
message:
'Mistral realtime transcription requires audio/pcm (mono, signed 16-bit little-endian) with a positive integer sample rate.',
});
}
const warnings: SharedV4Warning[] = [];
for (const option of [
'returnLanguage',
'temperature',
'timestampGranularities',
] as const) {
if (mistralOptions?.[option] != null) {
warnings.push({
type: 'unsupported',
feature: `providerOptions.mistral.${option}`,
details: 'This option is not supported for realtime transcription.',
});
}
}
const turnDetection = mistralOptions?.turnDetection;
const session = {
audio_format: { encoding: 'pcm_s16le', sample_rate: rate },
target_streaming_delay_ms: mistralOptions?.targetStreamingDelayMs,
language: mistralOptions?.language,
context_bias: mistralOptions?.contextBias,
diarize: mistralOptions?.diarize,
speaker_references: mistralOptions?.speakerReferences,
turn_detection:
turnDetection == null
? undefined
: {
start_threshold: turnDetection.startThreshold,
eager_end_threshold: turnDetection.eagerEndThreshold,
end_threshold: turnDetection.endThreshold,
min_silence_ms: turnDetection.minSilenceMs,
end_timeout_ms: turnDetection.endTimeoutMs,
},
};
const url = toWebSocketUrl(`${baseURL}/audio/transcriptions/realtime`);
url.searchParams.set('model', modelId);
let finished = false;
let cleanup: () => void = () => {};
return {
request: { body: url.toString() },
response: { timestamp: currentDate, modelId },
stream: new ReadableStream<TranscriptionModelV4StreamPart>({
start(controller) {
let connection: WebSocketConnection | undefined;
let reader:
| ReadableStreamDefaultReader<Uint8Array | string>
| undefined;
let configured = false;
let sending = false;
const stop = new AbortController();
cleanup = () => {
stop.abort();
void (
reader != null ? reader.cancel() : options.audio.cancel()
).catch(() => {});
connection?.close(1000);
};
const fail = (error: unknown) => {
if (finished) return;
finished = true;
cleanup();
controller.error(error);
};
const sendAudio = async () => {
const socket = connection!.socket!;
reader = options.audio.getReader();
try {
while (true) {
if (finished) break;
const { done, value } = await reader.read();
if (finished || done) break;
const bytes =
typeof value === 'string'
? convertBase64ToUint8Array(value)
: value;
// Keep each message below Mistral's 256 KiB limit, including
// base64 expansion, even when the caller supplies a large chunk.
for (let offset = 0; offset < bytes.length; offset += 64 * 1024) {
if (finished) break;
socket.send(
JSON.stringify({
type: 'input_audio.append',
audio: convertUint8ArrayToBase64(
bytes.subarray(offset, offset + 64 * 1024),
),
}),
);
await waitForWebSocketBufferDrain(socket, {
abortSignal: stop.signal,
});
}
}
if (!finished) {
socket.send(JSON.stringify({ type: 'input_audio.end' }));
}
} finally {
reader.releaseLock();
reader = undefined;
}
};
controller.enqueue({ type: 'stream-start', warnings });
connection = connectToWebSocket({
url,
headers,
webSocket,
abortSignal: options.abortSignal,
onAbort: fail,
onProcessingError: fail,
onMessageText: async text => {
if (finished) return;
const parsed = await safeParseJSON({
text,
schema: eventEnvelopeSchema,
});
if (finished) return;
if (!parsed.success) throw parsed.error;
const raw = parsed.value;
if (options.includeRawChunks) {
controller.enqueue({ type: 'raw', rawValue: raw });
}
// Unknown events (including turn events) remain available as raw
// chunks without making the adapter depend on their schema.
if (!handledEvents.has(raw.type)) return;
const validated = await safeValidateTypes({
value: raw,
schema: transcriptionEventSchema,
});
if (finished) return;
if (!validated.success) throw validated.error;
const event = validated.value;
switch (event.type) {
case 'session.created':
if (configured) break;
configured = true;
connection!.socket!.send(
JSON.stringify({ type: 'session.update', session }),
);
break;
case 'session.updated':
if (!configured || sending) break;
sending = true;
void sendAudio().catch(fail);
break;
case 'transcription.text.delta':
controller.enqueue({
type: 'transcript-delta',
delta: event.text,
});
break;
case 'transcription.segment':
controller.enqueue({
type: 'transcript-final',
text: event.text,
startSecond: event.start ?? undefined,
endSecond: event.end ?? undefined,
...(event.speaker_id != null && {
providerMetadata: {
mistral: { speakerId: event.speaker_id },
},
}),
});
break;
case 'transcription.done': {
const segments = event.segments ?? [];
controller.enqueue({
type: 'response-metadata',
modelId: event.model ?? modelId,
body: raw,
});
controller.enqueue({
type: 'finish',
text: event.text,
segments: segments.flatMap(segment =>
segment.start == null || segment.end == null
? []
: [
{
text: segment.text,
startSecond: segment.start,
endSecond: segment.end,
},
],
),
language: event.language ?? undefined,
durationInSeconds:
typeof event.usage?.prompt_audio_seconds === 'number'
? event.usage.prompt_audio_seconds
: undefined,
usage: event.usage ?? undefined,
providerMetadata: {
mistral: {
...(event.usage != null && { usage: event.usage }),
segments: segments.map(segment => ({
text: segment.text,
...(segment.start != null && {
startSecond: segment.start,
}),
...(segment.end != null && { endSecond: segment.end }),
...(segment.speaker_id != null && {
speakerId: segment.speaker_id,
}),
})),
},
},
});
finished = true;
controller.close();
cleanup();
break;
}
case 'error':
fail(new Error(event.error.message));
break;
}
},
onSocketError: () =>
fail(
new Error(
'Mistral realtime transcription WebSocket error.' +
(webSocket == null
? ' Pass a header-capable WebSocket implementation (e.g. ws) to createMistral({ webSocket }) to send the Authorization header.'
: ''),
),
),
onClose: ({ code, reason }) =>
fail(
new Error(
`Mistral realtime transcription connection closed before transcription.done (code: ${code ?? 'unknown'}${reason ? `, reason: ${reason}` : ''}).`,
),
),
});
// A constructor failure or an already-aborted signal can finish
// synchronously, before the connection has been assigned.
if (finished) connection.close();
},
cancel() {
if (finished) return;
finished = true;
cleanup();
},
}),
};
}
const handledEvents = new Set([
'session.created',
'session.updated',
'transcription.text.delta',
'transcription.segment',
'transcription.done',
'error',
]);
const eventEnvelopeSchema = lazySchema(() =>
zodSchema(z.looseObject({ type: z.string() })),
);
const transcriptionEventSchema = lazySchema(() => {
const segment = {
text: z.string(),
start: z.number().nullish(),
end: z.number().nullish(),
speaker_id: z.string().nullish(),
};
return zodSchema(
z.discriminatedUnion('type', [
z.object({ type: z.literal('session.created') }),
z.object({ type: z.literal('session.updated') }),
z.object({
type: z.literal('transcription.text.delta'),
text: z.string(),
}),
z.object({ type: z.literal('transcription.segment'), ...segment }),
z.object({
type: z.literal('transcription.done'),
text: z.string(),
model: z.string().nullish(),
language: z.string().nullish(),
segments: z.array(z.object(segment)).nullish(),
usage: z.record(z.string(), z.json()).nullish(),
}),
z.object({
type: z.literal('error'),
error: z.object({ message: z.string() }),
}),
]),
);
});