LLM backends#
Each role under llm picks its backend and model; the other keys are the backend’s
settings.
llm:
planner: {backend: openai_compatible, model: deepseek-v4-pro, base_url: https://api.deepseek.com,
api_key_env: DEEPSEEK_API_KEY, max_tokens: 32000, timeout_seconds: 900}
proposer: {backend: anthropic, model: claude-opus-5-5, max_tokens: 16000}
reviewer: {backend: claude_code, model: claude-opus-5-5, effort: high}
distiller: {backend: openai_compatible, model: deepseek-v4-pro, base_url: https://api.deepseek.com,
api_key_env: DEEPSEEK_API_KEY}
Backend |
Calls |
Credentials |
|---|---|---|
|
any OpenAI-compatible endpoint: OpenAI, DeepSeek, DashScope, Gemini’s compatible endpoint, vLLM, Ollama |
the variable |
|
the Anthropic Messages API |
|
|
|
the local Claude Code login |
|
the replies of a recorded LLM log, matched on role and prompt |
none |
For a reasoning model on an OpenAI-compatible endpoint give max_completion_tokens in
place of max_tokens, and reasoning_effort. A key written in the config is refused, and
so is an option the role’s backend does not take: the message lists the ones it does.
The LLM log#
Every call is written in full to <run_dir>/llm_log/: the prompt, the model, the
parameters, the reply, the token usage as the provider reports it, and the raw reply.
Calls are numbered in the order they finish, which with proposal groups need not be the
order they were sent.
Replaying a run#
Point every role at the replay backend and the run reproduces byte for byte without
calling a provider:
llm:
planner: {backend: replay, model: deepseek-v4-pro, log: runs/my_campaign/llm_log}
proposer: {backend: replay, model: deepseek-v4-pro, log: runs/my_campaign/llm_log}
reviewer: {backend: replay, model: deepseek-v4-pro, log: runs/my_campaign/llm_log}
distiller: {backend: replay, model: deepseek-v4-pro, log: runs/my_campaign/llm_log}
A replay searches with the snapshot of the run whose log it replays, not the config’s
files, and builds on parents with its settings: context.lineage, round.max_children
and round.picked_parents are the recorded run’s, whatever the config says. A request the log has no reply for fails (ReplayExhausted). A log written before
a prompt change cannot be replayed with the new prompts.
Cost and quota#
The library counts no money. Spending limits belong to the provider: a prepaid balance or
an account limit. The token usage in the log is there for working out a cost; a config
that names round.budget_usd or per-token prices is refused.
When the provider refuses a call because the balance or quota is exhausted (HTTP 402 from
an OpenAI-compatible provider; Anthropic’s billing error or too-low credit balance), the
backend raises ProviderQuotaExhausted. The campaign drops the round in progress, records
the stop provider_quota with the provider’s message and returns; run() after topping
up runs that round again.
A provider sometimes answers a call as a success with something that is not a reply: a body
of null (DeepSeek, after the blank lines it sends while a call waits), a reply without
its text or its token usage, or Claude Code output that is not its JSON. The SDKs do not
retry it. A campaign asks any role’s backend again after 5 s, then after 30 s, and then
raises ProviderReplyMalformed; a backend of your own raises it for such an answer to
be asked again too. Every other provider error is raised.
Your own backend#
A backend is anything with complete(request) -> reply (LLMBackend). Pass backends by
role to Campaign(config, backends={...}) to replace the ones the config names; the
model still comes from the config.