Current section

Files

Jump to
langchain lib chat_models chat_anthropic.ex
Raw

lib/chat_models/chat_anthropic.ex

defmodule LangChain.ChatModels.ChatAnthropic do
@moduledoc """
Module for interacting with [Anthropic models](https://docs.anthropic.com/claude/docs/models-overview#claude-3-a-new-generation-of-ai).
Parses and validates inputs for making requests to [Anthropic's messages API](https://docs.anthropic.com/claude/reference/messages_post).
Converts responses into more specialized `LangChain` data structures.
"""
use Ecto.Schema
require Logger
import Ecto.Changeset
import LangChain.Utils.ApiOverride
alias __MODULE__
alias LangChain.Config
alias LangChain.ChatModels.ChatModel
alias LangChain.LangChainError
alias LangChain.Message
alias LangChain.Message.ContentPart
alias LangChain.Message.ToolCall
alias LangChain.Message.ToolResult
alias LangChain.MessageDelta
alias LangChain.Function
alias LangChain.FunctionParam
alias LangChain.Utils
@behaviour ChatModel
# allow up to 1 minute for response.
@receive_timeout 60_000
@primary_key false
embedded_schema do
# API endpoint to use. Defaults to Anthropic's API
field :endpoint, :string, default: "https://api.anthropic.com/v1/messages"
# API key for Anthropic. If not set, will use global api key. Allows for usage
# of a different API key per-call if desired. For instance, allowing a
# customer to provide their own.
field :api_key, :string
# https://docs.anthropic.com/claude/reference/versions
field :api_version, :string, default: "2023-06-01"
# 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 :model, :string, default: "claude-3-haiku-20240307"
field :model, :string, default: "claude-3-haiku-20240307"
# The maximum tokens allowed
# This field is required to be present in the API request.
# For now, all Claude models support max of 4096, which makes this default easy.
field :max_tokens, :integer, default: 4096
# Amount of randomness injected into the response. Ranges from 0.0 to 1.0. Defaults to 1.0.
# Use temperature closer to 0.0 for analytical / multiple choice, and closer to 1.0 for
# creative and generative tasks.
field :temperature, :float, default: 1.0
# Use nucleus sampling.
# Recommended for advanced use cases only. You usually only need to use temperature.
#
# https://towardsdatascience.com/how-to-sample-from-language-models-682bceb97277
#
field :top_p, :float
# Only sample from the top K options for each subsequent token.
# Recommended for advanced use cases only. You usually only need to use temperature.
#
# https://towardsdatascience.com/how-to-sample-from-language-models-682bceb97277
#
field :top_k, :integer
# Whether to stream the response
field :stream, :boolean, default: false
end
@type t :: %ChatAnthropic{}
@create_fields [
:endpoint,
:api_key,
:api_version,
:receive_timeout,
:model,
:max_tokens,
:temperature,
:top_p,
:top_k,
:stream
]
@required_fields [:endpoint, :model]
@spec get_api_key(t()) :: String.t()
defp get_api_key(%ChatAnthropic{api_key: api_key}) do
# if no API key is set default to `""` which will raise an error
api_key || Config.resolve(:anthropic_key, "")
end
@doc """
Setup a ChatAnthropic client configuration.
"""
@spec new(attrs :: map()) :: {:ok, t} | {:error, Ecto.Changeset.t()}
def new(%{} = attrs \\ %{}) do
%ChatAnthropic{}
|> cast(attrs, @create_fields)
|> common_validation()
|> apply_action(:insert)
end
@doc """
Setup a ChatAnthropic 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)
|> validate_number(:temperature, greater_than_or_equal_to: 0, less_than_or_equal_to: 1)
|> validate_number(:receive_timeout, greater_than_or_equal_to: 0)
end
@doc """
Return the params formatted for an API request.
"""
@spec for_api(t, message :: [map()], ChatModel.tools()) :: %{atom() => any()}
def for_api(%ChatAnthropic{} = anthropic, messages, tools) do
# separate the system message from the rest. Handled separately.
{system, messages} = split_system_message(messages)
system_text =
case system do
nil ->
nil
%Message{role: :system, content: content} ->
content
end
messages =
messages
|> Enum.map(&for_api/1)
|> post_process_and_combine_messages()
%{
model: anthropic.model,
temperature: anthropic.temperature,
stream: anthropic.stream,
messages: messages
}
# Anthropic sets the `system` message on the request body, not as part of the messages list.
|> Utils.conditionally_add_to_map(:system, system_text)
|> Utils.conditionally_add_to_map(:tools, get_tools_for_api(tools))
|> Utils.conditionally_add_to_map(:max_tokens, anthropic.max_tokens)
|> Utils.conditionally_add_to_map(:top_p, anthropic.top_p)
|> Utils.conditionally_add_to_map(:top_k, anthropic.top_k)
end
defp get_tools_for_api(nil), do: []
defp get_tools_for_api(tools) do
Enum.map(tools, fn
%Function{} = function ->
for_api(function)
end)
end
# Unlike OpenAI, Anthropic only supports one system message.
@doc false
@spec split_system_message([Message.t()]) :: {nil | Message.t(), [Message.t()]} | no_return()
def split_system_message(messages) do
# split the messages into "system" and "other". Error if more than 1 system
# message. Return the other messages as a separate list.
{system, other} = Enum.split_with(messages, &(&1.role == :system))
if length(system) > 1 do
raise LangChainError, "Anthropic only supports a single System message"
end
{List.first(system), other}
end
@doc """
Calls the Anthropic API passing the ChatAnthropic struct with configuration, plus
either a simple message or the list of messages to act as the prompt.
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 `ChatAnthropic` module is more focused on
translating the `LangChain` data structures to and from the Anthropic 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(anthropic, prompt, functions \\ [], callback_fn \\ nil)
def call(%ChatAnthropic{} = anthropic, prompt, functions, callback_fn) when is_binary(prompt) do
messages = [
Message.new_system!(),
Message.new_user!(prompt)
]
call(anthropic, messages, functions, callback_fn)
end
def call(%ChatAnthropic{} = anthropic, messages, functions, callback_fn)
when is_list(messages) do
if override_api_return?() do
Logger.warning("Found override API response. Will not make live API call.")
case get_api_override() do
{:ok, {:ok, data} = response} ->
# fire callback for fake responses too
Utils.fire_callback(anthropic, data, callback_fn)
response
# fake error response
{:ok, {:error, _reason} = response} ->
response
_other ->
raise LangChainError,
"An unexpected fake API response was set. Should be an `{:ok, value}`"
end
else
try do
# make base api request and perform high-level success/failure checks
case do_api_request(anthropic, messages, functions, callback_fn) do
{:error, reason} ->
{:error, reason}
parsed_data ->
{:ok, parsed_data}
end
rescue
err in LangChainError ->
{:error, err.message}
end
end
end
# Call Anthropic's API.
#
# The result of the function is:
#
# - `result` - where `result` is a data-structure like a list or map.
# - `{:error, reason}` - Where reason is a string explanation of what went wrong.
#
# If a callback_fn is provided, it will fire with each
#
# If `stream: false`, the completed message is returned.
#
# If `stream: true`, the `callback_fn` is executed for the returned MessageDelta
# responses.
#
# Executes the callback function passing the response only parsed to the data
# structures.
# Retries the request up to 3 times on transient errors with a 1 second delay
@doc false
@spec do_api_request(t(), [Message.t()], ChatModel.tools(), (any() -> any())) ::
list() | struct() | {:error, String.t()}
def do_api_request(anthropic, messages, tools, callback_fn, retry_count \\ 3)
def do_api_request(_anthropic, _messages, _functions, _callback_fn, 0) do
raise LangChainError, "Retries exceeded. Connection failed."
end
def do_api_request(
%ChatAnthropic{stream: false} = anthropic,
messages,
tools,
callback_fn,
retry_count
) do
req =
Req.new(
url: anthropic.endpoint,
json: for_api(anthropic, messages, tools),
headers: headers(get_api_key(anthropic), anthropic.api_version),
receive_timeout: anthropic.receive_timeout,
retry: :transient,
max_retries: 3,
retry_delay: fn attempt -> 300 * attempt end
)
req
|> Req.post()
# parse the body and return it as parsed structs
|> case do
{:ok, %Req.Response{body: data}} ->
case do_process_response(data) do
{:error, reason} ->
{:error, reason}
result ->
Utils.fire_callback(anthropic, result, callback_fn)
result
end
{:error, %Mint.TransportError{reason: :timeout}} ->
{:error, "Request timed out"}
{:error, %Mint.TransportError{reason: :closed}} ->
# Force a retry by making a recursive call decrementing the counter
Logger.debug(fn -> "Mint connection closed: retry count = #{inspect(retry_count)}" end)
do_api_request(anthropic, messages, tools, callback_fn, retry_count - 1)
other ->
Logger.error("Unexpected and unhandled API response! #{inspect(other)}")
other
end
end
def do_api_request(
%ChatAnthropic{stream: true} = anthropic,
messages,
tools,
callback_fn,
retry_count
) do
Req.new(
url: anthropic.endpoint,
json: for_api(anthropic, messages, tools),
headers: headers(get_api_key(anthropic), anthropic.api_version),
receive_timeout: anthropic.receive_timeout
)
|> Req.post(
into:
Utils.handle_stream_fn(anthropic, &decode_stream/1, &do_process_response/1, callback_fn)
)
|> case do
{:ok, %Req.Response{body: data}} ->
data
{:error, %LangChainError{message: reason}} ->
{:error, reason}
{:error, %Mint.TransportError{reason: :timeout}} ->
{:error, "Request timed out"}
{:error, %Mint.TransportError{reason: :closed}} ->
# Force a retry by making a recursive call decrementing the counter
Logger.debug(fn -> "Mint connection closed: retry count = #{inspect(retry_count)}" end)
do_api_request(anthropic, messages, tools, callback_fn, retry_count - 1)
other ->
Logger.error(
"Unhandled and unexpected response from streamed post call. #{inspect(other)}"
)
{:error, "Unexpected response"}
end
end
defp headers(api_key, api_version) do
%{
"x-api-key" => api_key,
"content-type" => "application/json",
"anthropic-version" => api_version,
# https://docs.anthropic.com/claude/docs/tool-use - requires this header during beta
"anthropic-beta" => "tools-2024-04-04"
}
end
# Parse a new message response
@doc false
@spec do_process_response(data :: %{String.t() => any()} | {:error, any()}) ::
Message.t()
| [Message.t()]
| MessageDelta.t()
| [MessageDelta.t()]
| {:error, String.t()}
def do_process_response(%{
"role" => "assistant",
"content" => contents,
"stop_reason" => stop_reason
}) do
new_message =
%{
role: :assistant,
status: stop_reason_to_status(stop_reason)
}
|> Message.new()
|> to_response()
# reduce over the contents and accumulate to the message
Enum.reduce(contents, new_message, fn content, acc ->
do_process_content_response(acc, content)
end)
end
def do_process_response(%{
"type" => "content_block_start",
"content_block" => %{"type" => "text", "text" => content}
}) do
%{
role: :assistant,
content: content,
status: :incomplete
}
|> MessageDelta.new()
|> to_response()
end
def do_process_response(%{
"type" => "content_block_delta",
"delta" => %{"type" => "text_delta", "text" => content}
}) do
%{
role: :assistant,
content: content,
status: :incomplete
}
|> MessageDelta.new()
|> to_response()
end
def do_process_response(%{
"type" => "message_delta",
"delta" => %{"stop_reason" => stop_reason}
}) do
%{
role: :assistant,
content: "",
status: stop_reason_to_status(stop_reason)
}
|> MessageDelta.new()
|> to_response()
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
# for parsing a list of received content JSON objects
defp do_process_content_response(%Message{} = message, %{"type" => "text", "text" => ""}),
do: message
defp do_process_content_response(%Message{} = message, %{"type" => "text", "text" => text}) do
%Message{message | content: text}
end
defp do_process_content_response(
%Message{} = message,
%{"type" => "tool_use", "id" => call_id, "name" => name} = call
) do
arguments =
case call["input"] do
# when properties is an empty map, treat it as nil
%{"properties" => %{} = props} when props == %{} ->
nil
# when an empty map, return nil
%{} = data when data == %{} ->
nil
# when a map with data
%{} = data ->
data
end
%Message{
message
| tool_calls:
message.tool_calls ++
[
ToolCall.new!(%{
type: :function,
call_id: call_id,
name: name,
arguments: arguments,
status: :complete
})
]
}
end
defp do_process_content_response({:error, _reason} = error, _content) do
error
end
defp to_response({:ok, message}), do: message
defp to_response({:error, changeset}), do: {:error, Utils.changeset_error_to_string(changeset)}
defp stop_reason_to_status("end_turn"), do: :complete
defp stop_reason_to_status("tool_use"), do: :complete
defp stop_reason_to_status("max_tokens"), do: :length
defp stop_reason_to_status("stop_sequence"), do: :complete
defp stop_reason_to_status(other) do
Logger.warning("Unsupported stop_reason. Reason: #{inspect(other)}")
nil
end
@doc false
def decode_stream({chunk, buffer}) do
# Combine the incoming data with the buffered incomplete data
combined_data = buffer <> chunk
# Split data by double newline to find complete messages
entries = String.split(combined_data, "\n\n", trim: true)
# The last part may be incomplete if it doesn't end with "\n\n"
{to_process, incomplete} =
if String.ends_with?(combined_data, "\n\n") do
{entries, ""}
else
# process all but the last, keep the last as incomplete
{Enum.slice(entries, 0..-2//1), List.last(entries)}
end
processed =
to_process
# Trim whitespace from each line
|> Stream.map(&String.trim/1)
# Ignore empty lines
|> Stream.reject(&(&1 == ""))
# Filter lines based on some condition
|> Stream.filter(&relevant_event?/1)
# Split the event from the data into separate lines
|> Stream.map(&extract_data(&1))
|> Enum.reduce([], fn json, done ->
json
|> Jason.decode()
|> case do
{:ok, parsed} ->
# wrap each parsed response into an array of 1. This matches the
# return type of some LLMs where they return `n` number of responses.
# This is for compatibility.
# {done ++ Enum.map(parsed, &([&1])), ""}
done ++ [parsed]
{:error, reason} ->
Logger.error("Failed to JSON decode streamed data: #{inspect(reason)}")
done
end
end)
{processed, incomplete}
end
defp relevant_event?("event: content_block_delta\n" <> _rest), do: true
defp relevant_event?("event: content_block_start\n" <> _rest), do: true
defp relevant_event?("event: message_delta\n" <> _rest), do: true
# ignoring
defp relevant_event?("event: message_start\n" <> _rest), do: false
defp relevant_event?("event: ping\n" <> _rest), do: false
defp relevant_event?("event: content_block_stop\n" <> _rest), do: false
defp relevant_event?("event: message_stop\n" <> _rest), do: false
# catch-all for when we miss something
defp relevant_event?(event) do
Logger.error("Unsupported event received when parsing Anthropic response: #{inspect(event)}")
false
end
# process data for an event
defp extract_data("event: " <> line) do
[_prefix, json] = String.split(line, "data: ", trim: true)
json
end
# assumed the response is JSON. Return as-is
defp extract_data(json), do: json
@doc """
Convert a LangChain structure to the expected map of data for the OpenAI API.
"""
@spec for_api(Message.t() | ContentPart.t() | Function.t()) ::
%{String.t() => any()} | no_return()
def for_api(%Message{role: :assistant, tool_calls: calls} = msg)
when is_list(calls) and calls != [] do
text_content =
if is_binary(msg.content) do
[
%{
"type" => "text",
"text" => msg.content
}
]
else
[]
end
tool_calls = Enum.map(calls, &for_api(&1))
%{
"role" => "assistant",
"content" => text_content ++ tool_calls
}
end
def for_api(%Message{role: :tool, tool_results: results}) when is_list(results) do
# convert ToolResult into the expected format for Anthropic.
#
# A tool result is returned as a list within the content of a user message.
tool_results = Enum.map(results, &for_api(&1))
%{
"role" => "user",
"content" => tool_results
}
end
# when content is plain text
def for_api(%Message{content: content} = msg) when is_binary(content) do
%{
"role" => Atom.to_string(msg.role),
"content" => msg.content
}
end
def for_api(%Message{role: :user, content: content}) when is_list(content) do
%{
"role" => "user",
"content" => Enum.map(content, &for_api(&1))
}
end
def for_api(%ContentPart{type: :text} = part) do
%{"type" => "text", "text" => part.content}
end
def for_api(%ContentPart{type: :image} = part) do
%{
"type" => "image",
"source" => %{
"type" => "base64",
"data" => part.content,
"media_type" => Keyword.fetch!(part.options, :media)
}
}
end
def for_api(%ContentPart{type: :image_url} = _part) do
raise LangChainError, "Anthropic does not support image_url"
end
# Function support
def for_api(%Function{} = fun) do
# I'm here
%{
"name" => fun.name,
"input_schema" => get_parameters(fun)
}
|> Utils.conditionally_add_to_map("description", fun.description)
end
# ToolCall support
def for_api(%ToolCall{} = call) do
%{
"type" => "tool_use",
"id" => call.call_id,
"name" => call.name,
"input" => call.arguments || %{}
}
end
# ToolResult support
def for_api(%ToolResult{} = result) do
%{
"type" => "tool_result",
"tool_use_id" => result.tool_call_id,
"content" => result.content
}
|> Utils.conditionally_add_to_map("is_error", result.is_error)
end
defp get_parameters(%Function{parameters: [], parameters_schema: nil} = _fun) do
%{
"type" => "object",
"properties" => %{}
}
end
defp get_parameters(%Function{parameters: [], parameters_schema: schema} = _fun)
when is_map(schema) do
schema
end
defp get_parameters(%Function{parameters: params} = _fun) do
FunctionParam.to_parameters_schema(params)
end
@doc """
After all the messages have been converted using `for_api/1`, this combines
multiple sequential tool response messages. The Anthropic API is very strict
about user, assistant, user, assistant sequenced messages.
"""
def post_process_and_combine_messages(messages) do
messages
|> Enum.reverse()
|> Enum.reduce([], fn
# when two "user" role messages are listed together, combine them. This
# can happen because multiple ToolCalls require multiple tool response
# messages, but Anthropic does those as a User message and strictly
# enforces that multiple user messages in a row are not permitted.
%{"role" => "user"} = item, [%{"role" => "user"} = prev | rest] = _acc ->
updated_prev = merge_user_messages(item, prev)
# merge current item into the previous and return the updated list
# updated_prev = Map.put(prev, "content", item["content"] ++ prev["content"])
[updated_prev | rest]
item, acc ->
[item | acc]
end)
end
# Merge the two user messages
defp merge_user_messages(%{"role" => "user"} = item, %{"role" => "user"} = prev) do
item = get_merge_friendly_user_content(item)
prev = get_merge_friendly_user_content(prev)
Map.put(prev, "content", item["content"] ++ prev["content"])
end
defp get_merge_friendly_user_content(%{"role" => "user", "content" => content} = item)
when is_binary(content) do
# replace the string content with text object
Map.put(item, "content", [%{"type" => "text", "text" => content}])
end
defp get_merge_friendly_user_content(%{"role" => "user", "content" => content} = item)
when is_list(content) do
item
end
end