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