Packages

Lightweight Elixir client for LLM APIs

Current section

Files

Jump to
llm lib llm.ex
Raw

lib/llm.ex

defmodule LLM do
@moduledoc """
Lightweight Elixir client for LLM APIs.
Supports 4 adapters covering 99% of providers:
- **OpenAI Chat Completions** — `/v1/chat/completions` (also works with Ollama, Groq, OpenRouter, DeepSeek, xAI, etc.)
- **OpenAI Responses** — `/v1/responses`
- **Anthropic Messages** — `/v1/messages`
- **Gemini** — `/v1beta/models/{model}:generateContent`
## Quick Start
# Simple text generation
{:ok, response} = LLM.generate("What is Elixir?",
provider: :openai,
model: "gpt-4"
)
response.message.content #=> "Elixir is a..."
# Streaming
{:ok, stream} = LLM.stream("Tell me a story",
provider: :anthropic,
model: "claude-sonnet-4-5-20250514"
)
{:ok, response} = LLM.Stream.collect(stream, on_chunk: &IO.write/1)
# Using provider modules
{:ok, response} = LLM.generate("Hello",
provider: LLM.Provider.OpenAI,
model: "gpt-4"
)
# With explicit API key
{:ok, response} = LLM.generate("Hello",
provider: {LLM.Provider.OpenAI, api_key: "sk-..."},
model: "gpt-4"
)
# Custom provider (runtime)
{:ok, response} = LLM.generate("Hello",
provider: %{
adapter: LLM.Adapter.Anthropic,
base_url: "https://my-proxy.com",
api_key: "sk-ant-..."
},
model: "claude-sonnet-4-5-20250514"
)
# With tools
{:ok, response} = LLM.generate("Read mix.exs",
provider: :openai,
model: "gpt-4",
tools: [MyApp.ReadFileTool]
)
## Configuration
# config/config.exs
config :llm, :providers,
openai: [api_key: "sk-..."],
anthropic: [api_key: "sk-ant-..."]
# Or at runtime
LLM.put_key(:openai, "sk-...")
## Provider
A provider can be:
- An atom preset (`:openai`, `:anthropic`, `:gemini`, `:openrouter`, `:openai_responses`)
- A provider module (`LLM.Provider.OpenAI`, `LLM.Provider.Anthropic`, etc.)
- A tuple `{module, opts}` for runtime API key (`{LLM.Provider.OpenAI, api_key: "sk-..."}`)
- A map with `:adapter`, `:base_url`, and optionally `:api_key`
"""
@type stream_option ::
{:provider, atom() | map() | module()}
| {:model, String.t()}
| {:max_tokens, non_neg_integer()}
| {:temperature, float()}
| {:thinking, atom() | map()}
| {:tools, [module() | LLM.Tool.t() | {module(), map()}]}
| {:auto_tools, boolean()}
| {:max_rounds, non_neg_integer()}
| {:on_chunk, (LLM.Stream.chunk_type() -> any())}
| {:on_message, (LLM.Message.t() -> any())}
| {:system, String.t()}
| {:messages, [LLM.Message.t()]}
@doc """
Stream a prompt, returning a stream handle.
Returns `{:ok, stream}` on success, `{:error, reason}` on failure.
The stream can be consumed with `LLM.Stream.next/1` and `LLM.Stream.collect/2`.
"""
@spec stream(String.t() | LLM.Context.t(), [stream_option]) ::
{:ok, LLM.Stream.t()} | {:error, term()}
def stream(prompt, opts \\ []) do
context = to_context(prompt, opts)
LLM.Stream.start(context, opts)
end
@doc """
Generate text (non-streaming). Returns the final response.
Automatically collects the stream and executes tool calls if present.
"""
@spec generate(String.t() | LLM.Context.t(), [stream_option]) ::
{:ok, LLM.Response.t()} | {:error, term()}
def generate(prompt, opts \\ []) do
with {:ok, s} <- stream(prompt, opts) do
LLM.Stream.collect(s, opts)
end
end
@doc """
Generate text, raising on error.
"""
@spec generate!(String.t() | LLM.Context.t(), [stream_option]) :: LLM.Response.t()
def generate!(prompt, opts \\ []) do
case generate(prompt, opts) do
{:ok, response} -> response
{:error, reason} -> raise "LLM generation failed: #{inspect(reason)}"
end
end
@doc """
List available provider presets.
"""
@spec providers() :: [atom()]
def providers, do: LLM.Provider.Resolver.list_providers()
@doc """
List available models from a provider.
Returns `{:ok, models}` on success, where `models` is a list of model info maps.
Returns `{:error, reason}` on failure.
## Options
* `:provider` - provider preset atom, module, or config map (defaults to `:openai`)
## Examples
{:ok, models} = LLM.models(provider: :openai)
{:ok, models} = LLM.models(provider: :anthropic)
"""
@spec models(keyword()) :: {:ok, [LLM.Adapter.model_info()]} | {:error, term()}
def models(opts \\ []) do
provider = LLM.Provider.Resolver.resolve(opts[:provider] || :openai)
try do
provider.adapter.list_models(provider)
rescue
UndefinedFunctionError -> {:error, :not_supported}
end
end
@doc """
Store an API key at runtime.
"""
@spec put_key(atom(), String.t()) :: :ok
def put_key(provider_name, api_key) do
Process.put({__MODULE__, :provider_key, provider_name}, api_key)
:ok
end
@doc """
Get an API key, checking process dictionary first, then application config.
"""
@spec get_key(atom()) :: String.t() | nil
def get_key(provider_name) do
case Process.get({__MODULE__, :provider_key, provider_name}) do
nil ->
case Application.get_env(:llm, :providers, %{}) do
config when is_list(config) ->
config[provider_name][:api_key]
config when is_map(config) ->
config[provider_name][:api_key]
_ ->
nil
end
key ->
key
end
end
# --- Private ---
defp to_context(prompt, opts) when is_binary(prompt) do
tools = normalize_tools(opts[:tools] || [])
previous_messages = opts[:messages] || []
%LLM.Context{
system: opts[:system],
messages: previous_messages ++ [LLM.Message.new(prompt)],
tools: tools,
provider_state: %{}
}
end
defp to_context(%LLM.Context{} = ctx, opts) do
tools = normalize_tools(opts[:tools] || [])
%{ctx | tools: tools ++ ctx.tools}
end
defp normalize_tools(tools) do
Enum.map(tools, &LLM.Tool.normalize/1)
end
end