Custom Providers
Copy MarkdownSycophant's provider system is modular and extensible. Adding a new LLM provider requires implementing two behaviours and registering them.
Overview
A provider consists of:
- Wire Protocol -- encodes requests and decodes responses in the provider's API format
- Auth Strategy -- handles authentication (headers, signing, query params)
- Registration -- connects the provider to the pipeline at runtime
Wire Protocol
Implement the Sycophant.WireProtocol behaviour. This is the core of a
provider integration.
Required Callbacks
defmodule MyApp.WireProtocol.CustomProvider do
@behaviour Sycophant.WireProtocol
alias Sycophant.{Request, Response, StreamChunk, ParamDefs}
# Define accepted parameters by merging shared defs with provider-specific ones
@param_schema Zoi.map(
Map.merge(ParamDefs.shared(), %{
custom_param: Zoi.string() |> Zoi.optional()
})
)
@impl true
def param_schema, do: @param_schema
@impl true
def request_path(_request), do: "/v1/chat/completions"
@impl true
def stream_transport, do: :sse # or :event_stream for binary (like AWS)
@impl true
def encode_request(%Request{} = request) do
payload = %{
"model" => request.model,
"messages" => Enum.map(request.messages, &encode_message/1)
}
payload = add_params(payload, request.params)
payload = maybe_add_tools(payload, request.tools)
payload = maybe_add_stream(payload, request.stream)
{:ok, payload}
end
@impl true
def decode_response(body) do
{:ok,
%Response{
text: get_in(body, ["choices", Access.at(0), "message", "content"]),
model: body["model"],
usage: decode_usage(body["usage"]),
finish_reason: decode_finish_reason(body),
raw: body,
context: %Sycophant.Context{messages: []}
}}
end
@impl true
def encode_tools(tools) do
{:ok, Enum.map(tools, &encode_tool/1)}
end
@impl true
def encode_response_schema(schema) do
# schema is already a JSON Schema map (normalized by the pipeline)
{:ok, schema}
end
@impl true
def init_stream do
%{text: "", tool_calls: [], usage: nil}
end
@impl true
def decode_stream_chunk(state, %{data: "[DONE]"}) do
{:done,
%Response{
text: state.text,
usage: state.usage,
raw: %{},
context: %Sycophant.Context{messages: []}
}}
end
def decode_stream_chunk(state, %{data: data}) do
delta = get_in(data, ["choices", Access.at(0), "delta"])
new_text = state.text <> (delta["content"] || "")
chunks =
if delta["content"] do
[%StreamChunk{type: :text_delta, data: delta["content"]}]
else
[]
end
{:ok, %{state | text: new_text}, chunks}
end
# Private helpers for encoding/decoding...
endParameter Schema
The param_schema/0 callback defines which LLM parameters the provider
accepts. Use ParamDefs.shared() as a base -- it includes common params like
:temperature, :max_tokens, :top_p, :top_k, :stop, :reasoning,
:tool_choice, and others.
Add provider-specific params by merging into the shared map:
@param_schema Zoi.map(
Map.merge(ParamDefs.shared(), %{
logprobs: Zoi.boolean() |> Zoi.optional(),
seed: Zoi.integer() |> Zoi.optional(),
frequency_penalty: Zoi.number() |> Zoi.optional()
})
)The pipeline validates user-provided params against this schema. Params not in the schema are dropped with a warning log.
Stream Transport
Two transport modes are available:
:sse-- Server-Sent Events (used by OpenAI, Anthropic, Google, OpenRouter):event_stream-- Binary event-stream with frame decoding (used by AWS Bedrock)
Most providers use SSE. The decode_stream_chunk/2 callback receives parsed
events and must return one of:
{:ok, new_state, chunks}-- continue streaming with accumulated state{:done, response}-- stream complete, return final response{:done, response, final_chunks}-- stream complete with final chunks{:error, error}-- stream failed
Auth Strategy
Implement Sycophant.Auth to handle authentication. The callback returns
Tesla middleware tuples.
Header-based Auth
defmodule MyApp.Auth.CustomProvider do
@behaviour Sycophant.Auth
@impl true
def middlewares(%{api_key: key}) do
[{Tesla.Middleware.Headers, [{"authorization", "Bearer #{key}"}]}]
end
endAuth with Path Parameters
Some providers need dynamic URL segments (e.g., AWS region). Implement the
optional path_params/1 callback:
defmodule MyApp.Auth.RegionBased do
@behaviour Sycophant.Auth
@impl true
def middlewares(credentials) do
[{Tesla.Middleware.Headers, [{"x-api-key", credentials.api_key}]}]
end
@impl true
def path_params(credentials) do
[region: Map.get(credentials, :region, "us-east-1")]
end
endFallback
Providers without a registered auth strategy automatically use
Sycophant.Auth.Bearer, which sends the api_key credential as a
Bearer token in the Authorization header.
Registration
Register your provider at application startup or any time before first use:
# In your Application.start/2
def start(_type, _args) do
Sycophant.Registry.register_protocol!(:chat, :custom_chat, MyApp.WireProtocol.CustomProvider)
Sycophant.Registry.register_auth!(:custom, MyApp.Auth.CustomProvider)
children = [...]
Supervisor.start_link(children, strategy: :one_for_one)
endThe protocol name (second argument to register_protocol!/3) must match what
LLMDB returns in the model's wire.protocol metadata field (atomized).
The auth provider atom (first argument to register_auth!/2) must match the
provider atom from the model spec (e.g., :custom for "custom:model-id").
Built-in Registrations
Sycophant registers these automatically at startup:
Chat protocols:
| Name | Module |
|---|---|
:openai_chat | OpenAICompletions |
:openai_responses | OpenAIResponses |
:anthropic_messages | AnthropicMessages |
:google_gemini | GoogleGemini |
:bedrock_converse | BedrockConverse |
Embedding protocols:
| Name | Module |
|---|---|
:openai_embed | EmbeddingWireProtocol.OpenAIEmbed |
:bedrock_embed | EmbeddingWireProtocol.BedrockEmbed |
Auth strategies:
| Provider | Module |
|---|---|
:amazon_bedrock | Auth.Bedrock |
:anthropic | Auth.Anthropic |
:azure | Auth.Azure |
:google | Auth.Google |
All other providers fall back to Auth.Bearer.
Embedding Support
To add embedding support, implement Sycophant.EmbeddingWireProtocol and
register it:
Sycophant.Registry.register_protocol!(:embedding, :custom_embed, MyApp.EmbeddingWireProtocol.Custom)Credentials Configuration
Once registered, credentials work through the standard three-layer fallback:
# Per-request
Sycophant.generate_text(messages,
model: "custom:my-model",
credentials: %{api_key: "sk-..."}
)
# Application config
config :sycophant, :providers,
custom: [api_key: System.get_env("CUSTOM_API_KEY")]
# Environment variables (discovered via LLMDB provider metadata)Local Providers (Ollama, vLLM, LM Studio)
Local inference servers that expose an OpenAI-compatible API can be used
without writing any custom code. They reuse the built-in OpenAICompletions
wire protocol and the Bearer auth fallback.
Configuration
Register local providers as LLMDB custom providers. Set auth: :none (or
auth: :optional if the server accepts an API key) in the provider's
extra field, and wire_protocol: "openai_chat" in each model's extra:
# config/runtime.exs
config :llm_db, :runtime,
custom: %{
ollama: [
name: "Ollama",
base_url: "http://localhost:11434/v1",
extra: %{auth: :none},
models: %{
"llama3" => %{
capabilities: %{chat: true},
extra: %{wire: %{protocol: "openai_chat"}}
},
"deepseek-r1" => %{
capabilities: %{chat: true, tools: %{enabled: true}},
extra: %{wire: %{protocol: "openai_chat"}}
}
}
],
vllm: [
name: "vLLM",
base_url: "http://localhost:8000/v1",
extra: %{auth: :optional},
models: %{
"mistral-7b" => %{
capabilities: %{chat: true},
extra: %{wire: %{protocol: "openai_chat"}}
}
}
]
}For servers that require an API key (e.g., vLLM with --api-key), add
credentials through the standard config:
config :sycophant, :providers,
vllm: [api_key: System.get_env("VLLM_API_KEY")]Usage
Local providers work identically to cloud providers:
messages = [Sycophant.Message.user("Hello")]
{:ok, response} = Sycophant.generate_text("ollama:llama3", messages)
{:ok, response} = Sycophant.generate_text("vllm:mistral-7b", messages,
temperature: 0.7
)Streaming, tool use, and structured output all work as long as the local
server supports them and the model's capabilities are declared correctly.
Auth Modes
extra.auth | Behavior |
|---|---|
:none | No credentials required. Pipeline proceeds with empty auth. |
:optional | Credentials used when available, empty auth otherwise. |
| (absent) | Standard behavior. MissingCredentials error if no credentials found. |
Checklist
When adding a new provider:
- Implement
Sycophant.WireProtocolwith all callbacks - Define
@param_schemacomposingParamDefs.shared()with extras - Implement
Sycophant.Authfor authentication - Register via
Sycophant.Registry.register_protocol!/3andregister_auth!/2at startup - Add model metadata to LLMDB pointing to your wire protocol name
- Add credentials configuration
- Write recording tests (see Recording Tests)
Optional prepare_credentials/1
Sycophant.Auth supports an optional prepare_credentials/1 callback for
strategies that need side-effecting work before transport assembly -- typically
token exchange. It receives the resolved credentials map and returns an
augmented map or a Splode error.
@callback prepare_credentials(map()) :: {:ok, map()} | {:error, Splode.Error.t()}The pipeline invokes this callback between Credentials.resolve/2 and
transport_opts/3, so any :base_url or auth-relevant field set here flows
naturally into the upstream Tesla middleware stack. Anything written into
:base_url ends up in Tesla.Middleware.BaseUrl, which is how
Sycophant.Auth.GithubCopilot discovers the dynamic chat host returned by
GitHub's token-exchange response.
See GitHub Copilot for a worked example and
Sycophant.Auth.GithubCopilot for the implementation.