defmodule CouncilEx.Request do
@moduledoc """
A normalized LLM request passed from the runner to a provider adapter.
"""
@enforce_keys [:messages, :model]
defstruct [
:messages,
:model,
:temperature,
:max_tokens,
:member_id,
:round_name,
:run_id,
:response_schema,
tools: nil,
tool_choice: nil,
parallel_tools: true,
parallel_tools_strategy: :collect,
tool_concurrency_factor: 1.0,
tool_timeout_ms: 30_000,
metadata: %{}
]
@type message :: %{required(:role) => String.t(), required(:content) => String.t()}
@type t :: %__MODULE__{
messages: [message()],
model: String.t(),
temperature: float() | nil,
max_tokens: pos_integer() | nil,
member_id: atom() | nil,
round_name: atom() | nil,
run_id: String.t() | nil,
response_schema: module() | {:json_schema, map()} | nil,
tools: [module()] | nil,
tool_choice: nil | :auto | :required | :none | String.t(),
parallel_tools: boolean(),
parallel_tools_strategy: :collect | :fail_fast,
tool_concurrency_factor: float(),
tool_timeout_ms: pos_integer(),
metadata: map()
}
@opts_schema NimbleOptions.new!(
messages: [type: {:list, :map}, required: true],
model: [type: :string, required: true],
temperature: [type: {:or, [:float, nil]}, default: nil],
max_tokens: [type: {:or, [:pos_integer, nil]}, default: nil],
member_id: [type: {:or, [:atom, nil]}, default: nil],
round_name: [type: {:or, [:atom, nil]}, default: nil],
run_id: [type: {:or, [:string, nil]}, default: nil],
# `:any` because the value can be:
# nil — no schema
# atom (Ecto schema module) — typed module
# {:json_schema, map} — inline JSON Schema (string-keyed)
# Adapter layer validates the shape it receives.
response_schema: [type: :any, default: nil],
tools: [type: {:or, [{:list, :atom}, nil]}, default: nil],
# `:tool_choice` controls whether/how the model is forced to
# call a tool. Accepted values:
# * `nil` / `:auto` — model decides (provider default)
# * `:required` — model MUST call some tool
# * `:none` — disable tool calling for this request
# * `binary()` — force a specific tool by name
# Adapters that don't support tool_choice ignore this field.
tool_choice: [
type: {:or, [nil, :atom, :string]},
default: nil
],
parallel_tools: [type: :boolean, default: true],
parallel_tools_strategy: [type: {:in, [:collect, :fail_fast]}, default: :collect],
tool_concurrency_factor: [type: :float, default: 1.0],
tool_timeout_ms: [type: :pos_integer, default: 30_000],
metadata: [type: :map, default: %{}]
)
@spec new(keyword()) :: t()
def new(opts) do
validated = NimbleOptions.validate!(opts, @opts_schema)
struct!(__MODULE__, validated)
end
end