defmodule LLM do @moduledoc """ Lightweight Elixir client for LLM APIs. Supports 4 dialects 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) # Custom provider (runtime) {:ok, response} = LLM.generate("Hello", provider: %{ dialect: LLM.Dialect.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.Provider.Presets.put_key(:openai, "sk-...") ## Provider A provider can be: - An atom preset (`:openai`, `:anthropic`, `:gemini`, `:openai_responses`) - A map with `:dialect`, `:base_url`, and optionally `:api_key` - A module implementing `LLM.Provider` behaviour """ @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())} | {:system, String.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.Presets.list_presets() @doc """ Store an API key at runtime. """ @spec put_key(atom(), String.t()) :: :ok def put_key(provider_name, api_key) do LLM.Provider.Presets.put_key(provider_name, api_key) :ok end # --- Private --- defp to_context(prompt, opts) when is_binary(prompt) do tools = normalize_tools(opts[:tools] || []) %LLM.Context{ system: opts[:system], messages: [LLM.Message.new(prompt)], tools: tools } 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