Current section

Files

Jump to
langchain lib chat_models chat_google_ai.ex
Raw

lib/chat_models/chat_google_ai.ex

defmodule LangChain.ChatModels.ChatGoogleAI do
@moduledoc """
Parses and validates inputs for making a request for the Google AI Chat API.
Converts response into more specialized `LangChain` data structures.
"""
use Ecto.Schema
require Logger
import Ecto.Changeset
alias __MODULE__
alias LangChain.Config
alias LangChain.ChatModels.ChatModel
alias LangChain.Message
alias LangChain.MessageDelta
alias LangChain.LangChainError
alias LangChain.ForOpenAIApi
alias LangChain.Utils
@behaviour ChatModel
@default_base_url "https://generativelanguage.googleapis.com"
@default_api_version "v1beta"
@default_endpoint "#{@default_base_url}#{@default_api_version}"
# allow up to 2 minutes for response.
@receive_timeout 60_000
@primary_key false
embedded_schema do
field :endpoint, :string, default: @default_endpoint
# The version of the API to use.
field :version, :string, default: @default_api_version
field :model, :string, default: "gemini-pro"
field :api_key, :string
# What sampling temperature to use, between 0 and 2. Higher values like 0.8
# will make the output more random, while lower values like 0.2 will make it
# more focused and deterministic.
field :temperature, :float, default: 0.9
# The topP parameter changes how the model selects tokens for output. Tokens
# are selected from the most to least probable until the sum of their
# probabilities equals the topP value. For example, if tokens A, B, and C have
# a probability of 0.3, 0.2, and 0.1 and the topP value is 0.5, then the model
# will select either A or B as the next token by using the temperature and exclude
# C as a candidate. The default topP value is 0.95.
field :top_p, :float, default: 1.0
# The topK parameter changes how the model selects tokens for output. A topK of
# 1 means the selected token is the most probable among all the tokens in the
# model's vocabulary (also called greedy decoding), while a topK of 3 means that
# the next token is selected from among the 3 most probable using the temperature.
# For each token selection step, the topK tokens with the highest probabilities
# are sampled. Tokens are then further filtered based on topP with the final token
# selected using temperature sampling.
field :top_k, :float, default: 1.0
# Duration in seconds for the response to be received. When streaming a very
# lengthy response, a longer time limit may be required. However, when it
# goes on too long by itself, it tends to hallucinate more.
field :receive_timeout, :integer, default: @receive_timeout
field :stream, :boolean, default: false
end
@type t :: %ChatGoogleAI{}
@create_fields [
:endpoint,
:version,
:model,
:api_key,
:temperature,
:top_p,
:top_k,
:receive_timeout,
:stream
]
@required_fields [
:endpoint,
:version,
:model
]
@spec get_api_key(t) :: String.t()
defp get_api_key(%ChatGoogleAI{api_key: api_key}) do
# if no API key is set default to `""` which will raise an API error
api_key || Config.resolve(:google_ai_key, "")
end
@doc """
Setup a ChatGoogleAI client configuration.
"""
@spec new(attrs :: map()) :: {:ok, t} | {:error, Ecto.Changeset.t()}
def new(%{} = attrs \\ %{}) do
%ChatGoogleAI{}
|> cast(attrs, @create_fields)
|> common_validation()
|> apply_action(:insert)
end
@doc """
Setup a ChatGoogleAI client configuration and return it or raise an error if invalid.
"""
@spec new!(attrs :: map()) :: t() | no_return()
def new!(attrs \\ %{}) do
case new(attrs) do
{:ok, chain} ->
chain
{:error, changeset} ->
raise LangChainError, changeset
end
end
defp common_validation(changeset) do
changeset
|> validate_required(@required_fields)
end
def for_api(%ChatGoogleAI{} = google_ai, messages, functions) do
req = %{
"contents" =>
Stream.map(messages, &for_api/1)
|> Enum.flat_map(fn
list when is_list(list) -> list
not_list -> [not_list]
end),
"generationConfig" => %{
"temperature" => google_ai.temperature,
"topP" => google_ai.top_p,
"topK" => google_ai.top_k
}
}
if functions && not Enum.empty?(functions) do
req
|> Map.put("tools", [
%{
# Google AI functions use an OpenAI compatible format.
# See: https://ai.google.dev/docs/function_calling#how_it_works
"functionDeclarations" => Enum.map(functions, &ForOpenAIApi.for_api/1)
}
])
else
req
end
end
defp for_api(%Message{role: :assistant, function_name: fun_name} = fun)
when is_binary(fun_name) do
%{
"role" => map_role(:assistant),
"parts" => [
%{
"functionCall" => %{
"name" => fun_name,
"args" => fun.arguments
}
}
]
}
end
defp for_api(%Message{role: :function} = message) do
%{
"role" => map_role(:function),
"parts" => [
%{
"functionResponse" => %{
"name" => message.function_name,
"response" => Jason.decode!(message.content)
}
}
]
}
end
defp for_api(%Message{role: :system} = message) do
# No system messages support means we need to fake a prompt and response
# to pretend like it worked.
[
%{
"role" => :user,
"parts" => [%{"text" => message.content}]
},
%{
"role" => :model,
"parts" => [%{"text" => ""}]
}
]
end
defp for_api(%Message{} = message) do
%{
"role" => map_role(message.role),
"parts" => [%{"text" => message.content}]
}
end
defp map_role(role) do
case role do
:assistant -> :model
# System prompts are not supported yet. Google recommends using user prompt.
:system -> :user
role -> role
end
end
@doc """
Calls the Google AI API passing the ChatGoogleAI struct with configuration, plus
either a simple message or the list of messages to act as the prompt.
Optionally pass in a list of functions available to the LLM for requesting
execution in response.
Optionally pass in a callback function that can be executed as data is
received from the API.
**NOTE:** This function *can* be used directly, but the primary interface
should be through `LangChain.Chains.LLMChain`. The `ChatGoogleAI` module is more focused on
translating the `LangChain` data structures to and from the OpenAI API.
Another benefit of using `LangChain.Chains.LLMChain` is that it combines the
storage of messages, adding functions, adding custom context that should be
passed to functions, and automatically applying `LangChain.MessageDelta`
structs as they are are received, then converting those to the full
`LangChain.Message` once fully complete.
"""
@impl ChatModel
def call(openai, prompt, functions \\ [], callback_fn \\ nil)
def call(%ChatGoogleAI{} = google_ai, prompt, functions, callback_fn) when is_binary(prompt) do
messages = [
Message.new_system!(),
Message.new_user!(prompt)
]
call(google_ai, messages, functions, callback_fn)
end
def call(%ChatGoogleAI{} = google_ai, messages, functions, callback_fn)
when is_list(messages) do
try do
case do_api_request(google_ai, messages, functions, callback_fn) do
{:error, reason} ->
{:error, reason}
parsed_data ->
{:ok, parsed_data}
end
rescue
err in LangChainError ->
{:error, err.message}
end
end
@doc false
@spec do_api_request(t(), [Message.t()], [Function.t()], (any() -> any())) ::
list() | struct() | {:error, String.t()}
def do_api_request(%ChatGoogleAI{stream: false} = google_ai, messages, functions, callback_fn) do
req =
Req.new(
url: build_url(google_ai),
json: for_api(google_ai, messages, functions),
receive_timeout: google_ai.receive_timeout,
retry: :transient,
max_retries: 3,
retry_delay: fn attempt -> 300 * attempt end
)
req
|> Req.post()
|> case do
{:ok, %Req.Response{body: data}} ->
case do_process_response(data) do
{:error, reason} ->
{:error, reason}
result ->
Utils.fire_callback(google_ai, result, callback_fn)
result
end
{:error, %Mint.TransportError{reason: :timeout}} ->
{:error, "Request timed out"}
other ->
Logger.error("Unexpected and unhandled API response! #{inspect(other)}")
other
end
end
def do_api_request(%ChatGoogleAI{stream: true} = google_ai, messages, functions, callback_fn) do
Req.new(
url: build_url(google_ai),
json: for_api(google_ai, messages, functions),
receive_timeout: google_ai.receive_timeout
)
|> Req.Request.put_header("accept-encoding", "utf-8")
|> Req.post(
into: Utils.handle_stream_fn(google_ai, &do_process_response(&1, MessageDelta), callback_fn)
)
|> case do
{:ok, %Req.Response{body: data}} ->
# Google AI uses `finishReason: "STOP` for all messages in the stream.
# This field can't be used to terminate the list of deltas, so simulate
# this behavior by forcing the final delta to have `status: :complete`.
complete_final_delta(data)
{:error, %LangChainError{message: reason}} ->
{:error, reason}
{:error, %Mint.TransportError{reason: :timeout}} ->
{:error, "Request timed out"}
other ->
Logger.error(
"Unhandled and unexpected response from streamed post call. #{inspect(other)}"
)
{:error, "Unexpected response"}
end
end
@spec build_url(t()) :: String.t()
defp build_url(%ChatGoogleAI{endpoint: endpoint, version: version, model: model} = google_ai) do
"#{endpoint}/#{version}/models/#{model}:#{get_action(google_ai)}?key=#{get_api_key(google_ai)}"
|> use_sse(google_ai)
end
@spec use_sse(String.t(), t()) :: String.t()
defp use_sse(url, %ChatGoogleAI{stream: true}), do: url <> "&alt=sse"
defp use_sse(url, _model), do: url
@spec get_action(t()) :: String.t()
defp get_action(%ChatGoogleAI{stream: false}), do: "generateContent"
defp get_action(%ChatGoogleAI{stream: true}), do: "streamGenerateContent"
def complete_final_delta(data) when is_list(data) do
update_in(data, [Access.at(-1), Access.at(-1)], &%{&1 | status: :complete})
end
def do_process_response(response, message_type \\ Message)
def do_process_response(%{"candidates" => candidates}, message_type) when is_list(candidates) do
candidates
|> Enum.map(&do_process_response(&1, message_type))
end
def do_process_response(
%{
"content" => %{"parts" => [%{"functionCall" => %{"args" => raw_args, "name" => name}}]}
} = data,
message_type
) do
case message_type.new(%{
"role" => "assistant",
"function_name" => name,
"arguments" => raw_args,
"complete" => true,
"index" => data["index"]
}) do
{:ok, message} ->
message
{:error, changeset} ->
{:error, Utils.changeset_error_to_string(changeset)}
end
end
def do_process_response(
%{
"finishReason" => finish,
"content" => %{"parts" => parts, "role" => role},
"index" => index
},
message_type
)
when is_list(parts) do
status =
case message_type do
MessageDelta ->
:incomplete
Message ->
case finish do
"STOP" ->
:complete
"SAFETY" ->
:complete
other ->
Logger.warning("Unsupported finishReason in response. Reason: #{inspect(other)}")
nil
end
end
content = Enum.map_join(parts, & &1["text"])
case message_type.new(%{
"content" => content,
"role" => unmap_role(role),
"status" => status,
"index" => index
}) do
{:ok, message} ->
message
{:error, changeset} ->
{:error, Utils.changeset_error_to_string(changeset)}
end
end
def do_process_response(%{"error" => %{"message" => reason}}, _) do
Logger.error("Received error from API: #{inspect(reason)}")
{:error, reason}
end
def do_process_response({:error, %Jason.DecodeError{} = response}, _) do
error_message = "Received invalid JSON: #{inspect(response)}"
Logger.error(error_message)
{:error, error_message}
end
def do_process_response(other, _) do
Logger.error("Trying to process an unexpected response. #{inspect(other)}")
{:error, "Unexpected response"}
end
defp unmap_role("model"), do: "assistant"
defp unmap_role(role), do: role
end