Scope: adapter wiring + behaviour (stable reference). For current model IDs (which strings to put in
model "…") and retired names, seePROVIDER_MODELS.md: that catalog is volatile and refreshed on a separate cadence.
CouncilEx ships five provider adapters in core. The council DSL itself
(use CouncilEx, rounds, chair, router) is provider-agnostic: pick a
provider per member via the provider: opt, or set a default for the
whole council via a Profile.
This doc covers:
- Per-provider config snippets (OpenAI, Anthropic, Gemini, Ollama, OpenRouter).
- Adapter quirks (Anthropic + Gemini structured-output / tools mutual exclusion, Ollama defaults, OpenRouter attribution headers).
- The
CouncilEx.Provider.Adapterbehaviour for adding your own.
For prebaked profile bundles (OpenAIBalanced, AnthropicBalanced,
OpenRouterAuto, etc.), see PROFILES.md. For
recommended model IDs and the refresh protocol, see
PROVIDER_MODELS.md.
Configuration shape
Providers are declared once in your application config. Each entry is a keyword list with at minimum:
adapter:: the concreteCouncilEx.Provider.Adaptermodule.api_key::{:system, "ENV_VAR_NAME"}or a literal string.adapter:(optional): the dispatcher. Defaults toCouncilEx.Providers.Instructor— the dispatcher used by every shipped real-provider adapter. Override only for custom dispatchers (e.g. the test-onlyCouncilEx.Providers.Mock, or a recording proxy).
Provider IDs (:openai, :anthropic, …) are arbitrary atoms. The same
adapter can be configured under multiple IDs (e.g. one :openai and one
:openai_legacy pointing at different endpoints).
OpenAI
config :council_ex,
providers: [
openai: [
adapter: CouncilEx.Provider.Adapters.OpenAI,
api_key: {:system, "OPENAI_API_KEY"}
]
]Tool-calling, streaming, and Ecto-schema structured output all work.
Member opts: provider: :openai, model: "gpt-4o" | "gpt-4o-mini" | ….
Anthropic
config :council_ex,
providers: [
anthropic: [
adapter: CouncilEx.Provider.Adapters.Anthropic,
api_key: {:system, "ANTHROPIC_API_KEY"}
]
]The Anthropic adapter implements structured output through a synthetic
_respond tool whose input_schema mirrors your Ecto schema. This means
response_schema: and user-supplied tools: are mutually exclusive on
the same member: both set raises ArgumentError. If you need both,
split the work across two members or two rounds.
Streaming uses Anthropic's typed-event SSE; partial_json fragments are
emitted as :chunk events; Response.parsed is cast and validated
through your schema once the stream completes.
See examples/anthropic_streaming_example.exs
and examples/anthropic_structured_output_example.exs.
Gemini
Full-parity adapter for Google's Gemini Generative Language API. Auth
via x-goog-api-key header.
config :council_ex,
providers: [
gemini: [
adapter: CouncilEx.Provider.Adapters.Gemini,
api_key: {:system, "GEMINI_API_KEY"}
]
]
member :researcher do
provider :gemini
model "gemini-2.5-flash"
system_prompt "..."
tools [WebSearch]
endTool-calling, streaming, and structured output (via native
responseSchema) all work. As with Anthropic, response_schema: and
user-supplied tools: are mutually exclusive on the same member.
See examples/gemini_example.exs.
Ollama
Local-LLM support via Ollama's OpenAI-compatible endpoint. The adapter
is a config-preset shim that points the OpenAI adapter at Ollama's
/v1/chat/completions.
config :council_ex,
providers: [
ollama: CouncilEx.Provider.Adapters.Ollama.default_opts()
]
# Override base_url:
config :council_ex,
providers: [
ollama: CouncilEx.Provider.Adapters.Ollama.default_opts(
base_url: "http://gpu-box:11434/v1"
)
]Tool-calling and structured output work for any served model that supports them (Llama 3.1+, Mistral, Qwen 2.5+).
See examples/ollama_example.exs.
OpenRouter
CouncilEx.Provider.Adapters.OpenRouter is a thin wrapper over the
OpenAI adapter. It defaults base_url to https://openrouter.ai/api/v1
and adds optional attribution headers. Use it to reach any model
OpenRouter routes to (DeepSeek, Llama, Qwen, Kimi, Mistral, Grok,
OpenAI, Anthropic, Gemini, plus openrouter/auto) through one provider
config.
config :council_ex,
providers: [
openrouter: [
adapter: CouncilEx.Provider.Adapters.OpenRouter,
api_key: {:system, "OPENROUTER_API_KEY"},
referer: "https://example.com", # optional: HTTP-Referer
title: "MyApp" # optional: X-Title
]
]
member :reasoner do
provider :openrouter
model "deepseek/deepseek-chat" # any OpenRouter `provider/model` id
system_prompt "..."
endPrefer undated GA model IDs: date-suffixed slugs shift on upstream
updates without notice. See PROVIDER_MODELS.md §5
for the live catalog discovery + recommended IDs + refresh protocol, and
examples/openrouter_example.exs
for a multi-vendor council demo.
Multi-provider councils
Once two or more providers are configured, members on the same council can route to different providers:
defmodule MultiVendorPanel do
use CouncilEx
member :openai_voice do; provider :openai; model "gpt-4o-mini"; system_prompt "..."; end
member :anthropic_voice do; provider :anthropic; model "claude-sonnet-4"; system_prompt "..."; end
member :gemini_voice do; provider :gemini; model "gemini-2.5-flash"; system_prompt "..."; end
round :independent_analysis
chair MyApp.Members.Synthesizer, provider: :openai, model: "gpt-4o"
endSee examples/multi_model_panel_example.exs.
Custom provider adapters
CouncilEx.Provider.Adapter is the behaviour for adding new providers.
Stock adapters cover OpenAI-compatible APIs (OpenAI, OpenRouter, Groq)
and Anthropic / Gemini's bespoke shapes; community contributions for
additional providers (Cohere, Mistral La Plateforme, AWS Bedrock, Vertex
AI) are welcome.
Required callbacks (seven)
@callback validate_config(keyword()) :: :ok | {:error, term()}
@callback build_complete_request(Request.t(), keyword()) :: {url, headers, body}
@callback build_stream_request(Request.t(), keyword()) :: {url, headers, body}
@callback parse_complete_response(map()) :: {:ok, complete_response()} | {:error, term()}
@callback init_stream_state() :: map()
@callback parse_stream_chunk(binary(), map()) :: {[stream_event()], map()}
@callback finalize_stream(map()) :: {:ok, complete_response()} | {:error, term()}Reference implementations:
CouncilEx.Provider.Adapters.OpenAI: data-line SSE.CouncilEx.Provider.Adapters.Anthropic: typed-event SSE.CouncilEx.Provider.Adapters.Gemini: bespoke streaming format.
Optional tool-calling callbacks (four)
Adapters that support LLM-callable tools implement an additional four:
@callback build_complete_request_with_tools(Request.t(), keyword()) :: {url, headers, body}
@callback build_stream_request_with_tools(Request.t(), keyword()) :: {url, headers, body}
@callback parse_tool_calls(map()) :: [%CouncilEx.ToolCall{}]
@callback continue_with_tool_results(state, [%CouncilEx.ToolCallResult{}]) :: stateThe OpenAI, Anthropic, and Gemini adapters implement them. Without these, the dispatcher falls through to the no-tools path and silently drops tool definitions. The symptom is "the model never calls my tool."
Optional raw-content callback
extract_raw_content/1 (or extract_raw_content_from_state/1 for
streaming) lets the adapter surface the model's literal assistant turn
even when structured output or tool calls hijacked the response shape.
Used by the streaming tool-loop and by Response.raw.
Config validation
Each adapter exposes a @config_schema (NimbleOptions-shaped) listing
allowed config keys. Anything else returns {:invalid_config, _} from
validate_config/1. When you add a new opt, update the schema or it
won't pass validation.