lib/genai_providers/ollama/encoder_protocol.ex

defprotocol GenAI.Provider.Ollama.EncoderProtocol do
  @moduledoc """
  Encoders use their module's EncoderProtocol to prep messages and tool definitions
  To make future extensibility for third parties straight forward. If a new message
  type, or tool type is added one simply needs to implement a EncoderProtocol for it
  and most cases you can simply cast it to generic known type and then invoke the protocol
  again.
  
  Ollama uses a similar format to OpenAI but with some differences.
  """
  def encode(subject, model, session, context, options)
end

# -----------------------------
# GenAI.Tool
# -----------------------------
defimpl GenAI.Provider.Ollama.EncoderProtocol, for: GenAI.Tool do
  def encode(subject, _model, session, _context, _options) do
    # Ollama tool format
    encoded = %{
      type: :function,
      function: %{
        name: subject.name,
        description: subject.description,
        parameters: subject.parameters
      }
    }

    {:ok, {encoded, session}}
  end
end

# -----------------------------
# GenAI.Message
# -----------------------------
defimpl GenAI.Provider.Ollama.EncoderProtocol, for: GenAI.Message do
  def content(content)

  def content(content) when is_bitstring(content) do
    content
  end

  def content(%GenAI.Message.Content.TextContent{} = content) do
    content.text
  end

  def content(%GenAI.Message.Content.ImageContent{} = content) do
    # Ollama supports base64 encoded images
    {:ok, encoded} = GenAI.Message.Content.ImageContent.base64(content)
    encoded
  end

  def encode(subject, _model, session, _context, _options) do
    encoded =
      case subject.content do
        x when is_bitstring(x) ->
          %{role: subject.role, content: subject.content}

        x when is_list(x) ->
          # For Ollama, we need to handle mixed content differently
          # If there are images, they go in a separate images field
          {text_parts, image_parts} = 
            Enum.split_with(x, fn 
              %GenAI.Message.Content.TextContent{} -> true
              content when is_bitstring(content) -> true
              _ -> false
            end)
          
          text_content = 
            text_parts
            |> Enum.map(&content/1)
            |> Enum.join("\n")
          
          if Enum.empty?(image_parts) do
            %{role: subject.role, content: text_content}
          else
            images = Enum.map(image_parts, &content/1)
            %{role: subject.role, content: text_content, images: images}
          end
      end

    {:ok, {encoded, session}}
  end
end

# -----------------------------
# GenAI.Message.ToolResponse
# -----------------------------
defimpl GenAI.Provider.Ollama.EncoderProtocol, for: GenAI.Message.ToolResponse do
  def encode(subject, _model, session, _context, _options) do
    # Ollama format for tool responses
    encoded = %{
      role: :tool,
      content: Jason.encode!(subject.tool_response)
    }

    {:ok, {encoded, session}}
  end
end

# -----------------------------
# GenAI.Message.ToolUsage
# -----------------------------
defimpl GenAI.Provider.Ollama.EncoderProtocol, for: GenAI.Message.ToolUsage do
  def encode_call(%GenAI.Message.ToolCall{
        id: _id,
        type: _type,
        tool_name: tool_name,
        arguments: arguments
      }) do
    %{
      function: %{
        name: tool_name,
        arguments: arguments
      }
    }
  end

  def encode(subject, _model, session, _context, _options) do
    tool_calls = Enum.map(subject.tool_calls, &encode_call/1)

    encoded = %{
      role: subject.role,
      content: subject.content || "",
      tool_calls: tool_calls
    }

    {:ok, {encoded, session}}
  end
end