Packages
nous
0.13.1
0.17.0
0.16.6
0.16.5
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.8
0.15.7
0.15.6
0.15.5
0.15.4
0.15.3
0.15.2
0.15.1
0.15.0
0.14.3
0.14.2
0.14.1
0.14.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.17
0.12.16
0.12.15
0.12.14
0.12.13
0.12.12
0.12.11
0.12.9
0.12.7
0.12.6
0.12.5
0.12.3
0.12.2
0.12.0
0.11.3
0.11.0
0.10.1
0.10.0
0.9.0
0.8.1
0.8.0
0.7.2
0.7.1
0.7.0
0.5.0
AI agent framework for Elixir with multi-provider LLM support
Current section
Files
Jump to
Current section
Files
lib/nous/stream_normalizer/gemini.ex
defmodule Nous.StreamNormalizer.Gemini do
@moduledoc """
Stream normalizer for Google Gemini API.
Handles Gemini's SSE streaming format where events arrive as JSON maps
with a `candidates` array.
## Streaming Event Types
| Gemini Event | Normalized Output |
|-------------|-------------------|
| `candidates[0].content.parts[0].text` | `{:text_delta, text}` |
| `candidates[0].content.parts` with `functionCall` | `{:tool_call_delta, call}` |
| `candidates[0].finishReason` present | `{:finish, reason}` |
| `{:stream_done, reason}` | `{:finish, reason}` |
| Error with `"error"` key | `{:error, message}` |
"""
@behaviour Nous.StreamNormalizer
@impl true
def normalize_chunk({:stream_done, reason}) do
[{:finish, reason}]
end
def normalize_chunk(%{"error" => error}) when is_map(error) do
message = Map.get(error, "message", inspect(error))
[{:error, message}]
end
def normalize_chunk(%{"candidates" => candidates} = chunk) when is_list(candidates) do
case candidates do
[candidate | _] -> parse_candidate(candidate, chunk)
[] -> [{:unknown, chunk}]
end
end
def normalize_chunk(chunk) do
[{:unknown, chunk}]
end
@impl true
def complete_response?(%{"candidates" => [candidate | _]}) when is_map(candidate) do
Map.has_key?(candidate, "finishReason")
end
def complete_response?(_), do: false
@impl true
def convert_complete_response(%{"candidates" => [candidate | _]}) do
parts = get_in(candidate, ["content", "parts"]) || []
finish_reason = normalize_finish_reason(Map.get(candidate, "finishReason"))
events = Enum.flat_map(parts, &parse_part/1)
events ++ [{:finish, finish_reason}]
end
def convert_complete_response(chunk) do
[{:unknown, chunk}]
end
defp parse_candidate(candidate, _chunk) do
parts = get_in(candidate, ["content", "parts"]) || []
finish_reason = Map.get(candidate, "finishReason")
part_events = Enum.flat_map(parts, &parse_part/1)
if finish_reason do
part_events ++ [{:finish, normalize_finish_reason(finish_reason)}]
else
case part_events do
[] -> [{:unknown, candidate}]
events -> events
end
end
end
defp parse_part(%{"text" => text} = part) when text != "" do
if Map.get(part, "thought") do
[{:thinking_delta, text}]
else
[{:text_delta, text}]
end
end
defp parse_part(%{"functionCall" => %{"name" => name, "args" => args}}) do
[{:tool_call_delta, %{"name" => name, "arguments" => args}}]
end
defp parse_part(%{"functionCall" => %{"name" => name}}) do
[{:tool_call_delta, %{"name" => name, "arguments" => %{}}}]
end
defp parse_part(_), do: []
defp normalize_finish_reason("STOP"), do: "stop"
defp normalize_finish_reason("MAX_TOKENS"), do: "length"
defp normalize_finish_reason("SAFETY"), do: "safety"
defp normalize_finish_reason(reason) when is_binary(reason), do: String.downcase(reason)
defp normalize_finish_reason(_), do: "stop"
end