Current section
Files
Jump to
Current section
Files
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()]}
| {:structured_output, map()}
@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.
When `structured_output:` is set, the model is asked to return JSON matching the
given schema. On success, `response.parsed` contains the decoded map. 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",
structured_output: %{
"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:
structured_output: %{"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(), [stream_option]) ::
{:ok, LLM.Response.t()} | {:error, term()}
def generate(prompt, opts \\ []) do
opts =
if opts[:structured_output],
do: Keyword.put_new(opts, :auto_tools, false),
else: opts
with {:ok, s} <- stream(prompt, opts),
{:ok, response} <- LLM.Stream.collect(s, opts) do
{:ok, maybe_parse_structured_output(response, 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
defp maybe_parse_structured_output(response, opts) do
case opts[:structured_output] do
nil -> response
_ -> %{response | parsed: parse_structured_output(response)}
end
end
# Anthropic tool-forcing: the result is in the __structured_output__ tool call args.
defp parse_structured_output(%LLM.Response{message: %LLM.Message{tools: tools}})
when is_list(tools) do
case Enum.find(tools, &(&1.name == "__structured_output__")) do
%{args: args} when is_map(args) -> args
_ -> nil
end
end
# OpenAI / Gemini: the result is JSON in message content.
defp parse_structured_output(%LLM.Response{message: %LLM.Message{content: content}})
when is_binary(content) do
case Jason.decode(content) do
{:ok, parsed} -> parsed
_ -> nil
end
end
defp parse_structured_output(_), do: nil
end