Skip to content

Commit aa0969d

Browse files
committed
feat: Add support for thought blocks (reasoning_content) in Gemini provider
1 parent 2f37d22 commit aa0969d

File tree

3 files changed

+111
-17
lines changed

3 files changed

+111
-17
lines changed

lib/nous/messages/gemini.ex

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,12 @@ defmodule Nous.Messages.Gemini do
6060
content_data = Map.get(candidate, "content", %{})
6161
parts_data = Map.get(content_data, "parts", [])
6262

63-
{content_parts, tool_calls} = parse_content(parts_data)
63+
{content_parts, reasoning_content, tool_calls} = parse_content(parts_data)
6464

6565
attrs = %{
6666
role: :assistant,
6767
content: consolidate_content_parts(content_parts),
68+
reasoning_content: consolidate_content_parts(reasoning_content),
6869
metadata: %{
6970
model_name: "gemini-model",
7071
usage: parse_usage(usage_data),
@@ -91,9 +92,15 @@ defmodule Nous.Messages.Gemini do
9192
end
9293

9394
parts = Map.get(msg, "parts", [])
94-
{text_content, tool_calls} = parse_parts(parts)
95+
{text_content, reasoning_content, tool_calls} = parse_parts(parts)
9596

9697
attrs = %{role: role, content: text_content}
98+
99+
attrs =
100+
if reasoning_content != "",
101+
do: Map.put(attrs, :reasoning_content, reasoning_content),
102+
else: attrs
103+
97104
attrs = if length(tool_calls) > 0, do: Map.put(attrs, :tool_calls, tool_calls), else: attrs
98105

99106
Message.new!(attrs)
@@ -111,11 +118,21 @@ defmodule Nous.Messages.Gemini do
111118
%{"role" => "user", "parts" => gemini_parts}
112119
end
113120

114-
defp message_to_gemini(%Message{role: :assistant, content: content, tool_calls: tool_calls}) do
121+
defp message_to_gemini(%Message{
122+
role: :assistant,
123+
content: content,
124+
reasoning_content: reasoning,
125+
tool_calls: tool_calls
126+
}) do
115127
parts = []
116128

117129
parts = if content && content != "", do: [%{"text" => content} | parts], else: parts
118130

131+
parts =
132+
if reasoning && reasoning != "",
133+
do: [%{"text" => reasoning, "thought" => true} | parts],
134+
else: parts
135+
119136
parts =
120137
if length(tool_calls) > 0 do
121138
tool_parts =
@@ -168,11 +185,15 @@ defmodule Nous.Messages.Gemini do
168185
end
169186

170187
defp parse_content(parts_data) when is_list(parts_data) do
171-
{content_parts, tool_calls} =
172-
Enum.reduce(parts_data, {[], []}, fn item, {parts, tools} ->
188+
{content_parts, reasoning_content, tool_calls} =
189+
Enum.reduce(parts_data, {[], [], []}, fn item, {parts, reasoning, tools} ->
173190
case item do
174191
%{"text" => text} ->
175-
{[ContentPart.text(text) | parts], tools}
192+
if Map.get(item, "thought") do
193+
{parts, [ContentPart.thinking(text) | reasoning], tools}
194+
else
195+
{[ContentPart.text(text) | parts], reasoning, tools}
196+
end
176197

177198
%{"functionCall" => %{"name" => name, "args" => args}} ->
178199
tool_call = %{
@@ -181,23 +202,28 @@ defmodule Nous.Messages.Gemini do
181202
"arguments" => args
182203
}
183204

184-
{parts, [tool_call | tools]}
205+
{parts, reasoning, [tool_call | tools]}
185206

186207
_ ->
187-
{parts, tools}
208+
{parts, reasoning, tools}
188209
end
189210
end)
190211

191-
{Enum.reverse(content_parts), Enum.reverse(tool_calls)}
212+
{Enum.reverse(content_parts), Enum.reverse(reasoning_content), Enum.reverse(tool_calls)}
192213
end
193214

194215
defp parse_parts(parts) when is_list(parts) do
195-
{text_parts, tool_calls} =
196-
Enum.reduce(parts, {[], []}, fn part, {texts, tools} ->
216+
{text_parts, reasoning_parts, tool_calls} =
217+
Enum.reduce(parts, {[], [], []}, fn part, {texts, reasoning, tools} ->
197218
cond do
198219
Map.has_key?(part, "text") ->
199220
text = Map.get(part, "text", "")
200-
{[text | texts], tools}
221+
222+
if Map.get(part, "thought") do
223+
{texts, [text | reasoning], tools}
224+
else
225+
{[text | texts], reasoning, tools}
226+
end
201227

202228
Map.has_key?(part, "functionCall") ->
203229
function_call = Map.get(part, "functionCall")
@@ -209,14 +235,15 @@ defmodule Nous.Messages.Gemini do
209235
"arguments" => Map.get(function_call, "args", %{})
210236
}
211237

212-
{texts, [tool_call | tools]}
238+
{texts, reasoning, [tool_call | tools]}
213239

214240
true ->
215-
{texts, tools}
241+
{texts, reasoning, tools}
216242
end
217243
end)
218244

219245
text_content = text_parts |> Enum.reverse() |> Enum.join(" ") |> String.trim()
246+
reasoning_content = reasoning_parts |> Enum.reverse() |> Enum.join(" ") |> String.trim()
220247
# Add space after text if there are tool calls
221248
text_content =
222249
if text_content != "" and length(tool_calls) > 0 do
@@ -225,7 +252,7 @@ defmodule Nous.Messages.Gemini do
225252
text_content
226253
end
227254

228-
{text_content, Enum.reverse(tool_calls)}
255+
{text_content, reasoning_content, Enum.reverse(tool_calls)}
229256
end
230257

231258
defp parse_usage(usage_data) when is_map(usage_data) do
@@ -240,5 +267,6 @@ defmodule Nous.Messages.Gemini do
240267

241268
defp consolidate_content_parts([]), do: ""
242269
defp consolidate_content_parts([%ContentPart{type: :text, content: content}]), do: content
270+
defp consolidate_content_parts([%ContentPart{type: :thinking, content: content}]), do: content
243271
defp consolidate_content_parts(parts) when is_list(parts), do: parts
244272
end

lib/nous/stream_normalizer/gemini.ex

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,12 @@ defmodule Nous.StreamNormalizer.Gemini do
7575
end
7676
end
7777

78-
defp parse_part(%{"text" => text}) when text != "" do
79-
[{:text_delta, text}]
78+
defp parse_part(%{"text" => text} = part) when text != "" do
79+
if Map.get(part, "thought") do
80+
[{:thinking_delta, text}]
81+
else
82+
[{:text_delta, text}]
83+
end
8084
end
8185

8286
defp parse_part(%{"functionCall" => %{"name" => name, "args" => args}}) do

test/nous/messages_gemini_test.exs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
defmodule Nous.MessagesGeminiTest do
2+
use ExUnit.Case, async: true
3+
alias Nous.Messages.Gemini
4+
alias Nous.Message
5+
alias Nous.StreamNormalizer.Gemini, as: GeminiStream
6+
7+
describe "from_response/1" do
8+
test "extracts thought block into reasoning_content" do
9+
response = %{
10+
"candidates" => [
11+
%{
12+
"content" => %{
13+
"parts" => [
14+
%{"text" => "Thinking process", "thought" => true},
15+
%{"text" => "Final answer"}
16+
]
17+
}
18+
}
19+
]
20+
}
21+
22+
msg = Gemini.from_response(response)
23+
assert msg.role == :assistant
24+
assert msg.content == "Final answer"
25+
assert msg.reasoning_content == "Thinking process"
26+
end
27+
end
28+
29+
describe "to_format/1" do
30+
test "includes reasoning_content as a thought part" do
31+
msg = Message.new!(%{role: :assistant, content: "Answer", reasoning_content: "Thoughts"})
32+
33+
{_sys, formatted} = Gemini.to_format([msg])
34+
gemini_msg = List.first(formatted)
35+
36+
assert gemini_msg["role"] == "model"
37+
38+
parts = gemini_msg["parts"]
39+
assert Enum.any?(parts, fn part -> part["thought"] == true and part["text"] == "Thoughts" end)
40+
assert Enum.any?(parts, fn part -> part["text"] == "Answer" end)
41+
end
42+
end
43+
44+
describe "StreamNormalizer" do
45+
test "emits thinking delta when thought is true" do
46+
chunk = %{
47+
"candidates" => [
48+
%{
49+
"content" => %{
50+
"parts" => [
51+
%{"text" => "Thinking step", "thought" => true}
52+
]
53+
}
54+
}
55+
]
56+
}
57+
58+
events = GeminiStream.normalize_chunk(chunk)
59+
assert [{:thinking_delta, "Thinking step"}] = events
60+
end
61+
end
62+
end

0 commit comments

Comments
 (0)