LLM.Adapter behaviour (LLM v0.1.2)

Copy Markdown View Source

Behaviour for wire format translation between normalized messages and provider APIs.

Each adapter handles:

  • Encoding LLM.Context into provider-specific JSON request bodies
  • Providing the streaming endpoint path and extra headers
  • Decoding full responses back into LLM.Response.t()
  • Decoding SSE chunks back into normalized stream structs

Built-in adapters:

ModuleProvider API
LLM.Adapter.OpenAIOpenAI Chat Completions
LLM.Adapter.OpenAIResponseOpenAI Responses API
LLM.Adapter.AnthropicAnthropic Messages API
LLM.Adapter.GeminiGoogle Gemini API

Implementing a custom adapter

Implement all required callbacks plus any optional ones your provider needs:

defmodule MyApp.Adapter do
  @behaviour LLM.Adapter

  @impl true
  def build_request(context, opts) do
    %{
      "model" => Keyword.fetch!(opts, :model),
      "messages" => encode_messages(context)
    }
  end

  @impl true
  def decode_response(%{"choices" => [choice | _]} = raw) do
    {:ok,
     %LLM.Response{
       message: %LLM.Message{role: :assistant, content: choice["text"]},
       raw: raw
     }}
  end

  @impl true
  def stream_path, do: "/v1/chat/completions"

  @impl true
  def stream_headers(_opts), do: [{"content-type", "application/json"}]

  @impl true
  def decode_chunk("data: [DONE]", _state), do: {:done, %{}}
  def decode_chunk("data: " <> json, state) do
    # Parse SSE data and return normalized chunks
    {[%LLM.Stream.Chunk{text: text}], state}
  end
end

Summary

Callbacks

Return authentication headers for the provider.

Build the request body from a context and options.

Decode a single SSE event (stateless variant).

Decode a single SSE event into stream chunks.

Decode a full (non-streaming) response from the provider.

Initialize the adapter state for a new streaming request.

List available models from the provider.

Normalize a thinking/reasoning option to the provider's expected format.

Return extra HTTP headers needed for the streaming request.

Return the streaming endpoint path (e.g., "/v1/chat/completions").

Types

chunk_type()

model_info()

@type model_info() :: %{
  id: String.t(),
  name: String.t() | nil,
  description: String.t() | nil,
  context_window: non_neg_integer() | nil,
  max_output: non_neg_integer() | nil,
  capabilities: [atom()] | nil
}

Callbacks

auth_headers(map, keyword)

(optional)
@callback auth_headers(
  map(),
  keyword()
) :: [{String.t(), String.t()}]

Return authentication headers for the provider.

Receives the provider config map and options. Returns a list of header tuples.

build_request(t, keyword)

@callback build_request(
  LLM.Context.t(),
  keyword()
) :: map()

Build the request body from a context and options.

Receives an LLM.Context.t() and a keyword list of options (including :model). Returns a map suitable for Jason.encode!/1.

decode_chunk(t)

(optional)
@callback decode_chunk(String.t()) :: [chunk_type()] | :done

Decode a single SSE event (stateless variant).

Used by adapters that don't need to track state across chunks.

decode_chunk(t, map)

@callback decode_chunk(String.t(), map()) :: {[chunk_type()] | :done, map()}

Decode a single SSE event into stream chunks.

Receives the raw SSE data string and adapter state. Returns a tuple of {chunks, new_state} where chunks is a list of stream structs or :done, or {:done, new_state} to signal end of stream.

decode_response(map)

@callback decode_response(map()) :: {:ok, LLM.Response.t()} | {:error, term()}

Decode a full (non-streaming) response from the provider.

Receives the decoded JSON response body. Returns {:ok, LLM.Response.t()} or {:error, reason}.

init_stream_state()

(optional)
@callback init_stream_state() :: map()

Initialize the adapter state for a new streaming request.

Called once when a stream starts. The state is passed to decode_chunk/2.

list_models(map)

(optional)
@callback list_models(map()) :: {:ok, [model_info()]} | {:error, term()}

List available models from the provider.

Returns {:ok, [model_info()]} or {:error, reason}. Not all providers support this — implement as a no-op if unsupported.

normalize_thinking(term)

(optional)
@callback normalize_thinking(term()) :: term()

Normalize a thinking/reasoning option to the provider's expected format.

Maps atoms like :low, :medium, :high to provider-specific values.

stream_headers(keyword)

@callback stream_headers(keyword()) :: [{String.t(), String.t()}]

Return extra HTTP headers needed for the streaming request.

Content-Type and auth headers are added separately by LLM.Stream.

stream_path()

@callback stream_path() :: String.t()

Return the streaming endpoint path (e.g., "/v1/chat/completions").

May contain {model} which is replaced with the actual model name.