diff --git a/packages/ai/src/protocols/index.ts b/packages/ai/src/protocols/index.ts index 00ee4538753..9660c08fda5 100644 --- a/packages/ai/src/protocols/index.ts +++ b/packages/ai/src/protocols/index.ts @@ -1,6 +1,7 @@ export * as AnthropicMessages from "./anthropic-messages.js" export * as BedrockConverse from "./bedrock-converse.js" export * as Gemini from "./gemini.js" +export * as MistralChat from "./mistral-chat.js" export * as OpenAIChat from "./openai-chat.js" export * as OpenAIImages from "./openai-images.js" export * as OpenAICompatibleChat from "./openai-compatible-chat.js" diff --git a/packages/ai/src/protocols/mistral-chat.ts b/packages/ai/src/protocols/mistral-chat.ts new file mode 100644 index 00000000000..8de7a4ee0e4 --- /dev/null +++ b/packages/ai/src/protocols/mistral-chat.ts @@ -0,0 +1,780 @@ +import { Effect, Schema } from "effect" +import { Auth } from "../route/auth.js" +import { Route } from "../route/client.js" +import { Endpoint } from "../route/endpoint.js" +import { Framing } from "../route/framing.js" +import { Protocol } from "../route/protocol.js" +import { HttpTransport } from "../route/transport/index.js" +import { + AIError, + InvalidProviderOutputError, + LLMEvent, + Usage, + type FinishReasonDetails, + type LLMRequest, + type MediaPart, + type ToolCallPart, + type ToolDefinition, +} from "../schema/index.js" +import { classifyProviderFailure } from "../provider-error.js" +import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js" +import { Lifecycle } from "./utils/lifecycle.js" +import { ToolStream } from "./utils/tool-stream.js" + +const ADAPTER = "mistral-chat" +const DONE = "[DONE]" as const +const TOOL_ID = /^[A-Za-z0-9]{9}$/ +export const DEFAULT_BASE_URL = "https://api.mistral.ai/v1" +export const PATH = "/chat/completions" + +const MistralTextContent = Schema.Struct({ + type: Schema.Literal("text"), + text: Schema.String, +}) + +const MistralThinkingUnit = Schema.StructWithRest( + Schema.Struct({ + type: Schema.optional(Schema.String), + text: Schema.optional(Schema.String), + }), + [Schema.Record(Schema.String, Schema.Unknown)], +) +type MistralThinkingUnit = Schema.Schema.Type + +const MistralThinkingContent = Schema.StructWithRest( + Schema.Struct({ + type: Schema.Literal("thinking"), + thinking: Schema.Array(MistralThinkingUnit), + }), + [Schema.Record(Schema.String, Schema.Unknown)], +) +type MistralThinkingContent = Schema.Schema.Type +const isMistralThinkingContent = Schema.is(MistralThinkingContent) + +const MistralUserContent = Schema.Union([ + MistralTextContent, + Schema.Struct({ type: Schema.Literal("image_url"), image_url: Schema.String }), + Schema.Struct({ type: Schema.Literal("document_url"), document_url: Schema.String }), +]) +type MistralUserContent = Schema.Schema.Type + +const MistralAssistantToolCall = Schema.Struct({ + id: Schema.String, + type: Schema.Literal("function"), + function: Schema.Struct({ name: Schema.String, arguments: Schema.String }), +}) +type MistralAssistantToolCall = Schema.Schema.Type + +const MistralMessage = Schema.Union([ + Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }), + Schema.Struct({ + role: Schema.Literal("user"), + content: Schema.Union([Schema.String, Schema.Array(MistralUserContent)]), + }), + Schema.Struct({ + role: Schema.Literal("assistant"), + content: Schema.Union([Schema.String, Schema.Array(Schema.Union([MistralTextContent, MistralThinkingContent]))]), + tool_calls: optionalArray(MistralAssistantToolCall), + prefix: Schema.optional(Schema.Literal(true)), + }), + Schema.Struct({ + role: Schema.Literal("tool"), + tool_call_id: Schema.String, + name: Schema.String, + content: Schema.Union([Schema.String, Schema.Array(MistralUserContent)]), + }), +]).pipe(Schema.toTaggedUnion("role")) +type MistralMessage = Schema.Schema.Type + +const MistralTool = Schema.Struct({ + type: Schema.Literal("function"), + function: Schema.Struct({ + name: Schema.String, + description: Schema.String, + parameters: JsonObject, + strict: Schema.Literal(false), + }), +}) +type MistralTool = Schema.Schema.Type + +const MistralOptions = Schema.Struct({ + safePrompt: Schema.optional(Schema.Boolean), + documentImageLimit: Schema.optional(Schema.Number), + documentPageLimit: Schema.optional(Schema.Number), + parallelToolCalls: Schema.optional(Schema.Boolean), + reasoningEffort: Schema.optional(Schema.String), + promptMode: Schema.optional(Schema.Literal("reasoning")), + promptCacheKey: Schema.optional(Schema.String), +}) + +export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | (string & {}) + +export type ProviderOptionsInput = { + readonly safePrompt?: boolean + readonly documentImageLimit?: number + readonly documentPageLimit?: number + readonly parallelToolCalls?: boolean + readonly reasoningEffort?: ReasoningEffort + readonly promptMode?: "reasoning" + readonly promptCacheKey?: string + readonly [key: string]: unknown +} + +const MistralBody = Schema.Struct({ + model: Schema.String, + messages: Schema.Array(MistralMessage), + tools: optionalArray(MistralTool), + tool_choice: Schema.optional( + Schema.Union([ + Schema.Literals(["auto", "none", "any"]), + Schema.Struct({ type: Schema.Literal("function"), function: Schema.Struct({ name: Schema.String }) }), + ]), + ), + stream: Schema.Literal(true), + max_tokens: Schema.optional(Schema.Number), + random_seed: Schema.optional(Schema.Number), + temperature: Schema.optional(Schema.Number), + top_p: Schema.optional(Schema.Number), + frequency_penalty: Schema.optional(Schema.Number), + presence_penalty: Schema.optional(Schema.Number), + stop: optionalArray(Schema.String), + prompt_cache_key: Schema.optional(Schema.String), + safe_prompt: Schema.optional(Schema.Boolean), + document_image_limit: Schema.optional(Schema.Number), + document_page_limit: Schema.optional(Schema.Number), + parallel_tool_calls: Schema.optional(Schema.Boolean), + reasoning_effort: Schema.optional(Schema.String), + prompt_mode: Schema.optional(Schema.Literal("reasoning")), +}) +export type MistralBody = Schema.Schema.Type + +const MistralUsageDetails = Schema.StructWithRest(Schema.Struct({ cached_tokens: optionalNull(Schema.Number) }), [ + Schema.Record(Schema.String, Schema.Unknown), +]) + +const MistralUsage = Schema.StructWithRest( + Schema.Struct({ + prompt_tokens: optionalNull(Schema.Number), + completion_tokens: optionalNull(Schema.Number), + total_tokens: optionalNull(Schema.Number), + num_cached_tokens: optionalNull(Schema.Number), + prompt_token_details: optionalNull(MistralUsageDetails), + prompt_tokens_details: optionalNull(MistralUsageDetails), + }), + [Schema.Record(Schema.String, Schema.Unknown)], +) + +const MistralOutputContent = Schema.StructWithRest( + Schema.Struct({ + type: Schema.String, + text: optionalNull(Schema.String), + thinking: optionalNull(Schema.Unknown), + }), + [Schema.Record(Schema.String, Schema.Unknown)], +) +type MistralOutputContent = Schema.Schema.Type + +const MistralToolDelta = Schema.Struct({ + index: optionalNull(Schema.Number), + id: optionalNull(Schema.String), + function: optionalNull( + Schema.Struct({ + name: optionalNull(Schema.String), + arguments: optionalNull(Schema.Union([Schema.String, JsonObject])), + }), + ), +}) +type MistralToolDelta = Schema.Schema.Type + +const MistralChoice = Schema.StructWithRest( + Schema.Struct({ + delta: optionalNull( + Schema.StructWithRest( + Schema.Struct({ + content: optionalNull(Schema.Union([Schema.String, Schema.Array(MistralOutputContent)])), + tool_calls: optionalNull(Schema.Array(MistralToolDelta)), + }), + [Schema.Record(Schema.String, Schema.Unknown)], + ), + ), + finish_reason: optionalNull(Schema.String), + }), + [Schema.Record(Schema.String, Schema.Unknown)], +) + +const MistralError = Schema.StructWithRest( + Schema.Struct({ + message: Schema.String, + code: optionalNull(Schema.Union([Schema.String, Schema.Number])), + }), + [Schema.Record(Schema.String, Schema.Unknown)], +) + +const MistralEvent = Schema.StructWithRest( + Schema.Struct({ + choices: optionalNull(Schema.Array(MistralChoice)), + usage: optionalNull(MistralUsage), + error: optionalNull(MistralError), + }), + [Schema.Record(Schema.String, Schema.Unknown)], +) +type MistralEvent = Schema.Schema.Type +const MistralStreamEvent = Schema.Union([Schema.Literal(DONE), Protocol.jsonEvent(MistralEvent)]) + +const hashID = (value: string) => { + const hash = (seed: number) => { + let result = seed + for (const char of value) result = Math.imul(result ^ char.charCodeAt(0), 16777619) + return (result >>> 0).toString(36) + } + return `${hash(2166136261).padStart(7, "0")}${hash(2246822519).padStart(7, "0")}`.slice(-9) +} + +const toolIDNormalizer = (request: LLMRequest) => { + const ids = request.messages.flatMap((message) => + message.content.flatMap((part) => (part.type === "tool-call" || part.type === "tool-result" ? [part.id] : [])), + ) + const used = new Set(ids.filter((id) => TOOL_ID.test(id))) + const normalized = new Map() + return (id: string) => { + if (TOOL_ID.test(id)) return id + const previous = normalized.get(id) + if (previous) return previous + let attempt = 0 + let candidate = hashID(id) + while (used.has(candidate)) candidate = hashID(`${id}:${++attempt}`) + used.add(candidate) + normalized.set(id, candidate) + return candidate + } +} + +const lowerMedia = Effect.fn("MistralChat.lowerMedia")(function* (part: MediaPart) { + const media = ProviderShared.normalizeMedia(part) + const url = typeof part.data === "string" && /^(?:https?:|data:)/.test(part.data) ? part.data : media.dataUrl + if (media.mime.startsWith("image/")) return { type: "image_url" as const, image_url: url } + if (media.mime === "application/pdf") return { type: "document_url" as const, document_url: url } + return yield* ProviderShared.invalidRequest(`Mistral Chat does not support media type ${part.mediaType}`) +}) + +const lowerUser = Effect.fn("MistralChat.lowerUser")(function* (message: LLMRequest["messages"][number]) { + const content: MistralUserContent[] = [] + for (const part of message.content) { + if (part.type === "text") { + content.push({ type: "text", text: part.text }) + continue + } + if (part.type === "media") { + content.push(yield* lowerMedia(part)) + continue + } + return yield* ProviderShared.unsupportedContent("Mistral Chat", "user", ["text", "media"]) + } + if (content.every((part) => part.type === "text")) + return { role: "user" as const, content: content.map((part) => part.text).join("") } + return { role: "user" as const, content } +}) + +const lowerToolCall = (part: ToolCallPart, normalizeID: (id: string) => string): MistralAssistantToolCall => ({ + id: normalizeID(part.id), + type: "function", + function: { name: part.name, arguments: ProviderShared.encodeJson(part.input) }, +}) + +const lowerAssistant = Effect.fn("MistralChat.lowerAssistant")(function* ( + message: LLMRequest["messages"][number], + normalizeID: (id: string) => string, + prefix: boolean, +) { + const structured = message.content.some( + (part) => part.type === "reasoning" && isMistralThinkingContent(part.providerMetadata?.mistral?.thinking), + ) + const content: Array | MistralThinkingContent> = [] + const text: string[] = [] + const toolCalls: MistralAssistantToolCall[] = [] + for (const part of message.content) { + if (part.type === "text") { + if (structured) content.push({ type: "text", text: part.text }) + else text.push(part.text) + continue + } + if (part.type === "reasoning") { + const native = part.providerMetadata?.mistral?.thinking + if (structured && isMistralThinkingContent(native)) content.push(native) + else if (structured) content.push({ type: "text", text: part.text }) + else text.push(part.text) + continue + } + if (part.type === "tool-call") { + toolCalls.push(lowerToolCall(part, normalizeID)) + continue + } + return yield* ProviderShared.unsupportedContent("Mistral Chat", "assistant", ["text", "reasoning", "tool-call"]) + } + return { + role: "assistant" as const, + content: structured ? content : text.join(""), + ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}), + ...(prefix ? { prefix: true as const } : {}), + } +}) + +const lowerToolResults = Effect.fn("MistralChat.lowerToolResults")(function* ( + message: LLMRequest["messages"][number], + normalizeID: (id: string) => string, +) { + const output: MistralMessage[] = [] + for (const part of message.content) { + if (part.type !== "tool-result") + return yield* ProviderShared.unsupportedContent("Mistral Chat", "tool", ["tool-result"]) + if (part.result.type !== "content") { + output.push({ + role: "tool", + tool_call_id: normalizeID(part.id), + name: part.name, + content: ProviderShared.toolResultText(part), + }) + continue + } + const content: MistralUserContent[] = [] + for (const item of part.result.value) { + if (item.type === "text") { + content.push({ type: "text", text: item.text }) + continue + } + content.push(yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name })) + } + output.push({ + role: "tool", + tool_call_id: normalizeID(part.id), + name: part.name, + content: content.some((item) => item.type !== "text") + ? content + : content.map((item) => (item.type === "text" ? item.text : "")).join(""), + }) + } + return output +}) + +const lowerMessages = Effect.fn("MistralChat.lowerMessages")(function* (request: LLMRequest) { + const normalizeID = toolIDNormalizer(request) + const messages: MistralMessage[] = + request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }] + for (const message of request.messages) { + if (message.role === "system") { + const update = yield* ProviderShared.wrappedSystemUpdate("Mistral Chat", message) + messages.push({ + role: "user", + content: update.text, + }) + continue + } + if (message.role === "user") { + messages.push(yield* lowerUser(message)) + continue + } + if (message.role === "assistant") { + const hasToolCalls = message.content.some((part) => part.type === "tool-call") + const hasNativeThinking = message.content.some( + (part) => part.type === "reasoning" && isMistralThinkingContent(part.providerMetadata?.mistral?.thinking), + ) + const text = message.content + .flatMap((part) => (part.type === "text" || part.type === "reasoning" ? [part.text] : [])) + .join("") + if (!hasToolCalls && !hasNativeThinking && text.trim() === "") continue + messages.push(yield* lowerAssistant(message, normalizeID, !hasToolCalls && message === request.messages.at(-1))) + continue + } + messages.push(...(yield* lowerToolResults(message, normalizeID))) + } + return messages +}) + +const lowerTool = (tool: ToolDefinition): MistralTool => ({ + type: "function", + function: { name: tool.name, description: tool.description, parameters: tool.inputSchema, strict: false }, +}) + +export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (request: LLMRequest) { + const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(MistralOptions))( + request.providerOptions ?? {}, + ) + const selected = request.toolChoice?.type === "tool" ? request.toolChoice.name : undefined + if (request.toolChoice?.type === "tool" && !selected) + return yield* ProviderShared.invalidRequest("Mistral Chat tool choice requires a tool name") + if (options.reasoningEffort !== undefined && options.promptMode !== undefined) + return yield* ProviderShared.invalidRequest( + "Mistral Chat reasoningEffort and promptMode provider options are mutually exclusive", + ) + const toolChoice = request.toolChoice + ? yield* ProviderShared.matchToolChoice("Mistral Chat", request.toolChoice, { + auto: () => "auto" as const, + none: () => "none" as const, + required: () => "any" as const, + tool: (name) => ({ type: "function" as const, function: { name } }), + }) + : undefined + return { + model: request.model.id, + messages: yield* lowerMessages(request), + tools: request.tools.length > 0 ? request.tools.map(lowerTool) : undefined, + tool_choice: toolChoice, + stream: true as const, + max_tokens: request.generation?.maxTokens, + random_seed: request.generation?.seed, + temperature: request.generation?.temperature, + top_p: request.generation?.topP, + frequency_penalty: request.generation?.frequencyPenalty, + presence_penalty: request.generation?.presencePenalty, + stop: request.generation?.stop, + prompt_cache_key: request.cache === "none" ? undefined : (options.promptCacheKey ?? request.promptCacheKey), + safe_prompt: options.safePrompt, + document_image_limit: options.documentImageLimit, + document_page_limit: options.documentPageLimit, + parallel_tool_calls: + options.parallelToolCalls ?? (request.toolChoice?.disableParallelToolUse === true ? false : undefined), + reasoning_effort: options.reasoningEffort, + prompt_mode: options.promptMode, + } +}) + +type ToolKey = string | number +interface PendingTool { + readonly id: string + readonly name?: string + readonly input: string +} + +interface ActiveContent { + readonly type: "text" | "reasoning" + readonly id: string + readonly thinking?: MistralThinkingContent +} + +export interface ParserState { + readonly tools: ToolStream.State + readonly pendingTools: Partial> + readonly toolIDs: ReadonlyMap + readonly usedToolIDs: ReadonlySet + readonly completedTools: ReadonlyArray + readonly latestToolKey?: ToolKey + readonly generatedTools: number + readonly lifecycle: Lifecycle.State + readonly active?: ActiveContent + readonly nextContent: number + readonly usage?: Usage + readonly finishReason?: FinishReasonDetails +} + +const mapUsage = (usage: MistralEvent["usage"]): Usage | undefined => { + if (!usage) return undefined + const input = usage.prompt_tokens ?? undefined + const reported = + usage.num_cached_tokens ?? + usage.prompt_tokens_details?.cached_tokens ?? + usage.prompt_token_details?.cached_tokens ?? + undefined + const cached = input === undefined || reported === undefined ? undefined : Math.max(0, Math.min(input, reported)) + const output = usage.completion_tokens ?? undefined + return new Usage({ + inputTokens: input, + outputTokens: output, + nonCachedInputTokens: ProviderShared.subtractTokens(input, cached), + cacheReadInputTokens: cached, + totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined), + providerMetadata: { mistral: usage }, + }) +} + +const mapFinishReason = (reason: string) => { + switch (reason) { + case "stop": + return "stop" as const + case "length": + case "model_length": + return "length" as const + case "tool_calls": + return "tool-calls" as const + case "content_filter": + return "content-filter" as const + case "error": + case "network_error": + return "error" as const + default: + return "unknown" as const + } +} + +const thinkingUnits = (value: unknown): ReadonlyArray => { + if (typeof value === "string") return [{ type: "text", text: value }] + if (!Array.isArray(value)) return [] + return value.filter(Schema.is(MistralThinkingUnit)) +} + +const thinkingText = (thinking: ReadonlyArray) => + thinking.flatMap((unit) => (typeof unit.text === "string" ? [unit.text] : [])).join("") + +const thinkingMetadata = (thinking: MistralThinkingContent) => ({ mistral: { thinking } }) + +const closeActive = (state: ParserState, events: LLMEvent[]) => { + if (!state.active) return state + const lifecycle = + state.active.type === "text" + ? Lifecycle.textEnd(state.lifecycle, events, state.active.id) + : Lifecycle.reasoningEnd( + state.lifecycle, + events, + state.active.id, + thinkingMetadata(state.active.thinking ?? { type: "thinking", thinking: [] }), + thinkingText(state.active.thinking?.thinking ?? []), + ) + return { ...state, lifecycle, active: undefined } +} + +const appendText = (state: ParserState, events: LLMEvent[], text: string) => { + if (text.length === 0) return state + const current = state.active?.type === "text" ? state : closeActive(state, events) + const active = current.active ?? { type: "text" as const, id: `text-${current.nextContent}` } + return { + ...current, + lifecycle: Lifecycle.textDelta(current.lifecycle, events, active.id, text), + active, + nextContent: current.active ? current.nextContent : current.nextContent + 1, + } +} + +const appendThinking = (state: ParserState, events: LLMEvent[], part: MistralOutputContent) => { + const current = state.active?.type === "reasoning" ? state : closeActive(state, events) + const units = thinkingUnits(part.thinking) + const active = current.active ?? { type: "reasoning" as const, id: `reasoning-${current.nextContent}` } + const thinking = { + ...active.thinking, + ...part, + type: "thinking" as const, + thinking: [...(active.thinking?.thinking ?? []), ...units], + } + const text = thinkingText(units) + return { + ...current, + lifecycle: + text.length > 0 + ? Lifecycle.reasoningDelta(current.lifecycle, events, active.id, text, thinkingMetadata(thinking)) + : Lifecycle.reasoningStart(current.lifecycle, events, active.id, thinkingMetadata(thinking)), + active: { ...active, thinking }, + nextContent: current.active ? current.nextContent : current.nextContent + 1, + } +} + +const appendContent = ( + state: ParserState, + events: LLMEvent[], + content: string | ReadonlyArray, +) => { + if (typeof content === "string") return appendText(state, events, content) + return content.reduce((current, part) => { + if (part.type === "text") return appendText(current, events, part.text ?? "") + if (part.type === "thinking") return appendThinking(current, events, part) + return closeActive(current, events) + }, state) +} + +const normalizeStreamToolID = (state: ParserState, source: string) => { + if (TOOL_ID.test(source)) + return { id: source, state: { ...state, usedToolIDs: new Set([...state.usedToolIDs, source]) } } + const previous = state.toolIDs.get(source) + if (previous) return { id: previous, state } + let attempt = 0 + let id = hashID(source) + while (state.usedToolIDs.has(id)) id = hashID(`${source}:${++attempt}`) + return { + id, + state: { + ...state, + toolIDs: new Map([...state.toolIDs, [source, id]]), + usedToolIDs: new Set([...state.usedToolIDs, id]), + }, + } +} + +const toolText = (tool: MistralToolDelta) => { + const value = tool.function?.arguments + if (typeof value === "string") return value + return value === null || value === undefined ? "" : ProviderShared.encodeJson(value) +} + +const appendTools = Effect.fn("MistralChat.appendTools")(function* ( + initial: ParserState, + events: LLMEvent[], + deltas: ReadonlyArray, +) { + if (deltas.length === 0) return initial + let state = closeActive(initial, events) + for (const [position, delta] of deltas.entries()) { + const wireID = delta.id?.trim() || undefined + const providedID = wireID === "null" ? undefined : wireID + const key = + delta.index ?? + (providedID + ? `id:${providedID}` + : deltas.length > 1 + ? `position:${position}` + : (state.latestToolKey ?? `missing:${state.generatedTools}`)) + const existing = state.tools[key] + const pending = state.pendingTools[key] + const source = providedID ?? `generated:${String(key)}` + const normalized = + existing || pending ? { id: existing?.id ?? pending?.id ?? "", state } : normalizeStreamToolID(state, source) + state = normalized.state + const name = existing?.name ?? pending?.name ?? (delta.function?.name?.trim() || undefined) + const text = `${pending?.input ?? ""}${toolText(delta)}` + if (!name) { + state = { + ...state, + pendingTools: { ...state.pendingTools, [key]: { id: normalized.id, input: text } }, + latestToolKey: key, + generatedTools: state.generatedTools + (!providedID && !pending ? 1 : 0), + } + continue + } + const result = ToolStream.appendOrStart( + ADAPTER, + state.tools, + key, + { id: normalized.id, name, text }, + "Mistral Chat tool call delta is missing a name", + ) + if (ToolStream.isError(result)) return yield* result + if (result.events.length > 0) state = { ...state, lifecycle: Lifecycle.stepStart(state.lifecycle, events) } + events.push(...result.events) + const pendingTools = { ...state.pendingTools } + delete pendingTools[key] + state = { + ...state, + tools: result.tools, + pendingTools, + latestToolKey: key, + generatedTools: state.generatedTools + (!providedID && !existing && !pending ? 1 : 0), + } + } + return state +}) + +const hasLateContent = (event: MistralEvent) => { + const delta = event.choices?.[0]?.delta + if (typeof delta?.content === "string" && delta.content.length > 0) return true + if (Array.isArray(delta?.content) && delta.content.length > 0) return true + return (delta?.tool_calls ?? []).some( + (tool) => Boolean(tool.id) || Boolean(tool.function?.name) || tool.function?.arguments !== undefined, + ) +} + +const step = Effect.fn("MistralChat.step")(function* (state: ParserState, event: MistralEvent) { + if (event.error) { + const body = ProviderShared.encodeJson(event) + return yield* new AIError({ + reason: classifyProviderFailure({ + message: event.error.message, + status: typeof event.error.code === "number" ? event.error.code : undefined, + rawBody: body, + }), + }) + } + const events: LLMEvent[] = [] + const usage = mapUsage(event.usage) ?? state.usage + if (state.finishReason) { + if (hasLateContent(event)) + return yield* ProviderShared.eventError( + ADAPTER, + "Mistral Chat received content after the finish reason", + ProviderShared.encodeJson(event), + ) + return [{ ...state, usage }, events] as const + } + const choice = event.choices?.[0] + const withContent = choice?.delta?.content == null ? state : appendContent(state, events, choice.delta.content) + const withTools = yield* appendTools(withContent, events, choice?.delta?.tool_calls ?? []) + if (!choice?.finish_reason) return [{ ...withTools, usage }, events] as const + + const finishReason = { + normalized: mapFinishReason(choice.finish_reason), + raw: choice.finish_reason, + } + const incomplete = finishReason.normalized === "length" || finishReason.normalized === "content-filter" + if (!incomplete && Object.keys(withTools.pendingTools).length > 0) + return yield* ProviderShared.eventError( + ADAPTER, + "Mistral Chat tool call delta is missing a name", + ProviderShared.encodeJson(event), + ) + const finished = + !incomplete && Object.keys(withTools.tools).length > 0 + ? yield* ToolStream.finishAll(ADAPTER, withTools.tools) + : undefined + return [ + { + ...withTools, + tools: finished?.tools ?? withTools.tools, + completedTools: finished?.events ?? withTools.completedTools, + usage, + finishReason, + }, + events, + ] as const +}) + +const finishEvents = Effect.fn("MistralChat.finishEvents")(function* (state: ParserState) { + if (!state.finishReason) + return yield* new AIError({ + reason: new InvalidProviderOutputError({ + message: "Mistral Chat stream ended without finish_reason", + classification: "incomplete-stream", + route: ADAPTER, + }), + }) + const events: LLMEvent[] = [] + const closed = closeActive(state, events) + const lifecycle = closed.completedTools.length > 0 ? Lifecycle.stepStart(closed.lifecycle, events) : closed.lifecycle + events.push(...closed.completedTools) + const reason = + state.finishReason.normalized === "stop" && closed.completedTools.some(LLMEvent.is.toolCall) + ? { ...state.finishReason, normalized: "tool-calls" as const } + : state.finishReason + Lifecycle.finish(lifecycle, events, { reason, usage: closed.usage }) + return events +}) + +export const protocol = Protocol.make({ + id: ADAPTER, + body: { schema: MistralBody, from: fromRequest }, + stream: { + event: MistralStreamEvent, + initial: (): ParserState => ({ + tools: ToolStream.empty(), + pendingTools: {}, + toolIDs: new Map(), + usedToolIDs: new Set(), + completedTools: [], + generatedTools: 0, + lifecycle: Lifecycle.initial(), + nextContent: 0, + }), + step: (state: ParserState, event) => (event === DONE ? Effect.succeed([state, []] as const) : step(state, event)), + terminal: (event) => event === DONE, + onHalt: finishEvents, + }, +}) + +export const framing = Framing.sseWithDone +export const httpTransport = HttpTransport.sseJson.with().with({ framing }) + +export const route = Route.make({ + id: ADAPTER, + provider: "mistral", + providerMetadataKey: "mistral", + protocol, + endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }), + auth: Auth.none, + transport: httpTransport, +}) + +export * as MistralChat from "./mistral-chat.js" diff --git a/packages/ai/src/providers/index.ts b/packages/ai/src/providers/index.ts index 5321cd00da2..5b4854dbd23 100644 --- a/packages/ai/src/providers/index.ts +++ b/packages/ai/src/providers/index.ts @@ -13,6 +13,7 @@ export * as GoogleVertexChat from "./google-vertex-chat.js" export * as GoogleVertexMessages from "./google-vertex-messages.js" export * as GoogleVertexResponses from "./google-vertex-responses.js" export * as Groq from "./groq.js" +export * as Mistral from "./mistral.js" export * as OpenAI from "./openai.js" export * as OpenAICompatible from "./openai-compatible.js" export * as OpenAICompatibleResponses from "./openai-compatible-responses.js" diff --git a/packages/ai/src/providers/mistral.ts b/packages/ai/src/providers/mistral.ts new file mode 100644 index 00000000000..5103e4a7668 --- /dev/null +++ b/packages/ai/src/providers/mistral.ts @@ -0,0 +1,51 @@ +import type { ProviderPackage } from "../provider-package.js" +import { MistralChat } from "../protocols/mistral-chat.js" +import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js" +import type { RouteDefaultsInput } from "../route/client.js" +import { ProviderID, type ModelID } from "../schema/index.js" + +export const id = ProviderID.make("mistral") + +export type ProviderOptions = MistralChat.ProviderOptionsInput + +export type LanguageModelOptions = Omit & + ProviderAuthOption<"optional"> & { + readonly baseURL?: string + readonly providerOptions?: ProviderOptions + } + +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL?: string + readonly providerOptions?: ProviderOptions +} + +export const route = MistralChat.route +export const routes = [route] + +export const configure = (input: LanguageModelOptions = {}) => { + const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input + const configured = route.with({ + ...defaults, + endpoint: { baseURL: baseURL ?? MistralChat.DEFAULT_BASE_URL }, + auth: AuthOptions.bearer(input, "MISTRAL_API_KEY"), + }) + return { + id, + model: (modelID: string | ModelID) => configured.model({ id: modelID }), + configure, + } +} + +export const provider = configure() + +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => + configure({ + apiKey: settings.apiKey, + baseURL: settings.baseURL, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + providerOptions: settings.providerOptions, + }).model(modelID) + +export * as Mistral from "./mistral.js" diff --git a/packages/ai/test/fixtures/recordings/mistral-chat-glm/streams-an-indexed-tool-call.json b/packages/ai/test/fixtures/recordings/mistral-chat-glm/streams-an-indexed-tool-call.json new file mode 100644 index 00000000000..e8a0fe53227 --- /dev/null +++ b/packages/ai/test/fixtures/recordings/mistral-chat-glm/streams-an-indexed-tool-call.json @@ -0,0 +1,36 @@ +{ + "version": 1, + "metadata": { + "model": "zai-glm-5-2", + "tags": [ + "prefix:mistral-chat-glm", + "provider:mistral", + "protocol:mistral-chat", + "hosted-model", + "tool", + "tool-call" + ], + "name": "mistral-chat-glm/streams-an-indexed-tool-call", + "recordedAt": "2026-08-30T17:38:02.921Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.mistral.ai/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"zai-glm-5-2\",\"messages\":[{\"role\":\"system\",\"content\":\"Call lookup_weather exactly once with Paris.\"},{\"role\":\"user\",\"content\":\"What is the weather?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\"}},\"stream\":true,\"max_tokens\":256,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"f139bf0e4b984e51aabf6a83c237674d\",\"object\":\"chat.completion.chunk\",\"created\":1788111482,\"model\":\"zai-glm-5-2\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"f139bf0e4b984e51aabf6a83c237674d\",\"object\":\"chat.completion.chunk\",\"created\":1788111482,\"model\":\"zai-glm-5-2\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"index\":0,\"content\":\"\"},\"finish_reason\":null,\"logprobs\":null}]}\n\ndata: {\"id\":\"f139bf0e4b984e51aabf6a83c237674d\",\"object\":\"chat.completion.chunk\",\"created\":1788111482,\"model\":\"zai-glm-5-2\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"chatcmpl-tool-8cc4d8f9f07b298a\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\": \\\"\"},\"index\":0}],\"index\":0,\"content\":\"\"},\"finish_reason\":null,\"logprobs\":null}]}\n\ndata: {\"id\":\"f139bf0e4b984e51aabf6a83c237674d\",\"object\":\"chat.completion.chunk\",\"created\":1788111482,\"model\":\"zai-glm-5-2\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"type\":\"function\",\"function\":{\"name\":\"\",\"arguments\":\"Paris\\\"}\"},\"index\":0}],\"index\":0,\"content\":\"\"},\"finish_reason\":null,\"logprobs\":null}]}\n\ndata: {\"id\":\"f139bf0e4b984e51aabf6a83c237674d\",\"object\":\"chat.completion.chunk\",\"created\":1788111482,\"model\":\"zai-glm-5-2\",\"choices\":[{\"index\":0,\"delta\":{\"index\":0,\"content\":\"\"},\"finish_reason\":\"stop\",\"logprobs\":null}],\"usage\":{\"prompt_tokens\":171,\"total_tokens\":182,\"completion_tokens\":11,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/ai/test/fixtures/recordings/mistral-chat/drives-a-tool-loop.json b/packages/ai/test/fixtures/recordings/mistral-chat/drives-a-tool-loop.json new file mode 100644 index 00000000000..29ee5f01645 --- /dev/null +++ b/packages/ai/test/fixtures/recordings/mistral-chat/drives-a-tool-loop.json @@ -0,0 +1,47 @@ +{ + "version": 1, + "metadata": { + "model": "mistral-small-latest", + "tags": ["prefix:mistral-chat", "provider:mistral", "protocol:mistral-chat", "tool", "tool-loop", "usage"], + "name": "mistral-chat/drives-a-tool-loop", + "recordedAt": "2026-08-30T17:18:49.552Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.mistral.ai/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":\"Call lookup_weather exactly once with Paris.\"},{\"role\":\"user\",\"content\":\"What is the weather?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\"}},\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"07491e37a5ed48f9987f1583753a466b\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"07491e37a5ed48f9987f1583753a466b\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"ffJovBNqY\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\": \\\"Paris\\\"}\"},\"index\":0}]},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":110,\"total_tokens\":122,\"completion_tokens\":12,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklm\"}\n\ndata: [DONE]\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.mistral.ai/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":\"Call lookup_weather exactly once with Paris.\"},{\"role\":\"user\",\"content\":\"What is the weather?\"},{\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"id\":\"ffJovBNqY\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"ffJovBNqY\",\"name\":\"lookup_weather\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\",\\\"temperature\\\":\\\"18C\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather in Paris is\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" currently sunny with\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstu\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a temperature of \"},\"finish_reason\":null}],\"p\":\"abcdef\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"18°C\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqr\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":57,\"total_tokens\":74,\"completion_tokens\":17,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklmnopqrstuvwxyz\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/ai/test/fixtures/recordings/mistral-chat/replays-native-reasoning.json b/packages/ai/test/fixtures/recordings/mistral-chat/replays-native-reasoning.json new file mode 100644 index 00000000000..e29cf2a1d93 --- /dev/null +++ b/packages/ai/test/fixtures/recordings/mistral-chat/replays-native-reasoning.json @@ -0,0 +1,47 @@ +{ + "version": 1, + "metadata": { + "model": "mistral-small-latest", + "tags": ["prefix:mistral-chat", "provider:mistral", "protocol:mistral-chat", "reasoning", "replay", "usage"], + "name": "mistral-chat/replays-native-reasoning", + "recordedAt": "2026-08-30T17:18:48.108Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.mistral.ai/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"user\",\"content\":\"Calculate 17 multiplied by 23. Think briefly, then reply with only the integer.\"}],\"stream\":true,\"max_tokens\":512,\"temperature\":0,\"reasoning_effort\":\"high\"}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\"},\"finish_reason\":null}],\"p\":\"abcde\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\"Okay, I\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01234\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" need to calculate \"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\"17 multiplied by\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuv\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" 23.\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvw\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" Let me think about\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxy\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" how\"}]}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" to do this efficiently\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqr\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\".\\n\\nFirst, I\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" recall that multiplication\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz012345678\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" can\"}]}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstu\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" be broken down using\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" the distributive property\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklm\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" of\"}]}]},\"finish_reason\":null}],\"p\":\"abc\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" multiplication over addition.\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdef\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" So, I\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz0123456\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" can think\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdef\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" of 2\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz0123456\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\"3 as 2\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"a\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\"0 + 3\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvw\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\".\\n\\n\"}]}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01234\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\"Now\"}]}]},\"finish_reason\":null}],\"p\":\"ab\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\", I can multiply\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwx\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" 17 by\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijk\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" \"}]}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01234567\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\"20 and \"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmno\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\"17 by \"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijkl\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\"3 separately\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwx\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\", and then add\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdef\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" the results.\\n\\n1\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcd\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\". 17\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrst\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" × 20\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" = 34\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrst\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\"0\\n2.\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghij\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" 17 ×\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvw\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" 3 = \"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01234567\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\"51\\n\\n\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"ab\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\"Now, add these\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijkl\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" two results together:\\n\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz0123456\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\"340 +\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz012345\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" 51 =\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwx\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" 3\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxy\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\"91\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz0\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\"\\n\\nSo, \"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwx\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\"17 multiplied by\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01234567\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" 23 is\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnop\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" 391\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrst\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\".\"}],\"closed\":true},{\"type\":\"text\",\"text\":\"3\"}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01234\"}\n\ndata: {\"id\":\"2adec71befd044299e24147a4e54e6f1\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"91\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":35,\"total_tokens\":195,\"completion_tokens\":160,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abc\"}\n\ndata: [DONE]\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.mistral.ai/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"user\",\"content\":\"Calculate 17 multiplied by 23. Think briefly, then reply with only the integer.\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\"Okay, I\"},{\"type\":\"text\",\"text\":\" need to calculate \"},{\"type\":\"text\",\"text\":\"17 multiplied by\"},{\"type\":\"text\",\"text\":\" 23.\"},{\"type\":\"text\",\"text\":\" Let me think about\"},{\"type\":\"text\",\"text\":\" how\"},{\"type\":\"text\",\"text\":\" to do this efficiently\"},{\"type\":\"text\",\"text\":\".\\n\\nFirst, I\"},{\"type\":\"text\",\"text\":\" recall that multiplication\"},{\"type\":\"text\",\"text\":\" can\"},{\"type\":\"text\",\"text\":\" be broken down using\"},{\"type\":\"text\",\"text\":\" the distributive property\"},{\"type\":\"text\",\"text\":\" of\"},{\"type\":\"text\",\"text\":\" multiplication over addition.\"},{\"type\":\"text\",\"text\":\" So, I\"},{\"type\":\"text\",\"text\":\" can think\"},{\"type\":\"text\",\"text\":\" of 2\"},{\"type\":\"text\",\"text\":\"3 as 2\"},{\"type\":\"text\",\"text\":\"0 + 3\"},{\"type\":\"text\",\"text\":\".\\n\\n\"},{\"type\":\"text\",\"text\":\"Now\"},{\"type\":\"text\",\"text\":\", I can multiply\"},{\"type\":\"text\",\"text\":\" 17 by\"},{\"type\":\"text\",\"text\":\" \"},{\"type\":\"text\",\"text\":\"20 and \"},{\"type\":\"text\",\"text\":\"17 by \"},{\"type\":\"text\",\"text\":\"3 separately\"},{\"type\":\"text\",\"text\":\", and then add\"},{\"type\":\"text\",\"text\":\" the results.\\n\\n1\"},{\"type\":\"text\",\"text\":\". 17\"},{\"type\":\"text\",\"text\":\" × 20\"},{\"type\":\"text\",\"text\":\" = 34\"},{\"type\":\"text\",\"text\":\"0\\n2.\"},{\"type\":\"text\",\"text\":\" 17 ×\"},{\"type\":\"text\",\"text\":\" 3 = \"},{\"type\":\"text\",\"text\":\"51\\n\\n\"},{\"type\":\"text\",\"text\":\"Now, add these\"},{\"type\":\"text\",\"text\":\" two results together:\\n\"},{\"type\":\"text\",\"text\":\"340 +\"},{\"type\":\"text\",\"text\":\" 51 =\"},{\"type\":\"text\",\"text\":\" 3\"},{\"type\":\"text\",\"text\":\"91\"},{\"type\":\"text\",\"text\":\"\\n\\nSo, \"},{\"type\":\"text\",\"text\":\"17 multiplied by\"},{\"type\":\"text\",\"text\":\" 23 is\"},{\"type\":\"text\",\"text\":\" 391\"},{\"type\":\"text\",\"text\":\".\"}],\"closed\":true},{\"type\":\"text\",\"text\":\"391\"}]},{\"role\":\"user\",\"content\":\"Reply with exactly: Done.\"}],\"stream\":true,\"max_tokens\":256,\"temperature\":0,\"reasoning_effort\":\"high\"}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\"The\"}]}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz0\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" user wants\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqr\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" me to calculate \"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuv\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\"17 multiplied by\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxy\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" 23 and\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijk\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" then reply with only\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopq\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" the integer result\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghij\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\". I\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abc\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\"'ve already done\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz0123456789\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" the\"}]}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwx\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" calculation and got \"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopq\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\"391.\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz0\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" Now, the\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnop\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" user is\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"ab\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" asking me to reply\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcde\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" with exactly\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" \\\"Done.\\\"\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefg\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" after providing\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"a\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" the result\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"ab\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\".\\n\\nSo, I\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghij\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" should\"}]}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" first provide\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqr\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" the result\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz012\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" of the multiplication\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"a\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\", which is \"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopq\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\"391,\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijk\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" and then follow\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz012345678\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" it\"}]}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuv\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" up\"}]}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01234\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" with \\\"\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijkl\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\"Done.\\\" as\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuv\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" per\"}]}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz0123\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\" the user\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz0123456\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":[{\"type\":\"thinking\",\"thinking\":[{\"type\":\"text\",\"text\":\"'s instruction.\"}],\"closed\":true}]},\"finish_reason\":null}],\"p\":\"a\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"3\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstu\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"91\\n\"},\"finish_reason\":null}],\"p\":\"abcdefg\"}\n\ndata: {\"id\":\"85fb25e234e14be794158b3514f61ea5\",\"object\":\"chat.completion.chunk\",\"created\":1788110327,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Done.\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":203,\"total_tokens\":302,\"completion_tokens\":99,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklmnopqrstuv\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/ai/test/fixtures/recordings/mistral-chat/streams-text-with-usage.json b/packages/ai/test/fixtures/recordings/mistral-chat/streams-text-with-usage.json new file mode 100644 index 00000000000..24e5b52df2e --- /dev/null +++ b/packages/ai/test/fixtures/recordings/mistral-chat/streams-text-with-usage.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "metadata": { + "model": "mistral-small-latest", + "tags": ["prefix:mistral-chat", "provider:mistral", "protocol:mistral-chat", "text", "usage"], + "name": "mistral-chat/streams-text-with-usage", + "recordedAt": "2026-08-30T17:18:45.432Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.mistral.ai/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly one word: hello\"}],\"stream\":true,\"max_tokens\":40,\"temperature\":0,\"reasoning_effort\":\"none\"}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"9a4d16bdddb74e5e89c2cf9e9b91e065\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"9a4d16bdddb74e5e89c2cf9e9b91e065\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hi\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrs\"}\n\ndata: {\"id\":\"9a4d16bdddb74e5e89c2cf9e9b91e065\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":22,\"total_tokens\":24,\"completion_tokens\":2,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklmnopqrstuvwxyz0\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/ai/test/provider-options/mistral.types.ts b/packages/ai/test/provider-options/mistral.types.ts new file mode 100644 index 00000000000..4d1f7280bf1 --- /dev/null +++ b/packages/ai/test/provider-options/mistral.types.ts @@ -0,0 +1,24 @@ +import { LLM } from "../../src/index.js" +import { Mistral } from "../../src/providers.js" + +const selected = Mistral.provider.model("mistral-small-latest") + +LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "high" } }) +LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "future-effort" } }) +LLM.request({ model: selected, prompt: "Hello", providerOptions: { promptMode: "reasoning" } }) +LLM.request({ model: selected, prompt: "Hello", providerOptions: { parallelToolCalls: false } }) +LLM.request({ model: selected, prompt: "Hello", providerOptions: { promptCacheKey: "session-1" } }) + +LLM.request({ + model: selected, + prompt: "Hello", + // @ts-expect-error Mistral reasoning effort must be a string. + providerOptions: { reasoningEffort: 1 }, +}) + +LLM.request({ + model: selected, + prompt: "Hello", + // @ts-expect-error Mistral prompt mode only supports reasoning. + providerOptions: { promptMode: "standard" }, +}) diff --git a/packages/ai/test/provider/mistral-chat.test.ts b/packages/ai/test/provider/mistral-chat.test.ts new file mode 100644 index 00000000000..934b2c5a775 --- /dev/null +++ b/packages/ai/test/provider/mistral-chat.test.ts @@ -0,0 +1,694 @@ +import { describe, expect, test } from "bun:test" +import { ConfigProvider, Effect } from "effect" +import { HttpClientRequest } from "effect/unstable/http" +import { LLM, LLMEvent, Message, ToolDefinition } from "../../src/index.js" +import { Mistral } from "../../src/providers/index.js" +import { MistralChat } from "../../src/protocols/index.js" +import { LLMClient } from "../../src/route.js" +import { compileRequest } from "../../src/route/client.js" +import { it } from "../lib/effect.js" +import { dynamicResponse, fixedResponse } from "../lib/http.js" +import { sseEvents } from "../lib/sse.js" + +const model = Mistral.configure({ apiKey: "fixture" }).model("mistral-large-latest") +const request = LLM.request({ model, prompt: "Hello" }) +const chunk = (delta: object, finishReason: string | null = null, usage?: object) => ({ + choices: [{ delta, finish_reason: finishReason }], + usage, +}) + +describe("Mistral Chat", () => { + test("exposes native provider and protocol identities", async () => { + const entrypoint = await import("@opencode-ai/ai/providers/mistral") + + expect(Mistral.id).toBe("mistral") + expect(MistralChat.protocol.id).toBe("mistral-chat") + expect(Mistral.route).toMatchObject({ + id: "mistral-chat", + provider: "mistral", + providerMetadataKey: "mistral", + protocol: "mistral-chat", + }) + expect(Mistral.route.endpoint).toMatchObject({ + baseURL: "https://api.mistral.ai/v1", + path: "/chat/completions", + }) + expect(entrypoint.model).toBeFunction() + }) + + it.effect("lowers native messages, media, tool choice, options, and replay IDs", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model, + system: "Initial", + messages: [ + Message.system("Updated"), + Message.user([ + { type: "text", text: "Inspect" }, + { type: "media", mediaType: "image/png", data: "aW1hZ2U=" }, + { type: "media", mediaType: "application/pdf", data: "cGRm" }, + ]), + Message.assistant([ + { type: "reasoning", text: "Think" }, + { type: "text", text: "Calling" }, + { type: "tool-call", id: "call.same-prefix-1", name: "lookup", input: { city: "Paris" } }, + { type: "tool-call", id: "call.same-prefix-2", name: "other", input: {} }, + ]), + Message.tool({ id: "call.same-prefix-1", name: "lookup", result: { ok: true } }), + ], + tools: [ + ToolDefinition.make({ name: "lookup", description: "Look up a city", inputSchema: { type: "object" } }), + ToolDefinition.make({ name: "other", description: "Other operation", inputSchema: { type: "object" } }), + ], + toolChoice: "lookup", + promptCacheKey: "session-1", + generation: { + maxTokens: 64, + seed: 7, + temperature: 0.2, + topP: 0.8, + frequencyPenalty: 0.1, + presencePenalty: 0.3, + stop: ["done"], + }, + providerOptions: { + safePrompt: true, + documentImageLimit: 3, + documentPageLimit: 8, + parallelToolCalls: false, + reasoningEffort: "high", + }, + }), + ) + + expect(prepared.body).toMatchObject({ + model: "mistral-large-latest", + tools: [{ function: { name: "lookup", strict: false } }, { function: { name: "other", strict: false } }], + tool_choice: { type: "function", function: { name: "lookup" } }, + stream: true, + max_tokens: 64, + random_seed: 7, + temperature: 0.2, + top_p: 0.8, + frequency_penalty: 0.1, + presence_penalty: 0.3, + stop: ["done"], + prompt_cache_key: "session-1", + safe_prompt: true, + document_image_limit: 3, + document_page_limit: 8, + parallel_tool_calls: false, + reasoning_effort: "high", + }) + expect(prepared.body.messages.slice(0, 4)).toMatchObject([ + { role: "system", content: "Initial" }, + { role: "user", content: "\nUpdated\n" }, + { + role: "user", + content: [ + { type: "text", text: "Inspect" }, + { type: "image_url", image_url: "data:image/png;base64,aW1hZ2U=" }, + { type: "document_url", document_url: "data:application/pdf;base64,cGRm" }, + ], + }, + { + role: "assistant", + content: "ThinkCalling", + }, + ]) + const assistant = prepared.body.messages[3] + const toolResult = prepared.body.messages[4] + expect(assistant?.role).toBe("assistant") + expect(toolResult?.role).toBe("tool") + if (assistant?.role !== "assistant" || toolResult?.role !== "tool") return + const ids = assistant.tool_calls?.map((tool) => tool.id) ?? [] + expect(ids).toHaveLength(2) + expect(ids[0]).toMatch(/^[A-Za-z0-9]{9}$/) + expect(ids[1]).toMatch(/^[A-Za-z0-9]{9}$/) + expect(ids[0]).not.toBe(ids[1]) + expect(toolResult.tool_call_id).toBe(ids[0]) + expect(toolResult.name).toBe("lookup") + }), + ) + + it.effect("preserves valid replay IDs", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model, + messages: [ + Message.assistant({ type: "tool-call", id: "Ab12Cd34E", name: "lookup", input: {} }), + Message.tool({ id: "Ab12Cd34E", name: "lookup", result: "ok" }), + ], + }), + ) + expect(prepared.body.messages).toMatchObject([ + { tool_calls: [{ id: "Ab12Cd34E" }] }, + { tool_call_id: "Ab12Cd34E" }, + ]) + }), + ) + + it.effect("applies trailing prefix, cache, and reasoning options without changing earlier assistants", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model, + promptCacheKey: "common-key", + messages: [Message.assistant("Earlier"), Message.user("Continue"), Message.assistant("Prefix")], + providerOptions: { promptCacheKey: "native-key", promptMode: "reasoning" }, + }), + ) + expect(prepared.body.prompt_cache_key).toBe("native-key") + expect(prepared.body.prompt_mode).toBe("reasoning") + expect(prepared.body.messages).toEqual([ + { role: "assistant", content: "Earlier" }, + { role: "user", content: "Continue" }, + { role: "assistant", content: "Prefix", prefix: true }, + ]) + + const uncached = yield* compileRequest( + LLM.request({ + model, + prompt: "Hello", + promptCacheKey: "common-key", + cache: "none", + providerOptions: { promptCacheKey: "native-key" }, + }), + ) + expect(uncached.body.prompt_cache_key).toBeUndefined() + + const longKey = "cache-key-".repeat(10) + const unbounded = yield* compileRequest( + LLM.request({ + model, + prompt: "Hello", + promptCacheKey: longKey, + }), + ) + expect(unbounded.body.prompt_cache_key).toBe(longKey) + + const conflict = yield* compileRequest( + LLM.request({ + model, + prompt: "Hello", + providerOptions: { reasoningEffort: "high", promptMode: "reasoning" }, + }), + ).pipe(Effect.flip) + expect(conflict.message).toContain("mutually exclusive") + }), + ) + + it.effect("omits empty assistant history unless it carries a tool call", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model, + messages: [ + Message.assistant(" \n "), + Message.assistant({ type: "reasoning", text: "\t" }), + Message.assistant({ type: "tool-call", id: "Ab12Cd34E", name: "lookup", input: {} }), + ], + }), + ) + expect(prepared.body.messages).toEqual([ + { + role: "assistant", + content: "", + tool_calls: [{ id: "Ab12Cd34E", type: "function", function: { name: "lookup", arguments: "{}" } }], + }, + ]) + }), + ) + + it.effect("preserves remote media URLs and structured tool-result media", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model, + messages: [ + Message.user({ + type: "media", + mediaType: "image/png", + data: "https://assets.example.test/input.png", + }), + Message.tool({ + id: "Ab12Cd34E", + name: "inspect", + resultType: "content", + result: [ + { type: "text", text: "Result" }, + { type: "file", mime: "image/jpeg", uri: "https://assets.example.test/output.jpg" }, + { type: "file", mime: "application/pdf", uri: "cGRm" }, + ], + }), + ], + }), + ) + expect(prepared.body.messages).toEqual([ + { + role: "user", + content: [{ type: "image_url", image_url: "https://assets.example.test/input.png" }], + }, + { + role: "tool", + tool_call_id: "Ab12Cd34E", + name: "inspect", + content: [ + { type: "text", text: "Result" }, + { type: "image_url", image_url: "https://assets.example.test/output.jpg" }, + { type: "document_url", document_url: "data:application/pdf;base64,cGRm" }, + ], + }, + ]) + }), + ) + + it.effect("concatenates text-only user and tool content without separators", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model, + messages: [ + Message.user([ + { type: "text", text: "first" }, + { type: "text", text: "second" }, + ]), + Message.tool({ + id: "Ab12Cd34E", + name: "lookup", + resultType: "content", + result: [ + { type: "text", text: "third" }, + { type: "text", text: "fourth" }, + ], + }), + ], + }), + ) + expect(prepared.body.messages).toEqual([ + { role: "user", content: "firstsecond" }, + { role: "tool", tool_call_id: "Ab12Cd34E", name: "lookup", content: "thirdfourth" }, + ]) + }), + ) + + it.effect("streams ordered thinking and text and replays native thinking metadata", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + chunk({ content: [{ type: "thinking", thinking: [], marker: "empty" }] }), + chunk({ content: [{ type: "thinking", thinking: [{ type: "text", text: "Consider" }] }] }), + chunk({ content: [{ type: "text", text: "Answer" }] }), + chunk({}, "stop"), + ), + ), + ), + ) + + expect(response.reasoning).toBe("Consider") + expect(response.text).toBe("Answer") + expect(response.message.content).toEqual([ + { + type: "reasoning", + text: "Consider", + providerMetadata: { + mistral: { + thinking: { + type: "thinking", + thinking: [{ type: "text", text: "Consider" }], + marker: "empty", + }, + }, + }, + }, + { type: "text", text: "Answer" }, + ]) + + const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) + expect(replay.body.messages).toEqual([ + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: [{ type: "text", text: "Consider" }], + marker: "empty", + }, + { type: "text", text: "Answer" }, + ], + prefix: true, + }, + ]) + }), + ) + + it.effect("replays metadata-only native thinking", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents(chunk({ content: [{ type: "thinking", thinking: [], marker: "opaque" }] }), chunk({}, "stop")), + ), + ), + ) + expect(response.message.content).toEqual([ + { + type: "reasoning", + text: "", + providerMetadata: { + mistral: { thinking: { type: "thinking", thinking: [], marker: "opaque" } }, + }, + }, + ]) + + const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) + expect(replay.body.messages).toEqual([ + { + role: "assistant", + content: [{ type: "thinking", thinking: [], marker: "opaque" }], + prefix: true, + }, + ]) + }), + ) + + it.effect("merges indexed argument fragments with missing continuation identity", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + chunk({ + tool_calls: [{ index: 0, id: "Ab12Cd34E", function: { name: "lookup", arguments: '{"city":' } }], + }), + chunk({ tool_calls: [{ index: 0, function: { name: "", arguments: '"Paris"}' } }] }), + chunk({}, "tool_calls"), + ), + ), + ), + ) + + expect(response.message.content).toContainEqual({ + type: "tool-call", + id: "Ab12Cd34E", + name: "lookup", + input: { city: "Paris" }, + }) + expect( + response.events.filter( + (event) => + LLMEvent.is.toolInputStart(event) || + LLMEvent.is.toolInputDelta(event) || + LLMEvent.is.toolInputEnd(event) || + LLMEvent.is.toolCall(event), + ), + ).toEqual([ + { type: "tool-input-start", id: "Ab12Cd34E", name: "lookup", providerMetadata: undefined }, + { + type: "tool-input-delta", + id: "Ab12Cd34E", + name: "lookup", + text: '{"city":', + input: {}, + }, + { + type: "tool-input-delta", + id: "Ab12Cd34E", + name: "lookup", + text: '"Paris"}', + input: { city: "Paris" }, + }, + { type: "tool-input-end", id: "Ab12Cd34E", name: "lookup", providerMetadata: undefined }, + { + type: "tool-call", + id: "Ab12Cd34E", + name: "lookup", + input: { city: "Paris" }, + providerExecuted: undefined, + providerMetadata: undefined, + }, + ]) + expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1) + }), + ) + + it.effect("normalizes stop to tool calls when a hosted model emits indexed tool fragments", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + chunk({ + tool_calls: [ + { + index: 0, + id: "chatcmpl-tool-8cc4d8f9f07b298a", + function: { name: "lookup", arguments: '{"city":"' }, + }, + ], + }), + chunk({ tool_calls: [{ index: 0, function: { name: "", arguments: 'Paris"}' } }] }), + chunk({}, "stop"), + ), + ), + ), + ) + + expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "stop" }) + expect(response.toolCalls).toMatchObject([{ name: "lookup", input: { city: "Paris" } }]) + expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1) + }), + ) + + it.effect("generates a stable ID when the first indexed fragment has null identity", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + chunk({ + tool_calls: [{ index: 0, id: null, function: { name: "lookup", arguments: { city: "Paris" } } }], + }), + chunk({}, "tool_calls"), + ), + ), + ), + ) + const call = response.message.content.find((part) => part.type === "tool-call") + expect(call?.id).toMatch(/^[A-Za-z0-9]{9}$/) + expect(call).toMatchObject({ name: "lookup", input: { city: "Paris" } }) + }), + ) + + it.effect("generates distinct IDs for parallel null and literal-null identities", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + chunk({ + tool_calls: [ + { index: 0, id: null, function: { name: "first", arguments: {} } }, + { index: 1, id: "null", function: { name: "second", arguments: {} } }, + ], + }), + chunk({}, "tool_calls"), + ), + ), + ), + ) + const calls = response.message.content.filter((part) => part.type === "tool-call") + expect(calls).toHaveLength(2) + expect(calls[0]?.id).toMatch(/^[A-Za-z0-9]{9}$/) + expect(calls[1]?.id).toMatch(/^[A-Za-z0-9]{9}$/) + expect(calls[0]?.id).not.toBe(calls[1]?.id) + }), + ) + + it.effect("keeps parallel indexed calls independent", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + chunk({ + tool_calls: [ + { index: 0, id: "Ab12Cd34E", function: { name: "first", arguments: '{"n":' } }, + { index: 1, id: "Fg56Hi78J", function: { name: "second", arguments: '{"n":' } }, + ], + }), + chunk({ + tool_calls: [ + { index: 0, function: { arguments: "1}" } }, + { index: 1, function: { arguments: "2}" } }, + ], + }), + chunk({}, "tool_calls"), + ), + ), + ), + ) + expect(response.message.content.filter((part) => part.type === "tool-call")).toEqual([ + { type: "tool-call", id: "Ab12Cd34E", name: "first", input: { n: 1 } }, + { type: "tool-call", id: "Fg56Hi78J", name: "second", input: { n: 2 } }, + ]) + }), + ) + + it.effect("correlates parallel identity-less fragments by batch position", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + chunk({ + tool_calls: [ + { function: { name: "first", arguments: '{"n":' } }, + { function: { name: "second", arguments: '{"n":' } }, + ], + }), + chunk({ + tool_calls: [{ function: { arguments: "1}" } }, { function: { arguments: "2}" } }], + }), + chunk({}, "tool_calls"), + ), + ), + ), + ) + expect(response.message.content.filter((part) => part.type === "tool-call")).toMatchObject([ + { name: "first", input: { n: 1 } }, + { name: "second", input: { n: 2 } }, + ]) + }), + ) + + it.effect("maps usage variants and clamps cache reads", () => + Effect.gen(function* () { + for (const usage of [ + { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7, num_cached_tokens: 9 }, + { prompt_tokens: 5, completion_tokens: 2, prompt_token_details: { cached_tokens: 2 } }, + { prompt_tokens: 5, completion_tokens: 2, prompt_tokens_details: { cached_tokens: 3 } }, + ]) { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseEvents(chunk({}, "stop", usage)))), + ) + expect(response.usage).toMatchObject({ + inputTokens: 5, + outputTokens: 2, + totalTokens: 7, + }) + expect(response.usage?.cacheReadInputTokens).toBe( + Math.min( + 5, + usage.num_cached_tokens ?? + usage.prompt_token_details?.cached_tokens ?? + usage.prompt_tokens_details?.cached_tokens ?? + 0, + ), + ) + } + }), + ) + + it.effect("maps finish reasons and does not finalize truncated tool calls", () => + Effect.gen(function* () { + for (const [raw, normalized] of [ + ["stop", "stop"], + ["model_length", "length"], + ["tool_calls", "tool-calls"], + ["error", "error"], + ["future_reason", "unknown"], + ] as const) { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseEvents(chunk({}, raw)))), + ) + expect(response.finishReason).toEqual({ normalized, raw }) + } + + const truncated = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + chunk({ + tool_calls: [{ index: 0, id: "Ab12Cd34E", function: { name: "lookup", arguments: '{"city":' } }], + }), + chunk({}, "length"), + ), + ), + ), + ) + expect(truncated.finishReason).toEqual({ normalized: "length", raw: "length" }) + expect(truncated.events.some(LLMEvent.is.toolCall)).toBe(false) + expect(truncated.events.some(LLMEvent.is.toolInputEnd)).toBe(false) + }), + ) + + it.effect("ignores non-text output parts and rejects invalid stream endings", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + chunk({ content: null }), + chunk({ + content: [ + { type: "reference", reference_ids: [1] }, + { type: "image_url", image_url: "https://example.test/image.png" }, + { type: "text", text: "Answer" }, + ], + }), + chunk({}, "stop"), + ), + ), + ), + ) + expect(response.text).toBe("Answer") + + const missingFinish = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseEvents(chunk({ content: "partial" })))), + Effect.flip, + ) + expect(missingFinish.message).toContain("without finish_reason") + + const lateContent = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse(sseEvents(chunk({}, "stop"), chunk({ content: [{ type: "text", text: "late" }] }))), + ), + Effect.flip, + ) + expect(lateContent.message).toContain("content after the finish reason") + }), + ) + + it.effect("uses environment bearer auth and custom package settings", () => + LLMClient.generate( + LLM.request({ + model: Mistral.model("fixture-model", { + baseURL: "https://mistral.test/v1", + headers: { "x-app": "test" }, + body: { service_tier: "priority" }, + providerOptions: { safePrompt: true }, + }), + prompt: "Hello", + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe("https://mistral.test/v1/chat/completions") + expect(web.headers.get("authorization")).toBe("Bearer secret") + expect(web.headers.get("x-app")).toBe("test") + expect(input.text).toContain('"service_tier":"priority"') + return input.respond(sseEvents(chunk({}, "stop")), { headers: { "content-type": "text/event-stream" } }) + }), + ), + ), + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { MISTRAL_API_KEY: "secret" } }))), + ), + ) +}) diff --git a/packages/ai/test/provider/mistral.recorded.test.ts b/packages/ai/test/provider/mistral.recorded.test.ts new file mode 100644 index 00000000000..4d2a54b4c82 --- /dev/null +++ b/packages/ai/test/provider/mistral.recorded.test.ts @@ -0,0 +1,159 @@ +import { configure } from "@opencode-ai/ai/providers/mistral" +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLM, LLMEvent, LLMRequest, Message, ToolChoice, ToolDefinition } from "../../src/index.js" +import { LLMClient } from "../../src/route.js" +import { compileRequest } from "../../src/route/client.js" +import { recordedTests } from "../recorded-test.js" + +const apiKey = process.env.MISTRAL_API_KEY ?? "fixture" +const recorded = recordedTests({ + prefix: "mistral-chat", + provider: "mistral", + protocol: "mistral-chat", + requires: ["MISTRAL_API_KEY"], +}) +const glmRecorded = recordedTests({ + prefix: "mistral-chat-glm", + provider: "mistral", + protocol: "mistral-chat", + requires: ["MISTRAL_API_KEY"], +}) + +const weather = ToolDefinition.make({ + name: "lookup_weather", + description: "Look up the current weather for a city", + inputSchema: { + type: "object", + properties: { city: { type: "string", enum: ["Paris"] } }, + required: ["city"], + additionalProperties: false, + }, +}) + +describe("Mistral recorded", () => { + recorded.effect.with( + "streams text with usage", + { tags: ["text", "usage"], metadata: { model: "mistral-small-latest" } }, + () => + Effect.gen(function* () { + const response = yield* LLMClient.generate( + LLM.request({ + model: configure({ apiKey, providerOptions: { reasoningEffort: "none" } }).model("mistral-small-latest"), + prompt: "Reply with exactly one word: hello", + generation: { maxTokens: 40, temperature: 0 }, + }), + ) + + expect(response.text.trim()).toMatch(/^(?:hello|hi)[!.]?$/i) + expect(response.finishReason.normalized).toBe("stop") + expect(response.usage?.inputTokens).toBeGreaterThan(0) + expect(response.usage?.outputTokens).toBeGreaterThan(0) + }), + 60_000, + ) + + recorded.effect.with( + "replays native reasoning", + { tags: ["reasoning", "replay", "usage"], metadata: { model: "mistral-small-latest" } }, + () => + Effect.gen(function* () { + const model = configure({ apiKey, providerOptions: { reasoningEffort: "high" } }).model("mistral-small-latest") + const firstRequest = LLM.request({ + model, + prompt: "Calculate 17 multiplied by 23. Think briefly, then reply with only the integer.", + generation: { maxTokens: 512, temperature: 0 }, + }) + const first = yield* LLMClient.generate(firstRequest) + + expect(first.text.trim()).toBe("391") + expect(first.reasoning.length).toBeGreaterThan(0) + expect(first.events.some(LLMEvent.is.reasoningDelta)).toBe(true) + + const followUp = LLMRequest.update(firstRequest, { + messages: [...firstRequest.messages, first.message, Message.user("Reply with exactly: Done.")], + generation: { maxTokens: 256, temperature: 0 }, + }) + const replay = yield* compileRequest(followUp) + expect(replay.body.messages).toContainEqual( + expect.objectContaining({ + role: "assistant", + content: expect.arrayContaining([expect.objectContaining({ type: "thinking" })]), + }), + ) + + const second = yield* LLMClient.generate(followUp) + expect(second.text.trim()).toMatch(/Done\.?$/) + expect(second.finishReason.normalized).toBe("stop") + }), + 60_000, + ) + + recorded.effect.with( + "drives a tool loop", + { tags: ["tool", "tool-loop", "usage"], metadata: { model: "mistral-small-latest" } }, + () => + Effect.gen(function* () { + const model = configure({ apiKey, providerOptions: { reasoningEffort: "none" } }).model("mistral-small-latest") + const firstRequest = LLM.request({ + model, + system: "Call lookup_weather exactly once with Paris.", + prompt: "What is the weather?", + tools: [weather], + toolChoice: weather, + generation: { maxTokens: 160, temperature: 0 }, + }) + const first = yield* LLMClient.generate(firstRequest) + + expect(first.finishReason.normalized).toBe("tool-calls") + expect(first.toolCalls).toMatchObject([{ name: "lookup_weather", input: { city: "Paris" } }]) + expect(first.events.filter(LLMEvent.is.toolCall)).toHaveLength(1) + + const call = first.toolCalls[0] + if (!call) throw new Error("Mistral did not return a tool call") + const followUp = LLMRequest.update(firstRequest, { + toolChoice: ToolChoice.make("none"), + messages: [ + ...firstRequest.messages, + first.message, + Message.tool({ id: call.id, name: call.name, result: { condition: "sunny", temperature: "18C" } }), + ], + generation: { maxTokens: 160, temperature: 0 }, + }) + const second = yield* LLMClient.generate(followUp) + + expect(second.finishReason.normalized).toBe("stop") + expect(second.toolCalls).toHaveLength(0) + expect(second.text.toLowerCase()).toContain("sunny") + }), + 60_000, + ) +}) + +describe("Mistral hosted GLM recorded", () => { + glmRecorded.effect.with( + "streams an indexed tool call", + { tags: ["hosted-model", "tool", "tool-call"], metadata: { model: "zai-glm-5-2" } }, + () => + Effect.gen(function* () { + const response = yield* LLMClient.generate( + LLM.request({ + model: configure({ apiKey }).model("zai-glm-5-2"), + system: "Call lookup_weather exactly once with Paris.", + prompt: "What is the weather?", + tools: [weather], + toolChoice: weather, + generation: { maxTokens: 256, temperature: 0 }, + }), + ) + + expect(response.finishReason.normalized).toBe("tool-calls") + expect(response.toolCalls).toMatchObject([{ name: "lookup_weather", input: { city: "Paris" } }]) + expect(response.events.filter(LLMEvent.is.toolInputStart)).toHaveLength(1) + expect(response.events.filter(LLMEvent.is.toolInputDelta).length).toBeGreaterThan(0) + expect(response.events.filter(LLMEvent.is.toolInputEnd)).toHaveLength(1) + expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1) + }), + 60_000, + ) +}) diff --git a/packages/core/src/aisdk-native.ts b/packages/core/src/aisdk-native.ts index 9f2bf44e77b..c147d00e129 100644 --- a/packages/core/src/aisdk-native.ts +++ b/packages/core/src/aisdk-native.ts @@ -107,6 +107,17 @@ export function map(input: MapInput): Mapping | undefined { }, ...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}), } + case "@ai-sdk/mistral": + return { + package: "@opencode-ai/ai/providers/mistral", + settings: { + ...baseSettings, + ...mapAPIKey(input.settings), + ...mapMistralOptions(input.settings), + }, + ...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}), + ...(isRecord(input.settings.extraBody) ? { body: input.settings.extraBody } : {}), + } case "@ai-sdk/openai": return { package: "@opencode-ai/ai/providers/openai", @@ -283,6 +294,20 @@ function mapOpenAIOptions(settings: Readonly>) { return { providerOptions: options } } +function mapMistralOptions(settings: Readonly>) { + const options = { + ...(typeof settings.safePrompt === "boolean" ? { safePrompt: settings.safePrompt } : {}), + ...(typeof settings.documentImageLimit === "number" ? { documentImageLimit: settings.documentImageLimit } : {}), + ...(typeof settings.documentPageLimit === "number" ? { documentPageLimit: settings.documentPageLimit } : {}), + ...(typeof settings.parallelToolCalls === "boolean" ? { parallelToolCalls: settings.parallelToolCalls } : {}), + ...(typeof settings.promptCacheKey === "string" ? { promptCacheKey: settings.promptCacheKey } : {}), + ...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}), + ...(settings.promptMode === "reasoning" ? { promptMode: settings.promptMode } : {}), + } + if (Object.keys(options).length === 0) return {} + return { providerOptions: options } +} + function mapBaseSettings(settings: Readonly>) { return { ...(typeof settings.baseURL === "string" ? { baseURL: settings.baseURL } : {}), diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index 6750e7c2489..eba19a324dc 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -338,6 +338,7 @@ function usesAPIKeyAuth(packageName: string | undefined) { name === "@ai-sdk/openai-compatible" || name === "@ai-sdk/google" || name === "@ai-sdk/groq" || + name === "@ai-sdk/mistral" || name === "@ai-sdk/togetherai" || name === "@ai-sdk/xai" || name === "@openrouter/ai-sdk-provider" || @@ -351,6 +352,7 @@ function usesAPIKeyAuth(packageName: string | undefined) { name === "@opencode-ai/ai/providers/openai-compatible" || name === "@opencode-ai/ai/providers/google" || name === "@opencode-ai/ai/providers/groq" || + name === "@opencode-ai/ai/providers/mistral" || name === "@opencode-ai/ai/providers/togetherai" || name === "@opencode-ai/ai/providers/xai" || name === "@opencode-ai/ai/providers/openrouter" || diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index cd95248523c..61cda28be43 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -63,6 +63,7 @@ const builtins = new Map Promise>([ () => import("@opencode-ai/ai/providers/google-vertex/messages"), ], ["@opencode-ai/ai/providers/groq", () => import("@opencode-ai/ai/providers/groq")], + ["@opencode-ai/ai/providers/mistral", () => import("@opencode-ai/ai/providers/mistral")], ["@opencode-ai/ai/providers/openai", () => import("@opencode-ai/ai/providers/openai")], ["@opencode-ai/ai/providers/openai/chat", () => import("@opencode-ai/ai/providers/openai/chat")], ["@opencode-ai/ai/providers/openai/responses", () => import("@opencode-ai/ai/providers/openai/responses")], diff --git a/packages/core/test/aisdk-native.test.ts b/packages/core/test/aisdk-native.test.ts index 9de62931efd..becf22b00c7 100644 --- a/packages/core/test/aisdk-native.test.ts +++ b/packages/core/test/aisdk-native.test.ts @@ -109,6 +109,66 @@ describe("AISDKNative", () => { }) }) + test("maps supported Mistral settings and request overlays to the native provider", () => { + expect( + map("@ai-sdk/mistral", { + apiKey: "secret", + baseURL: "https://mistral.example/v1", + headers: { "x-provider": "mistral" }, + extraBody: { custom: { enabled: true } }, + safePrompt: false, + documentImageLimit: 4, + documentPageLimit: 12, + parallelToolCalls: false, + promptCacheKey: "session-123", + reasoningEffort: "high", + promptMode: "reasoning", + fetch: "ignored", + generateId: "ignored", + structuredOutputs: true, + unsupported: true, + }), + ).toEqual({ + package: "@opencode-ai/ai/providers/mistral", + settings: { + apiKey: "secret", + baseURL: "https://mistral.example/v1", + providerOptions: { + safePrompt: false, + documentImageLimit: 4, + documentPageLimit: 12, + parallelToolCalls: false, + promptCacheKey: "session-123", + reasoningEffort: "high", + promptMode: "reasoning", + }, + }, + headers: { "x-provider": "mistral" }, + body: { custom: { enabled: true } }, + }) + }) + + test("omits invalid and runtime-only Mistral settings", () => { + expect( + map("@ai-sdk/mistral", { + headers: { valid: "header", invalid: 1 }, + extraBody: "invalid", + safePrompt: "false", + documentImageLimit: "4", + documentPageLimit: null, + parallelToolCalls: 0, + promptCacheKey: false, + reasoningEffort: false, + promptMode: "unsupported", + fetch: "ignored", + generateId: "ignored", + }), + ).toEqual({ + package: "@opencode-ai/ai/providers/mistral", + settings: {}, + }) + }) + test("maps both models.dev Bedrock packages to native providers", () => { expect(map("@ai-sdk/amazon-bedrock", { region: "us-east-1" })).toEqual({ package: "@opencode-ai/ai/providers/amazon-bedrock", diff --git a/packages/core/test/generate.test.ts b/packages/core/test/generate.test.ts index 30d882459d1..494bcca2cee 100644 --- a/packages/core/test/generate.test.ts +++ b/packages/core/test/generate.test.ts @@ -15,7 +15,7 @@ import { testEffect } from "./lib/effect" const selected = Info.make({ ...Info.default(Provider.ID.make("test-provider"), ID.make("gemini")), - package: Provider.aisdk("@ai-sdk/mistral"), + package: Provider.aisdk("@ai-sdk/cohere"), }) const runtime = LanguageModel.make({ id: "gemini", provider: "test-provider", route: OpenAIChat.route }) diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index 2552153131d..9565fe773ef 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -280,12 +280,25 @@ describe("ModelResolver", () => { ), ) - it.effect("uses no native API-key auth for an explicitly enabled provider without credentials", () => { + it.effect("uses no native API-key auth for explicitly enabled providers without credentials", () => { const selected = model(Provider.aisdk("@ai-sdk/google"), { providerID: Provider.ID.make("gateway"), settings: { baseURL: "https://gateway.example.com/v1" }, headers: { "cf-access-token": "access-token" }, }) + const selections = [ + selected, + model(Provider.aisdk("@ai-sdk/mistral"), { + providerID: Provider.ID.make("gateway"), + settings: { baseURL: "https://mistral.example.com/v1" }, + headers: { "cf-access-token": "access-token" }, + }), + model("@opencode-ai/ai/providers/mistral", { + providerID: Provider.ID.make("gateway"), + settings: { baseURL: "https://native-mistral.example.com/v1" }, + headers: { "cf-access-token": "access-token" }, + }), + ] const provider = Provider.Info.make({ ...Provider.Info.empty(selected.providerID), activation: "enabled", @@ -344,20 +357,23 @@ describe("ModelResolver", () => { return withConfigEnv({}, () => Effect.gen(function* () { const resolver = yield* ModelResolver.Service - const resolved = yield* resolver.resolveModel(selected) + yield* Effect.forEach(selections, (selection) => + Effect.gen(function* () { + const resolved = yield* resolver.resolveModel(selection) + const headers = yield* resolved.model.route.auth.apply({ + request: LLM.request({ model: resolved.model, prompt: "Hello" }), + method: "POST", + url: resolved.model.route.endpoint.baseURL ?? "", + body: "{}", + headers: Headers.fromInput(resolved.model.route.defaults.headers), + }) - expect(resolved.limit).toEqual(selected.limit) - const headers = yield* resolved.model.route.auth.apply({ - request: LLM.request({ model: resolved.model, prompt: "Hello" }), - method: "POST", - url: "https://gateway.example.com/v1", - body: "{}", - headers: Headers.fromInput(resolved.model.route.defaults.headers), - }) - - expect(headers["cf-access-token"]).toBe("access-token") - expect(headers.authorization).toBeUndefined() - expect(headers["x-goog-api-key"]).toBeUndefined() + expect(resolved.limit).toEqual(selection.limit) + expect(headers["cf-access-token"]).toBe("access-token") + expect(headers.authorization).toBeUndefined() + expect(headers["x-goog-api-key"]).toBeUndefined() + }), + ) }).pipe(Effect.provide(layer)), ) }) @@ -921,6 +937,24 @@ describe("ModelResolver", () => { { reasoningEffort: "high", parallelToolCalls: false }, { reasoningEffort: "high", parallelToolCalls: false }, ], + [ + "@ai-sdk/mistral", + "@opencode-ai/ai/providers/mistral", + { + safePrompt: true, + documentImageLimit: 4, + promptCacheKey: "session-123", + promptMode: "reasoning", + reasoningEffort: "high", + }, + { + safePrompt: true, + documentImageLimit: 4, + promptCacheKey: "session-123", + promptMode: "reasoning", + reasoningEffort: "high", + }, + ], [ "@ai-sdk/togetherai", "@opencode-ai/ai/providers/togetherai", @@ -980,6 +1014,7 @@ describe("ModelResolver", () => { ["@ai-sdk/google-vertex", "@opencode-ai/ai/providers/google-vertex", "api-model"], ["@ai-sdk/google-vertex/anthropic", "@opencode-ai/ai/providers/google-vertex/messages", "claude-sonnet-4-6"], ["@ai-sdk/groq", "@opencode-ai/ai/providers/groq", "api-model"], + ["@ai-sdk/mistral", "@opencode-ai/ai/providers/mistral", "api-model"], ["@ai-sdk/openai", "@opencode-ai/ai/providers/openai", "api-model"], ["@ai-sdk/openai-compatible", "@opencode-ai/ai/providers/openai-compatible", "api-model"], ["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "api-model"], @@ -1088,6 +1123,36 @@ describe("ModelResolver", () => { ), ) + it.effect("merges mapped Mistral headers and body with catalog overlays", () => + ModelResolver.fromCatalogModel( + model(Provider.aisdk("@ai-sdk/mistral"), { + settings: { + headers: { "x-factory": "factory", "x-shared": "factory" }, + extraBody: { factory: true, custom: { source: true } }, + }, + headers: { "x-shared": "catalog" }, + body: { custom: { catalog: true } }, + }), + undefined, + { + loadPackage: () => + Effect.succeed({ + model: (modelID, settings) => { + expect(settings.headers).toEqual({ + "x-factory": "factory", + "x-shared": "catalog", + }) + expect(settings.body).toEqual({ + factory: true, + custom: { source: true, catalog: true }, + }) + return LanguageModel.make({ id: modelID, provider: "mistral", route: OpenAIChat.route }) + }, + }), + }, + ), + ) + it.effect("loads supported AISDK catalog packages as native routes", () => Effect.gen(function* () { const google = yield* ModelResolver.fromCatalogModel( @@ -1114,6 +1179,11 @@ describe("ModelResolver", () => { settings: { reasoningEffort: "high", parallelToolCalls: false }, }), ) + const mistral = yield* ModelResolver.fromCatalogModel( + model(Provider.aisdk("@ai-sdk/mistral"), { + settings: { safePrompt: true, promptCacheKey: "session-123", reasoningEffort: "high" }, + }), + ) const xai = yield* ModelResolver.fromCatalogModel( model(Provider.aisdk("@ai-sdk/xai"), { settings: { reasoningEffort: "high" } }), ) @@ -1148,6 +1218,13 @@ describe("ModelResolver", () => { expect(groq.route.protocol).toBe("groq-chat") expect(groq.route.defaults.providerOptions).toEqual({ reasoningEffort: "high", parallelToolCalls: false }) expect(String(groq.provider)).toBe("test-provider") + expect(mistral.route.id).toBe("mistral-chat") + expect(mistral.route.defaults.providerOptions).toEqual({ + safePrompt: true, + promptCacheKey: "session-123", + reasoningEffort: "high", + }) + expect(String(mistral.provider)).toBe("test-provider") expect(xai.route.id).toBe("openai-responses") expect(xai.route.defaults.providerOptions).toEqual({ reasoningEffort: "high", @@ -1170,8 +1247,8 @@ describe("ModelResolver", () => { }), ) const resolved = yield* ModelResolver.fromCatalogModel( - model(Provider.aisdk("@ai-sdk/mistral"), { - modelID: "mistral-api-model", + model(Provider.aisdk("@ai-sdk/cohere"), { + modelID: "cohere-api-model", settings: { project: "test" }, headers: { "x-aisdk": "header" }, body: { custom: true }, @@ -1186,9 +1263,9 @@ describe("ModelResolver", () => { Effect.sync(() => { expect(runtime).toMatchObject({ id: "test-model", - modelID: "mistral-api-model", + modelID: "cohere-api-model", providerID: "test-provider", - package: Provider.aisdk("@ai-sdk/mistral"), + package: Provider.aisdk("@ai-sdk/cohere"), settings: { project: "test", apiKey: "fallback-secret", accountId: "account" }, headers: { "x-aisdk": "header" }, body: { custom: true }, @@ -1202,7 +1279,7 @@ describe("ModelResolver", () => { }, ) - expect(resolved).toMatchObject({ id: "mistral-api-model", provider: "test-provider" }) + expect(resolved).toMatchObject({ id: "cohere-api-model", provider: "test-provider" }) }), ) @@ -1210,7 +1287,7 @@ describe("ModelResolver", () => { withEnv({ REQUIRED_HOST: undefined }, () => Effect.gen(function* () { const failure = yield* ModelResolver.fromCatalogModel( - model(Provider.aisdk("@ai-sdk/mistral"), { + model(Provider.aisdk("@ai-sdk/cohere"), { settings: { baseURL: "https://${REQUIRED_HOST}/v1" }, }), undefined, @@ -1229,7 +1306,7 @@ describe("ModelResolver", () => { withEnv({ PROVIDER_HOST: "${MISSING_HOST}", MISSING_HOST: undefined }, () => Effect.gen(function* () { const failure = yield* ModelResolver.fromCatalogModel( - model(Provider.aisdk("@ai-sdk/mistral"), { + model(Provider.aisdk("@ai-sdk/cohere"), { settings: { baseURL: "https://${PROVIDER_HOST}/v1" }, }), undefined, @@ -1266,8 +1343,8 @@ describe("ModelResolver", () => { it.effect("rejects AISDK packages without an available loader", () => Effect.gen(function* () { const failure = yield* ModelResolver.fromCatalogModel( - model(Provider.aisdk("@ai-sdk/mistral"), { - settings: { baseURL: "https://mistral.example/v1" }, + model(Provider.aisdk("@ai-sdk/cohere"), { + settings: { baseURL: "https://cohere.example/v1" }, }), ).pipe(Effect.flip) @@ -1275,9 +1352,9 @@ describe("ModelResolver", () => { _tag: "SessionRunnerModel.UnsupportedPackageError", providerID: "test-provider", modelID: "test-model", - package: "aisdk:@ai-sdk/mistral", + package: "aisdk:@ai-sdk/cohere", }) - expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/mistral") + expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/cohere") }), ) @@ -1289,8 +1366,8 @@ describe("ModelResolver", () => { }), ) yield* ModelResolver.fromCatalogModel( - model(Provider.aisdk("@ai-sdk/mistral"), { - settings: { apiKey: "", baseURL: "https://mistral.example/v1" }, + model(Provider.aisdk("@ai-sdk/cohere"), { + settings: { apiKey: "", baseURL: "https://cohere.example/v1" }, }), undefined, { diff --git a/packages/core/test/provider.test.ts b/packages/core/test/provider.test.ts index 56a95506c2f..90833d7bb2b 100644 --- a/packages/core/test/provider.test.ts +++ b/packages/core/test/provider.test.ts @@ -13,6 +13,7 @@ describe("Provider", () => { "@opencode-ai/ai/providers/google-vertex/responses", "@opencode-ai/ai/providers/google-vertex/messages", "@opencode-ai/ai/providers/groq", + "@opencode-ai/ai/providers/mistral", "@opencode-ai/ai/providers/togetherai", ]