Behaviour and shared logic for LLM providers.
A provider represents a specific LLM service — its endpoint, authentication mechanism, and any service-specific adaptations. It is the authenticated HTTP layer: it knows where to send requests and how to authenticate them, but not what the request body should contain (that's the dialect's job).
Omni separates providers from dialects because the mapping is many-to-one.
There are ~4–5 wire formats but ~20–30 services. Groq, Together, Fireworks,
and OpenRouter all speak the OpenAI Chat Completions format — their request
bodies and streaming events are identical. Only endpoint, authentication, and
the occasional quirk differ. A provider captures those differences; a dialect
(see Omni.Dialect) handles the wire format shared across many providers.
Most providers speak a single dialect, but multi-model gateways (like OpenCode
Zen) route to different upstream APIs depending on the model. These providers
omit the :dialect option — the dialect is resolved per-model from the
catalog data at load time. See "Multi-dialect providers" below.
Defining a provider
Use use Omni.Provider with a required :id option — the provider's
canonical id, its models.dev catalog key snake_cased
as an atom — and typically a :dialect. The only required callback is
config/0; models load automatically from the configured model source
(see Omni.Source) under the provider's id:
defmodule MyApp.Providers.Mistral do
use Omni.Provider,
id: :mistral,
dialect: Omni.Dialects.OpenAICompletions
@impl true
def config do
%{
base_url: "https://api.mistral.ai",
api_key: {:system, "MISTRAL_API_KEY"}
}
end
endThe use macro generates id/0 and dialect/0 accessors (dialect is
nil when omitted) and default implementations for all optional
callbacks. Override only what your provider needs — most providers are
just config/0.
Override models/0 to adjust what the source returns, or to skip catalog
loading entirely — see models/0. The decision rule: implement an
Omni.Source when you're replacing the data supply (reusable across
providers, user-configurable); override models/0 when one provider needs
local adjustments or its own loading path.
The request pipeline
Understanding the request pipeline clarifies when each callback runs and why.
Internally, Omni orchestrates the flow by calling dialect and provider
callbacks via the module references on the %Model{} struct:
# 1. Dialect builds the request body from Omni types
body = dialect.handle_body(model, context, opts)
# 2. Provider adjusts the body for service-specific quirks
body = provider.modify_body(body, context, opts)
# 3. Dialect determines the URL path for this API
path = dialect.handle_path(model, opts)
# 4. Provider builds the full URL (base_url + path)
url = provider.build_url(path, opts)
# 5. Provider adds authentication to the request
{:ok, req} = provider.authenticate(req, opts)On the response side, streaming events flow through a similar pipeline:
# 1. Dialect parses raw SSE JSON into normalized delta tuples
deltas = dialect.handle_event(raw_event)
# 2. Provider adjusts deltas for service-specific data
deltas = provider.modify_events(deltas, raw_event)The dialect does the heavy lifting (full type translation); the provider makes
small, targeted adjustments. Most providers don't override modify_body/3 or
modify_events/2 at all.
Callbacks
| Callback | Required? | Default |
|---|---|---|
config/0 | yes | — |
models/0 | no | loads from the configured model source |
build_url/2 | no | opts.base_url <> path |
authenticate/2 | no | resolves opts.api_key, sets header |
modify_body/3 | no | passthrough |
modify_events/2 | no | passthrough |
Authentication
The default authenticate/2 resolves opts.api_key via resolve_auth/1 and
sets the appropriate header. When no :auth_header is configured, it sends a
Bearer token on the "authorization" header (the most common scheme). When a
custom :auth_header is set in config/0 (e.g. "x-api-key"), the raw key
is sent on that header instead.
Override authenticate/2 only for unusual schemes like request signing or
token refresh:
# Custom authentication
@impl true
def authenticate(req, opts) do
with {:ok, key} <- Omni.Provider.resolve_auth(opts.api_key) do
{:ok, Req.Request.put_header(req, "x-custom-auth", sign(key))}
end
endAPI keys are resolved in priority order:
- Call-site opts —
:api_keypassed directly togenerate_text/3orstream_text/3 - Application config —
config :omni, MyProvider, api_key: ... - Provider default — the
:api_keyvalue fromconfig/0
All three tiers accept the same value types — see resolve_auth/1.
Config keys
config/0 returns a map with the following keys:
:base_url(required) — the service's base URL:api_key— default API key (seeresolve_auth/1for accepted formats):auth_header— custom header name for the API key; when set, the raw key is sent on this header instead of as a Bearer token on"authorization":headers— additional headers to include on every request (map)
These values serve as defaults. Users can override :base_url, :api_key,
and :headers at the application config level or at the call site.
Multi-dialect providers
Some providers act as gateways to multiple upstream APIs, each with its own wire format. For example, OpenCode Zen routes Claude models through the Anthropic Messages format and GPT models through OpenAI Responses.
These providers omit the :dialect option:
defmodule MyApp.Providers.Gateway do
use Omni.Provider, id: :gateway
@impl true
def config do
%{base_url: "https://gateway.example.com", api_key: {:system, "GW_KEY"}}
end
endThe model source resolves each model's dialect from its catalog data (see
Omni.Sources.ModelsDev for how the default source infers dialects). The
provider's declared dialect is only a fallback for models without one — when
dialect/0 returns nil, a model whose dialect the source cannot resolve
is skipped with a warning.
Model sources
Model catalog data comes from a pluggable source — see Omni.Source.
load_models/2 resolves the source per call, in priority order:
- Per-module config —
config :omni, Omni.Providers.OpenAI, source: ... - Call-site opt —
load_models(__MODULE__, source: ...)(a provider author's default for that provider) - Global config —
config :omni, :models, source: ... - Default —
Omni.Sources.ModelsDev(bundled models.dev snapshot)
A source value is a module or a {module, opts} tuple. Unlike API keys,
the user's provider-specific config beats the call site here, because the
call site is provider-author code rather than end-user code.
Sources look the provider up by its canonical id — the module's id/0 by
default. To load from a different catalog entry, set provider_id: in the
source's opts:
config :omni, MyApp.Providers.Gateway,
source: {Omni.Sources.ModelsDev, provider_id: :mistral}Choosing a dialect
Pick the dialect that matches your provider's wire format:
Omni.Dialects.OpenAICompletions— OpenAI Chat Completions format, used by the majority of providers (Groq, Together, Fireworks, DeepSeek, etc.)Omni.Dialects.OpenAIResponses— OpenAI's newer Responses API formatOmni.Dialects.AnthropicMessages— Anthropic Messages formatOmni.Dialects.GoogleGemini— Google Gemini formatOmni.Dialects.OllamaChat— Ollama native chat format (NDJSON streaming)
If your provider speaks a format not listed here, you'll need to implement a
new dialect — see Omni.Dialect for the behaviour specification.
Registering a provider
All built-in providers are loaded at startup. To restrict which providers
load, or to add custom ones, set the providers: key of the :models
config to a list of provider modules — :builtins (the default) names all
built-ins and may also appear inside the list:
config :omni, :models,
providers: [
Omni.Providers.Anthropic,
Omni.Providers.OpenAI,
MyApp.Providers.Acme
]
# or: all built-ins plus a custom provider
config :omni, :models,
providers: [:builtins, MyApp.Providers.Acme]To load a provider at runtime without restarting:
Omni.Provider.load([MyApp.Providers.Acme])The provider's models are then available under its id, e.g.
Omni.get_model(:acme, "acme-7b").
Summary
Callbacks
Adds authentication to a %Req.Request{}.
Builds the full request URL from a dialect-provided path and the merged options map.
Returns the provider's base configuration map.
Returns the provider's list of model structs.
Adjusts the dialect-built request body for this provider's quirks.
Adjusts dialect-parsed delta tuples for this provider's quirks.
Functions
Builds a %Model{} struct from a string-keyed data map.
Returns the list of built-in provider modules.
Loads providers' models into :persistent_term.
Loads a provider's models from the configured model source.
Resolves an API key value to a literal string.
Callbacks
@callback authenticate(req :: Req.Request.t(), opts :: map()) :: {:ok, Req.Request.t()} | {:error, term()}
Adds authentication to a %Req.Request{}.
Receives the built request and the merged options map (which includes
:api_key and :auth_header from the three-tier config merge). Returns
{:ok, req} with authentication applied, or {:error, reason} if the
key cannot be resolved.
This is the only callback that returns an ok/error tuple, because authentication depends on external state (environment variables, vaults, token endpoints) that can fail at runtime.
Default: resolves opts.api_key via resolve_auth/1 and sends a Bearer
token on "authorization". When :auth_header is set in config, sends the
raw key on that header instead. Override for request signing (e.g. AWS
SigV4) or token refresh flows.
Builds the full request URL from a dialect-provided path and the merged options map.
The opts map contains :base_url (from the three-tier config merge) plus
all validated inference options. The path comes from the dialect's
Omni.Dialect.handle_path/2 callback (e.g. "/v1/chat/completions").
Default: opts.base_url <> path.
Override when URL structure deviates from simple concatenation — for example, Azure OpenAI reorganizes the path around deployment names and API versions.
@callback config() :: map()
Returns the provider's base configuration map.
This is the only required callback. The map should include :base_url and
typically :api_key. Optional keys are :auth_header (defaults to
"authorization") and :headers (additional headers as a map).
@impl true
def config do
%{
base_url: "https://api.acme.ai",
auth_header: "x-api-key",
api_key: {:system, "ACME_API_KEY"},
headers: %{"x-api-version" => "2024-01-01"}
}
endThese values serve as defaults — users can override :base_url, :api_key,
and :headers via application config or call-site options.
@callback models() :: [Omni.Model.t()]
Returns the provider's list of model structs.
Default: Omni.Provider.load_models(__MODULE__) — catalog data from the
configured model source (see Omni.Source), looked up under the module's
id/0. Most providers don't override this.
Override to post-process what the source returns:
@impl true
def models do
__MODULE__
|> Omni.Provider.load_models()
|> Enum.map(&add_pdf_modality/1)
endOr to bypass catalog loading and build model structs directly:
@impl true
def models do
[
Omni.Model.new(
id: "acme-7b",
name: "Acme 7B",
provider: __MODULE__,
dialect: dialect(),
context_size: 128_000,
max_output_tokens: 4096
)
]
end
@callback modify_body(body :: map(), context :: Omni.Context.t(), opts :: map()) :: map()
Adjusts the dialect-built request body for this provider's quirks.
Called after Omni.Dialect.handle_body/3 with the body map it produced,
the original %Context{}, and the validated options. The context is
available for per-message transformations (e.g. encoding round-trip data
from Message.private onto wire-format messages). Returns the modified body.
Default: passthrough (returns body unchanged). Override when the provider
speaks a standard dialect but needs small adjustments — an extra field, a
renamed parameter, a restructured sub-object. For example, OpenRouter
remaps reasoning_effort into its own reasoning object.
@callback modify_events(deltas :: [{atom(), map() | term()}], raw_event :: map()) :: [ {atom(), map() | term()} ]
Adjusts dialect-parsed delta tuples for this provider's quirks.
Called after Omni.Dialect.handle_event/1 with the list of delta tuples
it produced and the original raw SSE event map. The raw event is passed so
the provider can inspect fields the dialect doesn't know about. Returns the
modified delta list — you can modify, remove, or append deltas.
See Omni.Dialect for the delta tuple types and their expected map shapes.
Default: passthrough (returns deltas unchanged). Override when the provider
embeds extra data in streaming events that the shared dialect doesn't parse.
For example, OpenRouter extracts reasoning_details from the raw event and
appends a :message delta carrying private data.
Functions
@spec build_model(module(), map()) :: Omni.Model.t()
Builds a %Model{} struct from a string-keyed data map.
The shared constructor used by model sources (see Omni.Source). The model
is stamped with the given provider module and a dialect, resolved in
priority order: the data's "dialect" string via Omni.Dialect.get!/1,
falling back to the provider's declared dialect (via use Omni.Provider, dialect: Module). Raises ArgumentError when neither resolves.
Recognized keys: "id", "name", "dialect", "release_date" (ISO 8601
YYYY-MM-DD, or YYYY-MM which parses as the first of the month),
"reasoning", "input_modalities"/"output_modalities" (lists of
strings), "input_cost", "output_cost", "cache_read_cost",
"cache_write_cost", "context_size", "max_output_tokens". Costs and
limits default to 0, modalities to ["text"], reasoning to false.
Modality strings are converted with String.to_atom/1 — callers feeding
data from untrusted or unpruned catalogs must filter them against
Omni.Model.supported_modalities/1 first.
@spec builtins() :: [module()]
Returns the list of built-in provider modules.
@spec load([module()]) :: :ok
Loads providers' models into :persistent_term.
Accepts a list of provider modules. Each provider's models are stored under
its id/0 and merged with any existing entries, so calling load/1
multiple times is safe. Raises when two different modules declare the same
id.
# Load a built-in provider on demand
Omni.Provider.load([Omni.Providers.OpenRouter])
# Load a custom provider
Omni.Provider.load([MyApp.Providers.CustomLLM])
@spec load_models( module(), keyword() ) :: [Omni.Model.t()]
Loads a provider's models from the configured model source.
Resolves the source (see the "Model sources" section in the moduledoc for
the resolution order), calls its Omni.Source.fetch/2, and returns the
models. When the source returns an error — most commonly
:unknown_provider for a module the source cannot match to a catalog
entry — a warning is logged and [] is returned; models never silently
load from a source the user didn't configure.
The provider's identity in the source's catalog defaults to the module's
id/0 (declared via use Omni.Provider, id: ...); a provider_id: in the
source's own opts overrides it, so a provider can be pointed at a different
catalog entry per source: {MySource, provider_id: :other}.
Options
:source— a source override for this call: a module or a{module, opts}tuple. Intended for provider authors setting their provider's default source; user config for the provider module still wins.
All other options are passed through to the source's fetch/2, merged over
the source's own configured opts, so sources can define additional
call-site options.
Resolves an API key value to a literal string.
Supports multiple formats:
"sk-..."— a literal string, returned as-is{:system, "ENV_VAR"}— resolved from the environment{Module, :function, args}— resolved viaapply/3nil— returns{:error, :no_api_key}