Multi-model fan-out pattern for workflow steps.
Provides two capabilities:
1. Model configuration at the workflow level
Instead of hardcoding model strings inside each worker, define a model map
when building the workflow and let workers pull their model from job.args:
Baton.new(workflow_name: "patent-analysis")
|> MultiModel.configure(%{
parse: "claude-sonnet-4-20250514",
assess: "claude-opus-4-20250514",
identify: "claude-sonnet-4-20250514",
infringers: "claude-opus-4-20250514",
report: "claude-sonnet-4-20250514"
})
|> Baton.add(:parse, ParsePatent.new(%{patent_text: text}))
|> ...Workers read the model from args:
def perform_workflow(%Oban.Job{args: args} = job) do
model = args["workflow_model"] # injected by configure/2
Debug.call_llm(job, messages, model: model)
end2. Multi-model fan-out with synthesis
Send the same analysis to N models in parallel, then synthesize the results with a voting/comparison step:
Baton.new(workflow_name: "multi-model-quality")
|> Baton.add(:parse_patent, ParsePatent.new(%{text: text}))
|> MultiModel.fan_out(:assess_quality, AssessQuality,
models: [
"claude-sonnet-4-20250514",
"claude-opus-4-20250514",
"gpt-4o",
"o3"
],
args: %{patent_number: "US11234567B2"},
deps: [:parse_patent],
synthesize_with: SynthesizeQuality,
synthesize_model: "claude-opus-4-20250514"
)
|> Baton.add(:generate_report, GenerateReport.new(%{}),
deps: [:assess_quality_synthesis]
)
|> Baton.insert!(MyApp.Repo)This generates:
:assess_quality_sonnet_4 — runs AssessQuality with sonnet
:assess_quality_opus_4 — runs AssessQuality with opus
:assess_quality_gpt_4o — runs AssessQuality with gpt-4o
:assess_quality_o3 — runs AssessQuality with o3
:assess_quality_synthesis — runs SynthesizeQuality with all 4 resultsThe synthesis step receives all model results via Results.get_all_results/1,
keyed by step name (e.g. "assess_quality_sonnet_4" => %{...}).
Summary
Functions
Deprecated alias for Baton.add/4.
Collect all fan-out results from upstream, keyed by model string.
Set a model map on the workflow. Workers can read their model from
job.args["workflow_model"].
Fan out a step to multiple models in parallel, with an optional synthesis step.
Extract the model from a job's args. Use this in workers instead of hardcoding a model string.
Functions
@spec add(Baton.t(), Baton.step_name(), Ecto.Changeset.t(), keyword()) :: Baton.t()
Deprecated alias for Baton.add/4.
Model injection from configure/2 now happens inside Baton.add/4
itself, so this is just a passthrough kept for backwards compatibility — use
Baton.add/4 directly.
@spec collect_fan_results(Oban.Job.t()) :: %{required(String.t()) => term()}
Collect all fan-out results from upstream, keyed by model string.
Call this in synthesis workers to get a map of %{model_string => result}.
Example
def perform_workflow(%Oban.Job{} = job) do
model_results = MultiModel.collect_fan_results(job)
# => %{
# "claude-sonnet-4-20250514" => %{"quality" => %{"score" => 7, ...}},
# "gpt-4o" => %{"quality" => %{"score" => 6, ...}},
# ...
# }
end
Set a model map on the workflow. Workers can read their model from
job.args["workflow_model"].
The map keys should correspond to step names (atoms) and values should be
model strings. When Baton.add/4 is called for a step whose name is in
this map, the model string is injected into the job's args automatically.
Example
workflow
|> MultiModel.configure(%{
parse: "claude-sonnet-4-20250514",
assess: "claude-opus-4-20250514"
})
|> Baton.add(:parse, ParseWorker.new(%{text: "..."}))
# ParseWorker receives args: %{"text" => "...", "workflow_model" => "claude-sonnet-4-20250514"}
@spec fan_out(Baton.t(), Baton.step_name(), module(), keyword()) :: Baton.t()
Fan out a step to multiple models in parallel, with an optional synthesis step.
Options (required)
:models— list of model strings to fan out to:args— base args map passed to each model's worker (model is injected):deps— upstream dependencies for all fan-out steps
Options (optional)
:synthesize_with— worker module for the synthesis step. If omitted, no synthesis step is added — you'd add your own downstream step that depends on all the fan-out step names.:synthesize_model— model string for the synthesis step (default: first model in the list):synthesize_args— additional args for the synthesis step (merged with%{workflow_model: model, fan_out_steps: [step_names]})
Returns
The updated workflow with N parallel steps + 1 synthesis step added.
Naming convention
Fan-out steps are named {base_name}_{model_short} where model_short is
derived from the model string. The synthesis step is named {base_name}_synthesis.
Example
MultiModel.fan_out(workflow, :assess_quality, AssessQuality,
models: ["claude-sonnet-4-20250514", "gpt-4o"],
args: %{patent_number: "US123"},
deps: [:parse_patent],
synthesize_with: SynthesizeQuality,
synthesize_model: "claude-opus-4-20250514"
)Produces steps:
:assess_quality_sonnet_4(deps: [:parse_patent]):assess_quality_gpt_4o(deps: [:parse_patent]):assess_quality_synthesis(deps: [:assess_quality_sonnet_4, :assess_quality_gpt_4o])
@spec model_for(Oban.Job.t(), String.t() | nil) :: String.t() | nil
Extract the model from a job's args. Use this in workers instead of hardcoding a model string.
Falls back to the provided default if no model is set.
Example
def perform_workflow(%Oban.Job{} = job) do
model = MultiModel.model_for(job, "claude-sonnet-4-20250514")
Debug.call_llm(job, messages, model: model)
end