Baton.MultiModel (Baton v0.1.0)

Copy Markdown View Source

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

2. 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 results

The 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

add(workflow, name, changeset, opts \\ [])

This function is deprecated. Use Baton.add/4; model injection happens there automatically..

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.

collect_fan_results(job)

@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

configure(workflow, model_map)

@spec configure(Baton.t(), %{required(atom()) => String.t()}) :: Baton.t()

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"}

fan_out(workflow, base_name, worker_module, opts)

@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])

model_for(job, default \\ nil)

@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