# LLM Ensemble Design: Static Lineups & Dynamic Router Selection `llm_ensemble` runs a **B5 fusion** turn: several *proposer* models each draft an answer, and one *aggregator* model fuses those drafts into the final response. This document describes how the **set of models** is chosen for a turn. It does not cover the ensemble runtime mechanics (streaming, timeouts, quorum, fallback) — only model selection. ## Why an ensemble instead of a single model Any single model has a fixed set of blind spots: the failure modes of its training data, its decoding randomness, and its particular biases. Asking that one model again doesn't remove them — it re-rolls the *same* distribution. An ensemble attacks the problem from a different angle: draft an answer with several *different* models, then have an aggregator reconcile them. The wins over a single-model turn: - **Error cancellation / higher accuracy.** Independent models rarely make the *same* mistake on the same input. When drafts disagree, the aggregator can cross-check them and keep the answer the majority supports; when they agree, that agreement is real signal that the answer is solid. Idiosyncratic one-off errors get outvoted instead of shipped. - **Coverage through diversity.** Different vendors/families/architectures have genuinely different strengths — one is better at code, another at long-form reasoning, another at careful instruction-following. A lineup that spans them covers more of the input space than any single model's strong suit. This is exactly why selection *rewards diversity* (distinct vendor/family/ architecture) rather than picking the top-N by raw quality. - **Robustness & availability.** A single model is a single point of failure — one timeout, rate-limit, or degraded response fails the whole turn. With a quorum of proposers the turn still succeeds as long as *enough* drafts come back, and the aggregator simply fuses what arrived. - **Reduced variance.** Fusing several drafts smooths out per-call sampling noise, so repeated runs of the same prompt are more stable and less sensitive to an unlucky roll of the dice on any one model. - **A critic pass, not just a vote.** The aggregator is a model in its own right: it can spot a draft that's confidently wrong, prefer the better-reasoned answer over the more verbose one, and synthesize the best parts of several drafts — a step a single-model turn never gets. The cost is real — an N-proposer turn spends roughly N+1 model calls and its latency is bounded by the slowest proposer plus the aggregator. The selection strategies below exist to spend that budget well: match ensemble size and composition to how hard the turn actually is, rather than always paying for the largest lineup. Lower-difficulty turns get a small, cheap lineup; harder turns get more proposers and stronger critics. ## Selection strategies There are three selection strategies, dispatched by `llm_ensemble.selection_mode` in `build_ensemble_provider_from_config` (`src/opensquilla/provider/ensemble.py`): | `selection_mode` | Family | Status | |------------------|--------|--------| | `static_openrouter_b5` | Static lineup | Default for fresh configs | | `static_tokenrhythm_b5` | Static lineup | Supported | | `custom_b5` | Static lineup (user-authored) | Supported | | `router_dynamic` | Dynamic selection | Legacy | The first two families are **static**: the lineup is fixed ahead of the turn, either from a packaged preset or from an explicit user-authored list. The last is **dynamic**: the lineup is scored and assembled per turn from the router's own tier decision. Fresh configs default to `static_openrouter_b5`. The Web UI offers only the static families (preset + custom); `router_dynamic` is no longer offered there and stored configs surface a one-click migration to `custom_b5`. Direct TOML/RPC configuration keeps working for every mode. --- # Part 1 — Static Lineups (current design) A static lineup is fixed before the turn runs: a set of proposer models plus one aggregator model, all known ahead of time. No per-turn scoring happens. Two variants share this shape: - **Presets** — `static_openrouter_b5` / `static_tokenrhythm_b5`: packaged, hard-coded lineups on a single provider. - **Custom** — `custom_b5`: an explicit user-authored lineup with role-labelled candidates and a single aggregator. Both variants belong to the same **fixed-lineup defaults family** (`is_static_b5` in the builder) and therefore inherit the same runtime defaults (quorum, timeouts, no shuffle, quorum grace) — see [Shared fixed-lineup defaults](#shared-fixed-lineup-defaults). ## 1.1 Static presets Source: `_build_static_b5_members`, `STATIC_B5_PROFILES` (`src/opensquilla/provider/ensemble.py`). Each preset is a `StaticB5Profile` — four fixed proposers plus one aggregator, all bound to a single provider: | Profile | Provider | Proposers | Aggregator | |---------|----------|-----------|------------| | `static_openrouter_b5` | `openrouter` | `deepseek/deepseek-v4-pro`, `z-ai/glm-5.2`, `moonshotai/kimi-k2.7-code`, `qwen/qwen3.7-max` | `z-ai/glm-5.2` | | `static_tokenrhythm_b5` | `tokenrhythm` | `deepseek-v4-pro`, `glm-5.2`, `kimi-k2.7-code`, `qwen3.7-max` | `glm-5.2` | The TokenRhythm profile is a mirror of the OpenRouter one: same aggregation shape and defaults, the same four models, only the provider and the model-id naming differ (OpenRouter-style `vendor/model` slugs vs. TokenRhythm's bare names). `_build_static_b5_members` simply materializes the profile: each proposer model becomes an `EnsembleMemberConfig` labeled `proposer_1..N`, the aggregator model becomes one labeled `aggregator`, and the selection plan records the profile name, proposer/aggregator models, and proposer count. There is nothing to score — the lineup is the profile. ### Credential gate `static_b5_credential_available` decides whether the ensemble may run: it resolves an API key for every member (all four proposers + aggregator) using the same key-resolution order as the runtime (see [Member provider resolution](#member-provider-resolution)). A user whose active provider differs but whose environment carries the profile provider's env key (e.g. `OPENROUTER_API_KEY`, `TOKENRHYTHM_API_KEY`) is treated as opted in. If any member cannot resolve a key, the ensemble is skipped rather than posting a turn upstream with an empty bearer token. ## 1.2 Custom lineup (`custom_b5`) Source: `_build_custom_b5_members`, `_custom_b5_candidates` (`src/opensquilla/provider/ensemble.py`); schema `LlmEnsembleCandidateConfig` (`src/opensquilla/gateway/config.py`). `custom_b5` lets an operator author the lineup explicitly via `llm_ensemble.candidates`. Each candidate row carries: - **`provider`** / **`model`** — required, non-empty; provider is lower-cased. - **`role`** — advisory label, one of `""` (unassigned), `primary`, `contrast`, `fast_check`, `critic`, or the structural `aggregator`. Unknown values coerce to `""` instead of failing, so a hand-edited config never blocks boot. - **`enabled`** — disabled rows are kept for read compatibility but never counted or run. Lineup assembly (`_build_custom_b5_members`): 1. Every enabled row whose role is **not** `aggregator` runs as a proposer, labeled by its role (or `proposer_N` when unassigned). 2. The single row with role `aggregator` fuses the drafts. Proposer rows dedupe on `(provider, model)`; the aggregator row may legitimately reuse a proposer's model (a model both drafts and fuses). 3. **Fallback:** if no `aggregator` row exists, the aggregator falls back to the currently routed model — the same model the user would have gotten without the ensemble — so a proposer-only config still runs instead of erroring at turn time. The plan records `aggregator.source` as `candidate_role` or `inherited_model` accordingly. ### Lineup bounds & validation Enforced by `LlmEnsembleConfig._validate_custom_b5_lineup` (`src/opensquilla/gateway/config.py`), checked **only** when `selection_mode == "custom_b5"` (presets carry fixed lineups; `router_dynamic` selects per turn): - At most **one** enabled candidate may carry role `aggregator`. - Enabled proposer count must stay within `[CUSTOM_B5_MIN_PROPOSERS=2, CUSTOM_B5_MAX_PROPOSERS=6]`. - Total per-turn calls are capped at `CUSTOM_B5_MAX_TOTAL_CALLS=8` (proposers + aggregator). ### Readiness gate `custom_b5_lineup_ready` returns `(ready, reason)` before wrapping the turn. It fails closed with a machine-readable reason when the lineup is not runnable: `no_proposers`, `unknown_provider:
`, or `missing_credential:
` (a member
whose provider requires a key but resolves none). This mirrors the static-preset
gate — a member with an empty bearer token would post the conversation upstream
unauthenticated, so the wrap is skipped.
## 1.3 Shared fixed-lineup defaults
Both static families set `is_static_b5 = True` in
`build_ensemble_provider_from_config`, which swaps the legacy per-turn defaults
for the fixed-lineup family defaults. The swap is **only** applied when the
configured value still equals the legacy default (`_static_default_if_legacy`),
so any operator override is preserved:
| Parameter | Legacy (`router_dynamic`) | Fixed-lineup default |
|-----------|---------------------------|----------------------|
| `min_successful_proposers` | 1 | 3 (presets) / `N-1` (custom, "all but one") |
| `proposer_timeout_seconds` | 3600 | 300 |
| `aggregator_timeout_seconds` | 3600 | 480 |
| `shuffle_candidates` | `True` | `False` |
| `quorum_grace_seconds` | 0 | 30 |
`min_successful_proposers` is additionally clamped down to the actual proposer
count. Both the configured and effective values (min-success, timeouts, shuffle)
are recorded in the selection plan for debugging.
## 1.4 Member provider resolution
Every member (static or custom) resolves its concrete `ProviderConfig` through
`_member_provider_config`, which layers member intent over the inherited/routed
provider config:
- **API key** — a member-level `api_key_env` env var if set; else the inherited
key when the member's provider matches the active provider; else the provider
registry's env key (e.g. `OPENROUTER_API_KEY`).
- **`base_url`** — member override, else the inherited base URL (same provider)
or the provider spec's default base URL.
- **`proxy` / `org_id` / `provider_routing`** — inherited only when the member
shares the active provider; otherwise reset.
This is what lets a static/custom lineup run against a provider the user isn't
actively routing to, as long as that provider's credential is present in the
environment.
## 1.5 Configuration surface
```toml
[llm_ensemble]
enabled = true
selection_mode = "static_openrouter_b5" # or static_tokenrhythm_b5 / custom_b5
```
Custom lineup:
```toml
[llm_ensemble]
enabled = true
selection_mode = "custom_b5"
[[llm_ensemble.candidates]]
provider = "openrouter"
model = "deepseek/deepseek-v4-pro"
role = "primary"
[[llm_ensemble.candidates]]
provider = "openrouter"
model = "z-ai/glm-5.2"
role = "contrast"
[[llm_ensemble.candidates]]
provider = "openrouter"
model = "z-ai/glm-5.2"
role = "aggregator"
```
Static presets expose no lineup tuning — the models are fixed in code. Custom
lineups are tuned entirely through the `candidates` list (subject to the bounds
above). Both share the fixed-lineup runtime defaults, which an operator may
still override explicitly (`min_successful_proposers`,
`proposer_timeout_seconds`, `aggregator_timeout_seconds`, `shuffle_candidates`).
---
# Part 2 — `router_dynamic` Selection (legacy)
> **Status: legacy.** `router_dynamic` remains fully supported for existing
> configs but is no longer offered in the Web UI. Stored `router_dynamic`
> configs surface a one-click migration to `custom_b5`. Direct TOML/RPC
> configuration keeps working as described below.
`router_dynamic` is the dynamic model-selection strategy: instead of a fixed
lineup, it picks proposers and the aggregator **per turn**, driven by
SquillaRouter's tier decision for that turn. Enable it with
`llm_ensemble.selection_mode = "router_dynamic"`.
Source: `src/opensquilla/provider/ensemble.py`
(`_candidate_pool`, `_score_dynamic_candidate`, `_select_dynamic_candidate`,
`_build_router_dynamic_members`).
## 2.1 Why dynamic selection
A fixed proposer/aggregator list can't adapt to the model actually chosen for a
turn, and forces operators to hand-tune which models pair well together at each
router tier. `router_dynamic` instead:
- reuses the model SquillaRouter already picked for the turn as the **anchor**
proposer, so the ensemble never contradicts the router's own tier decision;
- fills the remaining proposer slots and the aggregator slot by scoring a pool
of candidate models against a per-tier "slot template";
- penalizes re-selecting a model that's already in the ensemble, so proposers
stay diverse instead of collapsing onto a few high-quality models.
## 2.2 Inputs
`_build_router_dynamic_members` takes three things:
1. **`inherited_provider_config`** — the provider/model SquillaRouter already
resolved for this turn (becomes the anchor).
2. **`turn_metadata`** — carries `routed_tier` (`c0`–`c3`), `routing_confidence`
(0.0–1.0), and `routing_extra` (`final_tier`/`base_tier` fallbacks used if
`routed_tier` is missing). Defaults to tier `c1` if nothing usable is found.
3. **`config`** — `llm_ensemble.model_options` and `squilla_router.tiers`,
used to build the candidate pool.
## 2.3 Candidate pool
`_candidate_pool` assembles a deduplicated list of `(provider, model)`
candidates, in this order:
1. **Router anchor** — the inherited provider/model (`source="router_anchor"`).
This is always `pool[0]` and always becomes the first proposer.
2. **`llm_ensemble.model_options`** — the operator-configured candidate list
(`source="model_options"`). If a model string contains `/` it's assumed to
be an OpenRouter-style id and routed via `openrouter`; otherwise it inherits
the anchor's provider.
3. **`squilla_router.tiers[*].model`** — every model configured for a
SquillaRouter tier (`source="router_tier: