Skip to main content

lib/council_ex/request.ex

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