import { isFileSource, unsupportedFileSourceError } from '@tanstack/ai' import { BaseTextAdapter } from '@tanstack/ai/adapters' import { undoNullWidening } from '@tanstack/ai-utils' import { convertToolsToProviderFormat } from '../tools/tool-converter' import { createMistralClient, generateId, getMistralApiKeyFromEnv, } from '../utils/client' import { makeMistralStructuredOutputCompatibleWithMap } from '../utils/schema-converter' import { createToolInputNormalizer } from '../utils/tool-input-normalizer' import { MISTRAL_MODEL_INPUT_MODALITIES, MISTRAL_MODEL_REASONING, } from '../model-meta' import { hashToolCallId, orderedAssistantBlocks, resolveReasoning, transformMessagesForReplay, } from '@tanstack/ai/adapter-internals' import type { ContentPart, FetchWrapper, Modality, ModelMessage, AdapterYieldChunk, ModelReasoning, ReasoningRequest, ResolvedPromptCache, TextOptions, ToolChoice, } from '@tanstack/ai' import type { MISTRAL_CHAT_MODELS, MistralChatModelProviderOptionsByName, MistralModelInputModalitiesByName, MistralModelReasoningByName, MistralTextAdapterModel, } from '../model-meta' import type { StructuredOutputOptions, StructuredOutputResult, } from '@tanstack/ai/adapters' import type { Mistral } from '@mistralai/mistralai' import type { AssistantMessage, ChatCompletionStreamRequest, ContentChunk, ReasoningEffort, } from '@mistralai/mistralai/models/components' import type { ExternalTextProviderOptions, InternalTextProviderOptions, } from '../text/text-provider-options' import type { ChatCompletionContentPart, MistralImageMetadata, MistralMessageMetadataByModality, } from '../message-types' import type { MistralClientConfig } from '../utils/client' import type { ToolInputNormalizer } from '../utils/tool-input-normalizer' /** Cast an event object to StreamChunk. Adapters construct events with string * literal types which are structurally compatible with the EventType enum. */ const asChunk = (chunk: Record) => // oxlint-disable-next-line eslint-js/no-restricted-syntax -- Record doesn't structurally overlap the StreamChunk discriminated union; events are built with literal `type` fields the union accepts at runtime chunk as unknown as AdapterYieldChunk /** * Parse the accumulated streaming arguments for a tool call. Throws a clear * error if the JSON is malformed — silently substituting `{}` would let a * tool fire with empty inputs, masking truncated streams or mis-shaped output. */ function parseToolCallInput( toolCall: { id: string name: string arguments: string }, normalizeToolInput: ToolInputNormalizer, ): unknown { if (!toolCall.arguments) return {} try { return normalizeToolInput(toolCall.name, JSON.parse(toolCall.arguments)) } catch (cause) { const preview = toolCall.arguments.slice(0, 200) const ellipsis = toolCall.arguments.length > 200 ? '...' : '' throw new Error( `Failed to parse tool call arguments for tool '${toolCall.name}' (id: ${toolCall.id}). Arguments: ${preview}${ellipsis}`, { cause }, ) } } /** * Configuration for Mistral text adapter. */ export type MistralTextConfig = MistralClientConfig /** * Alias for TextProviderOptions for external use. */ export type MistralTextProviderOptions = ExternalTextProviderOptions // =========================== // Type Resolution Helpers // =========================== type ResolveProviderOptions = TModel extends keyof MistralChatModelProviderOptionsByName ? MistralChatModelProviderOptionsByName[TModel] : MistralTextProviderOptions /** The reasoning levels of a model, for `chat({ reasoning })`. `never`: none. */ type ResolveReasoning = TModel extends keyof MistralModelReasoningByName ? MistralModelReasoningByName[TModel] : never /** * The Mistral reasoning fields for `chat({ reasoning })`, by pi's rule: * - a model with effort levels (Mistral Small, Mistral Medium): * `reasoningEffort` with the level's value, `none` for `off`. * - Magistral: `promptMode: 'reasoning'`, and nothing for `off`. */ function mistralReasoning( model: string, request: ReasoningRequest | undefined, reasoning: ModelReasoning | undefined, ): Pick { const resolved = resolveReasoning(request, reasoning) if (!resolved) return {} if (model.startsWith('magistral')) { return resolved.level === 'off' ? {} : { promptMode: 'reasoning' } } // `ReasoningEffort` is an open enum, so a newer value still goes out. return { reasoningEffort: resolved.value as ReasoningEffort } } /** Maps `chat({ toolChoice })` to the Mistral `toolChoice`. */ function toMistralToolChoice( choice: ToolChoice, ): NonNullable { if (typeof choice === 'string') return choice return { type: 'function', function: { name: choice.name } } } /** * The Mistral wire field for `chat({ promptCache })`. Mistral caches without * a flag, so only the key goes out. There is no key for `'none'`. Send the * key only to `api.mistral.ai`. The SDK request schema drops unknown fields, * so structured output adds the key after that schema runs. */ function mistralPromptCacheKey(promptCache: ResolvedPromptCache | undefined) { if (!promptCache?.key || promptCache.retention === 'none') return undefined return promptCache.key } function mistralOwnApi(config: MistralClientConfig, provider: string) { if (provider === 'google-vertex') return false const serverURL = config.baseURL ?? config.serverURL ?? 'https://api.mistral.ai' try { return new URL(serverURL).hostname === 'api.mistral.ai' } catch { return false } } /** Add `prompt_cache_key` to a JSON body the SDK has already serialized. */ function withPromptCacheKey(next: typeof fetch, key: string): typeof fetch { return async (input, init) => { const body = init?.body const text = typeof body === 'string' ? body : body instanceof ArrayBuffer ? new TextDecoder().decode(body) : undefined if (text === undefined) return next(input, init) let parsed: unknown try { parsed = JSON.parse(text) } catch { return next(input, init) } if ( typeof parsed !== 'object' || parsed === null || Array.isArray(parsed) ) { return next(input, init) } return next(input, { ...init, body: JSON.stringify({ ...parsed, prompt_cache_key: key }), }) } } /** * `promptTokensDetails.cachedTokens` when Mistral read prompt tokens from the * cache. Mistral sends the count in `prompt_tokens_details.cached_tokens` or * in `num_cached_tokens`. The SDK keeps these raw keys on `usage` with no * types, so the values are checked here. */ function mistralCachedTokens(usage: Record) { const details = usage.prompt_tokens_details const hasDetailsCount = typeof details === 'object' && details !== null && 'cached_tokens' in details const cachedTokens = hasDetailsCount ? details.cached_tokens : usage.num_cached_tokens return typeof cachedTokens === 'number' && cachedTokens > 0 ? { promptTokensDetails: { cachedTokens } } : {} } type ResolveInputModalities = TModel extends keyof MistralModelInputModalitiesByName ? MistralModelInputModalitiesByName[TModel] : readonly ['text'] // =========================== // Wire-format chunk types // =========================== /** * Snake-case shape of a Mistral chat completion stream chunk as returned on the * wire. We bypass the SDK's `chat.stream` because its Zod validation rejects * tool-call argument deltas that omit `function.name` (only the first chunk in * a tool call carries the name). */ interface MistralRawToolCall { id?: string type?: string index?: number function?: { name?: string arguments?: string | Record } } interface MistralRawChoice { index?: number delta?: { role?: string | null content?: | string | Array<{ type: string text?: string // Mistral magistral models stream reasoning as content parts of // type 'thinking' whose `thinking` field is itself an array of // text/reference chunks. See Mistral SDK ThinkChunk type. thinking?: Array<{ type: string; text?: string }> }> | null // Some OpenAI-compatible deployments (DeepSeek, Groq for reasoning // models, and aimock-based test environments) emit reasoning via a // separate `reasoning_content` delta field rather than as a content // part. Accept both shapes — they cannot collide because real Mistral // never sets the OpenAI-compat field, and aimock never sets the // thinking content part. reasoning_content?: string | null tool_calls?: Array } finish_reason?: string | null } interface MistralRawChunk { id?: string model?: string choices?: Array usage?: { prompt_tokens?: number completion_tokens?: number total_tokens?: number // Cache reads. Mistral sends one of these two. prompt_tokens_details?: { cached_tokens?: number } | null num_cached_tokens?: number | null } } // =========================== // Adapter Implementation // =========================== /** * Mistral Text (Chat) Adapter. * * Tree-shakeable adapter for Mistral chat/text completion functionality. */ export class MistralTextAdapter< TModel extends MistralTextAdapterModel, TProviderOptions extends Record = ResolveProviderOptions, TInputModalities extends ReadonlyArray = ResolveInputModalities, > extends BaseTextAdapter< TModel, TProviderOptions, TInputModalities, MistralMessageMetadataByModality, ReadonlyArray, unknown, never, ResolveReasoning > { readonly name = 'mistral' as const override readonly api = 'mistral-conversations' as const override readonly provider: string override readonly inputModalities = MISTRAL_MODEL_INPUT_MODALITIES[this.model] private readonly client: Mistral private readonly rawConfig: MistralClientConfig constructor(config: MistralTextConfig, model: TModel) { super(config, model) // The SDK client is retained for `structuredOutput` (non-streaming). The // streaming path bypasses the SDK and uses `fetchRawMistralStream` because // the SDK's Zod schemas reject partial tool-call argument deltas. this.client = createMistralClient(config) this.rawConfig = config this.provider = config.getAccessToken && config.resolveRequestUrl ? 'google-vertex' : this.name } async *chatStream( options: TextOptions, ): AsyncIterable { const requestParams = this.mapTextOptionsToMistral(options) const timestamp = Date.now() const aguiState = { runId: options.runId ?? generateId(this.name), threadId: options.threadId, messageId: generateId(this.name), timestamp, hasEmittedRunStarted: false, } try { const cacheKey = mistralOwnApi(this.rawConfig, this.provider) ? mistralPromptCacheKey(options.promptCache) : undefined const body = { ...this.toWireBody(requestParams), ...(cacheKey !== undefined && { prompt_cache_key: cacheKey }), } const stream = this.fetchRawMistralStream( body, this.rawConfig, options.wrapFetch, ) for await (const chunk of this.processMistralStreamChunks( stream, options, aguiState, )) { yield chunk.type === 'RUN_STARTED' || chunk.type === 'RUN_ERROR' ? { ...chunk, metadata: { ...chunk.metadata, tanstack: { ...chunk.metadata?.tanstack, source: { provider: this.provider, api: this.api, model: options.model, }, }, }, } : chunk } } catch (error: unknown) { const err = error as Error & { code?: string } if (!aguiState.hasEmittedRunStarted) { aguiState.hasEmittedRunStarted = true yield asChunk({ type: 'RUN_STARTED', runId: aguiState.runId, threadId: aguiState.threadId, metadata: { tanstack: { source: { provider: this.provider, api: this.api, model: options.model, }, }, }, model: options.model, timestamp, }) } yield asChunk({ type: 'RUN_ERROR', metadata: { tanstack: { source: { provider: this.provider, api: this.api, model: options.model, }, }, }, runId: aguiState.runId, model: options.model, timestamp, message: err.message || 'Unknown error', code: err.code, error: { message: err.message || 'Unknown error', code: err.code, }, }) throw err } } /** * Generate structured output using Mistral's JSON Schema response format. */ async structuredOutput( options: StructuredOutputOptions, ): Promise> { const { chatOptions, outputSchema } = options const { stream: _stream, ...nonStreamParams } = this.mapTextOptionsToMistral(chatOptions) const { schema: jsonSchema, nullWideningMap, strict, } = makeMistralStructuredOutputCompatibleWithMap( outputSchema, outputSchema.required || [], ) const cacheKey = mistralOwnApi(this.rawConfig, this.provider) ? mistralPromptCacheKey(chatOptions.promptCache) : undefined const next = chatOptions.wrapFetch ? chatOptions.wrapFetch(fetch) : fetch const client = cacheKey ? createMistralClient(this.rawConfig, withPromptCacheKey(next, cacheKey)) : chatOptions.wrapFetch ? createMistralClient(this.rawConfig, next) : this.client const response = await client.chat.complete({ ...nonStreamParams, responseFormat: { type: 'json_schema', jsonSchema: { name: 'structured_output', schemaDefinition: jsonSchema, strict, }, }, }) // A response cut off at the output cap is a truncated JSON document; // report it before the parse error (issue #1426). if (response.choices[0]?.finishReason === 'length') { throw new Error( 'mistral.structuredOutput: the response was cut off because the maximum token limit was reached (finish_reason=length); raise modelOptions.max_tokens', ) } const rawText = response.choices[0]?.message?.content const textContent = typeof rawText === 'string' ? rawText : '' let parsed: unknown try { parsed = JSON.parse(textContent) } catch { throw new Error( `Failed to parse structured output as JSON. Content: ${textContent.slice(0, 200)}${textContent.length > 200 ? '...' : ''}`, ) } const usage = response.usage return { data: undoNullWidening(parsed, nullWideningMap), ...(response.id && { responseId: response.id }), ...(response.model && { model: response.model }), rawText: textContent, ...(usage && { usage: { promptTokens: usage.promptTokens ?? 0, completionTokens: usage.completionTokens ?? 0, totalTokens: usage.totalTokens ?? 0, ...mistralCachedTokens(usage), }, }), } } /** * Processes streaming chunks from the Mistral API and yields AG-UI stream events. */ private async *processMistralStreamChunks( stream: AsyncIterable, options: TextOptions, aguiState: { runId: string threadId: string messageId: string timestamp: number hasEmittedRunStarted: boolean }, ): AsyncIterable { let accumulatedContent = '' const timestamp = aguiState.timestamp let hasEmittedTextMessageStart = false let hasEmittedTextMessageEnd = false let hasEmittedToolCall = false let hasEmittedRunFinished = false let lastChunkModel = options.model let responseId: string | undefined const normalizeToolInput = createToolInputNormalizer(options.tools) // Reasoning lifecycle (magistral-* models stream `thinking` content // parts before any text). Mirrors the anthropic adapter's pattern: // open REASONING_* events on the first thinking delta, close them when // text/tool content begins or the run finishes. let reasoningMessageId: string | null = null let hasClosedReasoning = false const toolCallsInProgress = new Map< number, { id: string name: string arguments: string started: boolean ended: boolean } >() try { for await (const chunk of stream) { lastChunkModel = chunk.model || lastChunkModel if (chunk.id) responseId = chunk.id const choice = chunk.choices?.[0] if (!choice) continue const chunkModel = chunk.model || options.model if (!aguiState.hasEmittedRunStarted) { aguiState.hasEmittedRunStarted = true yield asChunk({ type: 'RUN_STARTED', runId: aguiState.runId, threadId: aguiState.threadId, model: chunkModel, timestamp, }) } const delta = choice.delta const { text: deltaContent, thinking: deltaThinkingFromContent } = this.extractDeltaParts(delta?.content) // Reasoning may also arrive as a separate top-level field // (`delta.reasoning_content`) on OpenAI-compatible deployments. const deltaThinking = deltaThinkingFromContent + (typeof delta?.reasoning_content === 'string' ? delta.reasoning_content : '') const deltaToolCalls = delta?.tool_calls // Emit reasoning events FIRST so they always precede the matching // text or tool deltas in the same chunk. if (deltaThinking) { if (reasoningMessageId === null) { reasoningMessageId = generateId(this.name) yield asChunk({ type: 'REASONING_START', messageId: reasoningMessageId, model: chunkModel, timestamp, }) yield asChunk({ type: 'REASONING_MESSAGE_START', messageId: reasoningMessageId, role: 'reasoning', model: chunkModel, timestamp, }) } yield asChunk({ type: 'REASONING_MESSAGE_CONTENT', messageId: reasoningMessageId, model: chunkModel, timestamp, delta: deltaThinking, }) } // Close reasoning before any text/tool output starts in this chunk. const aboutToEmitOutput = !!deltaContent || (!!deltaToolCalls && deltaToolCalls.length > 0) if ( reasoningMessageId !== null && !hasClosedReasoning && aboutToEmitOutput ) { hasClosedReasoning = true yield asChunk({ type: 'REASONING_MESSAGE_END', messageId: reasoningMessageId, model: chunkModel, timestamp, }) yield asChunk({ type: 'REASONING_END', messageId: reasoningMessageId, model: chunkModel, timestamp, }) } if (deltaContent) { if (!hasEmittedTextMessageStart) { hasEmittedTextMessageStart = true yield asChunk({ type: 'TEXT_MESSAGE_START', messageId: aguiState.messageId, model: chunkModel, timestamp, role: 'assistant', }) } accumulatedContent += deltaContent yield asChunk({ type: 'TEXT_MESSAGE_CONTENT', messageId: aguiState.messageId, model: chunkModel, timestamp, delta: deltaContent, content: accumulatedContent, }) } if (deltaToolCalls) { for (const [i, toolCallDelta] of deltaToolCalls.entries()) { const index = toolCallDelta.index ?? i let toolCall = toolCallsInProgress.get(index) if (!toolCall) { toolCall = { id: toolCallDelta.id || '', name: toolCallDelta.function?.name || '', arguments: '', started: false, ended: false, } toolCallsInProgress.set(index, toolCall) } if (toolCallDelta.id) toolCall.id = toolCallDelta.id if (toolCallDelta.function?.name) { toolCall.name = toolCallDelta.function.name } const rawArgs = toolCallDelta.function?.arguments const argsDelta = rawArgs === undefined ? undefined : typeof rawArgs === 'string' ? rawArgs : JSON.stringify(rawArgs) if (argsDelta !== undefined) { toolCall.arguments += argsDelta } const justStarted = !!toolCall.id && !!toolCall.name && !toolCall.started if (justStarted) { toolCall.started = true yield asChunk({ type: 'TOOL_CALL_START', toolCallId: toolCall.id, toolCallName: toolCall.name, model: chunkModel, timestamp, index, }) // Replay any args buffered before id+name arrived (including // this chunk's argsDelta, if any). if (toolCall.arguments.length > 0) { yield asChunk({ type: 'TOOL_CALL_ARGS', toolCallId: toolCall.id, model: chunkModel, timestamp, delta: toolCall.arguments, }) } } else if (argsDelta !== undefined && toolCall.started) { yield asChunk({ type: 'TOOL_CALL_ARGS', toolCallId: toolCall.id, model: chunkModel, timestamp, delta: argsDelta, }) } } } const finishReason = choice.finish_reason if (finishReason) { if (finishReason === 'tool_calls' || toolCallsInProgress.size > 0) { for (const [, toolCall] of toolCallsInProgress) { if ( !toolCall.started || !toolCall.id || !toolCall.name || toolCall.ended ) { continue } const parsedInput = parseToolCallInput( toolCall, normalizeToolInput, ) toolCall.ended = true hasEmittedToolCall = true yield asChunk({ type: 'TOOL_CALL_END', toolCallId: toolCall.id, toolCallName: toolCall.name, model: chunkModel, timestamp, args: toolCall.arguments, input: parsedInput, }) } } const computedFinishReason = finishReason === 'tool_calls' || hasEmittedToolCall ? 'tool_calls' : finishReason === 'length' ? 'length' : 'stop' // If the run finished while reasoning was still open (no text or // tool output ever followed), close reasoning before TEXT/RUN // finalization events. if (reasoningMessageId !== null && !hasClosedReasoning) { hasClosedReasoning = true yield asChunk({ type: 'REASONING_MESSAGE_END', messageId: reasoningMessageId, model: chunkModel, timestamp, }) yield asChunk({ type: 'REASONING_END', messageId: reasoningMessageId, model: chunkModel, timestamp, }) } if (hasEmittedTextMessageStart && !hasEmittedTextMessageEnd) { hasEmittedTextMessageEnd = true yield asChunk({ type: 'TEXT_MESSAGE_END', messageId: aguiState.messageId, model: chunkModel, timestamp, }) } const usage = chunk.usage hasEmittedRunFinished = true yield asChunk({ type: 'RUN_FINISHED', ...(responseId && { responseId }), runId: aguiState.runId, threadId: aguiState.threadId, model: chunkModel, timestamp, usage: usage ? { promptTokens: usage.prompt_tokens || 0, completionTokens: usage.completion_tokens || 0, totalTokens: usage.total_tokens || 0, ...mistralCachedTokens(usage), } : undefined, finishReason: computedFinishReason, }) } } // Stream ended cleanly without finish_reason — flush any open // lifecycle events so consumers don't see orphaned starts. This // happens for abrupt `[DONE]` or upstream cuts. if (!hasEmittedRunFinished) { if (reasoningMessageId !== null && !hasClosedReasoning) { hasClosedReasoning = true yield asChunk({ type: 'REASONING_MESSAGE_END', messageId: reasoningMessageId, model: lastChunkModel, timestamp, }) yield asChunk({ type: 'REASONING_END', messageId: reasoningMessageId, model: lastChunkModel, timestamp, }) } for (const [, toolCall] of toolCallsInProgress) { if (toolCall.started && !toolCall.ended) { // Parse before marking ended, so a parse error still lets the // catch below send TOOL_CALL_END with the raw args. const parsedInput = parseToolCallInput(toolCall, normalizeToolInput) toolCall.ended = true hasEmittedToolCall = true yield asChunk({ type: 'TOOL_CALL_END', toolCallId: toolCall.id, toolCallName: toolCall.name, model: lastChunkModel, timestamp, args: toolCall.arguments, input: parsedInput, }) } } if (hasEmittedTextMessageStart && !hasEmittedTextMessageEnd) { hasEmittedTextMessageEnd = true yield asChunk({ type: 'TEXT_MESSAGE_END', messageId: aguiState.messageId, model: lastChunkModel, timestamp, }) } hasEmittedRunFinished = true yield asChunk({ type: 'RUN_FINISHED', ...(responseId && { responseId }), runId: aguiState.runId, threadId: aguiState.threadId, model: lastChunkModel, timestamp, usage: undefined, finishReason: hasEmittedToolCall ? 'tool_calls' : 'stop', }) } } catch (error: unknown) { // Lifecycle cleanup (TEXT_MESSAGE_END / TOOL_CALL_END / REASONING_END) // on error path so consumers don't see orphaned starts. RUN_ERROR is // emitted by the outer chatStream catch — emitting it here would // duplicate the event. if (reasoningMessageId !== null && !hasClosedReasoning) { hasClosedReasoning = true yield asChunk({ type: 'REASONING_MESSAGE_END', messageId: reasoningMessageId, model: lastChunkModel, timestamp, }) yield asChunk({ type: 'REASONING_END', messageId: reasoningMessageId, model: lastChunkModel, timestamp, }) } if (hasEmittedTextMessageStart && !hasEmittedTextMessageEnd) { hasEmittedTextMessageEnd = true yield asChunk({ type: 'TEXT_MESSAGE_END', messageId: aguiState.messageId, model: lastChunkModel, timestamp, }) } for (const [, toolCall] of toolCallsInProgress) { if (toolCall.started && !toolCall.ended) { toolCall.ended = true // Best-effort parse for the partial args. If invalid, leave input // out and send the raw args, so core reports the parse error. let partialInput: unknown try { partialInput = toolCall.arguments ? normalizeToolInput( toolCall.name, JSON.parse(toolCall.arguments), ) : {} } catch { partialInput = undefined } yield asChunk({ type: 'TOOL_CALL_END', toolCallId: toolCall.id, toolCallName: toolCall.name, model: lastChunkModel, timestamp, args: toolCall.arguments, ...(partialInput !== undefined && { input: partialInput }), }) } } throw error } } /** * Makes a raw fetch request to the Mistral chat completions endpoint and * parses the SSE stream manually, bypassing the SDK's Zod validation which * rejects streaming tool call chunks that omit `name` in argument deltas. */ private async *fetchRawMistralStream( body: Record, config: MistralClientConfig, wrapFetch: FetchWrapper | undefined, ): AsyncGenerator { const serverURL = ( config.baseURL ?? config.serverURL ?? 'https://api.mistral.ai' ) .replace(/\/+$/, '') .replace(/\/v1$/, '') const url = config.resolveRequestUrl?.(true) ?? `${serverURL}/v1/chat/completions` const accessToken = config.getAccessToken === undefined ? config.apiKey : await config.getAccessToken() const headers: Record = { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, ...config.defaultHeaders, } const send = wrapFetch ? wrapFetch(fetch) : fetch const response = await send(url, { method: 'POST', headers, body: JSON.stringify(body), }) if (!response.ok) { const errorText = await response.text() throw new Error(`Mistral API error ${response.status}: ${errorText}`) } if (!response.body) { throw new Error( 'Mistral API returned a response with no body. This may indicate a proxy or runtime that does not support streaming.', ) } const reader = response.body.getReader() const decoder = new TextDecoder() let buffer = '' try { for (;;) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const lines = buffer.split('\n') // split('\n') always yields at least one element, so pop() is a // string here; `?? ''` only satisfies the type narrowing. buffer = lines.pop() ?? '' for (const line of lines) { const trimmed = line.trim() if (!trimmed.startsWith('data:')) continue const data = trimmed.slice(5).trimStart() if (data === '[DONE]') return let parsed: unknown try { parsed = JSON.parse(data) } catch (e) { if (e instanceof SyntaxError) { console.warn( `[mistral] skipped unparseable SSE chunk: ${data.slice(0, 200)}`, ) continue } throw e } // Mistral signals mid-stream errors via an `error` field. Surface // them as RUN_ERROR rather than swallowing them as empty chunks. if ( parsed && typeof parsed === 'object' && 'error' in parsed && !('choices' in parsed) ) { const errPayload = parsed.error const message = typeof errPayload === 'string' ? errPayload : errPayload && typeof errPayload === 'object' && 'message' in errPayload ? String(errPayload.message) : JSON.stringify(errPayload) throw new Error(`Mistral stream error: ${message}`) } yield parsed as MistralRawChunk } } } finally { await reader.cancel().catch(() => {}) reader.releaseLock() } } /** * Converts the SDK's camelCase `ChatCompletionStreamRequest` into the * snake_case wire body, including converting messages. */ private toWireBody( params: ChatCompletionStreamRequest, ): Record { const { messages, maxTokens, topP, randomSeed, responseFormat, toolChoice, parallelToolCalls, frequencyPenalty, presencePenalty, safePrompt, reasoningEffort, promptMode, stream: _stream, ...rest } = params return { ...rest, messages: messages.map(messageToWire), stream: true, // Opt in to usage on the final streaming chunk. stream_options: { include_usage: true }, ...(maxTokens != null && { max_tokens: maxTokens }), ...(topP != null && { top_p: topP }), ...(randomSeed != null && { random_seed: randomSeed }), ...(responseFormat != null && { response_format: responseFormat }), ...(toolChoice != null && { tool_choice: toolChoice }), ...(parallelToolCalls != null && { parallel_tool_calls: parallelToolCalls, }), ...(frequencyPenalty != null && { frequency_penalty: frequencyPenalty }), ...(presencePenalty != null && { presence_penalty: presencePenalty }), ...(safePrompt != null && { safe_prompt: safePrompt }), ...(reasoningEffort != null && { reasoning_effort: reasoningEffort }), ...(promptMode != null && { prompt_mode: promptMode }), } } /** * Splits a Mistral delta content payload into text and reasoning deltas. * Mistral reasoning models (magistral-*) stream reasoning content as * `{ type: 'thinking', thinking: [{ type: 'text', text }, ...] }` content * parts. A single delta may contain text only, thinking only, or — rarely — * both (when a step transitions); both fields are returned so the caller * can sequence REASONING and TEXT lifecycle events in order. */ private extractDeltaParts( content: | string | Array<{ type: string text?: string thinking?: Array<{ type: string; text?: string }> }> | null | undefined, ): { text: string; thinking: string } { if (!content) return { text: '', thinking: '' } if (typeof content === 'string') return { text: content, thinking: '' } let text = '' let thinking = '' for (const part of content) { if (part.type === 'text' && typeof part.text === 'string') { text += part.text } else if (part.type === 'thinking' && Array.isArray(part.thinking)) { for (const inner of part.thinking) { if (inner.type === 'text' && typeof inner.text === 'string') { thinking += inner.text } } } } return { text, thinking } } /** * Maps common TextOptions to Mistral Chat Completions request parameters. */ private mapTextOptionsToMistral( options: TextOptions, ): ChatCompletionStreamRequest { const modelOptions = options.modelOptions as | Omit | undefined const tools = options.tools ? convertToolsToProviderFormat(options.tools) : undefined // History from another model loses its signatures, and its tool IDs are // rewritten to the 9-character IDs Mistral accepts. const replay = transformMessagesForReplay( options.messages, { provider: this.provider, api: this.api, model: options.model }, (id, { attempt }) => { const normalized = id.replace(/[^a-zA-Z0-9]/g, '') if (attempt === 0 && normalized.length === 9) return normalized const seed = normalized || id return hashToolCallId(attempt === 0 ? seed : `${seed}:${attempt}`) .replace(/[^a-zA-Z0-9]/g, '') .slice(0, 9) }, ) const messages: ChatCompletionStreamRequest['messages'] = [] if (options.systemPrompts && options.systemPrompts.length > 0) { messages.push({ role: 'system', content: options.systemPrompts.join('\n'), }) } for (const message of replay.messages) { messages.push(this.convertMessageToMistral(message)) } // `chat({ toolChoice })` is sent only when the request has tools. It goes // before the `modelOptions` spread, so a `tool_choice` there wins. const toolChoiceField = tools?.length && options.toolChoice !== undefined ? { toolChoice: toMistralToolChoice(options.toolChoice) } : undefined return { ...toolChoiceField, model: this.rawConfig.requestModel ?? options.model, messages: messages, temperature: modelOptions?.temperature ?? undefined, maxTokens: modelOptions?.max_tokens ?? undefined, topP: modelOptions?.top_p ?? undefined, tools: tools as ChatCompletionStreamRequest['tools'], stream: true, ...mistralReasoning( options.model, options.reasoning, MISTRAL_MODEL_REASONING[options.model], ), ...(modelOptions && { ...(modelOptions.stop != null && { stop: modelOptions.stop }), ...(modelOptions.random_seed != null && { randomSeed: modelOptions.random_seed, }), ...(modelOptions.response_format != null && { responseFormat: modelOptions.response_format, }), ...(modelOptions.tool_choice != null && { toolChoice: modelOptions.tool_choice, }), ...(modelOptions.parallel_tool_calls != null && { parallelToolCalls: modelOptions.parallel_tool_calls, }), ...(modelOptions.frequency_penalty != null && { frequencyPenalty: modelOptions.frequency_penalty, }), ...(modelOptions.presence_penalty != null && { presencePenalty: modelOptions.presence_penalty, }), ...(modelOptions.n != null && { n: modelOptions.n }), ...(modelOptions.prediction != null && { prediction: modelOptions.prediction, }), ...(modelOptions.safe_prompt != null && { safePrompt: modelOptions.safe_prompt, }), }), } } /** * Converts a TanStack AI ModelMessage to a Mistral ChatCompletionMessageParam. */ private convertMessageToMistral( message: ModelMessage, ): ChatCompletionStreamRequest['messages'][number] { if (message.role === 'tool') { if (!message.toolCallId) { throw new Error('Missing toolCallId for tool message') } return { role: 'tool', toolCallId: message.toolCallId, // Mistral accepts image chunks in a tool message, so send the parts. content: Array.isArray(message.content) ? message.content.map((part) => this.convertContentPartToMistral(part), ) : typeof message.content === 'string' ? message.content : JSON.stringify(message.content), } } if (message.role === 'assistant') { const toolCalls = message.toolCalls?.map((tc) => ({ id: tc.id, type: 'function' as const, function: { name: tc.function.name, arguments: typeof tc.function.arguments === 'string' ? tc.function.arguments : JSON.stringify(tc.function.arguments), }, })) return { role: 'assistant', content: this.assistantContent(message), ...(toolCalls && toolCalls.length > 0 ? { toolCalls } : {}), } } const contentParts = this.normalizeContent(message.content) if (contentParts.length === 1 && contentParts[0]?.type === 'text') { return { role: 'user', content: contentParts[0].content, } } const parts = contentParts.map((part) => this.convertContentPartToMistral(part), ) return { role: 'user', content: parts.length > 0 ? parts : '', } } /** Readable thinking and text, in the stored block order. */ private assistantContent(message: ModelMessage): AssistantMessage['content'] { if (!message.thinking?.length) return this.extractTextContent(message.content) const blocks = orderedAssistantBlocks(message) ?? [ ...message.thinking.map((thinking) => ({ type: 'thinking' as const, thinking, })), { type: 'text' as const, text: this.extractTextContent(message.content) }, ] const content: Array = [] for (const block of blocks) { if (block.type === 'text') content.push({ type: 'text', text: block.text }) else if ( block.type === 'thinking' && !block.thinking.redacted && block.thinking.content.trim() ) { content.push({ type: 'thinking', thinking: [{ type: 'text', text: block.thinking.content }], }) } } return content } /** * Converts a ContentPart to a Mistral content part. Returns undefined for * unsupported part types. */ private convertContentPartToMistral( part: ContentPart, ): ChatCompletionContentPart { if (part.type === 'text') { return { type: 'text', text: part.content } } if (part.type === 'image') { if (isFileSource(part.source)) { throw unsupportedFileSourceError('mistral') } const imageMetadata = part.metadata as MistralImageMetadata | undefined const imageValue = part.source.value const imageUrl = part.source.type === 'data' && !imageValue.startsWith('data:') ? `data:${part.source.mimeType};base64,${imageValue}` : imageValue return { type: 'image_url', imageUrl: imageMetadata?.detail ? { url: imageUrl, detail: imageMetadata.detail } : imageUrl, } } if (part.type === 'document') { if (isFileSource(part.source)) { throw unsupportedFileSourceError('mistral') } const documentValue = part.source.value const documentUrl = part.source.type === 'data' && !documentValue.startsWith('data:') ? `data:${part.source.mimeType};base64,${documentValue}` : documentValue return { type: 'document_url', documentUrl } } throw new Error( `Mistral text adapter does not support content part of type '${(part as ContentPart).type}'. Supported types: text, image, document. Use a vision-capable model (mistral-medium-latest or mistral-small-latest) for images and documents.`, ) } /** * Normalizes message content to an array of ContentPart. */ private normalizeContent( content: string | null | undefined | Array, ): Array { if (content === null || content === undefined) return [] if (typeof content === 'string') return [{ type: 'text', content }] return content } /** * Extracts text content from a content value that may be string, null, or ContentPart array. */ private extractTextContent( content: string | null | undefined | Array, ): string { if (content === null || content === undefined) return '' if (typeof content === 'string') return content return content .filter((p) => p.type === 'text') .map((p) => p.content) .join('') } } /** * Snake-cases a Mistral SDK message into the wire format expected by the API. */ function messageToWire(msg: ChatCompletionStreamRequest['messages'][number]) { if (msg.role === 'tool') { return { role: 'tool', tool_call_id: msg.toolCallId, content: Array.isArray(msg.content) ? msg.content.map(contentPartToWire) : msg.content, ...(msg.name !== undefined ? { name: msg.name } : {}), } } if (msg.role === 'assistant') { const base: Record = { role: 'assistant', content: msg.content ?? null, } if (msg.toolCalls && msg.toolCalls.length > 0) { base.tool_calls = msg.toolCalls.map((tc) => ({ id: tc.id, type: tc.type ?? 'function', function: tc.function, })) } if (msg.prefix !== undefined) base.prefix = msg.prefix return base } if (msg.role === 'user' && Array.isArray(msg.content)) { return { role: 'user', content: msg.content.map(contentPartToWire), } } return msg } /** * Snake-cases one content chunk of a user or tool message. */ function contentPartToWire(part: ContentChunk) { if (part.type === 'image_url') { return { type: 'image_url', image_url: part.imageUrl } } if (part.type === 'document_url') { return { type: 'document_url', document_url: part.documentUrl } } return part } /** * Creates a Mistral text adapter with explicit API key. * * @param model - The model name (e.g., 'mistral-large-latest') * @param apiKey - Your Mistral API key * @param config - Optional additional configuration * @returns Configured Mistral text adapter instance * * @example * ```typescript * const adapter = createMistralText('mistral-large-latest', 'api_key'); * ``` */ export function createMistralText< TModel extends (typeof MISTRAL_CHAT_MODELS)[number], >( model: TModel, apiKey: string, config?: Omit, ): MistralTextAdapter { return new MistralTextAdapter({ apiKey, ...config }, model) } /** * Creates a Mistral text adapter using the `MISTRAL_API_KEY` environment variable. * * @param model - The model name (e.g., 'mistral-large-latest') * @param config - Optional configuration (excluding apiKey) * @returns Configured Mistral text adapter instance * @throws Error if MISTRAL_API_KEY is not found in environment * * @example * ```typescript * const adapter = mistralText('mistral-large-latest'); * ``` */ export function mistralText< TModel extends (typeof MISTRAL_CHAT_MODELS)[number], >( model: TModel, config?: Omit, ): MistralTextAdapter { const apiKey = getMistralApiKeyFromEnv() return createMistralText(model, apiKey, config) }