defmodule Anthropix do @version Keyword.fetch!(Mix.Project.config(), :version) @moduledoc """ ![Anthropix](https://raw.githubusercontent.com/lebrunel/anthropix/main/media/poster.webp) ![License](https://img.shields.io/github/license/lebrunel/anthropix?color=informational) Anthropix is an open-source Elixir client for the Anthropic API, providing a simple and convenient way to integrate Claude, Anthropic's powerful language model, into your applications. - ✅ API client fully implementing the [Anthropic API](https://docs.anthropic.com/claude/reference/getting-started-with-the-api) - 🛜 Streaming API requests - Stream to an Enumerable - Or stream messages to any Elixir process ## Installation The package can be installed by adding `anthropix` to your list of dependencies in `mix.exs`. ```elixir def deps do [ {:anthropix, "~> #{@version}"} ] end ``` ## Quickstart For more examples, refer to the [Anthropix documentation](https://hexdocs.pm/anthropix). ### Initiate a client. See `Anthropix.init/2`. ```elixir iex> client = Anthropix.init(api_key) ``` ### Chat with Claude See `Anthropix.chat/2`. ```elixir iex> messages = [ ...> %{role: "user", content: "Why is the sky blue?"}, ...> %{role: "assistant", content: "Due to rayleigh scattering."}, ...> %{role: "user", content: "How is that different than mie scattering?"}, ...> ] iex> Anthropix.chat(client, [ ...> model: "claude-3-opus-20240229", ...> messages: messages, ...> ]) {:ok, %{"content" => [%{ "type" => "text", "text" => "Mie scattering affects all wavelengths similarly, while Rayleigh favors shorter ones." }], ...}} ``` ### Streaming A streaming request can be initiated by setting the `:stream` option. When `:stream` is true a lazy `t:Enumerable.t/0` is returned which can be used with any `Stream` functions. ```elixir iex> {:ok, stream} = Anthropix.chat(client, [ ...> model: "claude-3-opus-20240229", ...> messages: messages, ...> stream: true, ...> ]) {:ok, #Function<52.53678557/2 in Stream.resource/3>} iex> stream ...> |> Stream.each(&update_ui_with_chunk/1) ...> |> Stream.run() :ok ``` Because the above approach builds the `t:Enumerable.t/0` by calling `receive`, using this approach inside GenServer callbacks may cause the GenServer to misbehave. Setting the `:stream` option to a `t:pid/0` returns a `t:Task.t/0` which will send messages to the specified process. """ use Anthropix.Schemas alias Anthropix.APIError defstruct [:req] @typedoc "Client struct" @type client() :: %__MODULE__{ req: Req.Request.t() } schema :chat_message, [ role: [ type: :string, required: true, doc: "The role of the message, either `user` or `assistant`." ], content: [ type: {:or, [:string, {:list, :map}]}, required: true, doc: "Message content, either a single string or an array of content blocks." ] ] schema :chat_tool, [ name: [ type: :string, required: true, doc: "Name of the tool." ], description: [ type: :string, required: true, doc: "Description of the tool" ], input_schema: [ type: :map, required: true, doc: "JSON schema for the tool input shape that the model will produce in tool_use output content blocks." ] ] @typedoc """ Chat message A chat message is a `t:map/0` with the following fields: #{doc(:chat_message)} """ @type message() :: %{ role: String.t(), content: String.t() | list(content_block()) } @typedoc "Message content block." @type content_block() :: content_text() | content_media() | content_tool_use() | content_tool_result() @type content_text() :: %{ type: String.t(), text: String.t() } @type content_media() :: %{ type: String.t(), source: %{ type: String.t(), media_type: String.t(), data: String.t(), } } @type content_tool_use() :: %{ type: String.t(), id: String.t(), name: String.t(), input: %{optional(String.t()) => String.t()} } @type content_tool_result() :: %{ type: String.t(), tool_use_id: String.t(), content: %{optional(String.t()) => String.t()} } @typedoc """ Tool. A chat tool is a `t:map/0` with the following fields: #{doc(:chat_tool)} """ @type tool() :: %{ name: String.t(), description: String.t(), input_schema: input_schema(), } @typedoc "JSON schema for the tool `input` shape." @type input_schema() :: %{ :type => String.t(), :properties => %{ optional(String.t()) => %{ optional(:enum) => list(String.t()), type: String.t(), description: String.t(), } }, optional(:required) => list(String.t()) } @typedoc "Client response" @type response() :: {:ok, map() | Enumerable.t() | Task.t()} | {:error, term()} @typep req_response() :: {:ok, Req.Response.t() | Task.t() | Enum.t()} | {:error, term()} @default_req_opts [ base_url: "https://api.anthropic.com/v1", headers: [ {"anthropic-version", "2023-06-01"}, {"user-agent", "anthropix/#{@version}"}, ], receive_timeout: 60_000, ] @doc """ Calling `init/1` without passing an API key, creates a new Anthropix API client using the API key set in your application's config. ```elixir config :anthropix, :api_key, "sk-ant-your-key" ``` If given, a keyword list of options will be passed to `Req.new/1`. ## Examples ```elixir iex> client = Anthropix.init() %Anthropix{} ``` """ @spec init() :: client() def init(), do: init([]) @spec init(keyword()) :: client() def init(opts) when is_list(opts) do Application.fetch_env!(:anthropix, :api_key) |> init(opts) end @doc """ Calling `init/2` with an API key creates a new Anthropix API client, using the given API key. Optionally, a keyword list of options can be passed through to `Req.new/1`. ## Examples ```elixir iex> client = Anthropix.init("sk-ant-your-key", receive_timeout: :infinity) %Anthropix{} ``` """ @spec init(String.t(), keyword()) :: client() def init(api_key, opts \\ []) when is_binary(api_key) do {headers, opts} = Keyword.pop(opts, :headers, []) req = @default_req_opts |> Keyword.merge(opts) |> Req.new() |> Req.Request.put_header("anthropic-beta", "tools-2024-04-04") |> Req.Request.put_header("x-api-key", api_key) |> Req.Request.put_headers(headers) struct(__MODULE__, req: req) end schema :chat, [ model: [ type: :string, required: true, doc: "The model that will complete your prompt.", ], messages: [ type: {:list, {:map, schema(:chat_message).schema}}, required: true, doc: "Input messages.", ], system: [ type: :string, doc: "System prompt.", ], max_tokens: [ type: :integer, default: 4096, doc: "The maximum number of tokens to generate before stopping.", ], metadata: [ type: :map, doc: "A map describing metadata about the request.", ], stop_sequences: [ type: {:list, :string}, doc: "Custom text sequences that will cause the model to stop generating.", ], stream: [ type: {:or, [:boolean, :pid]}, default: false, doc: "Whether to incrementally stream the response using server-sent events.", ], temperature: [ type: :float, doc: "Amount of randomness injected into the response." ], tools: [ type: {:list, {:map, schema(:chat_tool).schema}}, doc: "A list of tools the model may call.", ], top_k: [ type: :integer, doc: "Only sample from the top K options for each subsequent token." ], top_p: [ type: :float, doc: "Amount of randomness injected into the response." ], ] @doc """ Chat with Claude. Send a list of structured input messages with text and/or image content, and Claude will generate the next message in the conversation. ## Options #{doc(:chat)} ## Message structure Each message is a map with the following fields: #{doc(:chat_message)} ## Examples ```elixir iex> messages = [ ...> %{role: "user", content: "Why is the sky blue?"}, ...> %{role: "assistant", content: "Due to rayleigh scattering."}, ...> %{role: "user", content: "How is that different than mie scattering?"}, ...> ] iex> Anthropix.chat(client, [ ...> model: "claude-3-opus-20240229", ...> messages: messages, ...> ]) {:ok, %{"content" => [%{ "type" => "text", "text" => "Mie scattering affects all wavelengths similarly, while Rayleigh favors shorter ones." }], ...}} # Passing true to the :stream option initiates an async streaming request. iex> Anthropix.chat(client, [ ...> model: "claude-3-opus-20240229", ...> messages: messages, ...> stream: true, ...> ]) {:ok, #Function<52.53678557/2 in Stream.resource/3>} ``` """ @spec chat(client(), keyword()) :: response() def chat(%__MODULE__{} = client, params \\ []) do with {:ok, params} <- NimbleOptions.validate(params, schema(:chat)) do client |> req(:post, "/messages", json: Enum.into(params, %{})) |> res() end end ## If the params contains tools, setup the system prompt and stop sequnces #@spec use_tools(keyword()) :: keyword() #defp use_tools(params) do # case Keyword.get(params, :tools) do # tools when is_list(tools) and length(tools) > 0 -> # prompt = """ # In this environment you have access to a set of tools you can use to answer the user's question. # # You may call them like this: # # # # $TOOL_NAME # # <$PARAMETER_NAME>$PARAMETER_VALUE # ... # # # # # Here are the tools available: # # #{XML.encode(:tools, tools)} # """ # stop = "" # params # |> Keyword.delete(:tools) # |> Keyword.update(:stop_sequences, [stop], & [stop | &1]) # |> Keyword.update(:system, prompt, & prompt <> "\n" <> &1) # # _ -> # params # end #end # Builds the request from the given params @spec req(client(), atom(), Req.url(), keyword()) :: req_response() defp req(%__MODULE__{req: req}, method, url, opts) do opts = Keyword.merge(opts, method: method, url: url) case get_in(opts, [:json, :stream]) do true -> opts = Keyword.put(opts, :into, send_to(self())) task = Task.async(Req, :request, [req, opts]) {:ok, Stream.resource(fn -> task end, &stream_next/1, &stream_end/1)} pid when is_pid(pid) -> opts = opts |> Keyword.update!(:json, & Map.put(&1, :stream, true)) |> Keyword.put(:into, send_to(pid)) {:ok, Task.async(Req, :request, [req, opts])} _ -> Req.request(req, opts) end end # Normalizes the response returned from the request @spec res(req_response()) :: response() defp res({:ok, %Task{} = task}), do: {:ok, task} defp res({:ok, enum}) when is_function(enum), do: {:ok, enum} defp res({:ok, %{status: status, body: body}}) when status in 200..299 do {:ok, body} end defp res({:ok, %{body: body}}) do {:error, APIError.exception(body)} end defp res({:error, error}), do: {:error, error} @sse_regex ~r/event:\s*(\w+)\ndata:\s*({.+})\n/ @sse_events [ "message_start", "content_block_start", "content_block_delta", "content_block_stop", "message_delta", "message_stop", ] # Returns a callback to handle streaming responses @spec send_to(pid()) :: fun() defp send_to(pid) do fn {:data, data}, acc -> @sse_regex |> Regex.scan(data) |> Enum.each(fn [_, event, data] when event in @sse_events -> data = Jason.decode!(data) Process.send(pid, {self(), {:data, data}}, []) _event -> nil end) {:cont, acc} end end # Recieve messages into a stream defp stream_next(%Task{pid: pid, ref: ref} = task) do receive do {^pid, {:data, data}} -> {[data], task} {^ref, {:ok, %Req.Response{status: status}}} when status in 200..299 -> {:halt, task} {^ref, {:ok, %Req.Response{body: body}}} -> raise APIError.exception(body) {^ref, {:error, error}} -> raise error {:DOWN, _ref, _, _pid, _reason} -> {:halt, task} after 30_000 -> {:halt, task} end end # Tidy up when the streaming request is finished defp stream_end(%Task{ref: ref}), do: Process.demonitor(ref, [:flush]) end