Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion lib/chat_models/chat_mistral_ai.ex
Original file line number Diff line number Diff line change
Expand Up @@ -579,12 +579,25 @@ defmodule LangChain.ChatModels.ChatMistralAI do
_ -> "assistant"
end

# Convert Mistral's content format to ContentPart structs
content = process_mistral_content(delta_body["content"])

# Adjust index to prevent thinking and text content from merging.
# Thinking is always at index 0. Text content is offset by 1 to avoid collision.
# Similar to DeepSeek: thinking at index 0, text starts at index 1.
adjusted_index =
case content do
%ContentPart{type: :thinking} -> 0
_ -> (index || 0) + 1
end

data =
delta_body
|> Map.put("role", role)
|> Map.put("index", index)
|> Map.put("index", adjusted_index)
|> Map.put("status", status)
|> Map.put("tool_calls", tool_calls)
|> Map.put("content", content)

case MessageDelta.new(data) do
{:ok, message} ->
Expand Down Expand Up @@ -694,6 +707,33 @@ defmodule LangChain.ChatModels.ChatMistralAI do
)}
end

# Process Mistral's content format for thinking blocks and text in list format.
# Mistral can return:
# - Thinking: [%{"type" => "thinking", "thinking" => [%{"text" => "...", "type" => "text"}]}]
# - Text: [%{"type" => "text", "text" => "..."}]
defp process_mistral_content(nil), do: nil

defp process_mistral_content(content) when is_binary(content), do: content

defp process_mistral_content([%{"type" => "thinking", "thinking" => thinking_list} | _])
when is_list(thinking_list) do
# Extract text from thinking array and convert to ContentPart
thinking_text =
thinking_list
|> Enum.filter(&match?(%{"type" => "text"}, &1))
|> Enum.map(&Map.get(&1, "text", ""))
|> Enum.join("")

ContentPart.thinking!(thinking_text)
end

defp process_mistral_content([%{"type" => "text", "text" => text} | _]) do
ContentPart.text!(text)
end

# For any other content format, pass through unchanged
defp process_mistral_content(content), do: content

defp get_token_usage(%{"usage" => usage} = _response_body) do
# extract out the reported response token usage
#
Expand Down
90 changes: 90 additions & 0 deletions test/chat_models/chat_mistral_ai_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,100 @@ defmodule LangChain.ChatModels.ChatMistralAITest do

assert delta.role == :assistant
assert delta.content == "This is the first part of a mes"
assert delta.index == 1
assert delta.status == :incomplete
end

test "handles receiving MessageDeltas with thinking content", %{model: model} do
response = %{
"choices" => [
%{
"delta" => %{
"role" => "assistant",
"content" => [
%{
"type" => "thinking",
"thinking" => [
%{"type" => "text", "text" => "Let me think about this"}
]
}
]
},
"finish_reason" => nil,
"index" => 0
}
]
}

assert [%MessageDelta{} = delta] =
ChatMistralAI.do_process_response(model, response)

assert delta.role == :assistant
assert %ContentPart{type: :thinking, content: "Let me think about this"} = delta.content
assert delta.index == 0
assert delta.status == :incomplete
end

test "handles receiving MessageDeltas with multiple thinking text parts", %{model: model} do
response = %{
"choices" => [
%{
"delta" => %{
"role" => "assistant",
"content" => [
%{
"type" => "thinking",
"thinking" => [
%{"type" => "text", "text" => "First part "},
%{"type" => "text", "text" => "second part"}
]
}
]
},
"finish_reason" => nil,
"index" => 0
}
]
}

assert [%MessageDelta{} = delta] =
ChatMistralAI.do_process_response(model, response)

assert delta.role == :assistant
assert %ContentPart{type: :thinking, content: "First part second part"} = delta.content
assert delta.index == 0
assert delta.status == :incomplete
end

test "handles receiving MessageDeltas with text content in list format", %{model: model} do
response = %{
"choices" => [
%{
"delta" => %{
"role" => "assistant",
"content" => [
%{
"type" => "text",
"text" => "This is regular text"
}
]
},
"finish_reason" => nil,
"index" => 0
}
]
}

assert [%MessageDelta{} = delta] =
ChatMistralAI.do_process_response(model, response)

assert delta.role == :assistant
assert %ContentPart{type: :text, content: "This is regular text"} = delta.content
# Text content at index 0 is shifted to index 1 to avoid merging with thinking at index 0
assert delta.index == 1
assert delta.status == :incomplete
end

test "handles API error messages", %{model: model} do
response = %{
"error" => %{
Expand Down