Scope: adapter wiring + behaviour (stable reference). For current model IDs (which strings to put in model "…") and retired names, see PROVIDER_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.Adapter behaviour 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:

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]
end

Tool-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 "..."
end

Prefer 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"
end

See 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:

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{}]) :: state

The 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.