UNPKG

@ai-sdk/mistral

Version:
736 lines (705 loc) • 24.3 kB
import { UnsupportedFunctionalityError, type Experimental_RealtimeModelV4ClientEvent as ClientEvent, type Experimental_RealtimeModelV4ServerEvent as ServerEvent, type Experimental_RealtimeModelV4Transport as RealtimeTransport, type Experimental_RealtimeModelV4TransportOptions as TransportOptions, } from '@ai-sdk/provider'; import { safeParseJSON } from '@ai-sdk/provider-utils'; import type { LocalAudioTrack, RemoteParticipant, RemoteTrack, Room, RpcInvocationData, } from 'livekit-client'; import { z } from 'zod/v4'; import { buildMistralRealtimeSessionConfig } from './mistral-realtime-model-options'; const sessionSchema = z.object({ id: z.string().min(1), participantToken: z.string().min(1), serverUrl: z.string().min(1), }); const transcriptSchema = z.object({ type: z.literal('transcription.segment'), id: z.string(), role: z.enum(['user', 'assistant']), content: z.string(), final: z.boolean(), }); const errorSchema = z.object({ type: z.literal('error'), message: z.string(), code: z.string(), fatal: z.boolean(), }); const functionCallSchema = z.object({ name: z.string(), arguments: z.record(z.string(), z.unknown()).optional(), }); /** Voice transcripts contain replacement text, rather than append-only deltas. */ export function parseMistralRealtimeEvent(raw: unknown): ServerEvent[] { const type = raw != null && typeof raw === 'object' && 'type' in raw ? raw.type : undefined; switch (type) { case 'transcription.segment': { const segment = transcriptSchema.parse(raw); if (!segment.final) return []; return segment.role === 'user' ? [ { type: 'input-transcription-completed', itemId: segment.id, transcript: segment.content, raw, }, ] : [ { type: 'response-created', responseId: segment.id, raw }, { type: 'audio-transcript-delta', responseId: segment.id, itemId: segment.id, delta: segment.content, raw, }, { type: 'audio-transcript-done', responseId: segment.id, itemId: segment.id, transcript: segment.content, raw, }, { type: 'response-done', responseId: segment.id, status: 'completed', raw, }, ]; } case 'session.updated': return [{ type: 'session-updated', raw }]; case 'error': { const error = errorSchema.parse(raw); return [{ type: 'error', message: error.message, code: error.code, raw }]; } default: return [ { type: 'custom', rawType: typeof type === 'string' ? type : 'unknown', raw, }, ]; } } function asError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } async function loadLiveKit() { try { // Catch the import directly so bundlers treat the peer as optional. return await import('livekit-client'); } catch (cause) { throw Object.assign( new Error( 'Cannot load "livekit-client". Install it to use Mistral realtime voice connections.', ), { cause }, ); } } /** LiveKit stays inside the Mistral provider and is loaded only on connection. */ export class MistralRealtimeTransport implements RealtimeTransport { private room?: Room; private session?: z.infer<typeof sessionSchema>; private api?: string; private started = false; private closed = false; private accepting = true; private ready = false; private failed = false; private reconnecting = false; private agentDisconnected = false; private agentCheckQueued = false; private abort = new AbortController(); private setup?: Promise<void>; private cleanup?: Promise<void>; private removeAbortListener?: () => void; private removeRoomListeners?: () => void; private resolveAgent?: () => void; private incoming = Promise.resolve(); private deliveries = Promise.resolve(); private pendingEvents: ServerEvent[] = []; private pendingCount = 0; private pendingBytes = 0; private finalizedSegments = new Set<string>(); private captureVersion = 0; private captureOperations = Promise.resolve(); private sourceStream?: MediaStream; private ownedTracks = new Set<LocalAudioTrack>(); private microphone?: { track: MediaStreamTrack; owned?: LocalAudioTrack; removeListener: () => void; }; private playbackEnabled = true; private agentSpeaking = false; private remoteAudio = new Map<RemoteTrack, HTMLMediaElement>(); private pendingTools = new Map< string, { resolve: (result: string) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout>; } >(); constructor(private readonly options: TransportOptions) {} async connect({ api, sessionConfig, stream, capture = true, abortSignal, }: Parameters<RealtimeTransport['connect']>[0]): Promise<void> { if (this.started || this.closed) throw new Error('Create a new Mistral transport for each connection.'); this.started = true; this.api = api; const onAbort = () => this.dispose(); abortSignal.addEventListener('abort', onAbort, { once: true }); this.removeAbortListener = () => abortSignal.removeEventListener('abort', onAbort); if (abortSignal.aborted) { this.dispose(); throw new Error('Mistral voice connection cancelled.'); } this.setup = this.open({ sessionConfig, stream, capture }); try { await this.setup; } catch (error) { await this.close().catch(cleanupError => this.options.onError(asError(cleanupError)), ); throw error; } } private async open({ sessionConfig, stream, capture, }: Pick< Parameters<RealtimeTransport['connect']>[0], 'sessionConfig' | 'stream' | 'capture' >): Promise<void> { const livekit = await loadLiveKit(); this.assertOpen(); // Let an in-flight create request finish on cancellation, so its returned // session can still be deleted. A bounded timeout covers an unresponsive app. const timeout = new AbortController(); const timer = setTimeout(() => timeout.abort(), 30_000); try { const response = await fetch(this.api!, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionConfig }), signal: timeout.signal, }); if (!response.ok) throw new Error( `Failed to create Mistral voice session: ${response.status}`, ); const parsed = await safeParseJSON({ text: await response.text(), schema: sessionSchema, }); if (!parsed.success) throw parsed.error; this.session = parsed.value; } finally { clearTimeout(timer); } this.assertOpen(); const room = new livekit.Room(); this.room = room; const agentReady = new Promise<void>(resolve => { this.resolveAgent = resolve; }); const onParticipantConnected = (participant: RemoteParticipant) => { if (participant.isAgent) { this.agentDisconnected = false; this.resolveAgent?.(); } }; const onParticipantDisconnected = (participant: RemoteParticipant) => { if (participant.isAgent) { this.agentDisconnected = true; this.checkAgentDisconnect(); } }; const onReconnecting = () => { this.reconnecting = true; }; const onReconnected = () => { this.reconnecting = false; this.checkAgentDisconnect(); }; const onDisconnected = () => { if (!this.closed) this.fail(new Error('Mistral voice connection disconnected.')); }; const onTrackSubscribed = ( track: RemoteTrack, _publication: unknown, participant: RemoteParticipant, ) => { if ( this.closed || !participant.isAgent || track.kind !== livekit.Track.Kind.Audio ) return; const element = track.attach(); this.remoteAudio.set(track, element); if (this.playbackEnabled) { void element.play().catch(error => { if (!this.closed) this.options.onError(asError(error)); }); } else element.pause(); }; const onTrackUnsubscribed = (track: RemoteTrack) => { this.removeRemoteTrack(track); }; const onDataReceived = ( payload: Uint8Array, participant?: RemoteParticipant, _kind?: unknown, topic?: string, ) => { if (topic !== 'voice_agent.events' || !participant?.isAgent) return; this.receive(payload); }; room.on(livekit.RoomEvent.ParticipantConnected, onParticipantConnected); room.on( livekit.RoomEvent.ParticipantDisconnected, onParticipantDisconnected, ); room.on(livekit.RoomEvent.Disconnected, onDisconnected); room.on(livekit.RoomEvent.Reconnecting, onReconnecting); room.on(livekit.RoomEvent.Reconnected, onReconnected); room.on(livekit.RoomEvent.TrackSubscribed, onTrackSubscribed); room.on(livekit.RoomEvent.TrackUnsubscribed, onTrackUnsubscribed); room.on(livekit.RoomEvent.DataReceived, onDataReceived); this.removeRoomListeners = () => { room.off(livekit.RoomEvent.ParticipantConnected, onParticipantConnected); room.off( livekit.RoomEvent.ParticipantDisconnected, onParticipantDisconnected, ); room.off(livekit.RoomEvent.Disconnected, onDisconnected); room.off(livekit.RoomEvent.Reconnecting, onReconnecting); room.off(livekit.RoomEvent.Reconnected, onReconnected); room.off(livekit.RoomEvent.TrackSubscribed, onTrackSubscribed); room.off(livekit.RoomEvent.TrackUnsubscribed, onTrackUnsubscribed); room.off(livekit.RoomEvent.DataReceived, onDataReceived); }; const connecting = room.connect( this.session.serverUrl, this.session.participantToken, ); void connecting.then( () => { if (this.closed) void room.disconnect(false).catch(() => {}); }, () => {}, ); await this.untilAborted(connecting); this.assertOpen(); room.localParticipant.registerRpcMethod( 'mistral.function_tool', (data: RpcInvocationData) => this.handleToolCall(data), ); if (this.findAgent() != null) this.resolveAgent?.(); if (capture) await this.untilAborted(this.startCapture(stream)); await this.untilAborted(agentReady); this.assertOpen(); await this.options.onEvent({ type: 'session-created', sessionId: this.session.id, raw: { type: 'session.created', id: this.session.id }, }); this.assertOpen(); this.ready = true; const events = this.pendingEvents.splice(0); await Promise.all(events.map(event => this.emit(event))); } sendEvent(event: ClientEvent, shouldSend?: () => boolean): Promise<void> { this.assertOpen(); if (shouldSend?.() === false) return Promise.resolve(); switch (event.type) { case 'session-update': return this.updateSession(event.config, shouldSend); case 'input-audio-mute': return this.stopCapture(); case 'input-audio-unmute': return this.startCapture(); case 'conversation-item-create': if (event.item.type === 'function-call-output') { const pending = this.pendingTools.get(event.item.callId); if (pending == null) throw new Error('Mistral function call is no longer pending.'); this.pendingTools.delete(event.item.callId); clearTimeout(pending.timer); pending.resolve(event.item.output); return Promise.resolve(); } break; case 'session-close': return this.close(); } throw new UnsupportedFunctionalityError({ functionality: `${event.type} in Mistral voice sessions`, }); } private async updateSession( config: Parameters<typeof buildMistralRealtimeSessionConfig>[0], shouldSend?: () => boolean, ): Promise<void> { const payload = await buildMistralRealtimeSessionConfig(config, { update: true, }); this.assertOpen(); if (shouldSend?.() === false) return; const agent = this.findAgent(); if (agent == null) throw new Error('Mistral voice agent is not connected.'); const response = await this.room!.localParticipant.performRpc({ destinationIdentity: agent.identity, method: 'mistral.session_update', payload: JSON.stringify(payload), responseTimeout: 5000, }); const parsed = await safeParseJSON({ text: response }); if (!parsed.success) throw parsed.error; // The server also broadcasts session.updated. Its reply is an acknowledgement, // not another event, so delivering both would duplicate the update. } private async handleToolCall(data: RpcInvocationData): Promise<string> { this.assertOpen(); if (data.callerIdentity !== this.findAgent()?.identity) throw new Error('Only the Mistral voice agent may call tools.'); const parsed = await safeParseJSON({ text: data.payload, schema: functionCallSchema, }); if (!parsed.success) throw parsed.error; this.assertOpen(); const call = parsed.value; if (this.pendingTools.has(data.requestId) || this.pendingTools.size >= 64) throw new Error('Mistral voice has too many pending function calls.'); const result = new Promise<string>((resolve, reject) => { const timer = setTimeout(() => { this.pendingTools.delete(data.requestId); reject(new Error(`Mistral function ${call.name} timed out.`)); }, data.responseTimeout); this.pendingTools.set(data.requestId, { resolve, reject, timer }); }); // Attach a rejection handler before invoking application callbacks. void result.catch(() => {}); await this.emit({ type: 'function-call-arguments-delta', responseId: data.requestId, itemId: data.requestId, callId: data.requestId, delta: '', raw: call, }); await this.emit({ type: 'function-call-arguments-done', responseId: data.requestId, itemId: data.requestId, callId: data.requestId, name: call.name, arguments: JSON.stringify(call.arguments ?? {}), raw: call, }); return result; } async startCapture(stream?: MediaStream): Promise<void> { this.assertOpen(); const version = ++this.captureVersion; this.sourceStream = stream ?? this.sourceStream; const source = this.sourceStream; let owned: LocalAudioTrack | undefined; if (source == null) { const { createLocalAudioTrack } = await loadLiveKit(); if (this.closed || version !== this.captureVersion) return; owned = await createLocalAudioTrack({ echoCancellation: true, noiseSuppression: true, autoGainControl: true, }); this.ownedTracks.add(owned); } const track = owned?.mediaStreamTrack ?? source?.getAudioTracks().find(track => track.readyState === 'live'); if (this.closed || version !== this.captureVersion) { this.stopOwnedTrack(owned); return; } if (track == null) { this.stopOwnedTrack(owned); throw new Error('Mistral voice requires a live audio track.'); } const room = this.room; if (room == null) { this.stopOwnedTrack(owned); throw new Error('Mistral voice is not connected.'); } const operation = this.captureOperations.then(async () => { try { await this.releaseMicrophone(); const { Track } = await loadLiveKit(); if (this.closed || version !== this.captureVersion) { this.stopOwnedTrack(owned); return; } await room.localParticipant.publishTrack(track, { source: Track.Source.Microphone, }); if (this.closed || version !== this.captureVersion) { await room.localParticipant.unpublishTrack(track, false); this.stopOwnedTrack(owned); return; } const onEnded = () => { if (this.microphone?.track === track) this.options.onCapturing(false); }; track.addEventListener('ended', onEnded); this.microphone = { track, owned, removeListener: () => track.removeEventListener('ended', onEnded), }; this.options.onCapturing(track.enabled && !track.muted); } catch (error) { this.stopOwnedTrack(owned); throw error; } }); this.captureOperations = operation.catch(() => {}); return operation; } stopCapture(): Promise<void> { ++this.captureVersion; const operation = this.captureOperations.then(() => this.releaseMicrophone(), ); this.captureOperations = operation.catch(() => {}); return operation; } private async releaseMicrophone(): Promise<void> { const microphone = this.microphone; this.microphone = undefined; if (microphone != null) { microphone.removeListener(); try { await this.room?.localParticipant.unpublishTrack( microphone.track, false, ); } finally { this.stopOwnedTrack(microphone.owned); this.options.onCapturing(false); } } else this.options.onCapturing(false); } private stopOwnedTrack(track?: LocalAudioTrack): void { if (track != null && this.ownedTracks.delete(track)) track.stop(); } stopPlayback(): void { this.playbackEnabled = false; for (const element of this.remoteAudio.values()) element.pause(); this.options.onPlaying(false); } async resumePlayback(): Promise<void> { this.assertOpen(); this.playbackEnabled = true; await this.room?.startAudio(); await Promise.all( [...this.remoteAudio.values()].map(element => element.play()), ); if (!this.closed) this.options.onPlaying(this.agentSpeaking); } private receive(payload: Uint8Array): void { if (!this.accepting || this.closed) return; if ( this.pendingCount >= 512 || this.pendingBytes + payload.byteLength > 8 * 1024 * 1024 ) { this.fail(new Error('Mistral voice event queue exceeded its limit.')); return; } ++this.pendingCount; this.pendingBytes += payload.byteLength; this.incoming = this.incoming .then(async () => { if (this.closed) return; const parsed = await safeParseJSON({ text: new TextDecoder().decode(payload), }); if (!parsed.success) throw parsed.error; const raw = parsed.value; const transcript = transcriptSchema.safeParse(raw); if (transcript.success && transcript.data.final) { if (this.finalizedSegments.has(transcript.data.id)) return; this.finalizedSegments.add(transcript.data.id); if (this.finalizedSegments.size > 500) this.finalizedSegments.delete( this.finalizedSegments.values().next().value!, ); } const state = z .object({ type: z.literal('agent.state.changed'), state: z.string(), }) .safeParse(raw); if (state.success) { this.agentSpeaking = state.data.state === 'speaking'; this.options.onPlaying(this.playbackEnabled && this.agentSpeaking); } for (const event of parseMistralRealtimeEvent(raw)) await this.emit(event); const error = errorSchema.safeParse(raw); if (error.success && error.data.fatal) this.fail(new Error(error.data.message)); }) .catch(error => this.fail(asError(error))) .finally(() => { --this.pendingCount; this.pendingBytes -= payload.byteLength; }); } private async emit(event: ServerEvent): Promise<void> { if (this.closed) return; if (!this.ready) { if (this.pendingEvents.length >= 512) throw new Error('Mistral voice startup event queue is full.'); this.pendingEvents.push(event); return; } const delivery = this.deliveries.then(async () => { if (this.closed) return; try { await this.options.onEvent(event); } catch (error) { this.options.onError(asError(error)); } }); this.deliveries = delivery.catch(() => {}); return delivery; } private findAgent(): RemoteParticipant | undefined { return [...(this.room?.remoteParticipants.values() ?? [])].find( participant => participant.isAgent, ); } private checkAgentDisconnect(): void { if (!this.agentDisconnected || this.agentCheckQueued) return; this.agentCheckQueued = true; queueMicrotask(() => { this.agentCheckQueued = false; if (this.closed || this.reconnecting) return; if (this.findAgent() != null) this.agentDisconnected = false; else this.fail(new Error('Mistral voice agent disconnected.')); }); } private assertOpen(): void { if (this.closed || this.abort.signal.aborted) throw new Error('Mistral voice connection is closed.'); } private untilAborted<T>(operation: Promise<T>): Promise<T> { if (this.abort.signal.aborted) return Promise.reject(new Error('Mistral voice connection cancelled.')); return new Promise<T>((resolve, reject) => { const onAbort = () => reject(new Error('Mistral voice connection cancelled.')); this.abort.signal.addEventListener('abort', onAbort, { once: true }); operation.then(resolve, reject).finally(() => { this.abort.signal.removeEventListener('abort', onAbort); }); }); } private fail(error: Error): void { if (this.closed || this.failed) return; this.failed = true; this.accepting = false; this.options.onFatalError(error); this.dispose(); } async finish(): Promise<void> { this.accepting = false; await this.incoming; await this.deliveries; } private removeRemoteTrack(track: RemoteTrack): void { const element = this.remoteAudio.get(track); if (element == null) return; this.remoteAudio.delete(track); element.pause(); element.srcObject = null; track.detach(element); element.remove(); } close(): Promise<void> { if (this.cleanup != null) return this.cleanup; let disconnected: Promise<void> | undefined; // Cache cleanup before callbacks: consumers may dispose again while a media // state notification is being delivered. this.cleanup = Promise.resolve().then(async () => { try { await disconnected; } finally { await this.setup?.catch(() => {}); if (this.session != null && this.api != null) { const separator = this.api.includes('?') ? '&' : '?'; const response = await fetch( `${this.api}${separator}sessionId=${encodeURIComponent(this.session.id)}`, { method: 'DELETE', keepalive: true }, ); if (!response.ok) throw new Error( `Failed to close Mistral voice session: ${response.status}`, ); } } }); this.closed = true; this.accepting = false; ++this.captureVersion; this.abort.abort(); this.removeAbortListener?.(); this.removeRoomListeners?.(); this.pendingEvents = []; for (const pending of this.pendingTools.values()) { clearTimeout(pending.timer); pending.reject(new Error('Mistral voice session closed.')); } this.pendingTools.clear(); for (const track of this.remoteAudio.keys()) this.removeRemoteTrack(track); for (const track of this.ownedTracks) this.stopOwnedTrack(track); this.microphone?.removeListener(); this.microphone = undefined; this.options.onCapturing(false); this.options.onPlaying(false); // false is essential: LiveKit must not stop caller-owned microphone tracks. disconnected = this.room?.disconnect(false); return this.cleanup; } dispose(): void { void this.close().catch(error => this.options.onError(asError(error))); } }