Packages

Unofficial Anthropic API client for Elixir. Integrate Claude, Anthropic's powerful language model, into your applications.

Current section

Files

Jump to
anthropix lib anthropix.ex
Raw

lib/anthropix.ex

defmodule Anthropix do
@version Keyword.fetch!(Mix.Project.config(), :version)
@moduledoc """
![License](https://img.shields.io/github/license/lebrunel/anthropix?color=informational)
An up-to-date and fully-featured Elixir client library for
[Anthropic's REST API](https://docs.anthropic.com/claude/reference/getting-started-with-the-api).
- ✅ API client fully implementing the Anthropic API
- 🛜 Streaming API requests
- Stream to an Enumerable
- Or stream messages to any Elixir process
- 🔜 Advanced and flexible function calling workflow
This library is currently a WIP. Check back in a week or two, by which point
it should be bangin!
## 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
TODO...
"""
use Anthropix.Schemas
defstruct [:req]
@typedoc "Client struct"
@type client() :: %__MODULE__{
req: Req.Request.t()
}
schema :content, [
type: [type: :string, required: true],
text: [type: :string],
content: [type: :map, keys: [
type: [type: :string, required: :true],
media_type: [type: :string, required: :true],
data: [type: :string, required: :true],
]]
]
schema :message, [
role: [
type: :string,
required: true,
doc: "The role of the message, either `user` or `assistant`."
],
content: [
type: {:or, [:string, {:list, {:map, schema(:content).schema}}]},
required: true,
doc: "Message content, either a single string or an array of content blocks."
]
]
@typedoc """
Chat message
A chat message is a `t:map/0` with the following fields:
#{doc(:message)}
"""
@type message() :: map()
@typedoc "Client response"
@type response() ::
{:ok, map() | boolean() | 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("x-api-key", api_key)
|> Req.Request.put_headers(headers)
struct(__MODULE__, req: req)
end
schema :messages, [
model: [
type: :string,
required: true,
doc: "The model that will complete your prompt.",
],
messages: [
type: {:list, {:map, schema(: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."
],
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 """
Send a structured list of input messages with text and/or image content, and
the model will generate the next message in the conversation.
## Options
#{doc(:messages)}
## Message structure
Each message is a map with the following fields:
#{doc(:message)}
## Examples
```elixir
iex> messages = [
...> %{role: "system", content: "You are a helpful assistant."},
...> %{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.messages(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.messages(client, [
...> model: "claude-3-opus-20240229",
...> messages: messages,
...> stream: true,
...> ])
{:ok, #Function<52.53678557/2 in Stream.resource/3>}
```
"""
@spec messages(client(), keyword()) :: any()
def messages(%__MODULE__{} = client, params \\ []) do
with {:ok, params} <- NimbleOptions.validate(params, schema(:messages)) do
client
|> req(:post, "/messages", json: Enum.into(params, %{}))
|> res()
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, %{status: status}}) do
{:error, Anthropix.HTTPError.exception(status)}
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{status: status}}} ->
raise Anthropix.HTTPError.exception(status)
{^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