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, response} = LLM.stream("Tell me a story",
provider: :anthropic,
model: "claude-sonnet-4-5-20250514",
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
Summary
Functions
Generate text (non-streaming). Returns the final response.
Generate text, raising on error.
Get an API key, checking process dictionary first, then application config.
List available models from a provider.
List available provider presets.
Store an API key at runtime.
Stream a prompt and return the final response.
Stream a prompt and return the response, raising on error.
Types
@type generate_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_message, (LLM.Message.t() -> any())} | {:system, String.t()} | {:messages, [LLM.Message.t()]} | {:schema, map()}
@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_message, (LLM.Message.t() -> any())} | {:system, String.t()} | {:messages, [LLM.Message.t()]} | {:schema, map()}
Functions
@spec generate(String.t() | LLM.Context.t(), [generate_option()], keyword()) :: {:ok, LLM.Response.t()} | {:error, term()}
Generate text (non-streaming). Returns the final response.
Sends a regular HTTP request and decodes the provider's full response. Executes tool calls with follow-up regular HTTP requests when present.
Callbacks can be placed in opts (2nd arg) or passed separately as a third
keyword argument (which takes precedence):
# on_message in opts
{:ok, response} = LLM.generate("What is Elixir?",
provider: :openai,
model: "gpt-4",
on_message: fn msg -> IO.inspect(msg.role) end
)
# on_message as separate third argument
{:ok, response} = LLM.generate("What is Elixir?",
[provider: :openai, model: "gpt-4"],
on_message: fn msg -> IO.inspect(msg.role) end
)Callbacks
:on_message— called once per completedLLM.Message,fn message -> ... end
When schema: is set, the model is asked to return JSON matching the
given schema. On success, response.parsed contains the decoded map and the
last assistant message in response.messages also carries :parsed. Tool
auto-execution is disabled for that request since the response is structured
data, not a tool call round-trip.
Structured output
{:ok, response} = LLM.generate("Extract the name and age.",
provider: :openai,
model: "gpt-4o",
schema: %{
"name" => "person",
"schema" => %{
"type" => "object",
"properties" => %{
"name" => %{"type" => "string"},
"age" => %{"type" => "integer"}
},
"required" => ["name", "age"]
}
}
)
response.parsed #=> %{"name" => "Alice", "age" => 30}Pass a bare JSON Schema map to use "output" as the default name:
schema: %{"type" => "object", "properties" => %{...}}The schema is passed through to the provider unchanged — it must be valid for the target provider's structured-output feature (schema requirements vary by provider).
@spec generate!(String.t() | LLM.Context.t(), [generate_option()], keyword()) :: LLM.Response.t()
Generate text, raising on error.
Get an API key, checking process dictionary first, then application config.
@spec models(keyword()) :: {:ok, [LLM.Adapter.model_info()]} | {:error, term()}
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 providers() :: [atom()]
List available provider presets.
Store an API key at runtime.
@spec stream(String.t() | LLM.Context.t(), keyword(), keyword()) :: {:ok, LLM.Response.t()} | {:error, term()}
Stream a prompt and return the final response.
Returns {:ok, response} on success, {:error, reason} on failure.
Callbacks can be placed in opts (2nd arg) or passed separately as a third
keyword argument (which takes precedence):
# Callbacks in opts
{:ok, response} = LLM.stream("Tell me a story",
provider: :openai,
model: "gpt-4",
on_chunk: &IO.write/1
)
# Callbacks as separate third argument
{:ok, response} = LLM.stream("Tell me a story",
[provider: :openai, model: "gpt-4"],
on_chunk: &IO.write/1
)Callbacks
:on_chunk— called for each chunk,fn chunk -> ... end:on_message— called once per completedLLM.Message,fn message -> ... end
For manual stream control use LLM.Stream.start/2 and
LLM.Stream.collect/2 directly.
@spec stream!(String.t() | LLM.Context.t(), keyword(), keyword()) :: LLM.Response.t()
Stream a prompt and return the response, raising on error.